Repository: wavetermdev/waveterm Branch: main Commit: f2b8c201b147 Files: 969 Total size: 6.0 MB Directory structure: gitextract_d_p9wdmp/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yml │ │ ├── config.yml │ │ └── feature-request.yml │ ├── copilot-instructions.md │ ├── dependabot.yml │ └── workflows/ │ ├── build-helper.yml │ ├── bump-version.yml │ ├── codeql.yml │ ├── copilot-setup-steps.yml │ ├── deploy-docsite.yml │ ├── merge-gatekeeper.yml │ ├── publish-release.yml │ ├── testdriver-build.yml │ └── testdriver.yml ├── .gitignore ├── .golangci.yml ├── .kilocode/ │ ├── rules/ │ │ ├── overview.md │ │ └── rules.md │ └── skills/ │ ├── add-config/ │ │ └── SKILL.md │ ├── add-rpc/ │ │ └── SKILL.md │ ├── add-wshcmd/ │ │ └── SKILL.md │ ├── context-menu/ │ │ └── SKILL.md │ ├── create-view/ │ │ └── SKILL.md │ ├── electron-api/ │ │ └── SKILL.md │ ├── waveenv/ │ │ └── SKILL.md │ └── wps-events/ │ └── SKILL.md ├── .prettierignore ├── .roo/ │ └── rules/ │ ├── overview.md │ └── rules.md ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── .zed/ │ └── settings.json ├── ACKNOWLEDGEMENTS.md ├── BUILD.md ├── CNAME ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.ko.md ├── README.md ├── RELEASES.md ├── ROADMAP.md ├── SECURITY.md ├── Taskfile.yml ├── aiprompts/ │ ├── aimodesconfig.md │ ├── aisdk-streaming.md │ ├── aisdk-uimessage-type.md │ ├── anthropic-messages-api.md │ ├── anthropic-streaming.md │ ├── blockcontroller-lifecycle.md │ ├── config-system.md │ ├── conn-arch.md │ ├── contextmenu.md │ ├── fe-conn-arch.md │ ├── focus-layout.md │ ├── focus.md │ ├── getsetconfigvar.md │ ├── layout-simplification.md │ ├── layout.md │ ├── monaco-v0.53.md │ ├── newview.md │ ├── openai-request.md │ ├── openai-streaming-text.md │ ├── openai-streaming.md │ ├── tailwind-container-queries.md │ ├── tsunami-builder.md │ ├── usechat-backend-design.md │ ├── view-prompt.md │ ├── wave-osc-16162.md │ ├── waveai-architecture.md │ ├── waveai-focus-updates.md │ └── wps-events.md ├── build/ │ ├── deb-postinstall.tpl │ ├── entitlements.mac.plist │ └── icon.icns ├── cmd/ │ ├── generatego/ │ │ └── main-generatego.go │ ├── generateschema/ │ │ └── main-generateschema.go │ ├── generatets/ │ │ └── main-generatets.go │ ├── packfiles/ │ │ └── main-packfiles.go │ ├── server/ │ │ └── main-server.go │ ├── test/ │ │ └── test-main.go │ ├── test-conn/ │ │ ├── cliprovider.go │ │ ├── main-test-conn.go │ │ └── testutil.go │ ├── test-streammanager/ │ │ ├── bridge.go │ │ ├── deliverypipe.go │ │ ├── generator.go │ │ ├── main-test-streammanager.go │ │ ├── metrics.go │ │ └── verifier.go │ ├── testai/ │ │ ├── main-testai.go │ │ └── testschema.json │ ├── testopenai/ │ │ └── main-testopenai.go │ ├── testsummarize/ │ │ └── main-testsummarize.go │ └── wsh/ │ ├── cmd/ │ │ ├── csscolormap.go │ │ ├── setmeta_test.go │ │ ├── wshcmd-ai.go │ │ ├── wshcmd-badge.go │ │ ├── wshcmd-blocks.go │ │ ├── wshcmd-conn.go │ │ ├── wshcmd-connserver.go │ │ ├── wshcmd-createblock.go │ │ ├── wshcmd-debug.go │ │ ├── wshcmd-debugterm.go │ │ ├── wshcmd-debugterm_test.go │ │ ├── wshcmd-deleteblock.go │ │ ├── wshcmd-editconfig.go │ │ ├── wshcmd-editor.go │ │ ├── wshcmd-file-util.go │ │ ├── wshcmd-file.go │ │ ├── wshcmd-focusblock.go │ │ ├── wshcmd-getmeta.go │ │ ├── wshcmd-getvar.go │ │ ├── wshcmd-jobdebug.go │ │ ├── wshcmd-jobmanager.go │ │ ├── wshcmd-launch.go │ │ ├── wshcmd-notify.go │ │ ├── wshcmd-rcfiles.go │ │ ├── wshcmd-readfile.go │ │ ├── wshcmd-root.go │ │ ├── wshcmd-run.go │ │ ├── wshcmd-secret.go │ │ ├── wshcmd-setbg.go │ │ ├── wshcmd-setconfig.go │ │ ├── wshcmd-setmeta.go │ │ ├── wshcmd-setvar.go │ │ ├── wshcmd-shell-unix.go │ │ ├── wshcmd-shell-win.go │ │ ├── wshcmd-ssh.go │ │ ├── wshcmd-ssh_test.go │ │ ├── wshcmd-tabindicator.go │ │ ├── wshcmd-term.go │ │ ├── wshcmd-termscrollback.go │ │ ├── wshcmd-test.go │ │ ├── wshcmd-token.go │ │ ├── wshcmd-version.go │ │ ├── wshcmd-view.go │ │ ├── wshcmd-wavepath.go │ │ ├── wshcmd-web.go │ │ ├── wshcmd-workspace.go │ │ └── wshcmd-wsl.go │ └── main-wsh.go ├── db/ │ ├── db.go │ ├── migrations-filestore/ │ │ ├── 000001_init.down.sql │ │ └── 000001_init.up.sql │ └── migrations-wstore/ │ ├── 000001_init.down.sql │ ├── 000001_init.up.sql │ ├── 000002_init.down.sql │ ├── 000002_init.up.sql │ ├── 000003_activity.down.sql │ ├── 000003_activity.up.sql │ ├── 000004_history.down.sql │ ├── 000004_history.up.sql │ ├── 000005_blockparent.down.sql │ ├── 000005_blockparent.up.sql │ ├── 000006_workspace.down.sql │ ├── 000006_workspace.up.sql │ ├── 000007_events.down.sql │ ├── 000007_events.up.sql │ ├── 000008_aimeta.down.sql │ ├── 000008_aimeta.up.sql │ ├── 000009_mainserver.down.sql │ ├── 000009_mainserver.up.sql │ ├── 000010_merge_pinned_tabs.down.sql │ ├── 000010_merge_pinned_tabs.up.sql │ ├── 000011_job.down.sql │ └── 000011_job.up.sql ├── docs/ │ ├── .editorconfig │ ├── .gitignore │ ├── .prettierignore │ ├── .remarkrc │ ├── README.md │ ├── babel.config.js │ ├── docs/ │ │ ├── ai-presets.mdx │ │ ├── claude-code.mdx │ │ ├── config.mdx │ │ ├── connections.mdx │ │ ├── customization.mdx │ │ ├── customwidgets.mdx │ │ ├── durable-sessions.mdx │ │ ├── faq.mdx │ │ ├── gettingstarted.mdx │ │ ├── index.mdx │ │ ├── keybindings.mdx │ │ ├── layout.mdx │ │ ├── presets.mdx │ │ ├── releasenotes.mdx │ │ ├── secrets.mdx │ │ ├── tabs.mdx │ │ ├── telemetry-old.mdx │ │ ├── telemetry.mdx │ │ ├── waveai-modes.mdx │ │ ├── waveai.mdx │ │ ├── widgets.mdx │ │ ├── workspaces.mdx │ │ ├── wsh-reference.mdx │ │ └── wsh.mdx │ ├── docusaurus.config.ts │ ├── eslint.config.js │ ├── package.json │ ├── prettier.config.cjs │ ├── src/ │ │ ├── components/ │ │ │ ├── card.css │ │ │ ├── card.tsx │ │ │ ├── kbd.css │ │ │ ├── kbd.tsx │ │ │ ├── platformcontext.css │ │ │ ├── platformcontext.tsx │ │ │ ├── versionbadge.css │ │ │ └── versionbadge.tsx │ │ ├── css/ │ │ │ └── custom.scss │ │ ├── renderer/ │ │ │ └── image-renderers.ts │ │ └── theme/ │ │ └── MDXComponents/ │ │ └── Heading.tsx │ ├── static/ │ │ └── .nojekyll │ └── tsconfig.json ├── electron-builder.config.cjs ├── electron.vite.config.ts ├── emain/ │ ├── authkey.ts │ ├── emain-activity.ts │ ├── emain-builder.ts │ ├── emain-events.ts │ ├── emain-ipc.ts │ ├── emain-log.ts │ ├── emain-menu.ts │ ├── emain-platform.ts │ ├── emain-tabview.ts │ ├── emain-util.ts │ ├── emain-wavesrv.ts │ ├── emain-web.ts │ ├── emain-window.ts │ ├── emain-wsh.ts │ ├── emain.ts │ ├── launchsettings.ts │ ├── preload-webview.ts │ ├── preload.ts │ └── updater.ts ├── eslint.config.js ├── frontend/ │ ├── app/ │ │ ├── aipanel/ │ │ │ ├── ai-utils.ts │ │ │ ├── aidroppedfiles.tsx │ │ │ ├── aifeedbackbuttons.tsx │ │ │ ├── aimessage.tsx │ │ │ ├── aimode.tsx │ │ │ ├── aipanel-contextmenu.ts │ │ │ ├── aipanel.tsx │ │ │ ├── aipanelheader.tsx │ │ │ ├── aipanelinput.tsx │ │ │ ├── aipanelmessages.tsx │ │ │ ├── airatelimitstrip.tsx │ │ │ ├── aitooluse.tsx │ │ │ ├── aitypes.ts │ │ │ ├── byokannouncement.tsx │ │ │ ├── restorebackupmodal.tsx │ │ │ ├── telemetryrequired.tsx │ │ │ ├── waveai-focus-utils.ts │ │ │ └── waveai-model.tsx │ │ ├── app-bg.tsx │ │ ├── app.scss │ │ ├── app.tsx │ │ ├── block/ │ │ │ ├── block-model.ts │ │ │ ├── block.scss │ │ │ ├── block.tsx │ │ │ ├── blockenv.ts │ │ │ ├── blockframe-header.tsx │ │ │ ├── blockframe.tsx │ │ │ ├── blocktypes.ts │ │ │ ├── blockutil.tsx │ │ │ ├── connectionbutton.tsx │ │ │ ├── connstatusoverlay.tsx │ │ │ └── durable-session-flyover.tsx │ │ ├── element/ │ │ │ ├── ansiline.tsx │ │ │ ├── button.scss │ │ │ ├── button.tsx │ │ │ ├── copybutton.scss │ │ │ ├── copybutton.tsx │ │ │ ├── emojibutton.tsx │ │ │ ├── emojipalette.scss │ │ │ ├── emojipalette.tsx │ │ │ ├── errorboundary.tsx │ │ │ ├── expandablemenu.scss │ │ │ ├── expandablemenu.tsx │ │ │ ├── flyoutmenu.scss │ │ │ ├── flyoutmenu.tsx │ │ │ ├── iconbutton.scss │ │ │ ├── iconbutton.tsx │ │ │ ├── input.scss │ │ │ ├── input.tsx │ │ │ ├── linkbutton.scss │ │ │ ├── linkbutton.tsx │ │ │ ├── magnify.scss │ │ │ ├── magnify.tsx │ │ │ ├── markdown-contentblock-plugin.ts │ │ │ ├── markdown-util.ts │ │ │ ├── markdown.scss │ │ │ ├── markdown.tsx │ │ │ ├── menubutton.scss │ │ │ ├── menubutton.tsx │ │ │ ├── modal.scss │ │ │ ├── modal.tsx │ │ │ ├── multilineinput.scss │ │ │ ├── multilineinput.tsx │ │ │ ├── popover.scss │ │ │ ├── popover.tsx │ │ │ ├── progressbar.scss │ │ │ ├── progressbar.tsx │ │ │ ├── quickelems.scss │ │ │ ├── quickelems.tsx │ │ │ ├── quicktips.tsx │ │ │ ├── remark-mermaid-to-tag.ts │ │ │ ├── search.scss │ │ │ ├── search.tsx │ │ │ ├── streamdown.tsx │ │ │ ├── toggle.scss │ │ │ ├── toggle.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── typingindicator.scss │ │ │ └── typingindicator.tsx │ │ ├── hook/ │ │ │ ├── useDimensions.tsx │ │ │ └── useLongClick.tsx │ │ ├── modals/ │ │ │ ├── about.tsx │ │ │ ├── conntypeahead.tsx │ │ │ ├── messagemodal.scss │ │ │ ├── messagemodal.tsx │ │ │ ├── modal.scss │ │ │ ├── modal.tsx │ │ │ ├── modalregistry.tsx │ │ │ ├── modalsrenderer.tsx │ │ │ ├── typeaheadmodal.scss │ │ │ ├── typeaheadmodal.tsx │ │ │ └── userinputmodal.tsx │ │ ├── monaco/ │ │ │ ├── monaco-env.ts │ │ │ ├── monaco-react.tsx │ │ │ ├── schemaendpoints.ts │ │ │ └── yamlworker.js │ │ ├── onboarding/ │ │ │ ├── fakechat.tsx │ │ │ ├── onboarding-command.tsx │ │ │ ├── onboarding-common.tsx │ │ │ ├── onboarding-durable.tsx │ │ │ ├── onboarding-features-footer.tsx │ │ │ ├── onboarding-features.tsx │ │ │ ├── onboarding-layout-term.tsx │ │ │ ├── onboarding-layout.tsx │ │ │ ├── onboarding-starask.tsx │ │ │ ├── onboarding-upgrade-minor.tsx │ │ │ ├── onboarding-upgrade-patch.tsx │ │ │ ├── onboarding-upgrade-v0121.tsx │ │ │ ├── onboarding-upgrade-v0122.tsx │ │ │ ├── onboarding-upgrade-v0123.tsx │ │ │ ├── onboarding-upgrade-v0130.tsx │ │ │ ├── onboarding-upgrade-v0131.tsx │ │ │ ├── onboarding-upgrade-v0140.tsx │ │ │ ├── onboarding-upgrade-v0141.tsx │ │ │ ├── onboarding-upgrade-v0142.tsx │ │ │ ├── onboarding-upgrade.tsx │ │ │ └── onboarding.tsx │ │ ├── reset.scss │ │ ├── shadcn/ │ │ │ └── lib/ │ │ │ └── utils.ts │ │ ├── store/ │ │ │ ├── badge.ts │ │ │ ├── client-model.ts │ │ │ ├── connections-model.ts │ │ │ ├── contextmenu.test.ts │ │ │ ├── contextmenu.ts │ │ │ ├── counters.ts │ │ │ ├── focusManager.ts │ │ │ ├── global-atoms.test.ts │ │ │ ├── global-atoms.ts │ │ │ ├── global-model.ts │ │ │ ├── global.ts │ │ │ ├── jotaiStore.ts │ │ │ ├── keymodel.ts │ │ │ ├── modalmodel.ts │ │ │ ├── services.ts │ │ │ ├── tab-model.ts │ │ │ ├── tabrpcclient.ts │ │ │ ├── windowtype.ts │ │ │ ├── wos.ts │ │ │ ├── wps.ts │ │ │ ├── ws.ts │ │ │ ├── wshclient.ts │ │ │ ├── wshclientapi.ts │ │ │ ├── wshrouter.ts │ │ │ ├── wshrpcutil-base.ts │ │ │ └── wshrpcutil.ts │ │ ├── suggestion/ │ │ │ └── suggestion.tsx │ │ ├── tab/ │ │ │ ├── tab.scss │ │ │ ├── tab.tsx │ │ │ ├── tabbadges.tsx │ │ │ ├── tabbar-model.ts │ │ │ ├── tabbar.scss │ │ │ ├── tabbar.tsx │ │ │ ├── tabbarenv.ts │ │ │ ├── tabcontent.tsx │ │ │ ├── tabcontextmenu.ts │ │ │ ├── updatebanner.tsx │ │ │ ├── vtab.test.tsx │ │ │ ├── vtab.tsx │ │ │ ├── vtabbar.tsx │ │ │ ├── vtabbarenv.ts │ │ │ ├── workspaceeditor.scss │ │ │ ├── workspaceeditor.tsx │ │ │ ├── workspaceswitcher.scss │ │ │ └── workspaceswitcher.tsx │ │ ├── theme.scss │ │ ├── treeview/ │ │ │ ├── treeview.test.ts │ │ │ └── treeview.tsx │ │ ├── view/ │ │ │ ├── aifilediff/ │ │ │ │ └── aifilediff.tsx │ │ │ ├── codeeditor/ │ │ │ │ ├── codeeditor.tsx │ │ │ │ └── diffviewer.tsx │ │ │ ├── helpview/ │ │ │ │ └── helpview.tsx │ │ │ ├── launcher/ │ │ │ │ └── launcher.tsx │ │ │ ├── preview/ │ │ │ │ ├── csvview.scss │ │ │ │ ├── csvview.tsx │ │ │ │ ├── directorypreview.scss │ │ │ │ ├── entry-manager.tsx │ │ │ │ ├── preview-directory-utils.tsx │ │ │ │ ├── preview-directory.tsx │ │ │ │ ├── preview-edit.tsx │ │ │ │ ├── preview-error-overlay.tsx │ │ │ │ ├── preview-markdown.tsx │ │ │ │ ├── preview-model.tsx │ │ │ │ ├── preview-streaming.tsx │ │ │ │ ├── preview.tsx │ │ │ │ └── previewenv.ts │ │ │ ├── quicktipsview/ │ │ │ │ └── quicktipsview.tsx │ │ │ ├── sysinfo/ │ │ │ │ └── sysinfo.tsx │ │ │ ├── term/ │ │ │ │ ├── fitaddon.ts │ │ │ │ ├── ijson.tsx │ │ │ │ ├── osc-handlers.ts │ │ │ │ ├── shellblocking.ts │ │ │ │ ├── term-model.ts │ │ │ │ ├── term-tooltip.tsx │ │ │ │ ├── term-wsh.tsx │ │ │ │ ├── term.scss │ │ │ │ ├── term.tsx │ │ │ │ ├── termsticker.tsx │ │ │ │ ├── termtheme.ts │ │ │ │ ├── termutil.ts │ │ │ │ ├── termwrap.ts │ │ │ │ └── xterm.css │ │ │ ├── tsunami/ │ │ │ │ └── tsunami.tsx │ │ │ ├── vdom/ │ │ │ │ ├── vdom-model.tsx │ │ │ │ ├── vdom-utils.tsx │ │ │ │ └── vdom.tsx │ │ │ ├── waveai/ │ │ │ │ ├── waveai.scss │ │ │ │ └── waveai.tsx │ │ │ ├── waveconfig/ │ │ │ │ ├── secretscontent.tsx │ │ │ │ ├── waveaivisual.tsx │ │ │ │ ├── waveconfig-model.ts │ │ │ │ ├── waveconfig.tsx │ │ │ │ └── waveconfigenv.ts │ │ │ └── webview/ │ │ │ ├── webview.scss │ │ │ ├── webview.test.tsx │ │ │ ├── webview.tsx │ │ │ └── webviewenv.ts │ │ ├── waveenv/ │ │ │ ├── mockboundary.tsx │ │ │ ├── waveenv.ts │ │ │ └── waveenvimpl.ts │ │ └── workspace/ │ │ ├── widgetfilter.test.ts │ │ ├── widgetfilter.ts │ │ ├── widgets.tsx │ │ ├── workspace-layout-model.ts │ │ └── workspace.tsx │ ├── builder/ │ │ ├── app-selection-modal.tsx │ │ ├── builder-app.tsx │ │ ├── builder-apppanel.tsx │ │ ├── builder-buildpanel.tsx │ │ ├── builder-workspace.tsx │ │ ├── store/ │ │ │ ├── builder-apppanel-model.ts │ │ │ ├── builder-buildpanel-model.ts │ │ │ └── builder-focusmanager.ts │ │ ├── tabs/ │ │ │ ├── builder-codetab.tsx │ │ │ ├── builder-configdatatab.tsx │ │ │ ├── builder-filestab.tsx │ │ │ ├── builder-previewtab.tsx │ │ │ └── builder-secrettab.tsx │ │ └── utils/ │ │ └── builder-focus-utils.ts │ ├── layout/ │ │ ├── index.ts │ │ ├── lib/ │ │ │ ├── TileLayout.tsx │ │ │ ├── layoutAtom.ts │ │ │ ├── layoutModel.ts │ │ │ ├── layoutModelHooks.ts │ │ │ ├── layoutNode.ts │ │ │ ├── layoutTree.ts │ │ │ ├── nodeRefMap.ts │ │ │ ├── tilelayout.scss │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── tests/ │ │ ├── layoutNode.test.ts │ │ ├── layoutTree.test.ts │ │ ├── model.ts │ │ └── utils.test.ts │ ├── preview/ │ │ ├── index.html │ │ ├── mock/ │ │ │ ├── defaultconfig.ts │ │ │ ├── mock-node-model.ts │ │ │ ├── mockfilesystem.ts │ │ │ ├── mockwaveenv.test.ts │ │ │ ├── mockwaveenv.ts │ │ │ ├── preview-electron-api.ts │ │ │ ├── tabbar-mock.tsx │ │ │ └── use-rpc-override.ts │ │ ├── preview-contextmenu.tsx │ │ ├── preview.css │ │ ├── preview.tsx │ │ ├── previews/ │ │ │ ├── .gitkeep │ │ │ ├── aifilediff.preview-util.ts │ │ │ ├── aifilediff.preview.test.ts │ │ │ ├── aifilediff.preview.tsx │ │ │ ├── modal-about.preview.tsx │ │ │ ├── onboarding.preview.tsx │ │ │ ├── sysinfo.preview-util.ts │ │ │ ├── sysinfo.preview.test.ts │ │ │ ├── sysinfo.preview.tsx │ │ │ ├── tab.preview.tsx │ │ │ ├── tabbar.preview.tsx │ │ │ ├── treeview.preview.tsx │ │ │ ├── vtabbar.preview.tsx │ │ │ ├── web.preview.tsx │ │ │ └── widgets.preview.tsx │ │ └── vite.config.ts │ ├── tailwindsetup.css │ ├── types/ │ │ ├── custom.d.ts │ │ ├── gotypes.d.ts │ │ ├── jsx.d.ts │ │ ├── media.d.ts │ │ ├── vite-env.d.ts │ │ └── waveevent.d.ts │ ├── util/ │ │ ├── color-validator.test.ts │ │ ├── color-validator.ts │ │ ├── endpoints.ts │ │ ├── fetchutil.ts │ │ ├── focusutil.ts │ │ ├── fontutil.ts │ │ ├── getenv.ts │ │ ├── historyutil.ts │ │ ├── ijson.ts │ │ ├── isdev.ts │ │ ├── keyutil.ts │ │ ├── platformutil.ts │ │ ├── previewutil.ts │ │ ├── sharedconst.ts │ │ ├── util.ts │ │ ├── waveutil.ts │ │ └── wsutil.ts │ └── wave.ts ├── go.mod ├── go.sum ├── index.html ├── package.json ├── pkg/ │ ├── aiusechat/ │ │ ├── aiutil/ │ │ │ └── aiutil.go │ │ ├── anthropic/ │ │ │ ├── anthropic-backend.go │ │ │ ├── anthropic-backend_test.go │ │ │ └── anthropic-convertmessage.go │ │ ├── chatstore/ │ │ │ └── chatstore.go │ │ ├── gemini/ │ │ │ ├── doc.go │ │ │ ├── gemini-backend.go │ │ │ ├── gemini-convertmessage.go │ │ │ └── gemini-types.go │ │ ├── google/ │ │ │ ├── doc.go │ │ │ ├── google-summarize.go │ │ │ └── google-summarize_test.go │ │ ├── openai/ │ │ │ ├── openai-backend.go │ │ │ ├── openai-convertmessage.go │ │ │ ├── openai-util.go │ │ │ ├── stream-sample.txt │ │ │ └── tool-sample.txt │ │ ├── openaichat/ │ │ │ ├── openaichat-backend.go │ │ │ ├── openaichat-convertmessage.go │ │ │ └── openaichat-types.go │ │ ├── toolapproval.go │ │ ├── tools.go │ │ ├── tools_builder.go │ │ ├── tools_readdir.go │ │ ├── tools_readdir_test.go │ │ ├── tools_readfile.go │ │ ├── tools_screenshot.go │ │ ├── tools_term.go │ │ ├── tools_tsunami.go │ │ ├── tools_web.go │ │ ├── tools_writefile.go │ │ ├── uctypes/ │ │ │ └── uctypes.go │ │ ├── usechat-backend.go │ │ ├── usechat-mode.go │ │ ├── usechat-prompts.go │ │ ├── usechat-utils.go │ │ ├── usechat.go │ │ └── usechat_mode_test.go │ ├── authkey/ │ │ └── authkey.go │ ├── baseds/ │ │ └── baseds.go │ ├── blockcontroller/ │ │ ├── .gitignore │ │ ├── blockcontroller.go │ │ ├── durableshellcontroller.go │ │ ├── shellcontroller.go │ │ └── tsunamicontroller.go │ ├── blocklogger/ │ │ └── blocklogger.go │ ├── buildercontroller/ │ │ └── buildercontroller.go │ ├── eventbus/ │ │ └── eventbus.go │ ├── faviconcache/ │ │ └── faviconcache.go │ ├── filebackup/ │ │ └── filebackup.go │ ├── filestore/ │ │ ├── blockstore.go │ │ ├── blockstore_cache.go │ │ ├── blockstore_dbops.go │ │ ├── blockstore_dbsetup.go │ │ └── blockstore_test.go │ ├── genconn/ │ │ ├── genconn.go │ │ ├── ssh-impl.go │ │ └── wsl-impl.go │ ├── gogen/ │ │ ├── gogen.go │ │ └── gogen_test.go │ ├── ijson/ │ │ ├── ijson.go │ │ └── ijson_test.go │ ├── jobcontroller/ │ │ └── jobcontroller.go │ ├── jobmanager/ │ │ ├── cirbuf.go │ │ ├── jobcmd.go │ │ ├── jobmanager.go │ │ ├── jobmanager_unix.go │ │ ├── jobmanager_windows.go │ │ ├── mainserverconn.go │ │ ├── streammanager.go │ │ └── streammanager_test.go │ ├── panichandler/ │ │ └── panichandler.go │ ├── remote/ │ │ ├── conncontroller/ │ │ │ ├── conncontroller.go │ │ │ └── connmonitor.go │ │ ├── connparse/ │ │ │ ├── connparse.go │ │ │ └── connparse_test.go │ │ ├── connutil.go │ │ ├── fileshare/ │ │ │ ├── fspath/ │ │ │ │ └── fspath.go │ │ │ ├── fsutil/ │ │ │ │ └── fsutil.go │ │ │ └── wshfs/ │ │ │ └── wshfs.go │ │ ├── sshagent_unix.go │ │ ├── sshagent_unix_test.go │ │ ├── sshagent_windows.go │ │ ├── sshagent_windows_test.go │ │ └── sshclient.go │ ├── schema/ │ │ └── schema.go │ ├── secretstore/ │ │ └── secretstore.go │ ├── service/ │ │ ├── blockservice/ │ │ │ └── blockservice.go │ │ ├── clientservice/ │ │ │ └── clientservice.go │ │ ├── objectservice/ │ │ │ └── objectservice.go │ │ ├── service.go │ │ ├── userinputservice/ │ │ │ └── userinputservice.go │ │ ├── windowservice/ │ │ │ └── windowservice.go │ │ └── workspaceservice/ │ │ └── workspaceservice.go │ ├── shellexec/ │ │ ├── conninterface.go │ │ └── shellexec.go │ ├── streamclient/ │ │ ├── stream_test.go │ │ ├── streambroker.go │ │ ├── streambroker_test.go │ │ ├── streamreader.go │ │ └── streamwriter.go │ ├── suggestion/ │ │ ├── filewalk.go │ │ └── suggestion.go │ ├── telemetry/ │ │ ├── telemetry.go │ │ └── telemetrydata/ │ │ └── telemetrydata.go │ ├── trimquotes/ │ │ └── trimquotes.go │ ├── tsgen/ │ │ ├── tsgen.go │ │ ├── tsgen_wshclientapi_test.go │ │ ├── tsgenevent.go │ │ ├── tsgenevent_test.go │ │ └── tsgenmeta/ │ │ └── tsgenmeta.go │ ├── tsunamiutil/ │ │ └── tsunamiutil.go │ ├── userinput/ │ │ └── userinput.go │ ├── util/ │ │ ├── daystr/ │ │ │ ├── daystr.go │ │ │ └── daystr_test.go │ │ ├── dbutil/ │ │ │ ├── dbmappable.go │ │ │ └── dbutil.go │ │ ├── ds/ │ │ │ ├── expmap.go │ │ │ ├── syncmap.go │ │ │ └── syncmap_test.go │ │ ├── envutil/ │ │ │ └── envutil.go │ │ ├── fileutil/ │ │ │ ├── fileutil.go │ │ │ ├── fileutil_test.go │ │ │ ├── mimetypes.go │ │ │ └── readdir.go │ │ ├── iochan/ │ │ │ ├── iochan.go │ │ │ ├── iochan_test.go │ │ │ └── iochantypes/ │ │ │ └── iochantypes.go │ │ ├── iterfn/ │ │ │ ├── iterfn.go │ │ │ └── iterfn_test.go │ │ ├── logutil/ │ │ │ └── logutil.go │ │ ├── logview/ │ │ │ ├── logview.go │ │ │ └── multibuf.go │ │ ├── migrateutil/ │ │ │ └── migrateutil.go │ │ ├── packetparser/ │ │ │ └── packetparser.go │ │ ├── pamparse/ │ │ │ ├── pamparse.go │ │ │ └── pamparse_test.go │ │ ├── readutil/ │ │ │ └── readutil.go │ │ ├── shellutil/ │ │ │ ├── shellintegration/ │ │ │ │ ├── bash_bashrc.sh │ │ │ │ ├── bash_preexec.sh │ │ │ │ ├── fish_wavefish.sh │ │ │ │ ├── pwsh_wavepwsh.sh │ │ │ │ ├── zsh_zlogin.sh │ │ │ │ ├── zsh_zprofile.sh │ │ │ │ ├── zsh_zshenv.sh │ │ │ │ └── zsh_zshrc.sh │ │ │ ├── shellquote.go │ │ │ ├── shellquote_test.go │ │ │ ├── shellutil.go │ │ │ └── tokenswap.go │ │ ├── sigutil/ │ │ │ ├── sigusr1_notwindows.go │ │ │ ├── sigusr1_windows.go │ │ │ └── sigutil.go │ │ ├── syncbuf/ │ │ │ └── syncbuf.go │ │ ├── unixutil/ │ │ │ ├── unixutil_unix.go │ │ │ └── unixutil_windows.go │ │ └── utilfn/ │ │ ├── compare.go │ │ ├── marshal.go │ │ ├── partial.go │ │ ├── partial_test.go │ │ ├── streamtolines.go │ │ └── utilfn.go │ ├── utilds/ │ │ ├── codederror.go │ │ ├── idlist.go │ │ ├── multireaderlinebuffer.go │ │ ├── quickreorderqueue.go │ │ ├── quickreorderqueue_test.go │ │ ├── readerlinebuffer.go │ │ ├── synccache.go │ │ ├── versionts.go │ │ └── workqueue.go │ ├── vdom/ │ │ ├── cssparser/ │ │ │ ├── cssparser.go │ │ │ └── cssparser_test.go │ │ ├── vdom.go │ │ ├── vdom_comp.go │ │ ├── vdom_html.go │ │ ├── vdom_root.go │ │ ├── vdom_test.go │ │ └── vdom_types.go │ ├── waveai/ │ │ ├── anthropicbackend.go │ │ ├── cloudbackend.go │ │ ├── googlebackend.go │ │ ├── openaibackend.go │ │ ├── perplexitybackend.go │ │ └── waveai.go │ ├── waveapp/ │ │ ├── streamingresp.go │ │ ├── waveapp.go │ │ └── waveappserverimpl.go │ ├── waveappstore/ │ │ └── waveappstore.go │ ├── waveapputil/ │ │ └── waveapputil.go │ ├── wavebase/ │ │ ├── wavebase-posix.go │ │ ├── wavebase-win.go │ │ └── wavebase.go │ ├── wavejwt/ │ │ └── wavejwt.go │ ├── waveobj/ │ │ ├── ctxupdate.go │ │ ├── metaconsts.go │ │ ├── metamap.go │ │ ├── objrtinfo.go │ │ ├── waveobj.go │ │ ├── wtype.go │ │ └── wtypemeta.go │ ├── wcloud/ │ │ ├── wcloud.go │ │ └── wclouddata.go │ ├── wconfig/ │ │ ├── defaultconfig/ │ │ │ ├── defaultconfig.go │ │ │ ├── mimetypes.json │ │ │ ├── presets/ │ │ │ │ └── ai.json │ │ │ ├── presets.json │ │ │ ├── settings.json │ │ │ ├── termthemes.json │ │ │ ├── waveai.json │ │ │ └── widgets.json │ │ ├── filewatcher.go │ │ ├── metaconsts.go │ │ └── settingsconfig.go │ ├── wcore/ │ │ ├── badge.go │ │ ├── block.go │ │ ├── layout.go │ │ ├── wcore.go │ │ ├── window.go │ │ └── workspace.go │ ├── web/ │ │ ├── sse/ │ │ │ └── ssehandler.go │ │ ├── web.go │ │ ├── webcmd/ │ │ │ └── webcmd.go │ │ ├── webvdomproto.go │ │ └── ws.go │ ├── wps/ │ │ ├── wps.go │ │ └── wpstypes.go │ ├── wshrpc/ │ │ ├── wshclient/ │ │ │ ├── barerpcclient.go │ │ │ ├── wshclient.go │ │ │ └── wshclientutil.go │ │ ├── wshremote/ │ │ │ ├── sysinfo.go │ │ │ ├── wshremote.go │ │ │ ├── wshremote_file.go │ │ │ └── wshremote_job.go │ │ ├── wshrpcmeta.go │ │ ├── wshrpcmeta_test.go │ │ ├── wshrpctypes.go │ │ ├── wshrpctypes_builder.go │ │ ├── wshrpctypes_const.go │ │ ├── wshrpctypes_file.go │ │ └── wshserver/ │ │ ├── resolvers.go │ │ ├── wshserver.go │ │ └── wshserverutil.go │ ├── wshutil/ │ │ ├── wshadapter.go │ │ ├── wshcmdreader.go │ │ ├── wshevent.go │ │ ├── wshproxy.go │ │ ├── wshrouter.go │ │ ├── wshrouter_controlimpl.go │ │ ├── wshrpc.go │ │ ├── wshrpcio.go │ │ ├── wshstreamadapter.go │ │ └── wshutil.go │ ├── wsl/ │ │ ├── wsl-unix.go │ │ └── wsl-win.go │ ├── wslconn/ │ │ ├── wsl-util.go │ │ └── wslconn.go │ └── wstore/ │ ├── wstore.go │ ├── wstore_dboldmigration.go │ ├── wstore_dbops.go │ ├── wstore_dbsetup.go │ └── wstore_rtinfo.go ├── postinstall.cjs ├── prettier.config.cjs ├── schema/ │ ├── aipresets.json │ ├── bgpresets.json │ ├── connections.json │ ├── settings.json │ ├── waveai.json │ └── widgets.json ├── staticcheck.conf ├── testdriver/ │ └── onboarding.yml ├── tests/ │ └── copytests/ │ ├── cases/ │ │ ├── test000.sh │ │ ├── test001.sh │ │ ├── test002.sh │ │ ├── test003.sh │ │ ├── test004.sh │ │ ├── test005.sh │ │ ├── test006.sh │ │ ├── test007.sh │ │ ├── test008.sh │ │ ├── test009.sh │ │ ├── test010.sh │ │ ├── test011.sh │ │ ├── test012.sh │ │ ├── test013.sh │ │ ├── test014.sh │ │ ├── test015.sh │ │ ├── test016.sh │ │ ├── test017.sh │ │ ├── test018.sh │ │ ├── test019.sh │ │ ├── test020.sh │ │ ├── test021.sh │ │ ├── test022.sh │ │ ├── test023.sh │ │ ├── test024.sh │ │ ├── test025.sh │ │ ├── test026.sh │ │ ├── test027.sh │ │ ├── test028.sh │ │ ├── test029.sh │ │ ├── test030.sh │ │ ├── test032.sh │ │ ├── test034.sh │ │ ├── test036.sh │ │ ├── test037.sh │ │ ├── test038.sh │ │ ├── test040.sh │ │ ├── test041.sh │ │ ├── test042.sh │ │ ├── test043.sh │ │ ├── test044.sh │ │ ├── test045.sh │ │ ├── test046.sh │ │ ├── test047.sh │ │ ├── test048.sh │ │ ├── test049.sh │ │ ├── test051.sh │ │ └── test052.sh │ ├── runner.sh │ └── testutil.sh ├── tsconfig.json ├── tsunami/ │ ├── .gitignore │ ├── app/ │ │ ├── atom.go │ │ ├── defaultclient.go │ │ └── hooks.go │ ├── build/ │ │ ├── build-ast.go │ │ ├── build.go │ │ └── buildutil.go │ ├── cmd/ │ │ └── main-tsunami.go │ ├── demo/ │ │ ├── .gitignore │ │ ├── cpuchart/ │ │ │ ├── app.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── static/ │ │ │ └── tw.css │ │ ├── githubaction/ │ │ │ ├── app.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── static/ │ │ │ └── tw.css │ │ ├── modaltest/ │ │ │ ├── app.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── static/ │ │ │ └── tw.css │ │ ├── pomodoro/ │ │ │ ├── app.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── static/ │ │ │ └── tw.css │ │ ├── recharts/ │ │ │ ├── app.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── static/ │ │ │ └── tw.css │ │ ├── tabletest/ │ │ │ ├── app.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── static/ │ │ │ └── tw.css │ │ ├── todo/ │ │ │ ├── app.go │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ ├── static/ │ │ │ │ └── tw.css │ │ │ └── style.css │ │ └── tsunamiconfig/ │ │ ├── app.go │ │ ├── go.mod │ │ ├── go.sum │ │ └── static/ │ │ └── tw.css │ ├── engine/ │ │ ├── asyncnotify.go │ │ ├── atomimpl.go │ │ ├── clientimpl.go │ │ ├── comp.go │ │ ├── errcomponent.go │ │ ├── globalctx.go │ │ ├── hooks.go │ │ ├── render.go │ │ ├── render.md │ │ ├── rootelem.go │ │ ├── schema.go │ │ └── serverhandlers.go │ ├── frontend/ │ │ ├── .gitignore │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app.tsx │ │ │ ├── element/ │ │ │ │ ├── markdown.tsx │ │ │ │ ├── modals.tsx │ │ │ │ └── tsunamiterm.tsx │ │ │ ├── input.tsx │ │ │ ├── main.tsx │ │ │ ├── model/ │ │ │ │ ├── model-utils.ts │ │ │ │ └── tsunami-model.tsx │ │ │ ├── recharts/ │ │ │ │ └── recharts.tsx │ │ │ ├── tailwind.css │ │ │ ├── types/ │ │ │ │ ├── custom.d.ts │ │ │ │ └── vdom.d.ts │ │ │ ├── util/ │ │ │ │ ├── base64.ts │ │ │ │ ├── clientid.ts │ │ │ │ ├── keyutil.ts │ │ │ │ └── platformutil.ts │ │ │ └── vdom.tsx │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── go.mod │ ├── go.sum │ ├── rpctypes/ │ │ └── protocoltypes.go │ ├── templates/ │ │ ├── app-init.go.tmpl │ │ ├── app-main.go.tmpl │ │ ├── empty-gomod.tmpl │ │ ├── gitignore.tmpl │ │ ├── package.json.tmpl │ │ └── tailwind.css │ ├── tsunamibase/ │ │ └── tsunamibase.go │ ├── ui/ │ │ └── table.go │ ├── util/ │ │ ├── compare.go │ │ ├── marshal.go │ │ ├── streamtolines.go │ │ └── util.go │ └── vdom/ │ ├── vdom.go │ ├── vdom_test.go │ └── vdom_types.go ├── version.cjs └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true [*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less,scss}] charset = utf-8 indent_style = space indent_size = 4 [CNAME] insert_final_newline = false ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf ================================================ FILE: .github/FUNDING.yml ================================================ github: wavetermdev ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yml ================================================ name: 🐞 Bug Report description: Create a bug report to help us improve. title: "[Bug]: " labels: ["bug", "triage"] body: - type: markdown attributes: value: | ## Bug description - type: textarea attributes: label: Current Behavior description: A concise description of what you're experiencing. validations: required: true - type: textarea attributes: label: Expected Behavior description: A concise description of what you expected to happen. validations: required: true - type: textarea attributes: label: Steps To Reproduce description: Steps to reproduce the behavior. placeholder: | 1. In this environment... 2. With this config... 3. Run '...' 4. See error... validations: required: true - type: markdown attributes: value: | ## Environment details We require that you provide us the version of Wave you're running so we can track issues across versions. To find the Wave version, go to the app menu (this always visible on macOS, for Windows and Linux, click the `...` button) and navigate to `Wave -> About Wave Terminal`. This will bring up the About modal. Copy the client version and paste it below. - type: input attributes: label: Wave Version description: The version of Wave you are running placeholder: v0.8.8 validations: required: true - type: dropdown attributes: label: Platform description: The OS platform of the computer where you are running Wave options: - macOS - Linux - Windows validations: required: true - type: input attributes: label: OS Version/Distribution description: The version of the operating system of the computer where you are running Wave placeholder: Ubuntu 24.04 validations: required: false - type: dropdown attributes: label: Architecture description: The architecture of the computer where you are running Wave options: - arm64 - x64 validations: required: true - type: markdown attributes: value: | ## Extra details - type: textarea attributes: label: Anything else? description: | Links? References? Anything that will give us more context about the issue you are encountering! Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: General Question url: https://github.com/wavetermdev/waveterm/discussions about: Have a question on something? Start a new discussion thread. - name: Engage with us directly on Discord url: https://discord.gg/XfvZ334gwU about: Join our Discord server to get updates on new features, bug fixes, and more. - name: Review open issues url: https://github.com/wavetermdev/waveterm/issues about: Please check if your issue isn't already there. ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request.yml ================================================ name: 🚀 Feature Request / Idea description: Suggest a new idea for this project. title: "[Feature]: " labels: ["enhancement", "triage"] body: - type: textarea attributes: label: Feature description description: Describe the issue in detail and why we should add it. To help us out, please poke through our issue tracker and make sure it's not a duplicate issue. Ex. As a user, I can do [...] validations: required: true - type: textarea attributes: label: Implementation Suggestion description: If you have any suggestions on how to design this feature, list them here. validations: required: false - type: textarea attributes: label: Anything else? description: | Links? References? Anything that will give us more context about how to deliver your feature! Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. validations: required: false ================================================ FILE: .github/copilot-instructions.md ================================================ # Wave Terminal — Copilot Instructions ## Project Rules - See the overview of the project in `.kilocode/rules/overview.md` - Read and follow all guidelines in `.kilocode/rules/rules.md` --- ## Skill Guides This project uses a set of "skill" guides — focused how-to documents for common implementation tasks. When your task matches one of the descriptions below, **read the linked SKILL.md file before proceeding** and follow its instructions precisely. | Skill | File | Description | | ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | add-config | `.kilocode/skills/add-config/SKILL.md` | Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. | | add-rpc | `.kilocode/skills/add-rpc/SKILL.md` | Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. | | add-wshcmd | `.kilocode/skills/add-wshcmd/SKILL.md` | Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. | | context-menu | `.kilocode/skills/context-menu/SKILL.md` | Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. | | create-view | `.kilocode/skills/create-view/SKILL.md` | Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. | | electron-api | `.kilocode/skills/electron-api/SKILL.md` | Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. | | waveenv | `.kilocode/skills/waveenv/SKILL.md` | Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. | | wps-events | `.kilocode/skills/wps-events/SKILL.md` | Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. | > **How skills work:** Each skill is a self-contained guide covering the exact files to edit, patterns to follow, and steps to take for a specific type of task in this codebase. If your task matches a skill's description, open that SKILL.md and treat it as your primary reference for the implementation. --- ## Preview Server To run the standalone component preview (no Electron, no backend required): ``` task preview ``` This runs `cd frontend/preview && npx vite` and serves at **http://localhost:7007** (port configured in `frontend/preview/vite.config.ts`). To build a static preview: `task build:preview` **Do NOT use any of the following to start the preview — they all launch the full Electron app or serve the wrong content:** - `npm run dev` — runs `electron-vite dev`, launches Electron - `npm run start` — also launches Electron - `npx vite` from the repo root — uses the Electron-Vite config, not the preview app - Serving the `dist/` directory — the preview app is never built there; it has its own build output ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" day: "friday" time: "09:00" timezone: "America/Los_Angeles" groups: dev-dependencies-patch: dependency-type: "development" exclude-patterns: - "*storybook*" - "*electron*" - "jotai" - "react" - "@types/react" - "*react-dom" - "*docusaurus*" update-types: - "patch" dev-dependencies-minor: dependency-type: "development" exclude-patterns: - "*storybook*" - "*electron*" - "jotai" - "react" - "@types/react" - "*react-dom" - "*docusaurus*" update-types: - "minor" prod-dependencies-patch: dependency-type: "production" exclude-patterns: - "*storybook*" - "*electron*" - "jotai" - "react" - "@types/react" - "*react-dom" - "*docusaurus*" update-types: - "patch" prod-dependencies-minor: dependency-type: "production" exclude-patterns: - "*storybook*" - "*electron*" - "jotai" - "react" - "@types/react" - "*react-dom" - "*docusaurus*" update-types: - "minor" storybook-patch: patterns: - "*storybook*" update-types: - "patch" storybook-minor: patterns: - "*storybook*" update-types: - "minor" storybook-major: patterns: - "*storybook*" update-types: - "major" electron-patch: patterns: - "*electron*" update-types: - "patch" electron-minor: patterns: - "*electron*" update-types: - "minor" electron-major: patterns: - "*electron*" update-types: - "major" docusaurus-patch: patterns: - "*docusaurus*" update-types: - "patch" docusaurus-minor: patterns: - "*docusaurus*" update-types: - "minor" docusaurus-major: patterns: - "*docusaurus*" update-types: - "major" react-patch: patterns: - "react" - "@types/react" - "*react-dom" update-types: - "patch" react-minor: patterns: - "react" - "@types/react" - "*react-dom" update-types: - "minor" react-major: patterns: - "react" - "@types/react" - "*react-dom" update-types: - "major" jotai-patch: patterns: - "jotai" update-types: - "patch" jotai-minor: patterns: - "jotai" update-types: - "minor" jotai-major: patterns: - "jotai" update-types: - "major" - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" day: "friday" time: "09:00" timezone: "America/Los_Angeles" - package-ecosystem: "github-actions" directory: "/.github/workflows" schedule: interval: "weekly" day: "friday" time: "09:00" timezone: "America/Los_Angeles" ================================================ FILE: .github/workflows/build-helper.yml ================================================ # Build Helper workflow - Builds, signs, and packages binaries for each supported platform, then uploads to a staging bucket in S3 for wider distribution. # For more information on the macOS signing and notarization, see https://www.electron.build/code-signing and https://www.electron.build/configuration/mac # For more information on the Windows Code Signing, see https://docs.digicert.com/en/digicert-keylocker/ci-cd-integrations/plugins/github-custom-action-for-keypair-signing.html and https://docs.digicert.com/en/digicert-keylocker/signing-tools/sign-authenticode-with-electron-builder-using-ksp-integration.html name: Build Helper run-name: Build ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' - Manual' || '' }} on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+*" workflow_dispatch: env: GO_VERSION: "1.25.6" NODE_VERSION: 22 NODE_OPTIONS: --max-old-space-size=4096 jobs: build-app: outputs: version: ${{ steps.set-version.outputs.WAVETERM_VERSION }} strategy: matrix: include: - platform: "darwin" runner: "macos-latest" - platform: "linux" runner: "ubuntu-latest" - platform: "linux" runner: ubuntu-24.04-arm - platform: "windows" runner: "windows-latest" # - platform: "windows" # runner: "windows-11-arm64-16core" runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v6 - name: Install Linux Build Dependencies (Linux only) if: matrix.platform == 'linux' run: | sudo apt-get update sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm squashfs-tools sudo snap install snapcraft --classic sudo snap install lxd sudo lxd init --auto sudo snap refresh - name: Install Zig (not Mac) if: matrix.platform != 'darwin' uses: mlugg/setup-zig@v2 # The pre-installed version of the AWS CLI has a segfault problem so we'll install it via Homebrew instead. - name: Upgrade AWS CLI (Mac only) if: matrix.platform == 'darwin' run: brew install awscli # The version of FPM that comes bundled with electron-builder doesn't include a Linux ARM target. Installing Gems onto the runner is super quick so we'll just do this for all targets. - name: Install FPM (not Windows) if: matrix.platform != 'windows' run: sudo gem install fpm - name: Install FPM (Windows only) if: matrix.platform == 'windows' run: gem install fpm # General build dependencies - uses: actions/setup-go@v6 with: go-version: ${{env.GO_VERSION}} cache-dependency-path: | go.sum - uses: actions/setup-node@v6 with: node-version: ${{env.NODE_VERSION}} cache: npm cache-dependency-path: package-lock.json - name: Force git deps to HTTPS run: | git config --global url.https://github.com/.insteadof ssh://git@github.com/ git config --global url.https://github.com/.insteadof git@github.com: - uses: nick-fields/retry@v3 name: npm ci with: command: npm ci --no-audit --no-fund retry_on: error max_attempts: 3 timeout_minutes: 5 env: GIT_ASKPASS: "echo" GIT_TERMINAL_PROMPT: "0" - name: Install Task uses: arduino/setup-task@v2 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - name: "Set Version" id: set-version run: echo "WAVETERM_VERSION=$(task version)" >> "$GITHUB_OUTPUT" shell: bash # Windows Code Signing Setup - name: Set up certificate (Windows only) if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' run: | echo "${{ secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 shell: bash - name: Set signing variables (Windows only) if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' id: variables run: | echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" echo "SM_CODE_SIGNING_CERT_SHA1_HASH=${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" >> "$GITHUB_ENV" echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_OUTPUT" echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH echo "C:\Program Files\DigiCert\DigiCert Keylocker Tools" >> $GITHUB_PATH shell: bash - name: Setup Keylocker KSP (Windows only) if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' run: | curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o Keylockertools-windows-x64.msi msiexec /i Keylockertools-windows-x64.msi /quiet /qn C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user smctl windows certsync shell: cmd # Build and upload packages - name: Build (Linux) if: matrix.platform == 'linux' run: task package env: USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. SNAPCRAFT_BUILD_ENVIRONMENT: host # Retry Darwin build in case of notarization failures - uses: nick-fields/retry@v3 name: Build (Darwin) if: matrix.platform == 'darwin' with: command: task package timeout_minutes: 120 retry_on: error max_attempts: 3 env: USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_2}} CSC_KEY_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_PWD_2 }} APPLE_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_APPLE_ID_2 }} APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_PWD_2 }} APPLE_TEAM_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_TEAM_ID_2 }} STATIC_DOCSITE_PATH: ${{env.STATIC_DOCSITE_PATH}} - name: Build (Windows) if: matrix.platform == 'windows' run: task package env: USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. CSC_LINK: ${{ steps.variables.outputs.SM_CLIENT_CERT_FILE }} CSC_KEY_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} STATIC_DOCSITE_PATH: ${{env.STATIC_DOCSITE_PATH}} shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell # Upload artifacts to the S3 staging and to the workflow output for the draft release job - name: Upload to S3 staging if: github.event_name != 'workflow_dispatch' run: task artifacts:upload env: AWS_ACCESS_KEY_ID: "${{ secrets.ARTIFACTS_KEY_ID }}" AWS_SECRET_ACCESS_KEY: "${{ secrets.ARTIFACTS_KEY_SECRET }}" AWS_DEFAULT_REGION: us-west-2 - name: Upload artifacts uses: actions/upload-artifact@v5 with: name: ${{ matrix.runner }} path: make - name: Upload Snapcraft logs on failure if: failure() uses: actions/upload-artifact@v5 with: name: ${{ matrix.runner }}-log path: /home/runner/.local/state/snapcraft/log create-release: runs-on: ubuntu-latest needs: build-app permissions: contents: write if: ${{ github.event_name != 'workflow_dispatch' }} steps: - name: Download artifacts uses: actions/download-artifact@v4 with: path: make merge-multiple: true - name: Create draft release uses: softprops/action-gh-release@v2 with: prerelease: ${{ contains(github.ref_name, '-beta') }} name: Wave Terminal ${{ github.ref_name }} Release generate_release_notes: true draft: true files: | make/*.zip make/*.dmg make/*.exe make/*.msi make/*.rpm make/*.deb make/*.pacman make/*.snap make/*.flatpak make/*.AppImage ================================================ FILE: .github/workflows/bump-version.yml ================================================ # Workflow to manage bumping the package version and pushing it to the target branch with a new tag. # This workflow uses a GitHub App to bypass branch protection and uses the GitHub API directly to ensure commits and tags are signed. # For more information, see this doc: https://github.com/Nautilus-Cyberneering/pygithub/blob/main/docs/how_to_sign_automatic_commits_in_github_actions.md name: Bump Version run-name: "branch: ${{ github.ref_name }}; semver-bump: ${{ inputs.bump }}; prerelease: ${{ inputs.is-prerelease }}" on: workflow_dispatch: inputs: bump: description: SemVer Bump required: true type: choice default: none options: - none - patch - minor - major is-prerelease: description: Is Prerelease required: true type: boolean default: true env: NODE_VERSION: 22 jobs: bump-version: runs-on: ubuntu-latest steps: - name: Get App Token uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.WAVE_BUILDER_APPID }} private-key: ${{ secrets.WAVE_BUILDER_KEY }} - uses: actions/checkout@v6 with: token: ${{ steps.app-token.outputs.token }} # General build dependencies - uses: actions/setup-node@v6 with: node-version: ${{env.NODE_VERSION}} cache: npm cache-dependency-path: package-lock.json - uses: nick-fields/retry@v3 name: npm ci with: command: npm ci --no-audit --no-fund retry_on: error max_attempts: 3 timeout_minutes: 5 - name: Install Task uses: arduino/setup-task@v2 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - name: "Bump Version: ${{ inputs.bump }}" id: bump-version run: echo "WAVETERM_VERSION=$( task version -- ${{ inputs.bump }} ${{inputs.is-prerelease}} )" >> "$GITHUB_OUTPUT" shell: bash - name: "Push version bump: ${{ steps.bump-version.outputs.WAVETERM_VERSION }}" if: github.ref_protected run: | # Create a new commit for the package version bump in package.json export VERSION=${{ steps.bump-version.outputs.WAVETERM_VERSION }} export MESSAGE="chore: bump package version to $VERSION" export FILE=package.json export BRANCH=${{github.ref_name}} export SHA=$( git rev-parse $BRANCH:$FILE ) export CONTENT=$( base64 -i $FILE ) gh api --method PUT /repos/:owner/:repo/contents/$FILE \ --field branch="$BRANCH" \ --field message="$MESSAGE" \ --field content="$CONTENT" \ --field sha="$SHA" # Fetch the new commit and create a tag referencing it git fetch export TAG_SHA=$( git rev-parse origin/$BRANCH ) gh api --method POST /repos/:owner/:repo/git/refs \ --field ref="refs/tags/v$VERSION" \ --field sha="$TAG_SHA" shell: bash env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ================================================ FILE: .github/workflows/codeql.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: ["main"] paths: - "**/*.go" - "**/*.ts" - "**/*.tsx" pull_request: branches: ["main"] paths: - "**/*.go" - "**/*.ts" - "**/*.tsx" types: - opened - synchronize - reopened - ready_for_review schedule: - cron: "36 5 * * 5" env: NODE_VERSION: 22 GO_VERSION: "1.25.6" jobs: analyze: name: Analyze # Runner size impacts CodeQL analysis time. To learn more, please see: # - https://gh.io/recommended-hardware-resources-for-running-codeql # - https://gh.io/supported-runners-and-hardware-resources # - https://gh.io/using-larger-runners # Consider using larger runners for possible analysis time improvements. if: github.event.pull_request.draft == false runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: ["go", "javascript-typescript"] # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Task uses: arduino/setup-task@v2 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: actions/setup-node@v6 with: node-version: ${{env.NODE_VERSION}} cache: npm cache-dependency-path: package-lock.json - uses: nick-fields/retry@v3 name: npm ci with: command: npm ci --no-audit --no-fund retry_on: error max_attempts: 3 timeout_minutes: 5 - name: Setup Go uses: actions/setup-go@v6 with: go-version: ${{env.GO_VERSION}} cache-dependency-path: | go.sum # We use Zig instead of glibc for cgo compilation as it is more-easily statically linked - name: Setup Zig run: sudo snap install zig --classic --beta # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - name: Generate bindings run: task generate # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild (not Go) if: matrix.language != 'go' uses: github/codeql-action/autobuild@v4 - name: Build (Go only) if: matrix.language == 'go' run: | task build:server task build:wsh # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" ================================================ FILE: .github/workflows/copilot-setup-steps.yml ================================================ name: Copilot Setup Steps on: workflow_dispatch: push: paths: [.github/workflows/copilot-setup-steps.yml] pull_request: paths: [.github/workflows/copilot-setup-steps.yml] # Note: global env vars are NOT used here — they are not reliable in all # GitHub Actions contexts (e.g. Copilot setup steps). Values are inlined # directly into each step that needs them. jobs: copilot-setup-steps: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v6 # Go + Node versions match your helper - uses: actions/setup-go@v6 with: go-version: "1.25.6" cache-dependency-path: go.sum - uses: actions/setup-node@v6 with: node-version: 22 cache: npm cache-dependency-path: package-lock.json # Zig is used by your Linux CGO builds (kept available, but we won't build here) - uses: mlugg/setup-zig@v2 # Task CLI for your Taskfile - uses: arduino/setup-task@v2 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} # Git HTTPS so deps resolve non-interactively - name: Force git deps to HTTPS run: | git config --global url.https://github.com/.insteadof ssh://git@github.com/ git config --global url.https://github.com/.insteadof git@github.com: # Warm caches only (no builds) - uses: nick-fields/retry@v3 name: npm ci with: command: npm ci --no-audit --no-fund retry_on: error max_attempts: 3 timeout_minutes: 5 env: GIT_ASKPASS: "echo" GIT_TERMINAL_PROMPT: "0" - name: Pre-fetch Go modules env: GOTOOLCHAIN: auto run: | go version go mod download ================================================ FILE: .github/workflows/deploy-docsite.yml ================================================ name: Docsite CI/CD run-name: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'Build and Deploy' || 'Test Build' }} Docsite env: NODE_VERSION: 22 on: push: branches: - main workflow_dispatch: # Also run any time a PR is opened targeting the docs pull_request: branches: - main types: - opened - synchronize - reopened - ready_for_review paths: - "docs/**" - ".github/workflows/deploy-docsite.yml" - "Taskfile.yml" jobs: build: name: Build Docsite runs-on: ubuntu-latest if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: ${{env.NODE_VERSION}} cache: npm cache-dependency-path: package-lock.json - name: Install Task uses: arduino/setup-task@v2 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: nick-fields/retry@v3 name: npm ci with: command: npm ci --no-audit --no-fund retry_on: error max_attempts: 3 timeout_minutes: 5 - name: Build docsite run: task docsite:build:public - name: Upload Build Artifact # Only upload the build artifact when pushed to the main branch if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-pages-artifact@v3 with: path: docs/build deploy: name: Deploy to GitHub Pages # Only deploy when pushed to the main branch if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: build # Grant GITHUB_TOKEN the permissions required to make a Pages deployment permissions: pages: write # to deploy to Pages id-token: write # to verify the deployment originates from an appropriate source # Deploy to the github-pages environment environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/merge-gatekeeper.yml ================================================ --- name: Merge Gatekeeper on: pull_request_target: branches: - main - master types: - opened - synchronize - reopened - ready_for_review jobs: merge-gatekeeper: runs-on: ubuntu-latest if: github.event.pull_request.draft == false # Restrict permissions of the GITHUB_TOKEN. # Docs: https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs permissions: checks: read statuses: read steps: - name: Run Merge Gatekeeper # NOTE: v1 is updated to reflect the latest v1.x.y. Please use any tag/branch that suits your needs: # https://github.com/upsidr/merge-gatekeeper/tags # https://github.com/upsidr/merge-gatekeeper/branches uses: upsidr/merge-gatekeeper@v1 with: token: ${{ secrets.GITHUB_TOKEN }} ignored: Build for TestDriver.ai, TestDriver.ai Run, Analyze (go), Analyze (javascript-typescript), License Compliance, CodeRabbit ================================================ FILE: .github/workflows/publish-release.yml ================================================ # Workflow to copy artifacts from the staging bucket to the release bucket when a new GitHub Release is published. name: Publish Release run-name: Publish ${{ github.ref_name }} on: release: types: [published] jobs: publish-s3: name: Publish to Releases if: ${{ startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Task uses: arduino/setup-task@v2 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Publish from staging run: "task artifacts:publish:${{ github.ref_name }}" env: AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}" AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}" AWS_DEFAULT_REGION: us-west-2 shell: bash publish-snap-amd64: name: Publish AMD64 Snap if: ${{ startsWith(github.ref, 'refs/tags/') }} needs: [publish-s3] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Task uses: arduino/setup-task@v2 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Install Snapcraft run: sudo snap install snapcraft --classic shell: bash - name: Download Snap from Release uses: robinraju/release-downloader@v1 with: tag: ${{github.ref_name}} fileName: "*amd64.snap" - name: Publish to Snapcraft run: "task artifacts:snap:publish:${{ github.ref_name }}" env: SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" shell: bash publish-snap-arm64: name: Publish ARM64 Snap if: ${{ startsWith(github.ref, 'refs/tags/') }} needs: [publish-s3] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Task uses: arduino/setup-task@v2 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Install Snapcraft run: sudo snap install snapcraft --classic shell: bash - name: Download Snap from Release uses: robinraju/release-downloader@v1 with: tag: ${{github.ref_name}} fileName: "*arm64.snap" - name: Publish to Snapcraft run: "task artifacts:snap:publish:${{ github.ref_name }}" env: SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" shell: bash bump-winget: name: Submit WinGet PR if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }} needs: [publish-s3] runs-on: windows-latest steps: - uses: actions/checkout@v6 - name: Install Task uses: arduino/setup-task@v2 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Install wingetcreate run: winget install -e --silent --accept-package-agreements --accept-source-agreements wingetcreate shell: pwsh - name: Submit WinGet version bump run: "task artifacts:winget:publish:${{ github.ref_name }}" env: GITHUB_TOKEN: ${{ secrets.WINGET_BUMP_PAT }} shell: pwsh ================================================ FILE: .github/workflows/testdriver-build.yml ================================================ name: TestDriver.ai Build on: push: branches: - main tags: - "v[0-9]+.[0-9]+.[0-9]+*" pull_request: # branches: # - main # paths-ignore: # - "docs/**" # - ".storybook/**" # - ".vscode/**" # - ".editorconfig" # - ".gitignore" # - ".prettierrc" # - ".eslintrc.js" # - "**/*.md" types: - opened - synchronize - reopened - ready_for_review schedule: - cron: 0 21 * * * workflow_dispatch: null env: GO_VERSION: "1.25.6" NODE_VERSION: 22 permissions: contents: read # To allow the action to read repository contents pull-requests: write # To allow the action to create/update pull request comments jobs: build_and_upload: name: Build for TestDriver.ai runs-on: windows-latest if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v6 # General build dependencies - uses: actions/setup-go@v6 with: go-version: ${{env.GO_VERSION}} - uses: actions/setup-node@v6 with: node-version: ${{env.NODE_VERSION}} cache: npm cache-dependency-path: package-lock.json - uses: nick-fields/retry@v3 name: npm ci with: command: npm ci --no-audit --no-fund retry_on: error max_attempts: 3 timeout_minutes: 5 - name: Install Task uses: arduino/setup-task@v2 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Install Zig uses: mlugg/setup-zig@v2 - name: Build run: task package env: USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. CSC_IDENTITY_AUTO_DISCOVERY: false # disable codesign shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell # Upload .exe as an artifact - name: Upload .exe artifact id: upload uses: actions/upload-artifact@v5 with: name: windows-exe path: make/*.exe ================================================ FILE: .github/workflows/testdriver.yml ================================================ name: TestDriver.ai Run on: workflow_run: workflows: ["TestDriver.ai Build"] types: - completed env: GO_VERSION: "1.25.6" NODE_VERSION: 22 permissions: contents: read statuses: write jobs: context: runs-on: ubuntu-22.04 steps: - name: Dump GitHub context env: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - name: Dump job context env: JOB_CONTEXT: ${{ toJson(job) }} run: echo "$JOB_CONTEXT" - name: Dump steps context env: STEPS_CONTEXT: ${{ toJson(steps) }} run: echo "$STEPS_CONTEXT" - name: Dump runner context env: RUNNER_CONTEXT: ${{ toJson(runner) }} run: echo "$RUNNER_CONTEXT" - name: Dump strategy context env: STRATEGY_CONTEXT: ${{ toJson(strategy) }} run: echo "$STRATEGY_CONTEXT" - name: Dump matrix context env: MATRIX_CONTEXT: ${{ toJson(matrix) }} run: echo "$MATRIX_CONTEXT" run_testdriver: name: Run TestDriver.ai runs-on: windows-latest if: github.event.workflow_run.conclusion == 'success' steps: - uses: testdriverai/action@main id: testdriver env: FORCE_COLOR: "3" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: key: ${{ secrets.DASHCAM_API }} prerun: | $headers = @{ Authorization = "token ${{ secrets.GITHUB_TOKEN }}" } $downloadFolder = "./download" $artifactFileName = "waveterm.exe" $artifactFilePath = "$downloadFolder/$artifactFileName" Write-Host "Starting the artifact download process..." # Create the download directory if it doesn't exist if (-not (Test-Path -Path $downloadFolder)) { Write-Host "Creating download folder..." mkdir $downloadFolder } else { Write-Host "Download folder already exists." } # Fetch the artifact upload URL Write-Host "Fetching the artifact upload URL..." $artifactUrl = (Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}/artifacts" -Headers $headers).artifacts[0].archive_download_url if ($artifactUrl) { Write-Host "Artifact URL successfully fetched: $artifactUrl" } else { Write-Error "Failed to fetch the artifact URL." exit 1 } # Download the artifact (zipped file) Write-Host "Starting artifact download..." $artifactZipPath = "$env:TEMP\artifact.zip" try { Invoke-WebRequest -Uri $artifactUrl ` -Headers $headers ` -OutFile $artifactZipPath ` -MaximumRedirection 5 Write-Host "Artifact downloaded successfully to $artifactZipPath" } catch { Write-Error "Error downloading artifact: $_" exit 1 } # Unzip the artifact $artifactUnzipPath = "$env:TEMP\artifact" Write-Host "Unzipping the artifact to $artifactUnzipPath..." try { Expand-Archive -Path $artifactZipPath -DestinationPath $artifactUnzipPath -Force Write-Host "Artifact unzipped successfully to $artifactUnzipPath" } catch { Write-Error "Failed to unzip the artifact: $_" exit 1 } # Find the installer or app executable $artifactInstallerPath = Get-ChildItem -Path $artifactUnzipPath -Filter *.exe -Recurse | Select-Object -First 1 if ($artifactInstallerPath) { Write-Host "Executable file found: $($artifactInstallerPath.FullName)" } else { Write-Error "Executable file not found. Exiting." exit 1 } # Run the installer and log the result Write-Host "Running the installer: $($artifactInstallerPath.FullName)..." try { Start-Process -FilePath $artifactInstallerPath.FullName -Wait Write-Host "Installer ran successfully." } catch { Write-Error "Failed to run the installer: $_" exit 1 } # Optional: If the app executable is different from the installer, find and launch it $wavePath = Join-Path $env:USERPROFILE "AppData\Local\Programs\waveterm\Wave.exe" Write-Host "Launching the application: $($wavePath)" Start-Process -FilePath $wavePath Write-Host "Application launched." prompt: | 1. /run testdriver/onboarding.yml ================================================ FILE: .gitignore ================================================ .task frontend/dist dist/ dist-dev/ frontend/node_modules node_modules/ frontend/bindings bindings/ *.log bin/ *.dmg *.exe .DS_Store *~ out/ make/ artifacts/ mikework/ aiplans/ manifests/ .env out # Yarn Modern .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/sdks !.yarn/versions *storybook.log storybook-static/ test-results.xml docsite/ .kilo-format-temp-* ================================================ FILE: .golangci.yml ================================================ version: 2 linters: disable: - unused issues: exclude-rules: - linters: - unused text: "unused parameter" ================================================ FILE: .kilocode/rules/overview.md ================================================ # Wave Terminal - High Level Architecture Overview ## Project Description Wave Terminal is an open-source AI-native terminal built for seamless workflows. It's an Electron application that serves as a command line terminal host (it hosts CLI applications rather than running inside a CLI). The application combines a React frontend with a Go backend server to provide a modern terminal experience with advanced features. ## Top-Level Directory Structure ``` waveterm/ ├── emain/ # Electron main process code ├── frontend/ # React application (renderer process) ├── cmd/ # Go command-line applications ├── pkg/ # Go packages/modules ├── db/ # Database migrations ├── docs/ # Documentation (Docusaurus) ├── build/ # Build configuration and assets ├── assets/ # Application assets (icons, images) ├── public/ # Static public assets ├── tests/ # Test files ├── .github/ # GitHub workflows and configuration └── Configuration files (package.json, tsconfig.json, etc.) ``` ## Architecture Components ### 1. Electron Main Process (`emain/`) The Electron main process handles the native desktop application layer: **Key Files:** - [`emain.ts`](emain/emain.ts) - Main entry point, application lifecycle management - [`emain-window.ts`](emain/emain-window.ts) - Window management (`WaveBrowserWindow` class) - [`emain-tabview.ts`](emain/emain-tabview.ts) - Tab view management (`WaveTabView` class) - [`emain-wavesrv.ts`](emain/emain-wavesrv.ts) - Go backend server integration - [`emain-wsh.ts`](emain/emain-wsh.ts) - WSH (Wave Shell) client integration - [`emain-ipc.ts`](emain/emain-ipc.ts) - IPC handlers for frontend ↔ main process communication - [`emain-menu.ts`](emain/emain-menu.ts) - Application menu system - [`updater.ts`](emain/updater.ts) - Auto-update functionality - [`preload.ts`](emain/preload.ts) - Preload script for renderer security - [`preload-webview.ts`](emain/preload-webview.ts) - Webview preload script ### 2. Frontend React Application (`frontend/`) The React application runs in the Electron renderer process: **Structure:** ``` frontend/ ├── app/ # Main application code │ ├── app.tsx # Root App component │ ├── aipanel/ # AI panel UI │ ├── block/ # Block-based UI components │ ├── element/ # Reusable UI elements │ ├── hook/ # Custom React hooks │ ├── modals/ # Modal components │ ├── store/ # State management (Jotai) │ ├── tab/ # Tab components │ ├── view/ # Different view types │ │ ├── codeeditor/ # Code editor (Monaco) │ │ ├── preview/ # File preview │ │ ├── sysinfo/ # System info view │ │ ├── term/ # Terminal view │ │ ├── tsunami/ # Tsunami builder view │ │ ├── vdom/ # Virtual DOM view │ │ ├── waveai/ # AI chat integration │ │ ├── waveconfig/ # Config editor view │ │ └── webview/ # Web view │ └── workspace/ # Workspace management ├── builder/ # Builder app entry ├── layout/ # Layout system ├── preview/ # Standalone preview renderer ├── types/ # TypeScript type definitions └── util/ # Utility functions ``` **Key Technologies:** - Electron (desktop application shell) - React 19 with TypeScript - Jotai for state management - Monaco Editor for code editing - XTerm.js for terminal emulation - Tailwind CSS v4 for styling - SCSS for additional styling (deprecated, new components should use Tailwind) - Vite / electron-vite for bundling - Task (Taskfile.yml) for build and code generation commands ### 3. Go Backend Server (`cmd/server/`) The Go backend server handles all heavy lifting operations: **Entry Point:** [`main-server.go`](cmd/server/main-server.go) ### 4. Go Packages (`pkg/`) The Go codebase is organized into modular packages: **Key Packages:** - `wstore/` - Database and storage layer - `wconfig/` - Configuration management - `wcore/` - Core business logic - `wshrpc/` - RPC communication system - `wshutil/` - WSH (Wave Shell) utilities - `blockcontroller/` - Block execution management - `remote/` - Remote connection handling - `filestore/` - File storage system - `web/` - Web server and WebSocket handling - `telemetry/` - Usage analytics and telemetry - `waveobj/` - Core data objects - `service/` - Service layer - `wps/` - Wave PubSub event system - `waveai/` - AI functionality - `shellexec/` - Shell execution - `util/` - Common utilities ### 5. Command Line Tools (`cmd/`) Key Go command-line utilities: - `wsh/` - Wave Shell command-line tool - `server/` - Main backend server - `generatego/` - Code generation - `generateschema/` - Schema generation - `generatets/` - TypeScript generation ## Communication Architecture The core communication system is built around the **WSH RPC (Wave Shell RPC)** system, which provides a unified interface for all inter-process communication: frontend ↔ Go backend, Electron main process ↔ backend, and backend ↔ remote systems (SSH, WSL). ### WSH RPC System (`pkg/wshrpc/`) The WSH RPC system is the backbone of Wave Terminal's communication architecture: **Key Components:** - [`wshrpctypes.go`](pkg/wshrpc/wshrpctypes.go) - Core RPC interface and type definitions (source of truth for all RPC commands) - [`wshserver/`](pkg/wshrpc/wshserver/) - Server-side RPC implementation - [`wshremote/`](pkg/wshrpc/wshremote/) - Remote connection handling - [`wshclient.go`](pkg/wshrpc/wshclient.go) - Go client for making RPC calls - [`frontend/app/store/wshclientapi.ts`](frontend/app/store/wshclientapi.ts) - Generated TypeScript RPC client **Routing:** Callers address RPC calls using _routes_ (e.g. a block ID, connection name, or `"waveapp"`) rather than caring about the underlying transport. The RPC layer resolves the route to the correct transport (WebSocket, Unix socket, SSH tunnel, stdio) automatically. This means the same RPC interface works whether the target is local or a remote SSH connection. ## Development Notes - **Build commands** - Use `task` (Taskfile.yml) for all build, generate, and packaging commands - **Code generation** - Run `task generate` after modifying Go types in `pkg/wshrpc/wshrpctypes.go`, `pkg/wconfig/settingsconfig.go`, or `pkg/waveobj/wtypemeta.go` - **Testing** - Vitest for frontend unit tests; standard `go test` for Go packages - **Database migrations** - SQL migration files in `db/migrations-wstore/` and `db/migrations-filestore/` - **Documentation** - Docusaurus site in `docs/` ================================================ FILE: .kilocode/rules/rules.md ================================================ Wave Terminal is a modern terminal which provides graphical blocks, dynamic layout, workspaces, and SSH connection management. It is cross platform and built on electron. ### Project Structure It has a TypeScript/React frontend and a Go backend. They talk together over `wshrpc` a custom RPC protocol that is implemented over websocket (and domain sockets). ### Coding Guidelines - **Go Conventions**: - Don't use custom enum types in Go. Instead, use string constants (e.g., `const StatusRunning = "running"` rather than creating a custom type like `type Status string`). - Use string constants for status values, packet types, and other string-based enumerations. - in Go code, prefer using Printf() vs Println() - use "Make" as opposed to "New" for struct initialization func names - in general const decls go at the top of the file (before types and functions) - NEVER run `go build` (especially in weird sub-package directories). we can tell if everything compiles by seeing there are no problems/errors. - **Synchronization**: - Always prefer to use the `lock.Lock(); defer lock.Unlock()` pattern for synchronization if possible - Avoid inline lock/unlock pairs - instead create helper functions that use the defer pattern - When accessing shared data structures (maps, slices, etc.), ensure proper locking - Example: Instead of `gc.lock.Lock(); gc.map[key]++; gc.lock.Unlock()`, create a helper function like `getNextValue(key string) int { gc.lock.Lock(); defer gc.lock.Unlock(); gc.map[key]++; return gc.map[key] }` - **TypeScript Imports**: - Use `@/...` for imports from different parts of the project (configured in `tsconfig.json` as `"@/*": ["frontend/*"]`). - Prefer relative imports (`"./name"`) only within the same directory. - Use named exports exclusively; avoid default exports. It's acceptable to export functions directly (e.g., React Components). - Our indent is 4 spaces - **JSON Field Naming**: All fields must be lowercase, without underscores. - **TypeScript Conventions** - **Type Handling**: - In TypeScript we have strict null checks off, so no need to add "| null" to all the types. - In TypeScript for Jotai atoms, if we want to write, we need to type the atom as a PrimitiveAtom - Jotai has a bug with strict null checks off where if you create a null atom, e.g. atom(null) it does not "type" correctly. That's no issue, just cast it to the proper PrimitiveAtom type (no "| null") and it will work fine. - Generally never use "=== undefined" or "!== undefined". This is bad style. Just use a "== null" or "!= null" unless it is a very specific case where we need to distinguish undefined from null. - **Coding Style**: - Use all lowercase filenames (except where case is actually important like Taskfile.yml) - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) - For element variants use class-variance-authority - Do NOT create private fields in classes (they are impossible to inspect) - Use PascalCase for global consts at the top of files - **Component Practices**: - Make sure to add cursor-pointer to buttons/links and clickable items - NEVER use cursor-help (it looks terrible) - useAtom() and useAtomValue() are react HOOKS, so they must be called at the component level not inline in JSX - If you use React.memo(), make sure to add a displayName for the component - Other - never use atob() or btoa() (not UTF-8 safe). use functions in frontend/util/util.ts for base64 decoding and encoding - In general, when writing functions, we prefer _early returns_ rather than putting the majority of a function inside of an if block. ### Styling - We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css - _never_ use cursor-help, or cursor-not-allowed (it looks terrible) - We have custom CSS setup as well, so it is a hybrid system. For new code we prefer tailwind, and are working to migrate code to all use tailwind. - For accent buttons, use "bg-accent/80 text-primary rounded hover:bg-accent transition-colors cursor-pointer" (if you do "bg-accent hover:bg-accent/80" it looks weird as on hover the button gets darker instead of lighter) ### RPC System To define a new RPC call, add the new definition to `pkg/wshrpc/wshrpctypes.go` including any input/output data that is required. After modifying wshrpctypes.go run `task generate` to generate the client APIs. For normal "server" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`. ### Electron API From within the FE to get the electron API (e.g. the preload functions): ```ts import { getApi } from "@/store/global"; getApi().getIsDev(); ``` The full API is defined in custom.d.ts as type ElectronApi. ### Code Generation - **TypeScript Types**: TypeScript types are automatically generated from Go types. After modifying Go types in `pkg/wshrpc/wshrpctypes.go`, run `task generate` to update the TypeScript type definitions in `frontend/types/gotypes.d.ts`. - **Manual Edits**: Do not manually edit generated files like `frontend/types/gotypes.d.ts` or `frontend/app/store/wshclientapi.ts`. Instead, modify the source Go types and run `task generate`. ### Frontend Architecture - The application uses Jotai for state management. - When working with Jotai atoms that need to be updated, define them as `PrimitiveAtom` rather than just `atom`. ### Notes - **CRITICAL: Completion format MUST be: "Done: [one-line description]"** - **Keep your Task Completed summaries VERY short** - **No lengthy pre-completion summaries** - Do not provide detailed explanations of implementation before using attempt_completion - **No recaps of changes** - Skip explaining what was done before completion - **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing - The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes - With React hooks, always complete all hook calls at the top level before any conditional returns (including jotai hook calls useAtom and useAtomValue); when a user explicitly tells you a function handles null inputs, trust them and stop trying to "protect" it with unnecessary checks or workarounds. - **Match response length to question complexity** - For simple, direct questions in Ask mode (especially those that can be answered in 1-2 sentences), provide equally brief answers. Save detailed explanations for complex topics or when explicitly requested. - **CRITICAL** - useAtomValue and useAtom are React HOOKS. They cannot be used inline in JSX code, they must appear at the top of a component in the hooks area of the react code. - for simple functions, we prefer `if (!cond) { return }; functionality;` pattern over `if (cond) { functionality }` because it produces less indentation and is easier to follow. - It is now 2026, so if you write new files, or update files use 2026 for the copyright year - React.MutableRefObject is deprecated, just use React.RefObject now (in React 19 RefObject is always mutable) ### Strict Comment Rules - **NEVER add comments that merely describe what code is doing**: - ❌ `mutex.Lock() // Lock the mutex` - ❌ `counter++ // Increment the counter` - ❌ `buffer.Write(data) // Write data to buffer` - ❌ `// Header component for app run list` (above AppRunListHeader) - ❌ `// Updated function to include onClick parameter` - ❌ `// Changed padding calculation` - ❌ `// Removed unnecessary div` - ❌ `// Using the model's width value here` - **Only use comments for**: - Explaining WHY a particular approach was chosen - Documenting non-obvious edge cases or side effects - Warning about potential pitfalls in usage - Explaining complex algorithms that can't be simplified - **When in doubt, leave it out**. No comment is better than a redundant comment. - **Never add comments explaining code changes** - The code should speak for itself, and version control tracks changes. The one exception to this rule is if it is a very unobvious implementation. Something that someone would typically implement in a different (wrong) way. Then the comment helps us remember WHY we changed it to a less obvious implementation. - **Never remove existing comments** unless specifically directed by the user. Comments that are already defined in existing code have been vetted by the user. ### Jotai Model Pattern (our rules) - **Atoms live on the model.** - **Simple atoms:** define as **field initializers**. - **Atoms that depend on values/other atoms:** create in the **constructor**. - Models **never use React hooks**; they use `globalStore.get/set`. - It's fine to call model methods from **event handlers** or **`useEffect`**. - Models use the **singleton pattern** with a `private static instance` field, a `private constructor`, and a `static getInstance()` method. - The constructor is `private`; callers always use `getInstance()`. ```ts // model/MyModel.ts import * as jotai from "jotai"; import { globalStore } from "@/app/store/jotaiStore"; export class MyModel { private static instance: MyModel | null = null; // simple atoms (field init) statusAtom = jotai.atom<"idle" | "running" | "error">("idle"); outputAtom = jotai.atom(""); // ctor-built atoms (need types) lengthAtom!: jotai.Atom; thresholdedAtom!: jotai.Atom; private constructor(initialThreshold = 20) { this.lengthAtom = jotai.atom((get) => get(this.outputAtom).length); this.thresholdedAtom = jotai.atom((get) => get(this.lengthAtom) > initialThreshold); } static getInstance(): MyModel { if (!MyModel.instance) { MyModel.instance = new MyModel(); } return MyModel.instance; } static resetInstance(): void { MyModel.instance = null; } async doWork() { globalStore.set(this.statusAtom, "running"); // ... do work ... globalStore.set(this.statusAtom, "idle"); } } ``` ```tsx // component usage (events & effects OK) import { useAtomValue } from "jotai"; function Panel() { const model = MyModel.getInstance(); const status = useAtomValue(model.statusAtom); const isBig = useAtomValue(model.thresholdedAtom); const onClick = () => model.doWork(); return (
{status} • {String(isBig)}
); } ``` **Remember:** singleton pattern with `getInstance()`, `private constructor`, atoms on the model, simple-as-fields, ctor for dependent/derived, updates via `globalStore.set/get`. **Note** Older models may not use the singleton pattern ### Tool Use Do NOT use write_to_file unless it is a new file or very short. Always prefer to use replace_in_file. Often your diffs fail when a file may be out of date in your cache vs the actual on-disk format. You should RE-READ the file and try to create diffs again if your diffs fail rather than fall back to write_to_file. If you feel like your ONLY option is to use write_to_file please ask first. Also when adding content to the end of files prefer to use the new append_file tool rather than trying to create a diff (as your diffs are often not specific enough and end up inserting code in the middle of existing functions). ### Directory Awareness - **ALWAYS verify the current working directory before executing commands** - Either run "pwd" first to verify the directory, or do a "cd" to the correct absolute directory before running commands - When running tests, do not "cd" to the pkg directory and then run the test. This screws up the cwd and you never recover. run the test from the project root instead. ### Testing / Compiling Go Code No need to run a `go build` or a `go run` to just check if the Go code compiles. VSCode's errors/problems cover this well. If there are no Go errors in VSCode you can assume the code compiles fine. ================================================ FILE: .kilocode/skills/add-config/SKILL.md ================================================ --- name: add-config description: Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. --- # Adding a New Configuration Setting to Wave Terminal This guide explains how to add a new configuration setting to Wave Terminal's hierarchical configuration system. ## Configuration System Overview Wave Terminal uses a hierarchical configuration system with: 1. **Go Struct Definitions** - Type-safe configuration structure in `pkg/wconfig/settingsconfig.go` 2. **JSON Schema** - Auto-generated validation schema in `schema/settings.json` 3. **Default Values** - Built-in defaults in `pkg/wconfig/defaultconfig/settings.json` 4. **User Configuration** - User overrides in `~/.config/waveterm/settings.json` 5. **Block Metadata** - Block-level overrides in `pkg/waveobj/wtypemeta.go` 6. **Documentation** - User-facing docs in `docs/docs/config.mdx` Settings cascade from defaults → user settings → connection config → block overrides. ## Step-by-Step Guide ### Step 1: Add to Go Struct Definition Edit `pkg/wconfig/settingsconfig.go` and add your new field to the `SettingsType` struct: ```go type SettingsType struct { // ... existing fields ... // Add your new field with appropriate JSON tag MyNewSetting string `json:"mynew:setting,omitempty"` // For different types: MyBoolSetting bool `json:"mynew:boolsetting,omitempty"` MyNumberSetting float64 `json:"mynew:numbersetting,omitempty"` MyIntSetting *int64 `json:"mynew:intsetting,omitempty"` // Use pointer for optional ints MyArraySetting []string `json:"mynew:arraysetting,omitempty"` } ``` **Naming Conventions:** - Use namespace prefixes (e.g., `term:`, `window:`, `ai:`, `web:`, `app:`) - Use lowercase with colons as separators - Field names should be descriptive and follow Go naming conventions - Use `omitempty` tag to exclude empty values from JSON **Type Guidelines:** - Use `*int64` and `*float64` for optional numeric values - Use `*bool` for optional boolean values (or `bool` if default is false) - Use `string` for text values - Use `[]string` for arrays - Use `float64` for numbers that can be decimals **Namespace Organization:** - `app:*` - Application-level settings - `term:*` - Terminal-specific settings - `window:*` - Window and UI settings - `ai:*` - AI-related settings - `web:*` - Web browser settings - `editor:*` - Code editor settings - `conn:*` - Connection settings ### Step 1.5: Add to Block Metadata (Optional) If your setting should support block-level overrides, also add it to `pkg/waveobj/wtypemeta.go`: ```go type MetaTSType struct { // ... existing fields ... // Add your new field with matching JSON tag and type MyNewSetting *string `json:"mynew:setting,omitempty"` // Use pointer for optional values // For different types: MyBoolSetting *bool `json:"mynew:boolsetting,omitempty"` MyNumberSetting *float64 `json:"mynew:numbersetting,omitempty"` MyIntSetting *int `json:"mynew:intsetting,omitempty"` MyArraySetting []string `json:"mynew:arraysetting,omitempty"` } ``` **Block Metadata Guidelines:** - Use pointer types (`*string`, `*bool`, `*int`, `*float64`) for optional overrides - JSON tags should exactly match the corresponding settings field - This enables the hierarchical config system: block metadata → connection config → global settings - Only add settings here that make sense to override per-block or per-connection ### Step 2: Set Default Value (Optional) If your setting should have a default value, add it to `pkg/wconfig/defaultconfig/settings.json`: ```json { "ai:preset": "ai@global", "ai:model": "gpt-5-mini", // ... existing defaults ... "mynew:setting": "default value", "mynew:boolsetting": true, "mynew:numbersetting": 42.5, "mynew:intsetting": 100 } ``` **Default Value Guidelines:** - Only add defaults for settings that should have non-zero/non-empty initial values - Ensure defaults make sense for typical user experience - Keep defaults conservative and safe - Boolean settings often don't need defaults if `false` is the correct default ### Step 3: Update Documentation Add your new setting to the configuration table in `docs/docs/config.mdx`: ```markdown | Key Name | Type | Function | | ------------------- | -------- | ----------------------------------------- | | mynew:setting | string | Description of what this setting controls | | mynew:boolsetting | bool | Enable/disable some feature | | mynew:numbersetting | float | Numeric setting for some parameter | | mynew:intsetting | int | Integer setting for some configuration | | mynew:arraysetting | string[] | Array of strings for multiple values | ``` **Documentation Guidelines:** - Provide clear, concise descriptions - For new settings in upcoming releases, add `` - Update the default configuration example if you added defaults - Explain what values are valid and what they do ### Step 4: Regenerate Schema and TypeScript Types Run the generate task to automatically regenerate the JSON schema and TypeScript types: ```bash task generate ``` **What this does:** - Runs `task build:schema` (automatically generates JSON schema from Go structs) - Generates TypeScript type definitions in `frontend/types/gotypes.d.ts` - Generates RPC client APIs - Generates metadata constants **Important:** The JSON schema in `schema/settings.json` is **automatically generated** from the Go struct definitions - you don't need to edit it manually. ### Step 5: Use in Frontend Code Access your new setting in React components: ```typescript import { getOverrideConfigAtom, getSettingsKeyAtom, useAtomValue } from "@/store/global"; // In a React component const MyComponent = ({ blockId }: { blockId: string }) => { // Use override config atom for hierarchical resolution // This automatically checks: block metadata → connection config → global settings → default const mySettingAtom = getOverrideConfigAtom(blockId, "mynew:setting"); const mySetting = useAtomValue(mySettingAtom) ?? "fallback value"; // For global-only settings (no block overrides) const globalOnlySetting = useAtomValue(getSettingsKeyAtom("mynew:globalsetting")) ?? "fallback"; return
Setting value: {mySetting}
; }; ``` **Frontend Configuration Patterns:** ```typescript // 1. Settings with block-level overrides (recommended for most view/display settings) const termFontSize = useAtomValue(getOverrideConfigAtom(blockId, "term:fontsize")) ?? 12; // 2. Global-only settings (app-wide settings that don't vary by block) const appGlobalHotkey = useAtomValue(getSettingsKeyAtom("app:globalhotkey")) ?? ""; // 3. Connection-specific settings const connStatus = useAtomValue(getConnStatusAtom(connectionName)); ``` **When to use each pattern:** - Use `getOverrideConfigAtom()` for settings that can vary by block or connection (most UI/display settings) - Use `getSettingsKeyAtom()` for app-level settings that are always global - Always provide a fallback value with `??` operator ### Step 6: Use in Backend Code Access settings in Go code: ```go // Get the full config fullConfig := wconfig.GetWatcher().GetFullConfig() // Access your setting myValue := fullConfig.Settings.MyNewSetting // For optional values (pointers) if fullConfig.Settings.MyIntSetting != nil { intValue := *fullConfig.Settings.MyIntSetting // Use intValue } ``` ## Complete Examples ### Example 1: Simple Boolean Setting (No Block Override) **Use case:** Add a setting to hide the AI button globally #### 1. Go Struct (`pkg/wconfig/settingsconfig.go`) ```go type SettingsType struct { // ... existing fields ... AppHideAiButton bool `json:"app:hideaibutton,omitempty"` } ``` #### 2. Default Value (`pkg/wconfig/defaultconfig/settings.json`) ```json { "app:hideaibutton": false } ``` #### 3. Documentation (`docs/docs/config.mdx`) ```markdown | app:hideaibutton | bool | Hide the AI button in the tab bar (defaults to false) | ``` #### 4. Generate Types ```bash task generate ``` #### 5. Frontend Usage ```typescript import { getSettingsKeyAtom } from "@/store/global"; const TabBar = () => { const hideAiButton = useAtomValue(getSettingsKeyAtom("app:hideaibutton")); if (hideAiButton) { return null; // Don't render AI button } return ; }; ``` #### 6. Usage Examples ```bash # Set in settings file wsh setconfig app:hideaibutton=true # Or edit ~/.config/waveterm/settings.json { "app:hideaibutton": true } ``` ### Example 2: Terminal Setting with Block Override **Use case:** Add a terminal bell sound setting that can be overridden per block #### 1. Go Struct (`pkg/wconfig/settingsconfig.go`) ```go type SettingsType struct { // ... existing fields ... TermBellSound string `json:"term:bellsound,omitempty"` } ``` #### 2. Block Metadata (`pkg/waveobj/wtypemeta.go`) ```go type MetaTSType struct { // ... existing fields ... TermBellSound *string `json:"term:bellsound,omitempty"` // Pointer for optional override } ``` #### 3. Default Value (`pkg/wconfig/defaultconfig/settings.json`) ```json { "term:bellsound": "default" } ``` #### 4. Documentation (`docs/docs/config.mdx`) ```markdown | term:bellsound | string | Sound to play for terminal bell ("default", "none", or custom sound file path) | ``` #### 5. Generate Types ```bash task generate ``` #### 6. Frontend Usage ```typescript import { getOverrideConfigAtom } from "@/store/global"; const TerminalView = ({ blockId }: { blockId: string }) => { // Use override config for hierarchical resolution const bellSoundAtom = getOverrideConfigAtom(blockId, "term:bellsound"); const bellSound = useAtomValue(bellSoundAtom) ?? "default"; const playBellSound = () => { if (bellSound === "none") return; // Play the bell sound }; return
Terminal with bell: {bellSound}
; }; ``` #### 7. Usage Examples ```bash # Set globally in settings file wsh setconfig term:bellsound="custom.wav" # Set for current block only wsh setmeta term:bellsound="none" # Set for specific block wsh setmeta --block BLOCK_ID term:bellsound="beep" # Or edit ~/.config/waveterm/settings.json { "term:bellsound": "custom.wav" } ``` ## Configuration Patterns ### Clear/Reset Pattern Each namespace can have a "clear" field for resetting all settings in that namespace: ```go AppClear bool `json:"app:*,omitempty"` TermClear bool `json:"term:*,omitempty"` ``` ### Optional vs Required Settings - Use pointer types (`*bool`, `*int64`, `*float64`) for truly optional settings - Use regular types for settings that should always have a value - Provide sensible defaults for important settings ### Block-Level Overrides via RPC Settings can be overridden at the block level using metadata: ```typescript import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WOS } from "@/store/global"; // Set block-specific override await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { "mynew:setting": "block-specific value" }, }); ``` ## Common Pitfalls ### 1. Forgetting to Run `task generate` **Problem:** TypeScript types not updated, schema out of sync **Solution:** Always run `task generate` after modifying Go structs ### 2. Type Mismatch Between Settings and Metadata **Problem:** Settings uses `string`, metadata uses `*int` **Solution:** Ensure types match (except metadata uses pointers for optionals) ### 3. Not Providing Fallback Values **Problem:** Component breaks if setting is undefined **Solution:** Always use `??` operator with fallback: ```typescript const value = useAtomValue(getSettingsKeyAtom("key")) ?? "default"; ``` ### 4. Using Wrong Config Atom **Problem:** Using `getSettingsKeyAtom()` for settings that need block overrides **Solution:** Use `getOverrideConfigAtom()` for any setting in `MetaTSType` ## Best Practices ### Naming - **Use descriptive names**: `term:fontsize` not `term:fs` - **Follow namespace conventions**: Group related settings with common prefix - **Use consistent casing**: Always lowercase with colons ### Types - **Use `bool`** for simple on/off settings (no pointer if false is default) - **Use `*bool`** only if you need to distinguish unset from false - **Use `*int64`/`*float64`** for optional numeric values - **Use `string`** for text, paths, or enum-like values - **Use `[]string`** for lists ### Defaults - **Provide sensible defaults** for settings users will commonly change - **Omit defaults** for advanced/optional settings - **Keep defaults safe** - don't enable experimental features by default - **Document defaults** clearly in config.mdx ### Block Overrides - **Enable for view/display settings**: Font sizes, colors, themes, etc. - **Don't enable for app-wide settings**: Global hotkeys, window behavior, etc. - **Consider the use case**: Would a user want different values per block or connection? ### Documentation - **Be specific**: Explain what the setting does and what values are valid - **Provide examples**: Show common use cases - **Add version badges**: Mark new settings with `` - **Keep it current**: Update docs when behavior changes ## Quick Reference When adding a new configuration setting: - [ ] Add field to `SettingsType` in `pkg/wconfig/settingsconfig.go` - [ ] Add field to `MetaTSType` in `pkg/waveobj/wtypemeta.go` (if block override needed) - [ ] Add default to `pkg/wconfig/defaultconfig/settings.json` (if needed) - [ ] Document in `docs/docs/config.mdx` - [ ] Run `task generate` to update TypeScript types - [ ] Use appropriate atom (`getOverrideConfigAtom` or `getSettingsKeyAtom`) in frontend ## Related Documentation - **User Documentation**: `docs/docs/config.mdx` - User-facing configuration docs - **Type Definitions**: `pkg/wconfig/settingsconfig.go` - Go struct definitions - **Metadata Types**: `pkg/waveobj/wtypemeta.go` - Block metadata definitions ================================================ FILE: .kilocode/skills/add-rpc/SKILL.md ================================================ --- name: add-rpc description: Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. --- # Adding RPC Calls Guide ## Overview Wave Terminal uses a WebSocket-based RPC (Remote Procedure Call) system for communication between different components. The RPC system allows the frontend, backend, electron main process, remote servers, and terminal blocks to communicate with each other through well-defined commands. This guide covers how to add a new RPC command to the system. ## Key Files - `pkg/wshrpc/wshrpctypes.go` - RPC interface and type definitions - `pkg/wshrpc/wshserver/wshserver.go` - Main server implementation (most common) - `emain/emain-wsh.ts` - Electron main process implementation - `frontend/app/store/tabrpcclient.ts` - Frontend tab implementation - `pkg/wshrpc/wshremote/wshremote.go` - Remote server implementation - `frontend/app/view/term/term-wsh.tsx` - Terminal block implementation ## RPC Command Structure RPC commands in Wave Terminal follow these conventions: - **Method names** must end with `Command` - **First parameter** must be `context.Context` - **Remaining parameters** are a regular Go parameter list (zero or more typed args) - **Return values** can be either just an error, or one return value plus an error - **Streaming commands** return a channel instead of a direct value ## Adding a New RPC Call ### Step 1: Define the Command in the Interface Add your command to the `WshRpcInterface` in `pkg/wshrpc/wshrpctypes.go`: ```go type WshRpcInterface interface { // ... existing commands ... // Add your new command YourNewCommand(ctx context.Context, data CommandYourNewData) (*YourNewResponse, error) } ``` **Method Signature Rules:** - Method name must end with `Command` - First parameter must be `ctx context.Context` - Remaining parameters are a regular Go parameter list (zero or more) - Return either `error` or `(ReturnType, error)` - For streaming, return `chan RespOrErrorUnion[T]` ### Step 2: Define Request and Response Types If your command needs structured input or output, define types in the same file: ```go type CommandYourNewData struct { FieldOne string `json:"fieldone"` FieldTwo int `json:"fieldtwo"` SomeId string `json:"someid"` } type YourNewResponse struct { ResultField string `json:"resultfield"` Success bool `json:"success"` } ``` **Type Naming Conventions:** - Request types: `Command[Name]Data` (e.g., `CommandGetMetaData`) - Response types: `[Name]Response` or `Command[Name]RtnData` (e.g., `CommandResolveIdsRtnData`) - Use `json` struct tags with lowercase field names - Follow existing patterns in the file for consistency ### Step 3: Generate Bindings After modifying `pkg/wshrpc/wshrpctypes.go`, run code generation to create TypeScript bindings and Go helper code: ```bash task generate ``` This command will: - Generate TypeScript type definitions in `frontend/types/gotypes.d.ts` - Create RPC client bindings - Update routing code **Note:** If generation fails, check that your method signature follows all the rules above. ### Step 4: Implement the Command Choose where to implement your command based on what it needs to do: #### A. Main Server Implementation (Most Common) Implement in `pkg/wshrpc/wshserver/wshserver.go`: ```go func (ws *WshServer) YourNewCommand(ctx context.Context, data wshrpc.CommandYourNewData) (*wshrpc.YourNewResponse, error) { // Validate input if data.SomeId == "" { return nil, fmt.Errorf("someid is required") } // Implement your logic result := doSomething(data) // Return response return &wshrpc.YourNewResponse{ ResultField: result, Success: true, }, nil } ``` **Use main server when:** - Accessing the database - Managing blocks, tabs, or workspaces - Coordinating between components - Handling file operations on the main filesystem #### B. Electron Implementation Implement in `emain/emain-wsh.ts`: ```typescript async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise { // Electron-specific logic const result = await electronAPI.doSomething(data); return { resultfield: result, success: true, }; } ``` **Use Electron when:** - Accessing native OS features - Managing application windows - Using Electron APIs (notifications, system tray, etc.) - Handling encryption/decryption with safeStorage #### C. Frontend Tab Implementation Implement in `frontend/app/store/tabrpcclient.ts`: ```typescript async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise { // Access frontend state/models const layoutModel = getLayoutModelForStaticTab(); // Implement tab-specific logic const result = layoutModel.doSomething(data); return { resultfield: result, success: true, }; } ``` **Use tab client when:** - Accessing React state or Jotai atoms - Manipulating UI layout - Capturing screenshots - Reading frontend-only data #### D. Remote Server Implementation Implement in `pkg/wshrpc/wshremote/wshremote.go`: ```go func (impl *ServerImpl) RemoteYourNewCommand(ctx context.Context, data wshrpc.CommandRemoteYourNewData) (*wshrpc.YourNewResponse, error) { // Remote filesystem or process operations result, err := performRemoteOperation(data) if err != nil { return nil, fmt.Errorf("remote operation failed: %w", err) } return &wshrpc.YourNewResponse{ ResultField: result, Success: true, }, nil } ``` **Use remote server when:** - Operating on remote filesystems - Executing commands on remote hosts - Managing remote processes - Convention: prefix command name with `Remote` (e.g., `RemoteGetInfoCommand`) #### E. Terminal Block Implementation Implement in `frontend/app/view/term/term-wsh.tsx`: ```typescript async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise { // Access terminal-specific data const termWrap = this.model.termRef.current; // Implement terminal logic const result = termWrap.doSomething(data); return { resultfield: result, success: true, }; } ``` **Use terminal client when:** - Accessing terminal buffer/scrollback - Managing VDOM contexts - Reading terminal-specific state - Interacting with xterm.js ## Complete Example: Adding GetWaveInfo Command ### 1. Define Interface In `pkg/wshrpc/wshrpctypes.go`: ```go type WshRpcInterface interface { // ... other commands ... WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) } type WaveInfoData struct { Version string `json:"version"` BuildTime string `json:"buildtime"` ConfigPath string `json:"configpath"` DataPath string `json:"datapath"` } ``` ### 2. Generate Bindings ```bash task generate ``` ### 3. Implement in Main Server In `pkg/wshrpc/wshserver/wshserver.go`: ```go func (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData, error) { return &wshrpc.WaveInfoData{ Version: wavebase.WaveVersion, BuildTime: wavebase.BuildTime, ConfigPath: wavebase.GetConfigDir(), DataPath: wavebase.GetWaveDataDir(), }, nil } ``` ### 4. Call from Frontend ```typescript import { RpcApi } from "@/app/store/wshclientapi"; // Call the RPC const info = await RpcApi.WaveInfoCommand(TabRpcClient); console.log("Wave Version:", info.version); ``` ## Streaming Commands For commands that return data progressively, use channels: ### Define Streaming Interface ```go type WshRpcInterface interface { StreamYourDataCommand(ctx context.Context, request YourDataRequest) chan RespOrErrorUnion[YourDataType] } ``` ### Implement Streaming Command ```go func (ws *WshServer) StreamYourDataCommand(ctx context.Context, request wshrpc.YourDataRequest) chan wshrpc.RespOrErrorUnion[wshrpc.YourDataType] { rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.YourDataType]) go func() { defer close(rtn) defer func() { panichandler.PanicHandler("StreamYourDataCommand", recover()) }() // Stream data for i := 0; i < 10; i++ { select { case <-ctx.Done(): return default: rtn <- wshrpc.RespOrErrorUnion[wshrpc.YourDataType]{ Response: wshrpc.YourDataType{ Value: i, }, } time.Sleep(100 * time.Millisecond) } } }() return rtn } ``` ## Best Practices 1. **Validation First**: Always validate input parameters at the start of your implementation 2. **Descriptive Names**: Use clear, action-oriented command names (e.g., `GetFullConfigCommand`, not `ConfigCommand`) 3. **Error Handling**: Return descriptive errors with context: ```go return nil, fmt.Errorf("error creating block: %w", err) ``` 4. **Context Awareness**: Respect context cancellation for long-running operations: ```go select { case <-ctx.Done(): return ctx.Err() default: // continue } ``` 5. **Consistent Types**: Follow existing naming patterns for request/response types 6. **JSON Tags**: Always use lowercase JSON tags matching frontend conventions 7. **Documentation**: Add comments explaining complex commands or special behaviors 8. **Type Safety**: Leverage TypeScript generation - your types will be checked on both ends 9. **Panic Recovery**: Use `panichandler.PanicHandler` in goroutines to prevent crashes 10. **Route Awareness**: For multi-route scenarios, use `wshutil.GetRpcSourceFromContext(ctx)` to identify callers ## Common Command Patterns ### Simple Query ```go func (ws *WshServer) GetSomethingCommand(ctx context.Context, id string) (*Something, error) { obj, err := wstore.DBGet[*Something](ctx, id) if err != nil { return nil, fmt.Errorf("error getting something: %w", err) } return obj, nil } ``` ### Mutation with Updates ```go func (ws *WshServer) UpdateSomethingCommand(ctx context.Context, data wshrpc.CommandUpdateData) error { ctx = waveobj.ContextWithUpdates(ctx) // Make changes err := wstore.UpdateObject(ctx, data.ORef, data.Updates) if err != nil { return fmt.Errorf("error updating: %w", err) } // Broadcast updates updates := waveobj.ContextGetUpdatesRtn(ctx) wps.Broker.SendUpdateEvents(updates) return nil } ``` ### Command with Side Effects ```go func (ws *WshServer) DoActionCommand(ctx context.Context, data wshrpc.CommandActionData) error { // Perform action result, err := performAction(data) if err != nil { return err } // Publish event about the action go func() { wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_ActionComplete, Data: result, }) }() return nil } ``` ## Troubleshooting ### Command Not Found - Ensure method name ends with `Command` - Verify you ran `task generate` - Check that the interface is in `WshRpcInterface` ### Type Mismatch Errors - Run `task generate` after changing types - Ensure JSON tags are lowercase - Verify TypeScript code is using generated types ### Command Times Out - Check for blocking operations - Ensure context is passed through - Consider using a streaming command for long operations ### Routing Issues - For remote commands, ensure they're implemented in correct location - Check route configuration in RpcContext - Verify authentication for secured routes ## Quick Reference When adding a new RPC command: - [ ] Add method to `WshRpcInterface` in `pkg/wshrpc/wshrpctypes.go` (must end with `Command`) - [ ] Define request/response types with JSON tags (if needed) - [ ] Run `task generate` to create bindings - [ ] Implement in appropriate location: - [ ] `wshserver.go` for main server (most common) - [ ] `emain-wsh.ts` for Electron - [ ] `tabrpcclient.ts` for frontend - [ ] `wshremote.go` for remote (prefix with `Remote`) - [ ] `term-wsh.tsx` for terminal - [ ] Add input validation - [ ] Handle errors with context - [ ] Test the command end-to-end ## Related Documentation - **WPS Events**: See the `wps-events` skill - Publishing events from RPC commands ================================================ FILE: .kilocode/skills/add-wshcmd/SKILL.md ================================================ --- name: add-wshcmd description: Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. --- # Adding a New wsh Command to Wave Terminal This guide explains how to add a new command to the `wsh` CLI tool. ## wsh Command System Overview Wave Terminal's `wsh` command provides CLI access to Wave Terminal features. The system uses: 1. **Cobra Framework** - CLI command structure and parsing 2. **Command Files** - Individual command implementations in `cmd/wsh/cmd/wshcmd-*.go` 3. **RPC Client** - Communication with Wave Terminal backend via `RpcClient` 4. **Activity Tracking** - Telemetry for command usage analytics 5. **Documentation** - User-facing docs in `docs/docs/wsh-reference.mdx` Commands are registered in their `init()` functions and execute through the Cobra framework. ## Step-by-Step Guide ### Step 1: Create Command File Create a new file in `cmd/wsh/cmd/` named `wshcmd-[commandname].go`: ```go // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var myCommandCmd = &cobra.Command{ Use: "mycommand [args]", Short: "Brief description of what this command does", Long: `Detailed description of the command. Can include multiple lines and examples of usage.`, RunE: myCommandRun, PreRunE: preRunSetupRpcClient, // Include if command needs RPC DisableFlagsInUseLine: true, } // Flag variables var ( myCommandFlagExample string myCommandFlagVerbose bool ) func init() { // Add command to root rootCmd.AddCommand(myCommandCmd) // Define flags myCommandCmd.Flags().StringVarP(&myCommandFlagExample, "example", "e", "", "example flag description") myCommandCmd.Flags().BoolVarP(&myCommandFlagVerbose, "verbose", "v", false, "enable verbose output") } func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { // Always track activity for telemetry defer func() { sendActivity("mycommand", rtnErr == nil) }() // Validate arguments if len(args) == 0 { OutputHelpMessage(cmd) return fmt.Errorf("requires at least one argument") } // Command implementation fmt.Printf("Command executed successfully\n") return nil } ``` **File Naming Convention:** - Use `wshcmd-[commandname].go` format - Use lowercase, hyphenated names for multi-word commands - Examples: `wshcmd-getvar.go`, `wshcmd-setmeta.go`, `wshcmd-ai.go` ### Step 2: Command Structure #### Basic Command Structure ```go var myCommandCmd = &cobra.Command{ Use: "mycommand [required] [optional...]", Short: "One-line description (shown in help)", Long: `Detailed multi-line description`, // Argument validation Args: cobra.MinimumNArgs(1), // Or cobra.ExactArgs(1), cobra.NoArgs, etc. // Execution function RunE: myCommandRun, // Pre-execution setup (if needed) PreRunE: preRunSetupRpcClient, // Sets up RPC client for backend communication // Example usage (optional) Example: " wsh mycommand foo\n wsh mycommand --flag bar", // Disable flag notation in usage line DisableFlagsInUseLine: true, } ``` **Key Fields:** - `Use`: Command name and argument pattern - `Short`: Brief description for command list - `Long`: Detailed description shown in help - `Args`: Argument validator (optional) - `RunE`: Main execution function (returns error) - `PreRunE`: Setup function that runs before `RunE` - `Example`: Usage examples (optional) - `DisableFlagsInUseLine`: Clean up help display #### When to Use PreRunE Include `PreRunE: preRunSetupRpcClient` if your command: - Communicates with the Wave Terminal backend - Needs access to `RpcClient` - Requires JWT authentication (WAVETERM_JWT env var) - Makes RPC calls via `wshclient.*Command()` functions **Don't include PreRunE** for commands that: - Only manipulate local state - Don't need backend communication - Are purely informational/local operations ### Step 3: Implement Command Logic #### Command Function Pattern ```go func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { // Step 1: Always track activity (for telemetry) defer func() { sendActivity("mycommand", rtnErr == nil) }() // Step 2: Validate arguments and flags if len(args) != 1 { OutputHelpMessage(cmd) return fmt.Errorf("requires exactly one argument") } // Step 3: Parse/prepare data targetArg := args[0] // Step 4: Make RPC call if needed result, err := wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{ Field: targetArg, }, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("executing command: %w", err) } // Step 5: Output results fmt.Printf("Result: %s\n", result) return nil } ``` **Important Patterns:** 1. **Activity Tracking**: Always include deferred `sendActivity()` call ```go defer func() { sendActivity("commandname", rtnErr == nil) }() ``` 2. **Error Handling**: Return errors, don't call `os.Exit()` ```go if err != nil { return fmt.Errorf("context: %w", err) } ``` 3. **Output**: Use standard `fmt` package for output ```go fmt.Printf("Success message\n") fmt.Fprintf(os.Stderr, "Error message\n") ``` 4. **Help Messages**: Show help when arguments are invalid ```go if len(args) == 0 { OutputHelpMessage(cmd) return fmt.Errorf("requires arguments") } ``` 5. **Exit Codes**: Set custom exit code via `WshExitCode` ```go if notFound { WshExitCode = 1 return nil // Don't return error, just set exit code } ``` ### Step 4: Define Flags Add flags in the `init()` function: ```go var ( // Declare flag variables at package level myCommandFlagString string myCommandFlagBool bool myCommandFlagInt int ) func init() { rootCmd.AddCommand(myCommandCmd) // String flag with short version myCommandCmd.Flags().StringVarP(&myCommandFlagString, "name", "n", "default", "description") // Boolean flag myCommandCmd.Flags().BoolVarP(&myCommandFlagBool, "verbose", "v", false, "enable verbose") // Integer flag myCommandCmd.Flags().IntVar(&myCommandFlagInt, "count", 10, "set count") // Flag without short version myCommandCmd.Flags().StringVar(&myCommandFlagString, "longname", "", "description") } ``` **Flag Types:** - `StringVar/StringVarP` - String values - `BoolVar/BoolVarP` - Boolean flags - `IntVar/IntVarP` - Integer values - The `P` suffix versions include a short flag name **Flag Naming:** - Use camelCase for variable names: `myCommandFlagName` - Use kebab-case for flag names: `--flag-name` - Prefix variable names with command name for clarity ### Step 5: Working with Block Arguments Many commands operate on blocks. Use the standard block resolution pattern: ```go func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mycommand", rtnErr == nil) }() // Resolve block using the -b/--block flag fullORef, err := resolveBlockArg() if err != nil { return err } // Use the blockid in RPC call err = wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{ BlockId: fullORef.OID, }, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("command failed: %w", err) } return nil } ``` **Block Resolution:** - The `-b/--block` flag is defined globally in `wshcmd-root.go` - `resolveBlockArg()` resolves the block argument to a full ORef - Supports: `this`, `tab`, full UUIDs, 8-char prefixes, block numbers - Default is `"this"` (current block) **Alternative: Manual Block Resolution** ```go // Get tab ID from environment tabId := os.Getenv("WAVETERM_TABID") if tabId == "" { return fmt.Errorf("WAVETERM_TABID not set") } // Create route for tab-level operations route := wshutil.MakeTabRouteId(tabId) // Use route in RPC call err := wshclient.SomeCommand(RpcClient, commandData, &wshrpc.RpcOpts{ Route: route, Timeout: 2000, }) ``` ### Step 6: Making RPC Calls Use the `wshclient` package to make RPC calls: ```go import ( "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) // Simple RPC call result, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ ORef: *fullORef, }, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("getting metadata: %w", err) } // RPC call with routing err := wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{ ORef: *fullORef, Meta: metaMap, }, &wshrpc.RpcOpts{ Route: route, Timeout: 5000, }) if err != nil { return fmt.Errorf("setting metadata: %w", err) } ``` **RPC Options:** - `Timeout`: Request timeout in milliseconds (typically 2000-5000) - `Route`: Route ID for targeting specific components - Available routes: `wshutil.ControlRoute`, `wshutil.MakeTabRouteId(tabId)` ### Step 7: Add Documentation Add your command to `docs/docs/wsh-reference.mdx`: ````markdown ## mycommand Brief description of what the command does. ```sh wsh mycommand [args] [flags] ``` Detailed explanation of the command's purpose and behavior. Flags: - `-n, --name ` - description of this flag - `-v, --verbose` - enable verbose output - `-b, --block ` - specify target block (default: current block) Examples: ```sh # Basic usage wsh mycommand arg1 # With flags wsh mycommand --name value arg1 # With block targeting wsh mycommand -b 2 arg1 # Complex example wsh mycommand -v --name "example" arg1 arg2 ``` Additional notes, tips, or warnings about the command. --- ```` **Documentation Guidelines:** - Place in alphabetical order with other commands - Include command signature with argument pattern - List all flags with short and long versions - Provide practical examples (at least 3-5) - Explain common use cases and patterns - Add tips or warnings if relevant - Use `---` separator between commands ### Step 8: Test Your Command Build and test the command: ```bash # Build wsh task build:wsh # Or build everything task build # Test the command ./bin/wsh/wsh mycommand --help ./bin/wsh/wsh mycommand arg1 arg2 ``` **Testing Checklist:** - [ ] Help message displays correctly - [ ] Required arguments validated - [ ] Flags work as expected - [ ] Error messages are clear - [ ] Success cases work correctly - [ ] RPC calls complete successfully - [ ] Output is formatted correctly ## Complete Examples ### Example 1: Simple Command with No RPC **Use case:** A command that prints Wave Terminal version info #### Command File (`cmd/wsh/cmd/wshcmd-version.go`) ```go // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wavebase" ) var versionCmd = &cobra.Command{ Use: "version", Short: "Print Wave Terminal version", RunE: versionRun, } func init() { rootCmd.AddCommand(versionCmd) } func versionRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("version", rtnErr == nil) }() fmt.Printf("Wave Terminal %s\n", wavebase.WaveVersion) return nil } ``` #### Documentation ````markdown ## version Print the current Wave Terminal version. ```sh wsh version ``` Examples: ```sh # Print version wsh version ``` ```` ### Example 2: Command with Flags and RPC **Use case:** A command to update block title #### Command File (`cmd/wsh/cmd/wshcmd-settitle.go`) ```go // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var setTitleCmd = &cobra.Command{ Use: "settitle [title]", Short: "Set block title", Long: `Set the title for the current or specified block.`, Args: cobra.ExactArgs(1), RunE: setTitleRun, PreRunE: preRunSetupRpcClient, DisableFlagsInUseLine: true, } var setTitleIcon string func init() { rootCmd.AddCommand(setTitleCmd) setTitleCmd.Flags().StringVarP(&setTitleIcon, "icon", "i", "", "set block icon") } func setTitleRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("settitle", rtnErr == nil) }() title := args[0] // Resolve block fullORef, err := resolveBlockArg() if err != nil { return err } // Build metadata map meta := make(map[string]interface{}) meta["title"] = title if setTitleIcon != "" { meta["icon"] = setTitleIcon } // Make RPC call err = wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{ ORef: *fullORef, Meta: meta, }, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("setting title: %w", err) } fmt.Printf("title updated\n") return nil } ``` #### Documentation ````markdown ## settitle Set the title for a block. ```sh wsh settitle [title] ``` Update the display title for the current or specified block. Optionally set an icon as well. Flags: - `-i, --icon ` - set block icon along with title - `-b, --block ` - specify target block (default: current block) Examples: ```sh # Set title for current block wsh settitle "My Terminal" # Set title and icon wsh settitle --icon "terminal" "Development Shell" # Set title for specific block wsh settitle -b 2 "Build Output" ``` ```` ### Example 3: Subcommands **Use case:** Command with multiple subcommands (like `wsh conn`) #### Command File (`cmd/wsh/cmd/wshcmd-mygroup.go`) ```go // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var myGroupCmd = &cobra.Command{ Use: "mygroup", Short: "Manage something", } var myGroupListCmd = &cobra.Command{ Use: "list", Short: "List items", RunE: myGroupListRun, PreRunE: preRunSetupRpcClient, } var myGroupAddCmd = &cobra.Command{ Use: "add [name]", Short: "Add an item", Args: cobra.ExactArgs(1), RunE: myGroupAddRun, PreRunE: preRunSetupRpcClient, } func init() { // Add parent command rootCmd.AddCommand(myGroupCmd) // Add subcommands myGroupCmd.AddCommand(myGroupListCmd) myGroupCmd.AddCommand(myGroupAddCmd) } func myGroupListRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mygroup:list", rtnErr == nil) }() // Implementation fmt.Printf("Listing items...\n") return nil } func myGroupAddRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mygroup:add", rtnErr == nil) }() name := args[0] fmt.Printf("Adding item: %s\n", name) return nil } ``` #### Documentation ````markdown ## mygroup Manage something with subcommands. ### list List all items. ```sh wsh mygroup list ``` ### add Add a new item. ```sh wsh mygroup add [name] ``` Examples: ```sh # List items wsh mygroup list # Add an item wsh mygroup add "new-item" ``` ```` ## Common Patterns ### Reading from Stdin ```go import "io" func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mycommand", rtnErr == nil) }() // Check if reading from stdin (using "-" convention) var data []byte var err error if len(args) > 0 && args[0] == "-" { data, err = io.ReadAll(os.Stdin) if err != nil { return fmt.Errorf("reading stdin: %w", err) } } else { // Read from file or other source data, err = os.ReadFile(args[0]) if err != nil { return fmt.Errorf("reading file: %w", err) } } // Process data fmt.Printf("Read %d bytes\n", len(data)) return nil } ``` ### JSON File Input ```go import ( "encoding/json" "io" ) func loadJSONFile(filepath string) (map[string]interface{}, error) { var data []byte var err error if filepath == "-" { data, err = io.ReadAll(os.Stdin) if err != nil { return nil, fmt.Errorf("reading stdin: %w", err) } } else { data, err = os.ReadFile(filepath) if err != nil { return nil, fmt.Errorf("reading file: %w", err) } } var result map[string]interface{} if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("parsing JSON: %w", err) } return result, nil } ``` ### Conditional Output (TTY Detection) ```go func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mycommand", rtnErr == nil) }() isTty := getIsTty() // Output value fmt.Printf("%s", value) // Add newline only if TTY (for better piping experience) if isTty { fmt.Printf("\n") } return nil } ``` ### Environment Variable Access ```go func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mycommand", rtnErr == nil) }() // Get block ID from environment blockId := os.Getenv("WAVETERM_BLOCKID") if blockId == "" { return fmt.Errorf("WAVETERM_BLOCKID not set") } // Get tab ID from environment tabId := os.Getenv("WAVETERM_TABID") if tabId == "" { return fmt.Errorf("WAVETERM_TABID not set") } fmt.Printf("Block: %s, Tab: %s\n", blockId, tabId) return nil } ``` ## Best Practices ### Command Design 1. **Single Responsibility**: Each command should do one thing well 2. **Composable**: Design commands to work with pipes and other commands 3. **Consistent**: Follow existing wsh command patterns and conventions 4. **Documented**: Provide clear help text and examples ### Error Handling 1. **Context**: Wrap errors with context using `fmt.Errorf("context: %w", err)` 2. **User-Friendly**: Make error messages clear and actionable 3. **No Panics**: Return errors instead of calling `os.Exit()` or `panic()` 4. **Exit Codes**: Use `WshExitCode` for custom exit codes ### Output 1. **Structured**: Use consistent formatting for output 2. **Quiet by Default**: Only output what's necessary 3. **Verbose Flag**: Optionally provide `-v` for detailed output 4. **Stderr for Errors**: Use `fmt.Fprintf(os.Stderr, ...)` for error messages ### Flags 1. **Short Versions**: Provide `-x` short versions for common flags 2. **Sensible Defaults**: Choose defaults that work for most users 3. **Boolean Flags**: Use for on/off options 4. **String Flags**: Use for values that need user input ### RPC Calls 1. **Timeouts**: Always specify reasonable timeouts 2. **Error Context**: Wrap RPC errors with operation context 3. **Retries**: Don't retry automatically; let user retry command 4. **Routes**: Use appropriate routes for different operations ## Common Pitfalls ### 1. Forgetting Activity Tracking **Problem**: Command usage not tracked in telemetry **Solution**: Always include deferred `sendActivity()` call: ```go defer func() { sendActivity("commandname", rtnErr == nil) }() ``` ### 2. Using os.Exit() Instead of Returning Error **Problem**: Breaks defer statements and cleanup **Solution**: Return errors from RunE function: ```go // Bad if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } // Good if err != nil { return fmt.Errorf("operation failed: %w", err) } ``` ### 3. Not Validating Arguments **Problem**: Command crashes with nil pointer or index out of range **Solution**: Validate arguments early and show help: ```go if len(args) == 0 { OutputHelpMessage(cmd) return fmt.Errorf("requires at least one argument") } ``` ### 4. Forgetting to Add to init() **Problem**: Command not available when running wsh **Solution**: Always add command in `init()` function: ```go func init() { rootCmd.AddCommand(myCommandCmd) } ``` ### 5. Inconsistent Output **Problem**: Inconsistent use of output methods **Solution**: Use standard `fmt` package functions: ```go // For stdout fmt.Printf("output\n") // For stderr fmt.Fprintf(os.Stderr, "error message\n") ``` ## Quick Reference Checklist When adding a new wsh command: - [ ] Create `cmd/wsh/cmd/wshcmd-[commandname].go` - [ ] Define command struct with Use, Short, Long descriptions - [ ] Add `PreRunE: preRunSetupRpcClient` if using RPC - [ ] Implement command function with activity tracking - [ ] Add command to `rootCmd` in `init()` function - [ ] Define flags in `init()` function if needed - [ ] Add documentation to `docs/docs/wsh-reference.mdx` - [ ] Build and test: `task build:wsh` - [ ] Test help: `wsh [commandname] --help` - [ ] Test all flag combinations - [ ] Test error cases ## Related Files - **Root Command**: `cmd/wsh/cmd/wshcmd-root.go` - Main command setup and utilities - **RPC Client**: `pkg/wshrpc/wshclient/` - Client functions for RPC calls - **RPC Types**: `pkg/wshrpc/wshrpctypes.go` - RPC request/response data structures - **Documentation**: `docs/docs/wsh-reference.mdx` - User-facing command reference - **Examples**: `cmd/wsh/cmd/wshcmd-*.go` - Existing command implementations ================================================ FILE: .kilocode/skills/context-menu/SKILL.md ================================================ --- name: context-menu description: Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. --- # Context Menu Quick Reference This guide provides a quick overview of how to create and display a context menu using our system. --- ## ContextMenuItem Type Define each menu item using the `ContextMenuItem` type: ```ts type ContextMenuItem = { label?: string; type?: "separator" | "normal" | "submenu" | "checkbox" | "radio"; role?: string; // Electron role (optional) click?: () => void; // Callback for item selection (not needed if role is set) submenu?: ContextMenuItem[]; // For nested menus checked?: boolean; // For checkbox or radio items visible?: boolean; enabled?: boolean; sublabel?: string; }; ``` --- ## Import and Show the Menu Import the context menu module: ```ts import { ContextMenuModel } from "@/app/store/contextmenu"; ``` To display the context menu, call: ```ts ContextMenuModel.getInstance().showContextMenu(menu, event); ``` - **menu**: An array of `ContextMenuItem`. - **event**: The mouse event that triggered the context menu (typically from an onContextMenu handler). --- ## Basic Example A simple context menu with a separator: ```ts const menu: ContextMenuItem[] = [ { label: "New File", click: () => { /* create a new file */ }, }, { label: "New Folder", click: () => { /* create a new folder */ }, }, { type: "separator" }, { label: "Rename", click: () => { /* rename item */ }, }, ]; ContextMenuModel.getInstance().showContextMenu(menu, e); ``` --- ## Example with Submenu and Checkboxes Toggle settings using a submenu with checkbox items: ```ts const isClearOnStart = true; // Example setting const menu: ContextMenuItem[] = [ { label: "Clear Output On Restart", submenu: [ { label: "On", type: "checkbox", checked: isClearOnStart, click: () => { // Set the config to enable clear on restart }, }, { label: "Off", type: "checkbox", checked: !isClearOnStart, click: () => { // Set the config to disable clear on restart }, }, ], }, ]; ContextMenuModel.getInstance().showContextMenu(menu, e); ``` --- ## Editing a Config File Example Open a configuration file (e.g., `widgets.json`) in preview mode: ```ts { label: "Edit widgets.json", click: () => { fireAndForget(async () => { const path = `${getApi().getConfigDir()}/widgets.json`; const blockDef: BlockDef = { meta: { view: "preview", file: path }, }; await createBlock(blockDef, false, true); }); }, } ``` --- ## Summary - **Menu Definition**: Use the `ContextMenuItem` type. - **Actions**: Use `click` for actions; use `submenu` for nested options. - **Separators**: Use `type: "separator"` to group items. - **Toggles**: Use `type: "checkbox"` or `"radio"` with the `checked` property. - **Displaying**: Use `ContextMenuModel.getInstance().showContextMenu(menu, event)` to render the menu. ## Common Use Cases ### File/Folder Operations Context menus are commonly used for file operations like creating, renaming, and deleting files or folders. ### Settings Toggles Use checkbox menu items to toggle settings on and off, with the `checked` property reflecting the current state. ### Nested Options Use `submenu` to organize related options hierarchically, keeping the top-level menu clean and organized. ### Conditional Items Use the `visible` and `enabled` properties to dynamically show or disable menu items based on the current state. ================================================ FILE: .kilocode/skills/create-view/SKILL.md ================================================ --- name: create-view description: Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. --- # Creating a New View in Wave Terminal This guide explains how to implement a new view type in Wave Terminal. Views are the core content components displayed within blocks in the terminal interface. ## Architecture Overview Wave Terminal uses a **Model-View architecture** where: - **ViewModel** - Contains all state, logic, and UI configuration as Jotai atoms - **ViewComponent** - Pure React component that renders the UI using the model - **BlockFrame** - Wraps views with a header, connection management, and standard controls The separation between model and component ensures: - Models can update state without React hooks - Components remain pure and testable - State is centralized in Jotai atoms for easy access ## ViewModel Interface Every view must implement the `ViewModel` interface defined in `frontend/types/custom.d.ts`: ```typescript interface ViewModel { // Required: The type identifier for this view (e.g., "term", "web", "preview") viewType: string; // Required: The React component that renders this view viewComponent: ViewComponent; // Optional: Icon shown in block header (FontAwesome icon name or IconButtonDecl) viewIcon?: jotai.Atom; // Optional: Display name shown in block header (e.g., "Terminal", "Web", "Preview") viewName?: jotai.Atom; // Optional: Additional header elements (text, buttons, inputs) shown after the name viewText?: jotai.Atom; // Optional: Icon button shown before the view name in header preIconButton?: jotai.Atom; // Optional: Icon buttons shown at the end of the header (before settings/close) endIconButtons?: jotai.Atom; // Optional: Custom background styling for the block blockBg?: jotai.Atom; // Optional: If true, completely hides the block header noHeader?: jotai.Atom; // Optional: If true, shows connection picker in header for remote connections manageConnection?: jotai.Atom; // Optional: If true, filters out 'nowsh' connections from connection picker filterOutNowsh?: jotai.Atom; // Optional: If true, removes default padding from content area noPadding?: jotai.Atom; // Optional: Atoms for managing in-block search functionality searchAtoms?: SearchAtoms; // Optional: Returns whether this is a basic terminal (for multi-input feature) isBasicTerm?: (getFn: jotai.Getter) => boolean; // Optional: Returns context menu items for the settings dropdown getSettingsMenuItems?: () => ContextMenuItem[]; // Optional: Focuses the view when called, returns true if successful giveFocus?: () => boolean; // Optional: Handles keyboard events, returns true if handled keyDownHandler?: (e: WaveKeyboardEvent) => boolean; // Optional: Cleanup when block is closed dispose?: () => void; } ``` ### Key Concepts **Atoms**: All UI-related properties must be Jotai atoms. This enables: - Reactive updates when state changes - Access from anywhere via `globalStore.get()`/`globalStore.set()` - Derived atoms that compute values from other atoms **ViewComponent**: The React component receives these props: ```typescript type ViewComponentProps = { blockId: string; // Unique ID for this block blockRef: React.RefObject; // Ref to block container contentRef: React.RefObject; // Ref to content area model: T; // Your ViewModel instance }; ``` ## Step-by-Step Guide ### 1. Create the View Model Class Create a new file for your view model (e.g., `frontend/app/view/myview/myview-model.ts`): ```typescript import { BlockNodeModel } from "@/app/block/blocktypes"; import { globalStore } from "@/app/store/jotaiStore"; import { WOS, useBlockAtom } from "@/store/global"; import * as jotai from "jotai"; import { MyView } from "./myview"; export class MyViewModel implements ViewModel { viewType: string; blockId: string; nodeModel: BlockNodeModel; blockAtom: jotai.Atom; // Define your atoms (simple field initializers) viewIcon = jotai.atom("circle"); viewName = jotai.atom("My View"); noPadding = jotai.atom(true); // Derived atom (created in constructor) viewText!: jotai.Atom; constructor(blockId: string, nodeModel: BlockNodeModel) { this.viewType = "myview"; this.blockId = blockId; this.nodeModel = nodeModel; this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); // Create derived atoms that depend on block data or other atoms this.viewText = jotai.atom((get) => { const blockData = get(this.blockAtom); const rtn: HeaderElem[] = []; // Add header buttons/text based on state rtn.push({ elemtype: "iconbutton", icon: "refresh", title: "Refresh", click: () => this.refresh(), }); return rtn; }); } get viewComponent(): ViewComponent { return MyView; } refresh() { // Update state using globalStore // Never use React hooks in model methods console.log("refreshing..."); } giveFocus(): boolean { // Focus your view component return true; } dispose() { // Cleanup resources (unsubscribe from events, etc.) } } ``` ### 2. Create the View Component Create your React component (e.g., `frontend/app/view/myview/myview.tsx`): ```typescript import { ViewComponentProps } from "@/app/block/blocktypes"; import { MyViewModel } from "./myview-model"; import { useAtomValue } from "jotai"; import "./myview.scss"; export const MyView: React.FC> = ({ blockId, model, contentRef }) => { // Use atoms from the model (these are React hooks - call at top level!) const blockData = useAtomValue(model.blockAtom); return (
Block ID: {blockId}
View: {model.viewType}
{/* Your view content here */}
); }; ``` ### 3. Register the View Add your view to the `BlockRegistry` in `frontend/app/block/block.tsx`: ```typescript const BlockRegistry: Map = new Map(); BlockRegistry.set("term", TermViewModel); BlockRegistry.set("preview", PreviewModel); BlockRegistry.set("web", WebViewModel); // ... existing registrations ... BlockRegistry.set("myview", MyViewModel); // Add your view here ``` The registry key (e.g., `"myview"`) becomes the view type used in block metadata. ### 4. Create Blocks with Your View Users can create blocks with your view type: - Via CLI: `wsh view myview` - Via RPC: Use the block's `meta.view` field set to `"myview"` ## Real-World Examples ### Example 1: Terminal View (`term-model.ts`) The terminal view demonstrates: - **Connection management** via `manageConnection` atom - **Dynamic header buttons** showing shell status (play/restart) - **Mode switching** between terminal and vdom views - **Custom keyboard handling** for terminal-specific shortcuts - **Focus management** to focus the xterm.js instance - **Shell integration status** showing AI capability indicators Key features: ```typescript this.manageConnection = jotai.atom((get) => { const termMode = get(this.termMode); if (termMode == "vdom") return false; return true; // Show connection picker for regular terminal mode }); this.endIconButtons = jotai.atom((get) => { const shellProcStatus = get(this.shellProcStatus); const buttons: IconButtonDecl[] = []; if (shellProcStatus == "running") { buttons.push({ elemtype: "iconbutton", icon: "refresh", title: "Restart Shell", click: this.forceRestartController.bind(this), }); } return buttons; }); ``` ### Example 2: Web View (`webview.tsx`) The web view shows: - **Complex header controls** (back/forward/home/URL input) - **State management** for loading, URL, and navigation - **Event handling** for webview navigation events - **Custom styling** with `noPadding` for full-bleed content - **Media controls** showing play/pause/mute when media is active Key features: ```typescript this.viewText = jotai.atom((get) => { const url = get(this.url); const rtn: HeaderElem[] = []; // Navigation buttons rtn.push({ elemtype: "iconbutton", icon: "chevron-left", click: this.handleBack.bind(this), disabled: this.shouldDisableBackButton(), }); // URL input with nested controls rtn.push({ elemtype: "div", className: "block-frame-div-url", children: [ { elemtype: "input", value: url, onChange: this.handleUrlChange.bind(this), onKeyDown: this.handleKeyDown.bind(this), }, { elemtype: "iconbutton", icon: "rotate-right", click: this.handleRefresh.bind(this), }, ], }); return rtn; }); ``` ## Header Elements (`HeaderElem`) The `viewText` atom can return an array of these element types: ```typescript // Icon button { elemtype: "iconbutton", icon: "refresh", title: "Tooltip text", click: () => { /* handler */ }, disabled?: boolean, iconColor?: string, iconSpin?: boolean, noAction?: boolean, // Shows icon but no click action } // Text element { elemtype: "text", text: "Display text", className?: string, noGrow?: boolean, ref?: React.RefObject, onClick?: (e: React.MouseEvent) => void, } // Text button { elemtype: "textbutton", text: "Button text", className?: string, title: "Tooltip", onClick: (e: React.MouseEvent) => void, } // Input field { elemtype: "input", value: string, className?: string, onChange: (e: React.ChangeEvent) => void, onKeyDown?: (e: React.KeyboardEvent) => void, onFocus?: (e: React.FocusEvent) => void, onBlur?: (e: React.FocusEvent) => void, ref?: React.RefObject, } // Container with children { elemtype: "div", className?: string, children: HeaderElem[], onMouseOver?: (e: React.MouseEvent) => void, onMouseOut?: (e: React.MouseEvent) => void, } // Menu button (dropdown) { elemtype: "menubutton", // ... MenuButtonProps ... } ``` ## Best Practices ### Jotai Model Pattern Follow these rules for Jotai atoms in models: 1. **Simple atoms as field initializers**: ```typescript viewIcon = jotai.atom("circle"); noPadding = jotai.atom(true); ``` 2. **Derived atoms in constructor** (need dependency on other atoms): ```typescript constructor(blockId: string, nodeModel: BlockNodeModel) { this.viewText = jotai.atom((get) => { const blockData = get(this.blockAtom); return [/* computed based on blockData */]; }); } ``` 3. **Models never use React hooks** - Use `globalStore.get()`/`set()`: ```typescript refresh() { const currentData = globalStore.get(this.blockAtom); globalStore.set(this.dataAtom, newData); } ``` 4. **Components use hooks for atoms**: ```typescript const data = useAtomValue(model.dataAtom); const [value, setValue] = useAtom(model.valueAtom); ``` ### State Management - All view state should live in atoms on the model - Use `useBlockAtom()` helper for block-scoped atoms that persist - Use `globalStore` for imperative access outside React components - Subscribe to Wave events using `waveEventSubscribe()` ### Styling - Create a `.scss` file for your view styles - Use Tailwind utilities where possible (v4) - Add `noPadding: atom(true)` for full-bleed content - Use `blockBg` atom to customize block background ### Focus Management Implement `giveFocus()` to focus your view when: - Block gains focus via keyboard navigation - User clicks the block - Return `true` if successfully focused, `false` otherwise ### Keyboard Handling Implement `keyDownHandler(e: WaveKeyboardEvent)` for: - View-specific keyboard shortcuts - Return `true` if event was handled (prevents propagation) - Use `keyutil.checkKeyPressed(waveEvent, "Cmd:K")` for shortcut checks ### Cleanup Implement `dispose()` to: - Unsubscribe from Wave events - Unregister routes/handlers - Clear timers/intervals - Release resources ### Connection Management For views that need remote connections: ```typescript this.manageConnection = jotai.atom(true); // Show connection picker this.filterOutNowsh = jotai.atom(true); // Hide nowsh connections ``` Access connection status: ```typescript const connStatus = jotai.atom((get) => { const blockData = get(this.blockAtom); const connName = blockData?.meta?.connection; return get(getConnStatusAtom(connName)); }); ``` ## Common Patterns ### Reading Block Metadata ```typescript import { getBlockMetaKeyAtom } from "@/store/global"; // In constructor: this.someFlag = getBlockMetaKeyAtom(blockId, "myview:flag"); // In component: const flag = useAtomValue(model.someFlag); ``` ### Configuration Overrides Wave has a hierarchical config system (global → connection → block): ```typescript import { getOverrideConfigAtom } from "@/store/global"; this.settingAtom = jotai.atom((get) => { // Checks block meta, then connection config, then global settings return get(getOverrideConfigAtom(this.blockId, "myview:setting")) ?? defaultValue; }); ``` ### Updating Block Metadata ```typescript import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WOS } from "@/store/global"; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "myview:key": value }, }); ``` ## Additional Resources - `frontend/app/block/blockframe-header.tsx` - Block header rendering - `frontend/app/view/term/term-model.ts` - Complex view example - `frontend/app/view/webview/webview.tsx` - Navigation UI example - `frontend/types/custom.d.ts` - Type definitions ================================================ FILE: .kilocode/skills/electron-api/SKILL.md ================================================ --- name: electron-api description: Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. --- # Adding Electron APIs Electron APIs allow the frontend to call Electron main process functionality directly via IPC. ## Four Files to Edit 1. [`frontend/types/custom.d.ts`](frontend/types/custom.d.ts) - TypeScript [`ElectronApi`](frontend/types/custom.d.ts:82) type 2. [`emain/preload.ts`](emain/preload.ts) - Expose method via `contextBridge` 3. [`emain/emain-ipc.ts`](emain/emain-ipc.ts) - Implement IPC handler 4. [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts) - Add a no-op stub to keep the `previewElectronApi` object in sync with the `ElectronApi` type ## Three Communication Patterns 1. **Sync** - `ipcRenderer.sendSync()` + `ipcMain.on()` + `event.returnValue = ...` 2. **Async** - `ipcRenderer.invoke()` + `ipcMain.handle()` 3. **Fire-and-forget** - `ipcRenderer.send()` + `ipcMain.on()` ## Example: Async Method ### 1. Define TypeScript Interface In [`frontend/types/custom.d.ts`](frontend/types/custom.d.ts): ```typescript type ElectronApi = { captureScreenshot: (rect: Electron.Rectangle) => Promise; // capture-screenshot }; ``` ### 2. Expose in Preload In [`emain/preload.ts`](emain/preload.ts): ```typescript contextBridge.exposeInMainWorld("api", { captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke("capture-screenshot", rect), }); ``` ### 3. Implement Handler In [`emain/emain-ipc.ts`](emain/emain-ipc.ts): ```typescript electron.ipcMain.handle("capture-screenshot", async (event, rect) => { const tabView = getWaveTabViewByWebContentsId(event.sender.id); if (!tabView) throw new Error("No tab view found"); const image = await tabView.webContents.capturePage(rect); return `data:image/png;base64,${image.toPNG().toString("base64")}`; }); ``` ### 4. Add Preview Stub In [`frontend/preview/preview-electron-api.ts`](frontend/preview/preview-electron-api.ts): ```typescript captureScreenshot: (_rect: Electron.Rectangle) => Promise.resolve(""), ``` ### 5. Call from Frontend ```typescript import { getApi } from "@/store/global"; const dataUrl = await getApi().captureScreenshot({ x: 0, y: 0, width: 800, height: 600 }); ``` ## Example: Sync Method ### 1. Define ```typescript type ElectronApi = { getUserName: () => string; // get-user-name }; ``` ### 2. Preload ```typescript getUserName: () => ipcRenderer.sendSync("get-user-name"), ``` ### 3. Handler (⚠️ MUST set event.returnValue or browser hangs) ```typescript electron.ipcMain.on("get-user-name", (event) => { event.returnValue = process.env.USER || "unknown"; }); ``` ### 4. Call ```typescript import { getApi } from "@/store/global"; const userName = getApi().getUserName(); // blocks until returns ``` ## Example: Fire-and-Forget ### 1. Define ```typescript type ElectronApi = { openExternal: (url: string) => void; // open-external }; ``` ### 2. Preload ```typescript openExternal: (url) => ipcRenderer.send("open-external", url), ``` ### 3. Handler ```typescript electron.ipcMain.on("open-external", (event, url) => { electron.shell.openExternal(url); }); ``` ## Example: Event Listener ### 1. Define ```typescript type ElectronApi = { onZoomFactorChange: (callback: (zoomFactor: number) => void) => void; // zoom-factor-change }; ``` ### 2. Preload ```typescript onZoomFactorChange: (callback) => ipcRenderer.on("zoom-factor-change", (_event, zoomFactor) => callback(zoomFactor)), ``` ### 3. Send from Main ```typescript webContents.send("zoom-factor-change", newZoomFactor); ``` ## Quick Reference **Use Sync when:** - Getting config/env vars - Quick lookups, no I/O - ⚠️ **CRITICAL**: Always set `event.returnValue` or browser hangs **Use Async when:** - File operations - Network requests - Can fail or take time **Use Fire-and-forget when:** - No return value needed - Triggering actions **Electron API vs RPC:** - Electron API: Native OS features, window management, Electron APIs - RPC: Database, backend logic, remote servers ## Checklist - [ ] Add to [`ElectronApi`](frontend/types/custom.d.ts:82) in [`custom.d.ts`](frontend/types/custom.d.ts) - [ ] Include IPC channel name in comment - [ ] Expose in [`preload.ts`](emain/preload.ts) - [ ] Implement in [`emain-ipc.ts`](emain/emain-ipc.ts) - [ ] Add no-op stub to [`preview-electron-api.ts`](frontend/preview/preview-electron-api.ts) - [ ] IPC channel names match exactly - [ ] **For sync**: Set `event.returnValue` (or browser hangs!) - [ ] Test end-to-end ================================================ FILE: .kilocode/skills/waveenv/SKILL.md ================================================ --- name: waveenv description: Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. --- # WaveEnv Narrowing Skill ## Purpose A WaveEnv narrowing creates a _named subset type_ of `WaveEnv` that: 1. Documents exactly which parts of the environment a component tree actually uses. 2. Forms a type contract so callers and tests know what to provide. 3. Enables mocking in the preview/test server — you only need to implement what's listed. ## When To Create One Create a narrowing whenever you are writing a component (or group of components) that you want to test in the preview server, or when you want to make the environmental dependencies of a component tree explicit. ## Core Principle: Only Include What You Use **Only list the fields, methods, atoms, and keys that the component tree actually accesses.** If you don't call `wos`, don't include `wos`. If you only call one RPC command, only list that one command. The narrowing is a precise dependency declaration — not a copy of `WaveEnv`. ## File Location - **Separate file** (preferred for shared/complex envs): name it `env.ts` next to the component, e.g. `frontend/app/block/blockenv.ts`. - **Inline** (acceptable for small, single-file components): export the type directly from the component file, e.g. `WidgetsEnv` in `frontend/app/workspace/widgets.tsx`. ## Imports Required ```ts import { BlockMetaKeyAtomFnType, // only if you use getBlockMetaKeyAtom ConnConfigKeyAtomFnType, // only if you use getConnConfigKeyAtom SettingsKeyAtomFnType, // only if you use getSettingsKeyAtom WaveEnv, WaveEnvSubset, } from "@/app/waveenv/waveenv"; ``` ## The Shape ```ts export type MyEnv = WaveEnvSubset<{ // --- Simple WaveEnv properties --- // Copy the type verbatim from WaveEnv with WaveEnv["key"] syntax. isDev: WaveEnv["isDev"]; createBlock: WaveEnv["createBlock"]; showContextMenu: WaveEnv["showContextMenu"]; platform: WaveEnv["platform"]; // --- electron: list only the methods you call --- electron: { openExternal: WaveEnv["electron"]["openExternal"]; }; // --- rpc: list only the commands you call --- rpc: { ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; }; // --- atoms: list only the atoms you read --- atoms: { modalOpen: WaveEnv["atoms"]["modalOpen"]; fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; }; // --- wos: always take the whole thing, no sub-typing needed --- wos: WaveEnv["wos"]; // --- services: list only the services you call; no method-level narrowing --- services: { block: WaveEnv["services"]["block"]; workspace: WaveEnv["services"]["workspace"]; }; // --- key-parameterized atom factories: enumerate the keys you use --- getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"view" | "frame:title" | "connection">; getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; // --- other atom helpers: copy verbatim --- getConnStatusAtom: WaveEnv["getConnStatusAtom"]; getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; }>; ``` ### Automatically Included Fields Every `WaveEnvSubset` automatically includes the mock fields — you never need to declare them: - `isMock: boolean` - `mockSetWaveObj: (oref: string, obj: T) => void` - `mockModels?: Map` ### Rules for Each Section | Section | Pattern | Notes | | -------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | `electron` | `electron: { method: WaveEnv["electron"]["method"]; }` | List every method called; omit the rest. | | `rpc` | `rpc: { Cmd: WaveEnv["rpc"]["Cmd"]; }` | List every RPC command called; omit the rest. | | `atoms` | `atoms: { atom: WaveEnv["atoms"]["atom"]; }` | List every atom read; omit the rest. | | `wos` | `wos: WaveEnv["wos"]` | Take the whole `wos` object (no sub-typing needed), but **only add it if `wos` is actually used**. | | `services` | `services: { svc: WaveEnv["services"]["svc"]; }` | List each service used; take the whole service object (no method-level narrowing). | | `getSettingsKeyAtom` | `SettingsKeyAtomFnType<"key1" \| "key2">` | Union all settings keys accessed. | | `getBlockMetaKeyAtom` | `BlockMetaKeyAtomFnType<"key1" \| "key2">` | Union all block meta keys accessed. | | `getConnConfigKeyAtom` | `ConnConfigKeyAtomFnType<"key1">` | Union all conn config keys accessed. | | All other `WaveEnv` fields | `WaveEnv["fieldName"]` | Copy type verbatim. | ## Using the Narrowed Type in Components ```ts import { useWaveEnv } from "@/app/waveenv/waveenv"; import { MyEnv } from "./myenv"; const MyComponent = memo(() => { const env = useWaveEnv(); // TypeScript now enforces you only access what's in MyEnv. const val = useAtomValue(env.getSettingsKeyAtom("app:focusfollowscursor")); ... }); ``` The generic parameter on `useWaveEnv()` casts the context to your narrowed type. The real production `WaveEnv` satisfies every narrowing; mock envs only need to implement the listed subset. ## Real Examples - `BlockEnv` in `frontend/app/block/blockenv.ts` — complex narrowing with all section types, in a separate file. - `WidgetsEnv` in `frontend/app/workspace/widgets.tsx` — smaller narrowing defined inline in the component file. ================================================ FILE: .kilocode/skills/wps-events/SKILL.md ================================================ --- name: wps-events description: Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. --- # WPS Events Guide ## Overview WPS (Wave PubSub) is Wave Terminal's publish-subscribe event system that enables different parts of the application to communicate asynchronously. The system uses a broker pattern to route events from publishers to subscribers based on event types and scopes. ## Key Files - `pkg/wps/wpstypes.go` - Event type constants and data structures - `pkg/wps/wps.go` - Broker implementation and core logic - `pkg/wcore/wcore.go` - Example usage patterns ## Event Structure Events in WPS have the following structure: ```go type WaveEvent struct { Event string `json:"event"` // Event type constant Scopes []string `json:"scopes,omitempty"` // Optional scopes for targeted delivery Sender string `json:"sender,omitempty"` // Optional sender identifier Persist int `json:"persist,omitempty"` // Number of events to persist in history Data any `json:"data,omitempty"` // Event payload } ``` ## Adding a New Event Type ### Step 1: Define the Event Constant Add your event type constant to `pkg/wps/wpstypes.go`: ```go const ( Event_BlockClose = "blockclose" Event_ConnChange = "connchange" // ... other events ... Event_YourNewEvent = "your:newevent" // type: YourEventData (or "none" if no data) ) ``` **Naming Convention:** - Use descriptive PascalCase for the constant name with `Event_` prefix - Use lowercase with colons for the string value (e.g., "namespace:eventname") - Group related events with the same namespace prefix - Always add a `// type: ` comment; use `// type: none` if no data is sent ### Step 2: Add to AllEvents Add your new constant to the `AllEvents` slice in `pkg/wps/wpstypes.go`: ```go var AllEvents []string = []string{ // ... existing events ... Event_YourNewEvent, } ``` ### Step 3: Register in WaveEventDataTypes (REQUIRED) You **must** add an entry to `WaveEventDataTypes` in `pkg/tsgen/tsgenevent.go`. This drives TypeScript type generation for the event's `data` field: ```go var WaveEventDataTypes = map[string]reflect.Type{ // ... existing entries ... wps.Event_YourNewEvent: reflect.TypeOf(YourEventData{}), // value type // wps.Event_YourNewEvent: reflect.TypeOf((*YourEventData)(nil)), // pointer type // wps.Event_YourNewEvent: nil, // no data (type: none) } ``` - Use `reflect.TypeOf(YourType{})` for value types - Use `reflect.TypeOf((*YourType)(nil))` for pointer types - Use `nil` if no data is sent for the event ### Step 4: Define Event Data Structure (Optional) If your event carries structured data, define a type for it: ```go type YourEventData struct { Field1 string `json:"field1"` Field2 int `json:"field2"` } ``` ### Step 5: Expose Type to Frontend (If Needed) If your event data type isn't already exposed via an RPC call, you need to add it to `pkg/tsgen/tsgen.go` so TypeScript types are generated: ```go // add extra types to generate here var ExtraTypes = []any{ waveobj.ORef{}, // ... other types ... uctypes.RateLimitInfo{}, // Example: already added YourEventData{}, // Add your new type here } ``` Then run code generation: ```bash task generate ``` This will update `frontend/types/gotypes.d.ts` with TypeScript definitions for your type, ensuring type safety in the frontend when handling these events. ## Publishing Events ### Basic Publishing To publish an event, use the global broker: ```go import "github.com/wavetermdev/waveterm/pkg/wps" wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_YourNewEvent, Data: yourData, }) ``` ### Publishing with Scopes Scopes allow targeted event delivery. Subscribers can filter events by scope: ```go wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveObjUpdate, Scopes: []string{oref.String()}, // Target specific object Data: updateData, }) ``` ### Publishing in a Goroutine To avoid blocking the caller, publish events asynchronously: ```go go func() { wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_YourNewEvent, Data: data, }) }() ``` **When to use goroutines:** - When publishing from performance-critical code paths - When the event is informational and doesn't need immediate delivery - When publishing from code that holds locks (to prevent deadlocks) ### Event Persistence Events can be persisted in memory for late subscribers: ```go wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_YourNewEvent, Persist: 100, // Keep last 100 events Data: data, }) ``` ## Complete Example: Rate Limit Updates This example shows how rate limit information is published when AI chat responses include rate limit headers. ### 1. Define the Event Type In `pkg/wps/wpstypes.go`: ```go const ( // ... other events ... Event_WaveAIRateLimit = "waveai:ratelimit" ) ``` ### 2. Publish the Event In `pkg/aiusechat/usechat.go`: ```go import "github.com/wavetermdev/waveterm/pkg/wps" func updateRateLimit(info *uctypes.RateLimitInfo) { if info == nil { return } rateLimitLock.Lock() defer rateLimitLock.Unlock() globalRateLimitInfo = info // Publish event in goroutine to avoid blocking go func() { wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveAIRateLimit, Data: info, // RateLimitInfo struct }) }() } ``` ### 3. Subscribe to the Event (Frontend) In the frontend, subscribe to events via WebSocket: ```typescript // Subscribe to rate limit updates const subscription = { event: "waveai:ratelimit", allscopes: true, // Receive all rate limit events }; ``` ## Subscribing to Events ### From Go Code ```go // Subscribe to all events of a type wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ Event: wps.Event_YourNewEvent, AllScopes: true, }) // Subscribe to specific scopes wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ Event: wps.Event_WaveObjUpdate, Scopes: []string{"workspace:123"}, }) // Unsubscribe wps.Broker.Unsubscribe(routeId, wps.Event_YourNewEvent) ``` ### Scope Matching Scopes support wildcard matching: - `*` matches a single scope segment - `**` matches multiple scope segments ```go // Subscribe to all workspace events wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ Event: wps.Event_WaveObjUpdate, Scopes: []string{"workspace:*"}, }) ``` ## Best Practices 1. **Use Namespaces**: Prefix event names with a namespace (e.g., `waveai:`, `workspace:`, `block:`) 2. **Don't Block**: Use goroutines when publishing from performance-critical code or while holding locks 3. **Type-Safe Data**: Define struct types for event data rather than using maps 4. **Scope Wisely**: Use scopes to limit event delivery and reduce unnecessary processing 5. **Document Events**: Add comments explaining when events are fired and what data they carry 6. **Consider Persistence**: Use `Persist` for events that late subscribers might need (like status updates). This is normally not used. We normally do a live RPC call to get the current value and then subscribe for updates. ## Common Event Patterns ### Status Updates ```go wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_ControllerStatus, Scopes: []string{blockId}, Persist: 1, // Keep only latest status Data: statusData, }) ``` ### Object Updates ```go wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveObjUpdate, Scopes: []string{oref.String()}, Data: waveobj.WaveObjUpdate{ UpdateType: waveobj.UpdateType_Update, OType: obj.GetOType(), OID: waveobj.GetOID(obj), Obj: obj, }, }) ``` ### Batch Updates ```go // Helper function for multiple updates func (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) { for _, update := range updates { b.Publish(WaveEvent{ Event: Event_WaveObjUpdate, Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()}, Data: update, }) } } ``` ## Debugging To debug event flow: 1. Check broker subscription map: `wps.Broker.SubMap` 2. View persisted events: `wps.Broker.ReadEventHistory(eventType, scope, maxItems)` 3. Add logging in publish/subscribe methods 4. Monitor WebSocket traffic in browser dev tools ## Quick Reference When adding a new event: - [ ] Add event constant to [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go) with a `// type: ` comment (use `none` if no data) - [ ] Add the constant to `AllEvents` in [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go) - [ ] **REQUIRED**: Add an entry to `WaveEventDataTypes` in [`pkg/tsgen/tsgenevent.go`](pkg/tsgen/tsgenevent.go) — use `nil` for events with no data - [ ] Define event data structure (if needed) - [ ] Add data type to `pkg/tsgen/tsgen.go` for frontend use (if not already exposed via RPC) - [ ] Run `task generate` to update TypeScript types - [ ] Publish events using `wps.Broker.Publish()` - [ ] Use goroutines for non-blocking publish when appropriate - [ ] Subscribe to events in relevant components ================================================ FILE: .prettierignore ================================================ build bin .git frontend/dist frontend/node_modules *.min.* frontend/app/store/services.ts frontend/types/gotypes.d.ts ================================================ FILE: .roo/rules/overview.md ================================================ # Wave Terminal - High Level Architecture Overview ## Project Description Wave Terminal is an open-source AI-native terminal built for seamless workflows. It's an Electron application that serves as a command line terminal host (it hosts CLI applications rather than running inside a CLI). The application combines a React frontend with a Go backend server to provide a modern terminal experience with advanced features. ## Top-Level Directory Structure ``` waveterm/ ├── emain/ # Electron main process code ├── frontend/ # React application (renderer process) ├── cmd/ # Go command-line applications ├── pkg/ # Go packages/modules ├── db/ # Database migrations ├── docs/ # Documentation (Docusaurus) ├── build/ # Build configuration and assets ├── assets/ # Application assets (icons, images) ├── public/ # Static public assets ├── tests/ # Test files ├── .github/ # GitHub workflows and configuration └── Configuration files (package.json, tsconfig.json, etc.) ``` ## Architecture Components ### 1. Electron Main Process (`emain/`) The Electron main process handles the native desktop application layer: **Key Files:** - [`emain.ts`](emain/emain.ts) - Main entry point, application lifecycle management - [`emain-window.ts`](emain/emain-window.ts) - Window management (`WaveBrowserWindow` class) - [`emain-tabview.ts`](emain/emain-tabview.ts) - Tab view management (`WaveTabView` class) - [`emain-wavesrv.ts`](emain/emain-wavesrv.ts) - Go backend server integration - [`emain-wsh.ts`](emain/emain-wsh.ts) - WSH (Wave Shell) client integration - [`emain-ipc.ts`](emain/emain-ipc.ts) - IPC handlers for frontend ↔ main process communication - [`emain-menu.ts`](emain/emain-menu.ts) - Application menu system - [`updater.ts`](emain/updater.ts) - Auto-update functionality - [`preload.ts`](emain/preload.ts) - Preload script for renderer security - [`preload-webview.ts`](emain/preload-webview.ts) - Webview preload script ### 2. Frontend React Application (`frontend/`) The React application runs in the Electron renderer process: **Structure:** ``` frontend/ ├── app/ # Main application code │ ├── app.tsx # Root App component │ ├── aipanel/ # AI panel UI │ ├── block/ # Block-based UI components │ ├── element/ # Reusable UI elements │ ├── hook/ # Custom React hooks │ ├── modals/ # Modal components │ ├── store/ # State management (Jotai) │ ├── tab/ # Tab components │ ├── view/ # Different view types │ │ ├── codeeditor/ # Code editor (Monaco) │ │ ├── preview/ # File preview │ │ ├── sysinfo/ # System info view │ │ ├── term/ # Terminal view │ │ ├── tsunami/ # Tsunami builder view │ │ ├── vdom/ # Virtual DOM view │ │ ├── waveai/ # AI chat integration │ │ ├── waveconfig/ # Config editor view │ │ └── webview/ # Web view │ └── workspace/ # Workspace management ├── builder/ # Builder app entry ├── layout/ # Layout system ├── preview/ # Standalone preview renderer ├── types/ # TypeScript type definitions └── util/ # Utility functions ``` **Key Technologies:** - Electron (desktop application shell) - React 19 with TypeScript - Jotai for state management - Monaco Editor for code editing - XTerm.js for terminal emulation - Tailwind CSS v4 for styling - SCSS for additional styling (deprecated, new components should use Tailwind) - Vite / electron-vite for bundling - Task (Taskfile.yml) for build and code generation commands ### 3. Go Backend Server (`cmd/server/`) The Go backend server handles all heavy lifting operations: **Entry Point:** [`main-server.go`](cmd/server/main-server.go) ### 4. Go Packages (`pkg/`) The Go codebase is organized into modular packages: **Key Packages:** - `wstore/` - Database and storage layer - `wconfig/` - Configuration management - `wcore/` - Core business logic - `wshrpc/` - RPC communication system - `wshutil/` - WSH (Wave Shell) utilities - `blockcontroller/` - Block execution management - `remote/` - Remote connection handling - `filestore/` - File storage system - `web/` - Web server and WebSocket handling - `telemetry/` - Usage analytics and telemetry - `waveobj/` - Core data objects - `service/` - Service layer - `wps/` - Wave PubSub event system - `waveai/` - AI functionality - `shellexec/` - Shell execution - `util/` - Common utilities ### 5. Command Line Tools (`cmd/`) Key Go command-line utilities: - `wsh/` - Wave Shell command-line tool - `server/` - Main backend server - `generatego/` - Code generation - `generateschema/` - Schema generation - `generatets/` - TypeScript generation ## Communication Architecture The core communication system is built around the **WSH RPC (Wave Shell RPC)** system, which provides a unified interface for all inter-process communication: frontend ↔ Go backend, Electron main process ↔ backend, and backend ↔ remote systems (SSH, WSL). ### WSH RPC System (`pkg/wshrpc/`) The WSH RPC system is the backbone of Wave Terminal's communication architecture: **Key Components:** - [`wshrpctypes.go`](pkg/wshrpc/wshrpctypes.go) - Core RPC interface and type definitions (source of truth for all RPC commands) - [`wshserver/`](pkg/wshrpc/wshserver/) - Server-side RPC implementation - [`wshremote/`](pkg/wshrpc/wshremote/) - Remote connection handling - [`wshclient.go`](pkg/wshrpc/wshclient.go) - Go client for making RPC calls - [`frontend/app/store/wshclientapi.ts`](frontend/app/store/wshclientapi.ts) - Generated TypeScript RPC client **Routing:** Callers address RPC calls using _routes_ (e.g. a block ID, connection name, or `"waveapp"`) rather than caring about the underlying transport. The RPC layer resolves the route to the correct transport (WebSocket, Unix socket, SSH tunnel, stdio) automatically. This means the same RPC interface works whether the target is local or a remote SSH connection. ## Development Notes - **Build commands** - Use `task` (Taskfile.yml) for all build, generate, and packaging commands - **Code generation** - Run `task generate` after modifying Go types in `pkg/wshrpc/wshrpctypes.go`, `pkg/wconfig/settingsconfig.go`, or `pkg/waveobj/wtypemeta.go` - **Testing** - Vitest for frontend unit tests; standard `go test` for Go packages - **Database migrations** - SQL migration files in `db/migrations-wstore/` and `db/migrations-filestore/` - **Documentation** - Docusaurus site in `docs/` ================================================ FILE: .roo/rules/rules.md ================================================ Wave Terminal is a modern terminal which provides graphical blocks, dynamic layout, workspaces, and SSH connection management. It is cross platform and built on electron. ### Project Structure It has a TypeScript/React frontend and a Go backend. They talk together over `wshrpc` a custom RPC protocol that is implemented over websocket (and domain sockets). ### Coding Guidelines - **Go Conventions**: - Don't use custom enum types in Go. Instead, use string constants (e.g., `const StatusRunning = "running"` rather than creating a custom type like `type Status string`). - Use string constants for status values, packet types, and other string-based enumerations. - in Go code, prefer using Printf() vs Println() - use "Make" as opposed to "New" for struct initialization func names - in general const decls go at the top of the file (before types and functions) - NEVER run `go build` (especially in weird sub-package directories). we can tell if everything compiles by seeing there are no problems/errors. - **Synchronization**: - Always prefer to use the `lock.Lock(); defer lock.Unlock()` pattern for synchronization if possible - Avoid inline lock/unlock pairs - instead create helper functions that use the defer pattern - When accessing shared data structures (maps, slices, etc.), ensure proper locking - Example: Instead of `gc.lock.Lock(); gc.map[key]++; gc.lock.Unlock()`, create a helper function like `getNextValue(key string) int { gc.lock.Lock(); defer gc.lock.Unlock(); gc.map[key]++; return gc.map[key] }` - **TypeScript Imports**: - Use `@/...` for imports from different parts of the project (configured in `tsconfig.json` as `"@/*": ["frontend/*"]`). - Prefer relative imports (`"./name"`) only within the same directory. - Use named exports exclusively; avoid default exports. It's acceptable to export functions directly (e.g., React Components). - Our indent is 4 spaces - **JSON Field Naming**: All fields must be lowercase, without underscores. - **TypeScript Conventions** - **Type Handling**: - In TypeScript we have strict null checks off, so no need to add "| null" to all the types. - In TypeScript for Jotai atoms, if we want to write, we need to type the atom as a PrimitiveAtom - Jotai has a bug with strict null checks off where if you create a null atom, e.g. atom(null) it does not "type" correctly. That's no issue, just cast it to the proper PrimitiveAtom type (no "| null") and it will work fine. - Generally never use "=== undefined" or "!== undefined". This is bad style. Just use a "== null" or "!= null" unless it is a very specific case where we need to distinguish undefined from null. - **Coding Style**: - Use all lowercase filenames (except where case is actually important like Taskfile.yml) - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) - For element variants use class-variance-authority - Do NOT create private fields in classes (they are impossible to inspect) - Use PascalCase for global consts at the top of files - **Component Practices**: - Make sure to add cursor-pointer to buttons/links and clickable items - NEVER use cursor-help (it looks terrible) - useAtom() and useAtomValue() are react HOOKS, so they must be called at the component level not inline in JSX - If you use React.memo(), make sure to add a displayName for the component - Other - never use atob() or btoa() (not UTF-8 safe). use functions in frontend/util/util.ts for base64 decoding and encoding - In general, when writing functions, we prefer _early returns_ rather than putting the majority of a function inside of an if block. ### Styling - We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css - _never_ use cursor-help, or cursor-not-allowed (it looks terrible) - We have custom CSS setup as well, so it is a hybrid system. For new code we prefer tailwind, and are working to migrate code to all use tailwind. - For accent buttons, use "bg-accent/80 text-primary rounded hover:bg-accent transition-colors cursor-pointer" (if you do "bg-accent hover:bg-accent/80" it looks weird as on hover the button gets darker instead of lighter) ### RPC System To define a new RPC call, add the new definition to `pkg/wshrpc/wshrpctypes.go` including any input/output data that is required. After modifying wshrpctypes.go run `task generate` to generate the client APIs. For normal "server" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`. ### Electron API From within the FE to get the electron API (e.g. the preload functions): ```ts import { getApi } from "@/store/global"; getApi().getIsDev(); ``` The full API is defined in custom.d.ts as type ElectronApi. ### Code Generation - **TypeScript Types**: TypeScript types are automatically generated from Go types. After modifying Go types in `pkg/wshrpc/wshrpctypes.go`, run `task generate` to update the TypeScript type definitions in `frontend/types/gotypes.d.ts`. - **Manual Edits**: Do not manually edit generated files like `frontend/types/gotypes.d.ts` or `frontend/app/store/wshclientapi.ts`. Instead, modify the source Go types and run `task generate`. ### Frontend Architecture - The application uses Jotai for state management. - When working with Jotai atoms that need to be updated, define them as `PrimitiveAtom` rather than just `atom`. ### Notes - **CRITICAL: Completion format MUST be: "Done: [one-line description]"** - **Keep your Task Completed summaries VERY short** - **No double-summarization** - Put your summary ONLY inside attempt_completion. Do not write a summary in the message body AND then repeat it in attempt_completion. One summary, one place. - **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing - The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes - With React hooks, always complete all hook calls at the top level before any conditional returns (including jotai hook calls useAtom and useAtomValue); when a user explicitly tells you a function handles null inputs, trust them and stop trying to "protect" it with unnecessary checks or workarounds. - **Match response length to question complexity** - For simple, direct questions in Ask mode (especially those that can be answered in 1-2 sentences), provide equally brief answers. Save detailed explanations for complex topics or when explicitly requested. - **CRITICAL** - useAtomValue and useAtom are React HOOKS. They cannot be used inline in JSX code, they must appear at the top of a component in the hooks area of the react code. - for simple functions, we prefer `if (!cond) { return }; functionality;` pattern over `if (cond) { functionality }` because it produces less indentation and is easier to follow. - It is now 2026, so if you write new files, or update files use 2026 for the copyright year - React.MutableRefObject is deprecated, just use React.RefObject now (in React 19 RefObject is always mutable) ### Strict Comment Rules - **NEVER add comments that merely describe what code is doing**: - ❌ `mutex.Lock() // Lock the mutex` - ❌ `counter++ // Increment the counter` - ❌ `buffer.Write(data) // Write data to buffer` - ❌ `// Header component for app run list` (above AppRunListHeader) - ❌ `// Updated function to include onClick parameter` - ❌ `// Changed padding calculation` - ❌ `// Removed unnecessary div` - ❌ `// Using the model's width value here` - **Only use comments for**: - Explaining WHY a particular approach was chosen - Documenting non-obvious edge cases or side effects - Warning about potential pitfalls in usage - Explaining complex algorithms that can't be simplified - **When in doubt, leave it out**. No comment is better than a redundant comment. - **Never add comments explaining code changes** - The code should speak for itself, and version control tracks changes. The one exception to this rule is if it is a very unobvious implementation. Something that someone would typically implement in a different (wrong) way. Then the comment helps us remember WHY we changed it to a less obvious implementation. - **Never remove existing comments** unless specifically directed by the user. Comments that are already defined in existing code have been vetted by the user. ### Jotai Model Pattern (our rules) - **Atoms live on the model.** - **Simple atoms:** define as **field initializers**. - **Atoms that depend on values/other atoms:** create in the **constructor**. - Models **never use React hooks**; they use `globalStore.get/set`. - It's fine to call model methods from **event handlers** or **`useEffect`**. - Models use the **singleton pattern** with a `private static instance` field, a `private constructor`, and a `static getInstance()` method. - The constructor is `private`; callers always use `getInstance()`. ```ts // model/MyModel.ts import * as jotai from "jotai"; import { globalStore } from "@/app/store/jotaiStore"; export class MyModel { private static instance: MyModel | null = null; // simple atoms (field init) statusAtom = jotai.atom<"idle" | "running" | "error">("idle"); outputAtom = jotai.atom(""); // ctor-built atoms (need types) lengthAtom!: jotai.Atom; thresholdedAtom!: jotai.Atom; private constructor(initialThreshold = 20) { this.lengthAtom = jotai.atom((get) => get(this.outputAtom).length); this.thresholdedAtom = jotai.atom((get) => get(this.lengthAtom) > initialThreshold); } static getInstance(): MyModel { if (!MyModel.instance) { MyModel.instance = new MyModel(); } return MyModel.instance; } static resetInstance(): void { MyModel.instance = null; } async doWork() { globalStore.set(this.statusAtom, "running"); // ... do work ... globalStore.set(this.statusAtom, "idle"); } } ``` ```tsx // component usage (events & effects OK) import { useAtomValue } from "jotai"; function Panel() { const model = MyModel.getInstance(); const status = useAtomValue(model.statusAtom); const isBig = useAtomValue(model.thresholdedAtom); const onClick = () => model.doWork(); return (
{status} • {String(isBig)}
); } ``` **Remember:** singleton pattern with `getInstance()`, `private constructor`, atoms on the model, simple-as-fields, ctor for dependent/derived, updates via `globalStore.set/get`. **Note** Older models may not use the singleton pattern ### Tool Use Do NOT use write_to_file unless it is a new file or very short. Always prefer to use replace_in_file. Often your diffs fail when a file may be out of date in your cache vs the actual on-disk format. You should RE-READ the file and try to create diffs again if your diffs fail rather than fall back to write_to_file. If you feel like your ONLY option is to use write_to_file please ask first. Also when adding content to the end of files prefer to use the new append_file tool rather than trying to create a diff (as your diffs are often not specific enough and end up inserting code in the middle of existing functions). ### Directory Awareness - **ALWAYS verify the current working directory before executing commands** - Either run "pwd" first to verify the directory, or do a "cd" to the correct absolute directory before running commands - When running tests, do not "cd" to the pkg directory and then run the test. This screws up the cwd and you never recover. run the test from the project root instead. ### Testing / Compiling Go Code No need to run a `go build` or a `go run` to just check if the Go code compiles. VSCode's errors/problems cover this well. If there are no Go errors in VSCode you can assume the code compiles fine. ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "esbenp.prettier-vscode", "golang.go", "dbaeumer.vscode-eslint", "vitest.explorer", "task.vscode-task" ] } ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "editor.detectIndentation": false, "editor.formatOnPaste": true, "editor.tabSize": 4, "editor.insertSpaces": false, "prettier.useEditorConfig": true, "diffEditor.renderSideBySide": false, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[less]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[scss]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.insertSpaces": true, "editor.autoIndent": "keep" }, "[github-actions-workflow]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.insertSpaces": true, "editor.autoIndent": "keep" }, "[go]": { "editor.defaultFormatter": "golang.go" }, "[mdx]": { "editor.wordWrap": "on" }, "[md]": { "editor.wordWrap": "on" }, "files.associations": { "*.css": "tailwindcss" }, "gopls": { "analyses": { "QF1003": false }, "directoryFilters": ["-tsunami/frontend/scaffold", "-dist", "-make"] }, "tailwindCSS.lint.suggestCanonicalClasses": "ignore", "go.coverageDecorator": { "type": "gutter" } } ================================================ FILE: .zed/settings.json ================================================ { "format_on_save": "on", "languages": { "JavaScript": { "formatter": { "external": { "command": "./node_modules/.bin/prettier", "arguments": ["--stdin-filepath", "{buffer_path}"] } } }, "JSON": { "formatter": { "external": { "command": "./node_modules/.bin/prettier", "arguments": ["--stdin-filepath", "{buffer_path}"] } } }, "TypeScript": { "formatter": { "external": { "command": "./node_modules/.bin/prettier", "arguments": ["--stdin-filepath", "{buffer_path}"] } } }, "CSS": { "formatter": { "external": { "command": "./node_modules/.bin/prettier", "arguments": ["--stdin-filepath", "{buffer_path}"] } } }, "SCSS": { "formatter": { "external": { "command": "./node_modules/.bin/prettier", "arguments": ["--stdin-filepath", "{buffer_path}"] } } }, "YAML": { "formatter": { "external": { "command": "./node_modules/.bin/prettier", "arguments": ["--stdin-filepath", "{buffer_path}"] } } } }, "lsp": { "eslint": { "settings": { "codeActionOnSave": { "rules": ["import/order"] }, "nodePath": "./node_modules/.bin", "language_ids": ["typescript", "javascript", "typescriptreact", "javascriptreact"] } } } } ================================================ FILE: ACKNOWLEDGEMENTS.md ================================================ # Open-Source Acknowledgements We make use of many amazing open-source projects to build Wave Terminal. We automatically generate license reports via FOSSA to comply with the license distribution requirements of our dependencies. Below is a summary of the licenses used by our product. For a full report, see [here](https://app.fossa.com/reports/24d13570-624b-4450-8c22-756e513060c9?full=true) (the page may take 20-30s to load). [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_large) ================================================ FILE: BUILD.md ================================================ # Building Wave Terminal These instructions are for setting up dependencies and building Wave Terminal from source on macOS, Linux, and Windows. ## Prerequisites ### OS-specific dependencies See [Minimum requirements](README.md#minimum-requirements) to learn whether your OS is supported. #### macOS macOS does not have any platform-specific dependencies. #### Linux You must have `zip` installed. We also require the [Zig](https://ziglang.org/) compiler for statically linking CGO. Debian/Ubuntu: ```sh sudo apt install zip snapd sudo snap install zig --classic --beta ``` Fedora/RHEL: ```sh sudo dnf install zip zig ``` Arch: ```sh sudo pacman -S zip zig ``` ##### For packaging For packaging, the following additional packages are required: - `fpm` — If you're on x64 you can skip this. If you're on ARM64, install fpm via [Gem](https://rubygems.org/gems/fpm) - `rpm` — If you're not on Fedora, install RPM via your package manager. - `snapd` — If your distro doesn't already include it, [install `snapd`](https://snapcraft.io/docs/installing-snapd) - `lxd` — [Installation instructions](https://canonical.com/lxd/install) - `snapcraft` — Run `sudo snap install snapcraft --classic` - `libarchive-tools` — Install via your package manager - `binutils` — Install via your package manager - `libopenjp2-tools` — Install via your package manager - `squashfs-tools` — Install via your package manager #### Windows You will need the [Zig](https://ziglang.org/) compiler for statically linking CGO. You can find installation instructions for Zig on Windows [here](https://ziglang.org/learn/getting-started/#managers). ### Task Download and install Task (to run the build commands): https://taskfile.dev/installation/ Task is a modern equivalent to GNU Make. We use it to coordinate our build steps. You can find our full Task configuration in [Taskfile.yml](Taskfile.yml). ### Go Download and install Go via your package manager or directly from the website: https://go.dev/doc/install ### NodeJS Make sure you have a NodeJS 22 LTS installed. See NodeJS's website for platform-specific instructions: https://nodejs.org/en/download We now use `npm`, so you can just run an `npm install` to install node dependencies. ## Clone the Repo ```sh git clone git@github.com:wavetermdev/waveterm.git ``` or ```sh git clone https://github.com/wavetermdev/waveterm.git ``` ## Install code dependencies The first time you clone the repo, you'll need to run the following to load the dependencies. If you ever have issues building the app, try running this again: ```sh task init ``` ## Build and Run All the methods below will install Node and Go dependencies when they run the first time. All these should be run from within the Git repository. ### Development server Run the following command to build the app and run it via Vite's development server (this enables Hot Module Reloading): ```sh task dev ``` ### Standalone Run the following command to build the app and run it standalone, without the development server. This will not reload on change: ```sh task start ``` ### Packaged Run the following command to generate a production build and package it. This lets you install the app locally. All artifacts will be placed in `make/`. ```sh task package ``` If you're on Linux ARM64, run the following: ```sh USE_SYSTEM_FPM=1 task package ``` ## Debugging ### Frontend logs You can use the regular Chrome DevTools to debug the frontend application. You can open the DevTools using the keyboard shortcut `Cmd+Option+I` on macOS or `Ctrl+Option+I` on Linux and Windows. Logs will be sent to the Console tab in DevTools. ### Backend logs Backend logs for the development version of Wave can be found at `~/.waveterm-dev/waveapp.log`. Both the NodeJS backend from Electron and the main Go backend will log here. ================================================ FILE: CNAME ================================================ docs.waveterm.dev ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at coc@commandline.dev. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Wave Terminal Wave Terminal is an opinionated project with a single active maintainer. Contributions are welcome, but **alignment matters more than volume**. This document helps you decide _whether_ and _how_ to contribute in a way that's likely to be accepted, saving both of us time. ## High-level expectations - Wave has a strong product direction and centralized ownership. - Review bandwidth is limited. - Not all contributions can or will be accepted, even if they are technically correct. This is normal for a solo-maintainer project. ## What makes a great contribution The following are most likely to be accepted: - **Bug fixes** - especially with clear reproduction steps - **Documentation improvements** - typos, clarifications, examples - **Discussed features** - after alignment in Discord - **Small, focused changes** - easy to review and low risk If your change is small and obvious (typo fix, narrowly-scoped bug fix, small docs improvement), you are welcome to open a pull request directly. ## Keep changes focused **Only change what is necessary to accomplish your stated goal.** If you're fixing a bug in `file.ts`, do not: - Reformat other files - Clean up unrelated code - Fix style issues in files you didn't need to touch - Combine multiple unrelated fixes in one PR Even if these changes are "improvements," they make review harder and require unnecessary back-and-forth. If you want to clean up code, discuss it first and submit it as a separate, focused PR. **One PR = one logical change.** ## Discuss first (required for larger changes) For anything beyond a small fix, **discussion is required before opening a pull request**. This includes: - New features - UI/UX changes or changes to default behavior - Refactors or "cleanup" work - Performance rewrites - Architectural changes - Changes that touch many files or systems **Where to discuss:** Discord is the preferred place for these conversations -- https://discord.gg/XfvZ334gwU Pull requests that introduce larger changes without prior discussion will be closed without detailed review. This is not meant to discourage contribution — it is meant to ensure alignment before significant work is done. ## What this project is not To set expectations clearly: - Wave is not designed as a "first open source contribution" project - We do not currently curate beginner-friendly or mentorship issues - Large, unsolicited changes are unlikely to be accepted - Mechanical refactors, broad style changes, or drive-by rewrites are not helpful - AI-assisted contributions are welcome, but PRs must reflect clear understanding of context, existing patterns, and project direction. Low-effort or poorly supervised changes will be closed. Being clear about this helps everyone spend their time effectively. ## FAQ **Q: Should I ask before fixing a typo or obvious bug?** A: No, just open a PR for small, obvious fixes. **Q: I have an idea for a new feature.** A: Great! Come discuss it in Discord first. Do not open a PR without prior discussion. **Q: My PR was closed without detailed feedback.** A: This usually means it didn't align with project direction or required more review bandwidth than available. This is normal for a solo-maintained project. **Q: Can I work on an open issue?** A: Comment on the issue first to confirm it's still relevant and that nobody else is working on it. For anything non-trivial, discuss your approach before implementing. **Q: I noticed some code that could be cleaner while working on my fix.** A: Focus on your stated goal. Submit cleanup as a separate PR after discussion, if desired. ## Contributor License Agreement (CLA) Contributions to this project must be accompanied by a Contributor License Agreement (CLA). You (or your employer) retain the copyright to your contribution; the CLA simply gives us permission to use and redistribute your contributions as part of the project. On submission of your first pull request, you will be prompted to sign the CLA confirming that you own the intellectual property in your contribution. **A signed CLA is required before a pull request can be reviewed.** If the CLA is not completed within a reasonable timeframe, the pull request may be closed. ## Style guide The project uses American English. Please follow existing formatting and style conventions. Use gofmt and prettier where applicable. ## Development setup To build and run Wave locally, see instructions at [Building Wave Terminal](./BUILD.md). ## Code of Conduct All contributors are expected to follow the project's [Code of Conduct](./CODE_OF_CONDUCT.md). --- Thank you for your interest in Wave Terminal. Clear expectations help keep the project moving quickly and sustainably. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2025 Command Line Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: NOTICE ================================================ Copyright 2025, Command Line Inc. ================================================ FILE: README.ko.md ================================================

Wave Terminal Logo

# Wave Terminal
[English](README.md) | [한국어](README.ko.md)
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield) > 이 문서는 커뮤니티 한국어 번역본입니다. 최신 원문은 [README.md](README.md)에서 확인하세요. Wave는 macOS, Linux, Windows에서 동작하는 오픈소스 AI 통합 터미널입니다. 어떤 AI 모델과도 함께 사용할 수 있습니다. OpenAI, Claude, Gemini는 API 키를 직접 연결해 사용할 수 있고, Ollama 및 LM Studio를 통해 로컬 모델도 실행할 수 있습니다. 계정 생성은 필요하지 않습니다. 또한 Wave는 네트워크 중단이나 재시작 이후에도 유지되는 내구성 있는 SSH 세션을 지원하며, 자동 재연결 기능을 제공합니다. 내장 그래픽 에디터로 원격 파일을 편집하고, 터미널을 벗어나지 않고도 파일을 인라인으로 미리볼 수 있습니다. ![WaveTerm Screenshot](./assets/wave-screenshot.webp) ## 주요 기능 - Wave AI - 터미널 출력과 위젯을 이해하고 파일 작업까지 수행할 수 있는 컨텍스트 인지형 터미널 어시스턴트 - 내구성 있는 SSH 세션 - 연결 끊김, 네트워크 변경, Wave 재시작 상황에서도 자동 재연결로 세션 유지 - 터미널 블록, 에디터, 웹 브라우저, AI 어시스턴트를 유연하게 배치할 수 있는 드래그 앤 드롭 인터페이스 - 구문 강조와 최신 편집 기능을 제공하는 원격 파일 편집용 내장 에디터 - 원격 파일용 풍부한 미리보기 시스템 (Markdown, 이미지, 동영상, PDF, CSV, 디렉터리) - 블록 단위 빠른 전체 화면 토글 - 터미널/에디터/미리보기를 크게 보고 즉시 멀티 블록 보기로 복귀 - 다중 모델을 지원하는 AI 채팅 위젯 (OpenAI, Claude, Azure, Perplexity, Ollama) - 개별 명령을 분리하고 모니터링할 수 있는 Command Blocks - 한 번의 클릭으로 원격 연결 및 전체 터미널/파일 시스템 접근 - 네이티브 시스템 백엔드를 사용하는 안전한 시크릿 저장 - API 키와 자격 증명을 로컬에 저장하고 SSH 세션 간 공유 - 탭 테마, 터미널 스타일, 배경 이미지 등 폭넓은 커스터마이징 - CLI에서 워크스페이스를 제어하고 세션 간 데이터를 공유하는 강력한 `wsh` 명령 시스템 - `wsh file`을 통한 연결형 파일 관리 - 로컬과 원격 SSH 호스트 간 파일 복사/동기화 ## Wave AI Wave AI는 워크스페이스 맥락을 이해하는 터미널 어시스턴트입니다. - **터미널 컨텍스트**: 디버깅과 분석을 위해 터미널 출력과 스크롤백을 읽습니다. - **파일 작업**: 자동 백업 및 사용자 승인 기반으로 파일 읽기/쓰기/편집을 수행합니다. - **CLI 통합**: `wsh ai`로 명령줄에서 출력 파이프 연결 또는 파일 첨부가 가능합니다. - **BYOK 지원**: OpenAI, Claude, Gemini, Azure 등 다양한 제공자에 API 키를 직접 연결할 수 있습니다. - **로컬 모델**: Ollama, LM Studio 및 기타 OpenAI 호환 제공자를 통해 로컬 모델을 실행할 수 있습니다. - **무료 베타**: 경험 개선 기간 동안 AI 크레딧이 제공됩니다. - **곧 제공 예정**: 명령 실행 기능 (사용자 승인 기반) 자세한 내용은 [Wave AI 문서](https://docs.waveterm.dev/waveai)와 [Wave AI Modes 문서](https://docs.waveterm.dev/waveai-modes)를 참고하세요. ## 설치 Wave Terminal은 macOS, Linux, Windows에서 동작합니다. 플랫폼별 설치 방법은 [여기](https://docs.waveterm.dev/gettingstarted)에서 확인할 수 있습니다. 직접 다운로드하여 설치하려면 [www.waveterm.dev/download](https://www.waveterm.dev/download)을 이용하세요. ### 최소 요구 사항 Wave Terminal은 다음 플랫폼에서 실행됩니다. - macOS 11 이상 (arm64, x64) - Windows 10 1809 이상 (x64) - glibc-2.28 이상 기반 Linux (Debian 10, RHEL 8, Ubuntu 20.04 등) (arm64, x64) WSH 헬퍼는 다음 플랫폼에서 실행됩니다. - macOS 11 이상 (arm64, x64) - Windows 10 이상 (x64) - Linux Kernel 2.6.32 이상 (x64), Linux Kernel 3.1 이상 (arm64) ## 로드맵 Wave는 계속 발전하고 있습니다. 로드맵은 릴리스 목표에 맞춰 지속적으로 업데이트됩니다. [여기](./ROADMAP.md)에서 확인하세요. 향후 릴리스 방향에 의견을 주고 싶다면 [Discord](https://discord.gg/XfvZ334gwU)에 참여하거나 [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)를 등록해 주세요. ## 링크 - 홈페이지 — https://www.waveterm.dev - 다운로드 페이지 — https://www.waveterm.dev/download - 문서 — https://docs.waveterm.dev - X — https://x.com/wavetermdev - Discord 커뮤니티 — https://discord.gg/XfvZ334gwU ## 소스에서 빌드 [Building Wave Terminal](BUILD.md)을 참고하세요. ## 기여하기 Wave는 GitHub Issues를 이슈 추적에 사용합니다. [기여 가이드](CONTRIBUTING.md)에서 더 많은 정보를 확인할 수 있습니다. - [기여 방법](CONTRIBUTING.md#contributing-to-wave-terminal) - [기여 가이드라인](CONTRIBUTING.md#high-level-expectations) ## 라이선스 Wave Terminal은 Apache-2.0 라이선스를 따릅니다. 의존성 정보는 [여기](./ACKNOWLEDGEMENTS.md)에서 확인할 수 있습니다. ================================================ FILE: README.md ================================================

Wave Terminal Logo

# Wave Terminal
[English](README.md) | [한국어](README.ko.md)
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield) Wave is an open-source, AI-integrated terminal for macOS, Linux, and Windows. It works with any AI model. Bring your own API keys for OpenAI, Claude, or Gemini, or run local models via Ollama and LM Studio. No accounts required. Wave also supports durable SSH sessions that survive network interruptions and restarts, with automatic reconnection. Edit remote files with a built-in graphical editor and preview files inline without leaving the terminal. ![WaveTerm Screenshot](./assets/wave-screenshot.webp) ## Key Features - Wave AI - Context-aware terminal assistant that reads your terminal output, analyzes widgets, and performs file operations - Durable SSH Sessions - Remote terminal sessions survive connection interruptions, network changes, and Wave restarts with automatic reconnection - Flexible drag & drop interface to organize terminal blocks, editors, web browsers, and AI assistants - Built-in editor for editing remote files with syntax highlighting and modern editor features - Rich file preview system for remote files (markdown, images, video, PDFs, CSVs, directories) - Quick full-screen toggle for any block - expand terminals, editors, and previews for better visibility, then instantly return to multi-block view - AI chat widget with support for multiple models (OpenAI, Claude, Azure, Perplexity, Ollama) - Command Blocks for isolating and monitoring individual commands - One-click remote connections with full terminal and file system access - Secure secret storage using native system backends - store API keys and credentials locally, access them across SSH sessions - Rich customization including tab themes, terminal styles, and background images - Powerful `wsh` command system for managing your workspace from the CLI and sharing data between terminal sessions - Connected file management with `wsh file` - seamlessly copy and sync files between local and remote SSH hosts ## Wave AI Wave AI is your context-aware terminal assistant with access to your workspace: - **Terminal Context**: Reads terminal output and scrollback for debugging and analysis - **File Operations**: Read, write, and edit files with automatic backups and user approval - **CLI Integration**: Use `wsh ai` to pipe output or attach files directly from the command line - **BYOK Support**: Bring your own API keys for OpenAI, Claude, Gemini, Azure, and other providers - **Local Models**: Run local models with Ollama, LM Studio, and other OpenAI-compatible providers - **Free Beta**: Included AI credits while we refine the experience - **Coming Soon**: Command execution (with approval) Learn more in our [Wave AI documentation](https://docs.waveterm.dev/waveai) and [Wave AI Modes documentation](https://docs.waveterm.dev/waveai-modes). ## Installation Wave Terminal works on macOS, Linux, and Windows. Platform-specific installation instructions can be found [here](https://docs.waveterm.dev/gettingstarted). You can also install Wave Terminal directly from: [www.waveterm.dev/download](https://www.waveterm.dev/download). ### Minimum requirements Wave Terminal runs on the following platforms: - macOS 11 or later (arm64, x64) - Windows 10 1809 or later (x64) - Linux based on glibc-2.28 or later (Debian 10, RHEL 8, Ubuntu 20.04, etc.) (arm64, x64) The WSH helper runs on the following platforms: - macOS 11 or later (arm64, x64) - Windows 10 or later (x64) - Linux Kernel 2.6.32 or later (x64), Linux Kernel 3.1 or later (arm64) ## Roadmap Wave is constantly improving! Our roadmap will be continuously updated with our goals for each release. You can find it [here](./ROADMAP.md). Want to provide input to our future releases? Connect with us on [Discord](https://discord.gg/XfvZ334gwU) or open a [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)! ## Links - Homepage — https://www.waveterm.dev - Download Page — https://www.waveterm.dev/download - Documentation — https://docs.waveterm.dev - X — https://x.com/wavetermdev - Discord Community — https://discord.gg/XfvZ334gwU ## Building from Source See [Building Wave Terminal](BUILD.md). ## Contributing Wave uses GitHub Issues for issue tracking. Find more information in our [Contributions Guide](CONTRIBUTING.md), which includes: - [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal) - [Contribution guidelines](CONTRIBUTING.md#before-you-start) ### Sponsoring Wave ❤️ If Wave Terminal is useful to you or your company, consider sponsoring development. Sponsorship helps support the time spent building and maintaining the project. - https://github.com/sponsors/wavetermdev ## License Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./ACKNOWLEDGEMENTS.md). ================================================ FILE: RELEASES.md ================================================ # Building for release ## Step-by-step guide 1. Go to the [Actions tab](https://github.com/wavetermdev/waveterm/actions) and select "Bump Version" from the left sidebar. 2. Click on "Run workflow". - You will see two options: - "SemVer Bump": This defaults to `none`. Adjust this if you want to increment the version number according to semantic versioning rules (`patch`, `minor`, `major`). - "Is Prerelease": This defaults to `true`. If set to `true`, a `-beta.X` version will be appended to the end of the version. If one is already present and the base SemVer is not being incremented, the `-beta` version will be incremented (i.e. `0.11.1-beta.0` to `0.11.1-beta.1`). If set to `false`, the `-beta.X` suffix will be removed from the version number. If one was not already present, it will remain absent. - Some examples: - If you are creating a new prerelease following an official release, you would set "SemVer Bump" to to the expected version bump (`patch`, `minor`, or `major`) and "Is Prerelease" to `true`. - If you are bumping an existing prerelease to a new prerelease under the same version, you would set "SemVer Bump" to `none` and "Is Prerelease" to `true`. - If you are promoting a prerelease version to an official release, you would set "SemVer Bump" to `none` and "Is Prerelease" to `false`. 3. After "Bump Version" a "Build Helper" run will kick off automatically for the new version. When this completes, it will generate a [draft GitHub Release](https://github.com/wavetermdev/waveterm/releases) with all the built artifacts. 4. Review the artifacts in the release and test them locally. 5. When you are confident that the build is good, edit the GitHub Release to add a changelog and release summary and publish the release. 6. The new version will be published to our release feed automatically when the GitHub Release is published. If the build is a prerelease, it will only release to users subscribed to the `beta` channel. If it is a general release, it will be released to all users. ## Details ### Bump Version workflow All releases start by first bumping the package version and creating a new Git tag. We have a workflow set up to automate this. To run it, trigger a new run of the [Bump Version workflow](https://github.com/wavetermdev/waveterm/actions/workflows/bump-version.yml). When triggering the run, you will be prompted to select a version bump type, either `none`, `patch`, `minor`, or `major`, and whether the version is prerelease or not. This determines how much the version number is incremented. See [`version.cjs`](./version.cjs) for more details on how this works. Once the tag has been created, a new [Build Helper](#build-helper-workflow) run will be automatically queued to generate the artifacts. ### Build Helper workflow Our release builds are managed by the [Build Helper workflow](https://github.com/wavetermdev/waveterm/actions/workflows/build-helper.yml). Under the hood, this will call the `package` task in [`Taskfile.yml`](./Taskfile.yml), which will build the `wavesrv` and `wsh` binaries, then the frontend and Electron codebases using Vite, then it will call `electron-builder` to generate the distributable app packages. The configuration for `electron-builder` is defined in [`electron-builder.config.cjs`](./electron-builder.config.cjs). This will also sign and notarize the macOS app packages and sign the Windows packages. Once a build is complete, the artifacts will be placed in `s3://waveterm-github-artifacts/staging-w2/`. A new draft release will be created on GitHub and the artifacts will be uploaded there too. ### Testing new releases The [Build Helper workflow](https://github.com/wavetermdev/waveterm/actions/workflows/build-helper.yml). creates a draft release on GitHub once it completes. You can find this on the [Releases page](https://github.com/wavetermdev/waveterm/releases) of the repo. You can use this to download the build artifacts for testing. You can also use the `artifacts:download` task in the [`Taskfile.yml`](./Taskfile.yml) to download all the artifacts for a build. You will need to configure an AWS CLI profile with write permissions for the S3 buckets in order for the script to work. You should invoke the tasks as follows: ```bash task artifacts:download: -- --profile ``` ### Publishing a release Once you have validated that the new release is ready, navigate to the [Releases page](https://github.com/wavetermdev/waveterm/releases) and click on the draft release for the version that is ready. Click the pencil button in the top right corner to edit the draft. Use this opportunity to adjust the release notes as needed. When you are ready to publish, scroll all the way to the bottom of the release editor and click Publish. This will kick off the [Publish Release workflow](https://github.com/wavetermdev/waveterm/actions/workflows/publish-release.yml), at which point all further tasks are automated and hands-off. ### Automatic updates Thanks to [`electron-updater`](https://www.electron.build/auto-update.html), we are able to provide automatic app updates for macOS, Linux, and Windows, as long as the app was distributed as a DMG, AppImage, RPM, or DEB file (all Windows targets support auto updates). With each release, YAML files will be produced that point to the newest release for the current channel. These also include file sizes and checksums to aid in validating the packages. The app will check these files in our S3 bucket every hour to see if a new version is available. #### Update channels We utilize update channels to roll out beta and stable releases. These are determined based on the package versioning [described above](#bump-version-workflow). Users can select their update channel using the `autoupdate:channel` setting in Wave. See [here](https://www.electron.build/tutorials/release-using-channels.html) for more information. ### Package Managers We currently publish to Homebrew (macOS), WinGet (Windows), Chocolatey (Windows), and Snap (Linux or macOS). #### Homebrew Homebrew maintains an Autobump bot that regularly checks our release feed for new general releases and updates our Cask automatically. You can find the configuration for our cask [here](https://github.com/Homebrew/homebrew-cask/blob/master/Casks/w/wave.rb). We added ourselves to [this list](https://github.com/Homebrew/homebrew-cask/blob/master/.github/autobump.txt) to indicate that we want the bot to autobump us. #### WinGet WinGet uses PRs to manage version bumps for packages. They ship a tool called [`wingetcreate`](https://github.com/microsoft/winget-create) which automates most of this process. We run this tool in our [Publish Release workflow](https://github.com/wavetermdev/waveterm/actions/workflows/publish-release.yml) for all general releases. This publishes a PR to their repository using our [Wave Release Bot](https://github.com/wave-releaser) service account. They usually pick up these changes within a day. #### Chocolatey Chocolatey maintains a [PowerShell module](https://github.com/chocolatey-community/chocolatey-au) for publishing releases to their system. We have a separate repository which contains this script and the workflow to run it: [wavetermdev/chocolatey](https://github.com/wavetermdev/chocolatey). This workflow gets run once a day. It checks whether there are new changes, validates the SHA and that the package can install, and then pushes the new version to Chocolatey. It then commits the updated package spec back to our repository. They usually take up to two weeks to accept our updates. #### Snap Snap maintains [snapcraft](https://snapcraft.io/docs/snapcraft) to build and publish Snaps to the Snap Store. We run this tool in our [Publish Release workflow](https://github.com/wavetermdev/waveterm/actions/workflows/publish-release.yml) workflow for all beta and general releases. Beta releases publish only to the `beta` channel, while general releases publish to both `beta` and `stable`. These changes are picked up immediately. ### `electron-build` configuration Most of our configuration is fairly standard. The main exception to this is that we exclude our Go binaries from the ASAR archive that Electron generates. ASAR files cannot be executed by NodeJS because they are not seen as files and therefore cannot be executed via a Shell command. More information can be found [here](https://www.electronjs.org/docs/latest/tutorial/asar-archives#executing-binaries-inside-asar-archive). We also exclude most of our `node_modules` from packaging, as Vite handles packaging of any dependencies for us. The one exception is `monaco-editor`. ================================================ FILE: ROADMAP.md ================================================ # Wave Terminal Roadmap This roadmap outlines major upcoming features and improvements for Wave Terminal. As with any roadmap, priorities and timelines may shift as development progresses. Want input on the roadmap? Join the discussion on [Discord](https://discord.gg/XfvZ334gwU). Legend: ✅ Done | 🔧 In Progress | 🔷 Planned | 🤞 Stretch Goal ## Current AI Capabilities Wave Terminal's AI assistant is already powerful and continues to evolve. Here's what works today: ### AI Provider Support - ✅ OpenAI (including gpt-5 and gpt-5-mini models) - ✅ Google Gemini (v0.13) - ✅ OpenRouter and custom OpenAI-compatible endpoints (v0.13) - ✅ Azure OpenAI (modern and legacy APIs) (v0.13) - ✅ Local AI models via Ollama, LM Studio, vLLM, and other OpenAI-compatible servers (v0.13) ### Context & Input - ✅ Widget context integration - AI sees your open terminals, web views, and other widgets - ✅ Image and document upload - Attach images and files to conversations - ✅ Local file reading - Read text files and directory listings on local machine - ✅ Web search - Native web search capability for current information - ✅ Shell integration awareness - AI understands terminal state (shell, version, OS, etc.) ### Widget Interaction Tools - ✅ Widget screenshots - Capture visual state of any widget - ✅ Terminal scrollback access - Read terminal history and output - ✅ Web navigation - Control browser widgets ## ROADMAP Enhanced AI Capabilities ### AI Configuration & Flexibility - ✅ BYOK (Bring Your Own Key) - Use your own API keys for any supported provider (v0.13) - ✅ Local AI agents - Run AI models locally on your machine (v0.13) - 🔧 Enhanced provider configuration options - 🔷 Context (add markdown files to give persistent system context) ### Expanded Provider Support - 🔷 Anthropic Claude - Full integration with extended thinking and tool use ### Advanced AI Tools #### File Operations - ✅ AI file writing with intelligent diff previews - ✅ Rollback support for AI-made changes - 🔷 Multi-file editing workflows - 🔷 Safe file modification patterns #### Terminal Command Execution - 🔧 Execute commands directly from AI - ✅ Intelligent terminal state detection - 🔧 Command result capture and parsing ### Remote & Advanced Capabilities - 🔷 Remote file operations - Read and write files on SSH connections - 🔷 Custom AI-powered widgets (Tsunami framework) - 🔷 AI Can spawn Wave Blocks - 🔷 Drag&Drop from Preview Widgets to Wave AI ### Wave AI Widget Builder - 🔷 Visual builder for creating custom AI-powered widgets - 🔷 Template library for common AI workflows - 🔷 Rapid prototyping and iteration tools ## Other Platform & UX Improvements (Non AI) - 🔷 Import/Export tab layouts and widgets - 🔧 Enhanced layout actions (splitting, replacing blocks) - 🔷 Extended drag & drop for files/URLs - 🔷 Tab templates for quick workspace setup - 🔷 Advanced keybinding customization - 🔷 Widget launch shortcuts - 🔷 System keybinding reassignment - 🔷 Command Palette - 🔷 Monaco Editor theming ================================================ FILE: SECURITY.md ================================================ ## Reporting Security Issues To report vulnerabilities or security concerns, please email us at: [security@commandline.dev](mailto:security@commandline.dev) **Please do not report security vulnerabilities through public github issues.** ================================================ FILE: Taskfile.yml ================================================ # Copyright 2026, Command Line Inc. # SPDX-License-Identifier: Apache-2.0 version: "3" vars: APP_NAME: "Wave" BIN_DIR: "bin" VERSION: sh: node version.cjs RMRF: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Remove-Item -Force -Recurse -ErrorAction SilentlyContinue{{else}}rm -rf{{end}}' DATE: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Get-Date -UFormat{{else}}date{{end}}' ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2 RELEASES_BUCKET: dl.waveterm.dev/releases-w2 WINGET_PACKAGE: CommandLine.Wave tasks: electron:dev: desc: Run the Electron application via the Vite dev server (enables hot reloading). cmd: npm run dev aliases: - dev deps: - npm:install - build:backend - build:tsunamiscaffold env: WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central" WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central" WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev" WAVETERM_NOCONFIRMQUIT: "1" electron:start: desc: Run the Electron application directly. cmd: npm run start aliases: - start deps: - npm:install - build:backend env: WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central" WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central" WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev" electron:quickdev: desc: Run the Electron application via the Vite dev server (quick dev - no docsite, arm64 only, no generate, no wsh). cmd: npm run dev deps: - npm:install - build:backend:quickdev env: WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central" WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central" WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/" WAVETERM_NOCONFIRMQUIT: "1" preview: desc: Run the standalone component preview server with HMR (no Electron, no backend). dir: frontend/preview cmd: npx vite deps: - npm:install build:preview: desc: Build the component preview server for static deployment. dir: frontend/preview cmd: npx vite build deps: - npm:install electron:winquickdev: desc: Run the Electron application via the Vite dev server (quick dev - Windows amd64 only, no generate, no wsh). cmd: npm run dev deps: - npm:install - build:backend:quickdev:windows env: WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central" WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central" WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/" docs:npm:install: desc: Runs `npm install` in docs directory internal: true generates: - docs/node_modules/**/* - docs/package-lock.json sources: - docs/package-lock.json - docs/package.json cmd: npm install dir: docs docsite:start: desc: Start the docsite dev server. cmd: npm run start dir: docs aliases: - docsite deps: - docs:npm:install docsite:build:public: desc: Build the full docsite. cmds: - cd docs && npm run build env: USE_SIMPLE_CSS_MINIFIER: "true" sources: - "docs/*" - "docs/src/**/*" - "docs/docs/**/*" - "docs/static/**/*" generates: - "docs/build/**/*" deps: - docs:npm:install package: desc: Package the application for the current platform. cmds: - npm run build:prod && npm exec electron-builder -- -c electron-builder.config.cjs -p never {{.CLI_ARGS}} deps: - clean - npm:install - build:backend - build:tsunamiscaffold build:frontend:dev: desc: Build the frontend in development mode. cmd: npm run build:dev deps: - npm:install build:backend: desc: Build the wavesrv and wsh components. cmds: - task: build:server - task: build:wsh build:backend:quickdev: desc: Build only the wavesrv component for quickdev (arm64 macOS only, no generate, no wsh). cmds: - task: build:server:quickdev sources: - go.mod - go.sum - pkg/**/*.go - pkg/**/*.sh - cmd/**/*.go - tsunami/go.mod - tsunami/go.sum - tsunami/**/*.go build:schema: desc: Build the schema for configuration. sources: - "cmd/generateschema/*.go" - "pkg/wconfig/*.go" generates: - "dist/schema/**/*" cmds: - go run cmd/generateschema/main-generateschema.go - cmd: '{{.RMRF}} "dist/schema"' ignore_error: true - task: copyfiles:'schema':'dist/schema' build:server: desc: Build the wavesrv component. cmds: - task: build:server:linux - task: build:server:macos - task: build:server:windows deps: - go:mod:tidy - generate sources: - "cmd/server/*.go" - "pkg/**/*.go" - "pkg/**/*.json" - "pkg/**/*.sh" - tsunami/**/*.go generates: - dist/bin/wavesrv.* build:server:macos: desc: Build the wavesrv component for macOS (Darwin) platforms (generates artifacts for both arm64 and amd64). platforms: [darwin] cmds: - cmd: rm -f dist/bin/wavesrv* ignore_error: true - task: build:server:internal vars: ARCHS: arm64,amd64 build:server:quickdev: desc: Build the wavesrv component for quickdev (arm64 macOS only, no generate). platforms: [darwin] cmds: - task: build:server:internal vars: ARCHS: arm64 deps: - go:mod:tidy sources: - "cmd/server/*.go" - "pkg/**/*.go" - "pkg/**/*.json" - "pkg/**/*.sh" - "tsunami/**/*.go" generates: - dist/bin/wavesrv.* build:backend:quickdev:windows: desc: Build only the wavesrv component for quickdev (Windows amd64 only, no generate, no wsh). platforms: [windows] cmds: - task: build:server:internal vars: ARCHS: amd64 GO_ENV_VARS: CC="zig cc -target x86_64-windows-gnu" deps: - go:mod:tidy sources: - "cmd/server/*.go" - "pkg/**/*.go" - "pkg/**/*.json" - "pkg/**/*.sh" - "tsunami/**/*.go" generates: - dist/bin/wavesrv.x64.exe build:server:windows: desc: Build the wavesrv component for Windows platforms (only generates artifacts for the current architecture). platforms: [windows] cmds: - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wavesrv*" ignore_error: true - task: build:server:internal vars: ARCHS: sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}} GO_ENV_VARS: sh: echo "{{if eq "amd64" ARCH}}CC=\"zig cc -target x86_64-windows-gnu\"{{else}}CC=\"zig cc -target aarch64-windows-gnu\"{{end}}" build:server:linux: desc: Build the wavesrv component for Linux platforms (only generates artifacts for the current architecture). platforms: [linux] cmds: - cmd: rm -f dist/bin/wavesrv* ignore_error: true - task: build:server:internal vars: ARCHS: sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}} GO_ENV_VARS: sh: echo "{{if eq "amd64" ARCH}}CC=\"zig cc -target x86_64-linux-gnu.2.28\"{{else}}CC=\"zig cc -target aarch64-linux-gnu.2.28\"{{end}}" build:server:internal: requires: vars: - ARCHS cmd: cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} {{.GO_ENV_VARS}} go build -tags "osusergo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go for: var: ARCHS split: "," as: GOARCH internal: true build:wsh: desc: Build the wsh component for all possible targets. cmds: - cmd: rm -f dist/bin/wsh* platforms: [darwin, linux] ignore_error: true - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*" platforms: [windows] ignore_error: true - task: build:wsh:parallel deps: - go:mod:tidy - generate sources: - "cmd/wsh/**/*.go" - "pkg/**/*.go" generates: - "dist/bin/wsh*" build:wsh:parallel: deps: - task: build:wsh:internal vars: GOOS: darwin GOARCH: arm64 - task: build:wsh:internal vars: GOOS: darwin GOARCH: amd64 - task: build:wsh:internal vars: GOOS: linux GOARCH: arm64 - task: build:wsh:internal vars: GOOS: linux GOARCH: amd64 - task: build:wsh:internal vars: GOOS: linux GOARCH: mips - task: build:wsh:internal vars: GOOS: linux GOARCH: mips64 - task: build:wsh:internal vars: GOOS: windows GOARCH: amd64 - task: build:wsh:internal vars: GOOS: windows GOARCH: arm64 internal: true build:wsh:internal: vars: EXT: sh: echo {{if eq .GOOS "windows"}}.exe{{end}} NORMALIZEDARCH: sh: echo {{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}} requires: vars: - GOOS - GOARCH - VERSION cmd: (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go) internal: true build:tsunamiscaffold: desc: Build and copy tsunami scaffold to dist directory. cmds: - cmd: "{{.RMRF}} dist/tsunamiscaffold" ignore_error: true - task: copyfiles:'tsunami/frontend/scaffold':'dist/tsunamiscaffold' - cmd: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Copy-Item -Path tsunami/templates/empty-gomod.tmpl -Destination dist/tsunamiscaffold/go.mod{{else}}cp tsunami/templates/empty-gomod.tmpl dist/tsunamiscaffold/go.mod{{end}}' deps: - tsunami:scaffold sources: - "tsunami/frontend/dist/**/*" - "tsunami/templates/**/*" generates: - "dist/tsunamiscaffold/**/*" generate: desc: Generate Typescript bindings for the Go backend. cmds: - go run cmd/generatets/main-generatets.go - go run cmd/generatego/main-generatego.go deps: - build:schema sources: - "cmd/generatego/*.go" - "cmd/generatets/*.go" - "pkg/**/*.go" # don't add generates key (otherwise will always execute) outdated: desc: Check for outdated packages using npm-check-updates. cmd: npx npm-check-updates@latest version: desc: Get the current package version, or bump version if args are present. To pass args to `version.cjs`, add them after `--`. See `version.cjs` for usage definitions for the arguments. cmd: node version.cjs {{.CLI_ARGS}} artifacts:upload: desc: Uploads build artifacts to the staging bucket in S3. To add additional AWS CLI arguments, add them after `--`. vars: ORIGIN: "make/" DESTINATION: "{{.ARTIFACTS_BUCKET}}/{{.VERSION}}" cmd: aws s3 cp {{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive --exclude "*/*" --exclude "builder-*.yml" {{.CLI_ARGS}} artifacts:download:*: desc: Downloads the specified artifacts version from the staging bucket. To add additional AWS CLI arguments, add them after `--`. vars: DL_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' ORIGIN: "{{.ARTIFACTS_BUCKET}}/{{.DL_VERSION}}" DESTINATION: "artifacts/{{.DL_VERSION}}" cmds: - '{{.RMRF}} "{{.DESTINATION}}"' - aws s3 cp s3://{{.ORIGIN}}/ {{.DESTINATION}}/ --recursive {{.CLI_ARGS}} artifacts:publish:*: desc: Publishes the specified artifacts version from the staging bucket to the releases bucket. To add additional AWS CLI arguments, add them after `--`. vars: UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' ORIGIN: "{{.ARTIFACTS_BUCKET}}/{{.UP_VERSION}}" DESTINATION: "{{.RELEASES_BUCKET}}" cmd: | OUTPUT=$(aws s3 cp s3://{{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive {{.CLI_ARGS}}) for line in $OUTPUT; do PREFIX=${line%%{{.DESTINATION}}*} SUFFIX=${line:${#PREFIX}} if [[ -n "$SUFFIX" ]]; then echo "https://$SUFFIX" fi done artifacts:snap:publish:*: desc: Publishes the specified artifacts version to Snapcraft. vars: UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' CHANNEL: '{{if contains "beta" .UP_VERSION}}beta{{else}}beta,stable{{end}}' cmd: | echo "Releasing to channels: [{{.CHANNEL}}]" for file in waveterm_{{.UP_VERSION}}_*.snap; do echo "Publishing $file" snapcraft upload --release={{.CHANNEL}} $file echo "Finished publishing $file" done artifacts:winget:publish:*: desc: Submits a version bump request to WinGet for the latest release. status: - exit {{if contains "beta" .UP_VERSION}}0{{else}}1{{end}} vars: UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' cmd: | wingetcreate update {{.WINGET_PACKAGE}} -s -v {{.UP_VERSION}} -u "https://{{.RELEASES_BUCKET}}/{{.APP_NAME}}-win32-x64-{{.UP_VERSION}}.msi" -t {{.GITHUB_TOKEN}} dev:installwsh: desc: quick shortcut to rebuild wsh and install for macos arm64 requires: vars: - VERSION cmds: - task: build:wsh:internal vars: GOOS: darwin GOARCH: arm64 - cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/Library/Application\ Support/waveterm-dev/bin/wsh dev:clearconfig: desc: Clear the config directory for waveterm-dev cmd: "{{.RMRF}} ~/.config/waveterm-dev" dev:cleardata: desc: Clear the data directory for waveterm-dev cmds: - task: dev:cleardata:windows - task: dev:cleardata:linux - task: dev:cleardata:macos check:ts: desc: Typecheck TypeScript code (frontend and electron). cmd: npx tsc --noEmit deps: - npm:install init: desc: Initialize the project for development. cmds: - npm install - go mod tidy - cd docs && npm install dev:cleardata:windows: internal: true platforms: [windows] cmd: '{{.RMRF}} %LOCALAPPDATA%\waveterm-dev\Data' dev:cleardata:linux: internal: true platforms: [linux] cmd: "rm -rf ~/.local/share/waveterm-dev" dev:cleardata:macos: internal: true platforms: [darwin] cmd: 'rm -rf ~/Library/Application\ Support/waveterm-dev' npm:install: desc: Runs `npm install` internal: true generates: - node_modules/**/* - package-lock.json sources: - package-lock.json - package.json cmd: npm install go:mod:tidy: desc: Runs `go mod tidy` internal: true generates: - go.sum sources: - go.mod cmd: go mod tidy copyfiles:*:*: desc: Recursively copy directory and its contents. internal: true cmd: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Copy-Item -Recurse -Force -Path {{index .MATCH 0}} -Destination {{index .MATCH 1}}{{else}}mkdir -p "$(dirname {{index .MATCH 1}})" && cp -r {{index .MATCH 0}} {{index .MATCH 1}}{{end}}' clean: desc: clean make/dist directories cmds: - cmd: '{{.RMRF}} "make"' ignore_error: true - cmd: '{{.RMRF}} "dist"' ignore_error: true tsunami:demo:todo: desc: Run the tsunami todo demo application cmd: go run demo/todo/*.go dir: tsunami env: TSUNAMI_LISTENADDR: "localhost:12026" tsunami:frontend:dev: desc: Run the tsunami frontend vite dev server cmd: npm run dev dir: tsunami/frontend tsunami:frontend:build: desc: Build the tsunami frontend cmd: npm run build dir: tsunami/frontend tsunami:frontend:devbuild: desc: Build the tsunami frontend in development mode (with source maps and symbols) cmd: npm run build:dev dir: tsunami/frontend tsunami:scaffold: desc: Build scaffold for tsunami frontend development deps: - tsunami:frontend:build cmds: - task: tsunami:scaffold:internal tsunami:devscaffold: desc: Build scaffold for tsunami frontend development (with source maps and symbols) deps: - tsunami:frontend:devbuild cmds: - task: tsunami:scaffold:internal tsunami:scaffold:packagejson: desc: Create package.json for tsunami scaffold using npm commands dir: tsunami/frontend/scaffold cmds: - cmd: rm -f package.json platforms: [darwin, linux] ignore_error: true - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path package.json" platforms: [windows] ignore_error: true - npm --no-workspaces init -y --init-license Apache-2.0 - npm pkg set name=tsunami-scaffold - npm pkg delete author - npm pkg set author.name="Command Line Inc" - npm pkg set author.email="info@commandline.dev" - npm --no-workspaces install tailwindcss@4.1.13 @tailwindcss/cli@4.1.13 tsunami:scaffold:internal: desc: Internal task to create scaffold directory structure internal: true cmds: - task: tsunami:scaffold:internal:unix - task: tsunami:scaffold:internal:windows tsunami:scaffold:internal:unix: desc: Internal task to create scaffold directory structure (Unix) dir: tsunami/frontend internal: true platforms: [darwin, linux] cmds: - cmd: "{{.RMRF}} scaffold" ignore_error: true - mkdir -p scaffold - cp ../templates/package.json.tmpl scaffold/package.json - cd scaffold && npm install - mv scaffold/node_modules scaffold/nm - cp -r dist scaffold/ - mkdir -p scaffold/dist/tw - cp ../templates/*.go.tmpl scaffold/ - cp ../templates/tailwind.css scaffold/ - cp ../templates/gitignore.tmpl scaffold/.gitignore - cp src/element/*.tsx scaffold/dist/tw/ - cp ../ui/*.go scaffold/dist/tw/ - cp ../engine/errcomponent.go scaffold/dist/tw/ tsunami:scaffold:internal:windows: desc: Internal task to create scaffold directory structure (Windows) dir: tsunami/frontend internal: true platforms: [windows] cmds: - cmd: "{{.RMRF}} scaffold" ignore_error: true - powershell -NoProfile -NonInteractive New-Item -ItemType Directory -Force -Path scaffold - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/package.json.tmpl -Destination scaffold/package.json - powershell -NoProfile -NonInteractive -Command "Set-Location scaffold; npm install" - powershell -NoProfile -NonInteractive Move-Item -Path scaffold/node_modules -Destination scaffold/nm - powershell -NoProfile -NonInteractive Copy-Item -Recurse -Force -Path dist -Destination scaffold/ - powershell -NoProfile -NonInteractive New-Item -ItemType Directory -Force -Path scaffold/dist/tw - powershell -NoProfile -NonInteractive Copy-Item -Path '../templates/*.go.tmpl' -Destination scaffold/ - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/tailwind.css -Destination scaffold/ - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/gitignore.tmpl -Destination scaffold/.gitignore - powershell -NoProfile -NonInteractive Copy-Item -Path 'src/element/*.tsx' -Destination scaffold/dist/tw/ - powershell -NoProfile -NonInteractive Copy-Item -Path '../ui/*.go' -Destination scaffold/dist/tw/ - powershell -NoProfile -NonInteractive Copy-Item -Path ../engine/errcomponent.go -Destination scaffold/dist/tw/ tsunami:build: desc: Build the tsunami binary. cmds: - cmd: rm -f bin/tsunami* platforms: [darwin, linux] ignore_error: true - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path bin/tsunami*" platforms: [windows] ignore_error: true - mkdir -p bin - cd tsunami && go build -ldflags "-X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.TsunamiVersion={{.VERSION}}" -o ../bin/tsunami{{exeExt}} cmd/main-tsunami.go sources: - "tsunami/**/*.go" - "tsunami/go.mod" - "tsunami/go.sum" generates: - "bin/tsunami{{exeExt}}" tsunami:clean: desc: Clean tsunami frontend build artifacts dir: tsunami/frontend cmds: - cmd: "{{.RMRF}} dist" ignore_error: true - cmd: "{{.RMRF}} scaffold" ignore_error: true godoc: desc: Start the Go documentation server for the root module cmd: $(go env GOPATH)/bin/pkgsite -http=:6060 tsunami:godoc: desc: Start the Go documentation server for the tsunami module cmd: $(go env GOPATH)/bin/pkgsite -http=:6060 dir: tsunami ================================================ FILE: aiprompts/aimodesconfig.md ================================================ # Wave AI Modes Configuration - Visual Editor Architecture ## Overview Wave Terminal's AI modes configuration system allows users to define custom AI assistants with different providers, models, and capabilities. The configuration is stored in `~/.waveterm/config/waveai.json` and provides a flexible way to configure multiple AI modes that appear in the Wave AI panel. **Key Design Decisions:** - Visual editor works on **valid JSON only** - if JSON is invalid, fall back to JSON editor - Default modes (`waveai@quick`, `waveai@balanced`, `waveai@deep`) are **read-only** in visual editor - Edits modify the **in-memory JSON directly** - changes saved via existing save button - Mode keys are **auto-generated** from provider + model or random ID (last 4-6 chars) - Secrets use **fixed naming convention** per provider (e.g., `OPENAI_KEY`, `OPENROUTER_KEY`) - Quick **inline secret editor** instead of complex secret management ## Current System Architecture ### Data Structure **Location:** `pkg/wconfig/settingsconfig.go:264-284` ```go type AIModeConfigType struct { // Display Configuration DisplayName string `json:"display:name"` // Required DisplayOrder float64 `json:"display:order,omitempty"` DisplayIcon string `json:"display:icon,omitempty"` DisplayShortDesc string `json:"display:shortdesc,omitempty"` DisplayDescription string `json:"display:description,omitempty"` // Provider & Model Provider string `json:"ai:provider,omitempty"` // wave, google, openrouter, openai, azure, azure-legacy, custom APIType string `json:"ai:apitype"` // Required: anthropic-messages, openai-responses, openai-chat Model string `json:"ai:model"` // Required // AI Behavior ThinkingLevel string `json:"ai:thinkinglevel,omitempty"` // low, medium, high Capabilities []string `json:"ai:capabilities,omitempty"` // pdfs, images, tools // Connection Details Endpoint string `json:"ai:endpoint,omitempty"` APIVersion string `json:"ai:apiversion,omitempty"` APIToken string `json:"ai:apitoken,omitempty"` APITokenSecretName string `json:"ai:apitokensecretname,omitempty"` // Azure-Specific AzureResourceName string `json:"ai:azureresourcename,omitempty"` AzureDeployment string `json:"ai:azuredeployment,omitempty"` // Wave AI Specific WaveAICloud bool `json:"waveai:cloud,omitempty"` WaveAIPremium bool `json:"waveai:premium,omitempty"` } ``` **Storage:** `FullConfigType.WaveAIModes` - `map[string]AIModeConfigType` Keys follow pattern: `provider@modename` (e.g., `waveai@quick`, `openai@gpt4`) ### Provider Types & Defaults **Defined in:** `pkg/aiusechat/uctypes/uctypes.go:27-35` 1. **wave** - Wave AI Cloud service - Auto-sets: `waveai:cloud = true`, endpoint from env or default - Default endpoint: `https://cfapi.waveterm.dev/api/waveai` - Used for Wave's hosted AI modes 2. **openai** - OpenAI API - Auto-sets: endpoint `https://api.openai.com/v1` - Auto-detects API type based on model: - Legacy models (gpt-4o, gpt-3.5): `openai-chat` - New models (gpt-5*, gpt-4.1*, o1*, o3*): `openai-responses` 3. **openrouter** - OpenRouter service - Auto-sets: endpoint `https://openrouter.ai/api/v1`, API type `openai-chat` 4. **google** - Google AI (Gemini, etc.) - No auto-defaults currently 5. **azure** - Azure OpenAI (new unified API) - Auto-sets: API version `v1`, endpoint from resource name - Endpoint pattern: `https://{resource}.openai.azure.com/openai/v1/{responses|chat/completions}` - Auto-detects API type based on model 6. **azure-legacy** - Azure OpenAI (legacy chat completions) - Auto-sets: API version `2025-04-01-preview`, API type `openai-chat` - Endpoint pattern: `https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version={version}` - Requires `AzureResourceName` and `AzureDeployment` 7. **custom** - Custom provider - No auto-defaults - User must specify all fields manually ### Default Configuration **Location:** `pkg/wconfig/defaultconfig/waveai.json` Ships with three Wave AI modes: - `waveai@quick` - Fast responses (gpt-5-mini, low thinking) - `waveai@balanced` - Balanced (gpt-5.1, low thinking) [premium] - `waveai@deep` - Maximum capability (gpt-5.1, medium thinking) [premium] ### Current UI State **Location:** `frontend/app/view/waveconfig/waveaivisual.tsx` Currently shows placeholder: "Visual editor coming soon..." The component receives: - `model: WaveConfigViewModel` - Access to config file operations - Existing patterns from `SecretsContent` for list/detail views ## Visual Editor Design Plan ### High-Level Architecture ``` ┌─────────────────────────────────────────────────────────┐ │ Wave AI Modes Configuration │ │ ┌───────────────┐ ┌──────────────────────────────┐ │ │ │ │ │ │ │ │ │ Mode List │ │ Mode Editor/Viewer │ │ │ │ │ │ │ │ │ │ [Quick] │ │ Provider: [wave ▼] │ │ │ │ [Balanced] │ │ │ │ │ │ [Deep] │ │ Display Configuration │ │ │ │ [Custom] │ │ ├─ Name: ... │ │ │ │ │ │ ├─ Icon: ... │ │ │ │ [+ Add New] │ │ └─ Description: ... │ │ │ │ │ │ │ │ │ │ │ │ Provider Configuration │ │ │ │ │ │ (Provider-specific fields) │ │ │ │ │ │ │ │ │ │ │ │ [Save] [Delete] [Cancel] │ │ │ └───────────────┘ └──────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ ``` ### Component Structure ```typescript WaveAIVisualContent ├─ ModeList (left panel) │ ├─ Header with "Add New Mode" button │ ├─ List of existing modes (sorted by display:order) │ │ └─ ModeListItem (icon, name, short desc, provider badge) │ └─ Empty state if no modes │ └─ ModeEditor (right panel) ├─ Provider selector dropdown (when creating/editing) ├─ Display section (common to all providers) │ ├─ Name input (required) │ ├─ Icon picker (optional) │ ├─ Display order (optional, number) │ ├─ Short description (optional) │ └─ Description textarea (optional) │ ├─ Provider Configuration section (dynamic based on provider) │ └─ [Provider-specific form fields] │ └─ Action buttons (Save, Delete, Cancel) ``` ### Provider-Specific Form Fields #### 1. Wave Provider (`wave`) **Read-only/Auto-managed:** - Endpoint (shows default or env override) - Cloud flag (always true) - Secret: Not applicable (managed by Wave) **User-configurable:** - Model (required, text input with suggestions: gpt-5-mini, gpt-5.1) - API Type (required, dropdown: openai-responses, openai-chat) - Thinking Level (optional, dropdown: low, medium, high) - Capabilities (optional, checkboxes: tools, images, pdfs) - Premium flag (checkbox) #### 2. OpenAI Provider (`openai`) **Auto-managed:** - Endpoint (shows: api.openai.com/v1) - API Type (auto-detected from model, editable) - Secret Name: Fixed as `OPENAI_KEY` **User-configurable:** - Model (required, text input with suggestions: gpt-4o, gpt-5-mini, gpt-5.1, o1-preview) - API Key (via secret modal - see Secret Management below) - Thinking Level (optional) - Capabilities (optional) #### 3. OpenRouter Provider (`openrouter`) **Auto-managed:** - Endpoint (shows: openrouter.ai/api/v1) - API Type (always openai-chat) - Secret Name: Fixed as `OPENROUTER_KEY` **User-configurable:** - Model (required, text input - OpenRouter model format) - API Key (via secret modal) - Thinking Level (optional) - Capabilities (optional) #### 4. Azure Provider (`azure`) **Auto-managed:** - API Version (always v1) - Endpoint (computed from resource name) - API Type (auto-detected from model) - Secret Name: Fixed as `AZURE_KEY` **User-configurable:** - Azure Resource Name (required, validated format) - Model (required) - API Key (via secret modal) - Thinking Level (optional) - Capabilities (optional) #### 5. Azure Legacy Provider (`azure-legacy`) **Auto-managed:** - API Version (default: 2025-04-01-preview, editable) - API Type (always openai-chat) - Endpoint (computed from resource + deployment + version) - Secret Name: Fixed as `AZURE_KEY` **User-configurable:** - Azure Resource Name (required, validated) - Azure Deployment (required) - Model (required) - API Key (via secret modal) - Thinking Level (optional) - Capabilities (optional) #### 6. Google Provider (`google`) **Auto-managed:** - Secret Name: Fixed as `GOOGLE_KEY` **User-configurable:** - Model (required) - API Type (required dropdown) - Endpoint (required) - API Key (via secret modal) - API Version (optional) - Thinking Level (optional) - Capabilities (optional) #### 7. Custom Provider (`custom`) **User must specify everything:** - Model (required) - API Type (required dropdown) - Endpoint (required) - Secret Name (required text input - user defines their own secret name) - API Key (via secret modal using custom secret name) - API Version (optional) - Thinking Level (optional) - Capabilities (optional) - Azure Resource Name (optional) - Azure Deployment (optional) ### Data Flow ``` Load JSON → Parse → Render Visual Editor ↓ User Edits Mode → Update fileContentAtom (JSON string) ↓ Click Save → Existing save logic validates & writes ``` **Simplified Operations:** 1. **Load:** Parse `fileContentAtom` JSON string into mode objects for display 2. **Edit Mode:** Update parsed object → stringify → set `fileContentAtom` → marks as edited 3. **Add Mode:** - Generate unique key from provider/model or random ID - Add new mode to parsed object → stringify → set `fileContentAtom` 4. **Delete Mode:** Remove key from parsed object → stringify → set `fileContentAtom` 5. **Save:** Existing `model.saveFile()` handles validation and write **Mode Key Generation:** ```typescript function generateModeKey(provider: string, model: string): string { // Try semantic key first: provider@model-sanitized const sanitized = model.toLowerCase() .replace(/[^a-z0-9]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); const semanticKey = `${provider}@${sanitized}`; // Check for collision, if exists append random suffix if (existingModes[semanticKey]) { const randomId = crypto.randomUUID().slice(-6); return `${provider}@${sanitized}-${randomId}`; } return semanticKey; } // Examples: openai@gpt-4o, openrouter@claude-3-5-sonnet, azure@custom-fb4a2c ``` **Secret Naming Convention:** ```typescript // Fixed secret names per provider (except custom) const SECRET_NAMES = { openai: "OPENAI_KEY", openrouter: "OPENROUTER_KEY", azure: "AZURE_KEY", "azure-legacy": "AZURE_KEY", google: "GOOGLE_KEY", // custom provider: user specifies their own secret name } as const; function getSecretName(provider: string, customSecretName?: string): string { if (provider === "custom") { return customSecretName || "CUSTOM_API_KEY"; } return SECRET_NAMES[provider]; } ``` ### Secret Management UI **Secret Status Indicator:** Display next to API Key field for providers that need one: - ✅ Green check icon: Secret exists and is set - ⚠️ Warning icon (yellow/orange): Secret not set or empty - Click icon to open secret modal **Secret Modal:** ``` ┌─────────────────────────────────────┐ │ Set API Key for OpenAI │ │ │ │ Secret Name: OPENAI_KEY │ │ [read-only for non-custom] │ │ │ │ API Key: │ │ [********************] [Show/Hide]│ │ │ │ [Cancel] [Save] │ └─────────────────────────────────────┘ ``` **Modal Behavior:** 1. **Open Modal:** Click status icon or "Set API Key" button 2. **Show Secret Name:** - Non-custom providers: Read-only, shows fixed name - Custom provider: Editable text input (user specifies) 3. **API Key Input:** - Masked password field - Show/Hide toggle button - Load existing value if secret already exists 4. **Save:** - Validates not empty - Calls RPC to set secret - Updates status icon 5. **Cancel:** Close without changes **Integration with Mode Editor:** - Check secret existence on mode load/select - Update icon based on RPC `GetSecretsCommand` result - "Save" button for mode only saves JSON config - Secret is set immediately via modal (separate from JSON save) ### Key Features #### 1. Mode List - Display modes sorted by `display:order` (ascending) - Show icon, name, short description - Badge showing provider type - Highlight Wave AI premium modes - Click to edit #### 2. Add New Mode Flow 1. Click "Add New Mode" 2. Enter mode key (validated: alphanumeric, @, -, ., _) 3. Select provider from dropdown 4. Form dynamically updates to show provider-specific fields 5. Fill required fields (marked with *) 6. Save → validates → adds to config → refreshes list #### 3. Edit Mode Flow 1. Click mode from list 2. Load mode data into form 3. Provider is fixed (show read-only or with warning about changing) 4. Edit fields 5. Save → validates → updates config → refreshes list **Raw JSON Editor Option:** - "Edit Raw JSON" button in mode editor (available for all modes) - Opens modal with Monaco editor showing just this mode's JSON - Validates JSON structure before allowing save - Useful for: - Modes without a provider field (edge cases) - Advanced users who want precise control - Copying/modifying complex configurations - Validation checks: - Valid JSON syntax - Required fields present (`display:name`, `ai:apitype`, `ai:model`) - Enum values valid - Custom error messages for each validation failure #### 4. Delete Mode Flow 1. Click mode from list 2. Delete button in editor 3. Confirm dialog 4. Remove from config → save → refresh list #### 5. Secret Integration - For API Token fields, provide two options: - Direct input (text field, masked) - Secret reference (dropdown of existing secrets + link to secrets page) - When secret is selected, store name in `ai:apitokensecretname` - When direct token, store in `ai:apitoken` #### 6. Validation - **Mode Key:** Must match pattern `^[a-zA-Z0-9_@.-]+$` - **Required Fields:** `display:name`, `ai:apitype`, `ai:model` - **Azure Resource Name:** Must match `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` (1-63 chars) - **Provider:** Must be one of the valid enum values - **API Type:** Must be valid enum value - **Thinking Level:** Must be low/medium/high if present - **Capabilities:** Must be from valid enum (pdfs, images, tools) #### 7. Smart Defaults When provider changes or model changes: - Show info about what will be auto-configured - Display computed endpoint (read-only with info icon) - Display auto-detected API type (editable with warning) - Pre-fill common values based on provider ### UI Components Needed #### New Components ```typescript // Main container WaveAIVisualContent // Left panel ModeList ├─ ModeListItem (icon, name, provider badge, premium badge, drag handle) └─ AddModeButton // Right panel - viewer ModeViewer ├─ ModeHeader (name, icon, actions) ├─ DisplaySection (read-only view of display fields) ├─ ProviderSection (read-only view of provider config) └─ EditButton // Right panel - editor ModeEditor ├─ ProviderSelector (dropdown, only for new modes) ├─ DisplayFieldsForm ├─ ProviderFieldsForm (dynamic based on provider) │ ├─ WaveProviderForm │ ├─ OpenAIProviderForm │ ├─ OpenRouterProviderForm │ ├─ AzureProviderForm │ ├─ AzureLegacyProviderForm │ ├─ GoogleProviderForm │ └─ CustomProviderForm └─ ActionButtons (Edit Raw JSON, Delete, Cancel) // Modals RawJSONModal ├─ Title ("Edit Raw JSON: {mode name}") ├─ MonacoEditor (JSON, single mode object) ├─ ValidationErrors (inline display) └─ Actions (Cancel, Save) // Shared components SecretSelector (dropdown + link to secrets) InfoTooltip (explains auto-configured fields) ProviderBadge (visual indicator) IconPicker (select from available icons) DragHandle (for reordering modes in list) ``` **Drag & Drop for Reordering:** ```typescript // Reordering updates display:order automatically function handleModeReorder(draggedKey: string, targetKey: string) { const modes = parseAIModes(fileContent); const modesList = Object.entries(modes) .sort((a, b) => (a[1]["display:order"] || 0) - (b[1]["display:order"] || 0)); // Find indices const draggedIndex = modesList.findIndex(([k]) => k === draggedKey); const targetIndex = modesList.findIndex(([k]) => k === targetKey); // Recalculate display:order for all modes const newOrder = [...modesList]; newOrder.splice(draggedIndex, 1); newOrder.splice(targetIndex, 0, modesList[draggedIndex]); // Assign new order values (0, 10, 20, 30...) newOrder.forEach(([key, mode], index) => { modes[key] = { ...mode, "display:order": index * 10 }; }); updateFileContent(JSON.stringify(modes, null, 2)); } ``` ### Model Extensions (Minimal) **No new atoms needed!** Visual editor uses existing `fileContentAtom`: ```typescript // Use existing atoms from WaveConfigViewModel: // - fileContentAtom (contains JSON string) // - hasEditedAtom (tracks if modified) // - errorMessageAtom (for errors) // Visual editor parses fileContentAtom on render: function parseAIModes(jsonString: string): Record | null { try { return JSON.parse(jsonString); } catch { return null; // Show "invalid JSON" error } } // Updates modify fileContentAtom: function updateMode(key: string, mode: AIModeConfigType) { const modes = parseAIModes(globalStore.get(model.fileContentAtom)); if (!modes) return; modes[key] = mode; const newJson = JSON.stringify(modes, null, 2); globalStore.set(model.fileContentAtom, newJson); globalStore.set(model.hasEditedAtom, true); } // Secrets use existing model methods: // - model.refreshSecrets() - already exists // - RpcApi.GetSecretsCommand() - check if secret exists // - RpcApi.SetSecretsCommand() - set secret value ``` **Component State (useState):** ```typescript // In WaveAIVisualContent component: const [selectedModeKey, setSelectedModeKey] = useState(null); const [isAddingMode, setIsAddingMode] = useState(false); const [showSecretModal, setShowSecretModal] = useState(false); const [secretModalProvider, setSecretModalProvider] = useState(""); ``` ### Implementation Phases #### Phase 1: Foundation & List View - Parse `fileContentAtom` JSON into modes on render - Display mode list (left panel, ~300px) - Built-in modes with 🔒 icon at top - Custom modes below - Sort by `display:order` - Select mode → show in right panel (empty state initially) - Handle invalid JSON → show error, switch to JSON tab #### Phase 2: Built-in Mode Viewer - Click built-in mode → show read-only details - Display all fields (display, provider, config) - "Built-in Mode" badge/banner - No edit/delete buttons #### Phase 3: Custom Mode Editor (Basic) - Click custom mode → load into editor form - Display fields (name, icon, order, description) - Provider field (read-only, badge) - Model field (text input) - Save → update `fileContentAtom` JSON - Cancel → revert to previous selection #### Phase 4: Provider-Specific Fields - Dynamic form based on provider type - OpenAI: model, thinking level, capabilities - Azure: resource name, model, thinking, capabilities - Azure Legacy: resource name, deployment, model - OpenRouter: model - Google: model, API type, endpoint - Custom: everything manual - Info tooltips for auto-configured fields #### Phase 5: Secret Integration - Check secret existence on mode select - Display status icon (✅ / ⚠️) - Click icon → open secret modal - Secret modal: fixed name (or custom input), password field - Save secret → immediate RPC call - Update status icon after save #### Phase 6: Add New Mode - "Add New Mode" button - Provider dropdown selector - Auto-generate mode key from provider + model - Form with provider-specific fields - Add to modes → update JSON → mark edited - Select newly created mode #### Phase 7: Delete Mode - Delete button for custom modes only - Simple confirmation dialog - Remove from modes → update JSON → deselect #### Phase 8: Raw JSON Editor - "Edit Raw JSON" button in mode editor (all modes) - Modal with Monaco editor for single mode - JSON validation before save: - Syntax check with error highlighting - Required fields check (`display:name`, `ai:apitype`, `ai:model`) - Enum validation (provider, apitype, thinkinglevel, capabilities) - Display specific error messages per validation failure - Parse validated JSON and update mode in main JSON - Useful for edge cases (modes without provider) and power users #### Phase 9: Drag & Drop Reordering - Add drag handle icon to custom mode list items - Implement drag & drop functionality: - Visual feedback during drag (opacity, cursor) - Drop target highlighting - Smooth reordering animation - On drop: - Recalculate `display:order` for all affected modes - Use spacing (0, 10, 20, 30...) for easy manual adjustment - Update JSON with new order values - Built-in modes always stay at top (negative order values) #### Phase 10: Polish & UX Refinements - Field validation with inline error messages - Empty state when no mode selected - Icon picker dropdown (Font Awesome icons) - Capabilities checkboxes with descriptions - Thinking level dropdown with explanations - Help tooltips throughout - Keyboard shortcuts (e.g., Ctrl/Cmd+E for raw JSON) - Loading states for secret checks - Smooth transitions and animations #### Phase 8: Raw JSON Editor - "Edit Raw JSON" button in mode editor - Modal with Monaco editor for single mode - JSON validation before save: - Syntax check - Required fields check - Enum validation - Display specific error messages - Parse and update mode in main JSON #### Phase 9: Drag & Drop Reordering - Make mode list items draggable (custom modes only) - Visual feedback during drag (drag handle icon) - Drop target highlighting - On drop: - Calculate new `display:order` values - Maintain spacing between modes - Update all affected modes in JSON - Preserve built-in modes at top #### Phase 10: Polish & UX Refinements - Field validation (required, format) - Error messages inline - Empty state when no mode selected - Icon picker dropdown - Capabilities checkboxes - Thinking level dropdown - Help tooltips throughout - Keyboard shortcuts (e.g., Cmd+E for raw JSON) ### Technical Considerations 1. **JSON Sync:** Parse/stringify from `fileContentAtom` on every read/write 2. **Validation:** Validate on blur or before updating JSON 3. **Built-in Detection:** Check if key starts with `waveai@` → read-only 4. **Type Safety:** Use `AIModeConfigType` from gotypes.d.ts 5. **State Management:** - Model atoms for shared state (`fileContentAtom`, `hasEditedAtom`) - Component useState for UI state (selected mode, modals) 6. **Error Handling:** - Invalid JSON → show message, disable visual editor - Parse errors → gracefully handle, don't crash 7. **Performance:** - Parse JSON on mount and when `fileContentAtom` changes externally - Debounce frequent updates if needed 8. **Secret Checks:** - Load secret existence on mode select - Cache results to avoid repeated RPC calls ### Testing Strategy 1. **Unit Tests:** Validation functions, key generation 2. **Integration Tests:** Form submission, backend sync 3. **E2E Tests:** Full add/edit/delete flows 4. **Provider Tests:** Each provider form with various inputs 5. **Edge Cases:** Empty config, invalid JSON, malformed data ### Documentation Needs 1. **In-app help:** Tooltips and info bubbles explaining fields 2. **Provider guides:** What each provider needs, where to get API keys 3. **Examples:** Show example configurations for common setups 4. **Troubleshooting:** Common errors and solutions ## Next Steps 1. Create detailed mockups/wireframes 2. Implement Phase 1 (basic list view) 3. Add RPC methods if needed for secrets integration 4. Iterate on provider forms 5. Polish and ship This design provides a user-friendly way to configure AI modes without directly editing JSON, while still maintaining the power and flexibility of the underlying system. ================================================ FILE: aiprompts/aisdk-streaming.md ================================================ ## Data Stream Protocol A data stream follows a special protocol that the AI SDK provides to send information to the frontend. The data stream protocol uses Server-Sent Events (SSE) format for improved standardization, keep-alive through ping, reconnect capabilities, and better cache handling. When you provide data streams from a custom backend, you need to set the `x-vercel-ai-ui-message-stream` header to `v1`. The following stream parts are currently supported: ### Message Start Part Indicates the beginning of a new message with metadata. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"start","messageId":"..."} ``` ### Text Parts Text content is streamed using a start/delta/end pattern with unique IDs for each text block. #### Text Start Part Indicates the beginning of a text block. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"text-start","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d"} ``` #### Text Delta Part Contains incremental text content for the text block. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"text-delta","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d","delta":"Hello"} ``` #### Text End Part Indicates the completion of a text block. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"text-end","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d"} ``` ### Reasoning Parts Reasoning content is streamed using a start/delta/end pattern with unique IDs for each reasoning block. #### Reasoning Start Part Indicates the beginning of a reasoning block. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"reasoning-start","id":"reasoning_123"} ``` #### Reasoning Delta Part Contains incremental reasoning content for the reasoning block. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"reasoning-delta","id":"reasoning_123","delta":"This is some reasoning"} ``` #### Reasoning End Part Indicates the completion of a reasoning block. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"reasoning-end","id":"reasoning_123"} ``` ### Source Parts Source parts provide references to external content sources. #### Source URL Part References to external URLs. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"source-url","sourceId":"https://example.com","url":"https://example.com"} ``` #### Source Document Part References to documents or files. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"source-document","sourceId":"https://example.com","mediaType":"file","title":"Title"} ``` ### File Part The file parts contain references to files with their media type. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"file","url":"https://example.com/file.png","mediaType":"image/png"} ``` ### Data Parts Custom data parts allow streaming of arbitrary structured data with type-specific handling. Format: Server-Sent Event with JSON object where the type includes a custom suffix Example: ``` data: {"type":"data-weather","data":{"location":"SF","temperature":100}} ``` The `data-*` type pattern allows you to define custom data types that your frontend can handle specifically. ### Error Part The error parts are appended to the message as they are received. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"error","errorText":"error message"} ``` ### Tool Input Start Part Indicates the beginning of tool input streaming. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"tool-input-start","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","toolName":"getWeatherInformation"} ``` ### Tool Input Delta Part Incremental chunks of tool input as it's being generated. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"tool-input-delta","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","inputTextDelta":"San Francisco"} ``` ### Tool Input Available Part Indicates that tool input is complete and ready for execution. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"tool-input-available","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","toolName":"getWeatherInformation","input":{"city":"San Francisco"}} ``` ### Tool Output Available Part Contains the result of tool execution. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"tool-output-available","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","output":{"city":"San Francisco","weather":"sunny"}} ``` ### Start Step Part A part indicating the start of a step. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"start-step"} ``` ### Finish Step Part A part indicating that a step (i.e., one LLM API call in the backend) has been completed. This part is necessary to correctly process multiple stitched assistant calls, e.g. when calling tools in the backend, and using steps in `useChat` at the same time. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"finish-step"} ``` ### Finish Message Part A part indicating the completion of a message. Format: Server-Sent Event with JSON object Example: ``` data: {"type":"finish"} ``` ### Stream Termination The stream ends with a special `[DONE]` marker. Format: Server-Sent Event with literal `[DONE]` Example: ``` data: [DONE] ``` ================================================ FILE: aiprompts/aisdk-uimessage-type.md ================================================ # `UIMessage` `UIMessage` serves as the source of truth for your application's state, representing the complete message history including metadata, data parts, and all contextual information. In contrast to `ModelMessage`, which represents the state or context passed to the model, `UIMessage` contains the full application state needed for UI rendering and client-side functionality. ## Type Safety `UIMessage` is designed to be type-safe and accepts three generic parameters to ensure proper typing throughout your application: 1. **`METADATA`** - Custom metadata type for additional message information 2. **`DATA_PARTS`** - Custom data part types for structured data components 3. **`TOOLS`** - Tool definitions for type-safe tool interactions ## Creating Your Own UIMessage Type Here's an example of how to create a custom typed UIMessage for your application: ```typescript import { InferUITools, ToolSet, UIMessage, tool } from "ai"; import z from "zod"; const metadataSchema = z.object({ someMetadata: z.string().datetime(), }); type MyMetadata = z.infer; const dataPartSchema = z.object({ someDataPart: z.object({}), anotherDataPart: z.object({}), }); type MyDataPart = z.infer; const tools = { someTool: tool({}), } satisfies ToolSet; type MyTools = InferUITools; export type MyUIMessage = UIMessage; ``` ## `UIMessage` Interface ```typescript interface UIMessage { /** * A unique identifier for the message. */ id: string; /** * The role of the message. */ role: "system" | "user" | "assistant"; /** * The metadata of the message. */ metadata?: METADATA; /** * The parts of the message. Use this for rendering the message in the UI. */ parts: Array>; } ``` ## `UIMessagePart` Types ### `TextUIPart` A text part of a message. ```typescript type TextUIPart = { type: "text"; /** * The text content. */ text: string; /** * The state of the text part. */ state?: "streaming" | "done"; }; ``` ### `ReasoningUIPart` A reasoning part of a message. ```typescript type ReasoningUIPart = { type: "reasoning"; /** * The reasoning text. */ text: string; /** * The state of the reasoning part. */ state?: "streaming" | "done"; /** * The provider metadata. */ providerMetadata?: Record; }; ``` ### `ToolUIPart` A tool part of a message that represents tool invocations and their results. The type is based on the name of the tool (e.g., `tool-someTool` for a tool named `someTool`). ```typescript type ToolUIPart = ValueOf<{ [NAME in keyof TOOLS & string]: { type: `tool-${NAME}`; toolCallId: string; } & ( | { state: "input-streaming"; input: DeepPartial | undefined; providerExecuted?: boolean; output?: never; errorText?: never; } | { state: "input-available"; input: TOOLS[NAME]["input"]; providerExecuted?: boolean; output?: never; errorText?: never; } | { state: "output-available"; input: TOOLS[NAME]["input"]; output: TOOLS[NAME]["output"]; errorText?: never; providerExecuted?: boolean; } | { state: "output-error"; input: TOOLS[NAME]["input"]; output?: never; errorText: string; providerExecuted?: boolean; } ); }>; ``` ### `SourceUrlUIPart` A source URL part of a message. ```typescript type SourceUrlUIPart = { type: "source-url"; sourceId: string; url: string; title?: string; providerMetadata?: Record; }; ``` ### `SourceDocumentUIPart` A document source part of a message. ```typescript type SourceDocumentUIPart = { type: "source-document"; sourceId: string; mediaType: string; title: string; filename?: string; providerMetadata?: Record; }; ``` ### `FileUIPart` A file part of a message. ```typescript type FileUIPart = { type: "file"; /** * IANA media type of the file. */ mediaType: string; /** * Optional filename of the file. */ filename?: string; /** * The URL of the file. * It can either be a URL to a hosted file or a Data URL. */ url: string; }; ``` ### `DataUIPart` A data part of a message for custom data types. The type is based on the name of the data part (e.g., `data-someDataPart` for a data part named `someDataPart`). ```typescript type DataUIPart = ValueOf<{ [NAME in keyof DATA_TYPES & string]: { type: `data-${NAME}`; id?: string; data: DATA_TYPES[NAME]; }; }>; ``` ### `StepStartUIPart` A step boundary part of a message. ```typescript type StepStartUIPart = { type: "step-start"; }; ``` ================================================ FILE: aiprompts/anthropic-messages-api.md ================================================ # Messages > Send a structured list of input messages with text and/or image content, and the model will generate the next message in the conversation. The Messages API can be used for either single queries or stateless multi-turn conversations. Learn more about the Messages API in our [user guide](/en/docs/initial-setup) ## OpenAPI ````yaml post /v1/messages paths: path: /v1/messages method: post servers: - url: https://api.anthropic.com request: security: [] parameters: path: {} query: {} header: anthropic-beta: schema: - type: array items: allOf: - type: string required: false title: Anthropic-Beta description: >- Optional header to specify the beta version(s) you want to use. To use multiple betas, use a comma separated list like `beta1,beta2` or specify the header multiple times for each beta. anthropic-version: schema: - type: string required: true title: Anthropic-Version description: >- The version of the Anthropic API you want to use. Read more about versioning and our version history [here](https://docs.anthropic.com/en/api/versioning). x-api-key: schema: - type: string required: true title: X-Api-Key description: >- Your unique API key for authentication. This key is required in the header of all API requests, to authenticate your account and access Anthropic's services. Get your API key through the [Console](https://console.anthropic.com/settings/keys). Each key is scoped to a Workspace. cookie: {} body: application/json: schemaArray: - type: object properties: model: allOf: - description: >- The model that will complete your prompt. See [models](https://docs.anthropic.com/en/docs/models-overview) for additional details and options. examples: - claude-sonnet-4-20250514 maxLength: 256 minLength: 1 title: Model type: string messages: allOf: - description: >- Input messages. Our models are trained to operate on alternating `user` and `assistant` conversational turns. When creating a new `Message`, you specify the prior conversational turns with the `messages` parameter, and the model then generates the next `Message` in the conversation. Consecutive `user` or `assistant` turns in your request will be combined into a single turn. Each input message must be an object with a `role` and `content`. You can specify a single `user`-role message, or you can include multiple `user` and `assistant` messages. If the final message uses the `assistant` role, the response content will continue immediately from the content in that message. This can be used to constrain part of the model's response. Example with a single `user` message: ```json [{"role": "user", "content": "Hello, Claude"}] ``` Example with multiple conversational turns: ```json [ {"role": "user", "content": "Hello there."}, {"role": "assistant", "content": "Hi, I'm Claude. How can I help you?"}, {"role": "user", "content": "Can you explain LLMs in plain English?"}, ] ``` Example with a partially-filled response from Claude: ```json [ {"role": "user", "content": "What's the Greek name for Sun? (A) Sol (B) Helios (C) Sun"}, {"role": "assistant", "content": "The best answer is ("}, ] ``` Each input message `content` may be either a single `string` or an array of content blocks, where each block has a specific `type`. Using a `string` for `content` is shorthand for an array of one content block of type `"text"`. The following input messages are equivalent: ```json {"role": "user", "content": "Hello, Claude"} ``` ```json {"role": "user", "content": [{"type": "text", "text": "Hello, Claude"}]} ``` See [examples](https://docs.anthropic.com/en/api/messages-examples) for more input examples. Note that if you want to include a [system prompt](https://docs.anthropic.com/en/docs/system-prompts), you can use the top-level `system` parameter — there is no `"system"` role for input messages in the Messages API. There is a limit of 100,000 messages in a single request. items: $ref: "#/components/schemas/InputMessage" title: Messages type: array container: allOf: - anyOf: - type: string - type: "null" description: Container identifier for reuse across requests. title: Container max_tokens: allOf: - description: >- The maximum number of tokens to generate before stopping. Note that our models may stop _before_ reaching this maximum. This parameter only specifies the absolute maximum number of tokens to generate. Different models have different maximum values for this parameter. See [models](https://docs.anthropic.com/en/docs/models-overview) for details. examples: - 1024 minimum: 1 title: Max Tokens type: integer mcp_servers: allOf: - description: MCP servers to be utilized in this request items: $ref: "#/components/schemas/RequestMCPServerURLDefinition" maxItems: 20 title: Mcp Servers type: array metadata: allOf: - $ref: "#/components/schemas/Metadata" description: An object describing metadata about the request. service_tier: allOf: - description: >- Determines whether to use priority capacity (if available) or standard capacity for this request. Anthropic offers different levels of service for your API requests. See [service-tiers](https://docs.anthropic.com/en/api/service-tiers) for details. enum: - auto - standard_only title: Service Tier type: string stop_sequences: allOf: - description: >- Custom text sequences that will cause the model to stop generating. Our models will normally stop when they have naturally completed their turn, which will result in a response `stop_reason` of `"end_turn"`. If you want the model to stop generating when it encounters custom strings of text, you can use the `stop_sequences` parameter. If the model encounters one of the custom sequences, the response `stop_reason` value will be `"stop_sequence"` and the response `stop_sequence` value will contain the matched stop sequence. items: type: string title: Stop Sequences type: array stream: allOf: - description: >- Whether to incrementally stream the response using server-sent events. See [streaming](https://docs.anthropic.com/en/api/messages-streaming) for details. title: Stream type: boolean system: allOf: - anyOf: - type: string - items: $ref: "#/components/schemas/RequestTextBlock" type: array description: >- System prompt. A system prompt is a way of providing context and instructions to Claude, such as specifying a particular goal or role. See our [guide to system prompts](https://docs.anthropic.com/en/docs/system-prompts). examples: - - text: Today's date is 2024-06-01. type: text - Today's date is 2023-01-01. title: System temperature: allOf: - description: >- Amount of randomness injected into the response. Defaults to `1.0`. Ranges from `0.0` to `1.0`. Use `temperature` closer to `0.0` for analytical / multiple choice, and closer to `1.0` for creative and generative tasks. Note that even with `temperature` of `0.0`, the results will not be fully deterministic. examples: - 1 maximum: 1 minimum: 0 title: Temperature type: number thinking: allOf: - description: >- Configuration for enabling Claude's extended thinking. When enabled, responses include `thinking` content blocks showing Claude's thinking process before the final answer. Requires a minimum budget of 1,024 tokens and counts towards your `max_tokens` limit. See [extended thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) for details. discriminator: mapping: disabled: "#/components/schemas/ThinkingConfigDisabled" enabled: "#/components/schemas/ThinkingConfigEnabled" propertyName: type oneOf: - $ref: "#/components/schemas/ThinkingConfigEnabled" - $ref: "#/components/schemas/ThinkingConfigDisabled" tool_choice: allOf: - description: >- How the model should use the provided tools. The model can use a specific tool, any available tool, decide by itself, or not use tools at all. discriminator: mapping: any: "#/components/schemas/ToolChoiceAny" auto: "#/components/schemas/ToolChoiceAuto" none: "#/components/schemas/ToolChoiceNone" tool: "#/components/schemas/ToolChoiceTool" propertyName: type oneOf: - $ref: "#/components/schemas/ToolChoiceAuto" - $ref: "#/components/schemas/ToolChoiceAny" - $ref: "#/components/schemas/ToolChoiceTool" - $ref: "#/components/schemas/ToolChoiceNone" tools: allOf: - description: >- Definitions of tools that the model may use. If you include `tools` in your API request, the model may return `tool_use` content blocks that represent the model's use of those tools. You can then run those tools using the tool input generated by the model and then optionally return results back to the model using `tool_result` content blocks. There are two types of tools: **client tools** and **server tools**. The behavior described below applies to client tools. For [server tools](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview\#server-tools), see their individual documentation as each has its own behavior (e.g., the [web search tool](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool)). Each tool definition includes: * `name`: Name of the tool. * `description`: Optional, but strongly-recommended description of the tool. * `input_schema`: [JSON schema](https://json-schema.org/draft/2020-12) for the tool `input` shape that the model will produce in `tool_use` output content blocks. For example, if you defined `tools` as: ```json [ { "name": "get_stock_price", "description": "Get the current stock price for a given ticker symbol.", "input_schema": { "type": "object", "properties": { "ticker": { "type": "string", "description": "The stock ticker symbol, e.g. AAPL for Apple Inc." } }, "required": ["ticker"] } } ] ``` And then asked the model "What's the S&P 500 at today?", the model might produce `tool_use` content blocks in the response like this: ```json [ { "type": "tool_use", "id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV", "name": "get_stock_price", "input": { "ticker": "^GSPC" } } ] ``` You might then run your `get_stock_price` tool with `{"ticker": "^GSPC"}` as an input, and return the following back to the model in a subsequent `user` message: ```json [ { "type": "tool_result", "tool_use_id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV", "content": "259.75 USD" } ] ``` Tools can be used for workflows that include running client-side tools and functions, or more generally whenever you want the model to produce a particular JSON structure of output. See our [guide](https://docs.anthropic.com/en/docs/tool-use) for more details. examples: - description: Get the current weather in a given location input_schema: properties: location: description: The city and state, e.g. San Francisco, CA type: string unit: description: >- Unit for the output - one of (celsius, fahrenheit) type: string required: - location type: object name: get_weather items: oneOf: - $ref: "#/components/schemas/Tool" - $ref: "#/components/schemas/BashTool_20241022" - $ref: "#/components/schemas/BashTool_20250124" - $ref: "#/components/schemas/CodeExecutionTool_20250522" - $ref: "#/components/schemas/ComputerUseTool_20241022" - $ref: "#/components/schemas/ComputerUseTool_20250124" - $ref: "#/components/schemas/TextEditor_20241022" - $ref: "#/components/schemas/TextEditor_20250124" - $ref: "#/components/schemas/TextEditor_20250429" - $ref: "#/components/schemas/TextEditor_20250728" - $ref: "#/components/schemas/WebSearchTool_20250305" title: Tools type: array top_k: allOf: - description: >- Only sample from the top K options for each subsequent token. Used to remove "long tail" low probability responses. [Learn more technical details here](https://towardsdatascience.com/how-to-sample-from-language-models-682bceb97277). Recommended for advanced use cases only. You usually only need to use `temperature`. examples: - 5 minimum: 0 title: Top K type: integer top_p: allOf: - description: >- Use nucleus sampling. In nucleus sampling, we compute the cumulative distribution over all the options for each subsequent token in decreasing probability order and cut it off once it reaches a particular probability specified by `top_p`. You should either alter `temperature` or `top_p`, but not both. Recommended for advanced use cases only. You usually only need to use `temperature`. examples: - 0.7 maximum: 1 minimum: 0 title: Top P type: number required: true title: CreateMessageParams requiredProperties: - model - messages - max_tokens additionalProperties: false example: max_tokens: 1024 messages: - content: Hello, world role: user model: claude-sonnet-4-20250514 examples: example: value: max_tokens: 1024 messages: - content: Hello, world role: user model: claude-sonnet-4-20250514 codeSamples: - lang: bash source: |- curl https://api.anthropic.com/v1/messages \ --header "x-api-key: $ANTHROPIC_API_KEY" \ --header "anthropic-version: 2023-06-01" \ --header "content-type: application/json" \ --data \ '{ "model": "claude-sonnet-4-20250514", "max_tokens": 1024, "messages": [ {"role": "user", "content": "Hello, world"} ] }' - lang: python source: |- import anthropic anthropic.Anthropic().messages.create( model="claude-sonnet-4-20250514", max_tokens=1024, messages=[ {"role": "user", "content": "Hello, world"} ] ) - lang: javascript source: |- import { Anthropic } from '@anthropic-ai/sdk'; const anthropic = new Anthropic(); await anthropic.messages.create({ model: "claude-sonnet-4-20250514", max_tokens: 1024, messages: [ {"role": "user", "content": "Hello, world"} ] }); response: "200": application/json: schemaArray: - type: object properties: id: allOf: - description: |- Unique object identifier. The format and length of IDs may change over time. examples: - msg_013Zva2CMHLNnXjNJJKqJ2EF title: Id type: string type: allOf: - const: message default: message description: |- Object type. For Messages, this is always `"message"`. enum: - message title: Type type: string role: allOf: - const: assistant default: assistant description: |- Conversational role of the generated message. This will always be `"assistant"`. enum: - assistant title: Role type: string content: allOf: - description: >- Content generated by the model. This is an array of content blocks, each of which has a `type` that determines its shape. Example: ```json [{"type": "text", "text": "Hi, I'm Claude."}] ``` If the request input `messages` ended with an `assistant` turn, then the response `content` will continue directly from that last turn. You can use this to constrain the model's output. For example, if the input `messages` were: ```json [ {"role": "user", "content": "What's the Greek name for Sun? (A) Sol (B) Helios (C) Sun"}, {"role": "assistant", "content": "The best answer is ("} ] ``` Then the response `content` might be: ```json [{"type": "text", "text": "B)"}] ``` examples: - - text: Hi! My name is Claude. type: text items: discriminator: mapping: code_execution_tool_result: >- #/components/schemas/ResponseCodeExecutionToolResultBlock container_upload: "#/components/schemas/ResponseContainerUploadBlock" mcp_tool_result: "#/components/schemas/ResponseMCPToolResultBlock" mcp_tool_use: "#/components/schemas/ResponseMCPToolUseBlock" redacted_thinking: "#/components/schemas/ResponseRedactedThinkingBlock" server_tool_use: "#/components/schemas/ResponseServerToolUseBlock" text: "#/components/schemas/ResponseTextBlock" thinking: "#/components/schemas/ResponseThinkingBlock" tool_use: "#/components/schemas/ResponseToolUseBlock" web_search_tool_result: >- #/components/schemas/ResponseWebSearchToolResultBlock propertyName: type oneOf: - $ref: "#/components/schemas/ResponseTextBlock" - $ref: "#/components/schemas/ResponseThinkingBlock" - $ref: "#/components/schemas/ResponseRedactedThinkingBlock" - $ref: "#/components/schemas/ResponseToolUseBlock" - $ref: "#/components/schemas/ResponseServerToolUseBlock" - $ref: >- #/components/schemas/ResponseWebSearchToolResultBlock - $ref: >- #/components/schemas/ResponseCodeExecutionToolResultBlock - $ref: "#/components/schemas/ResponseMCPToolUseBlock" - $ref: "#/components/schemas/ResponseMCPToolResultBlock" - $ref: "#/components/schemas/ResponseContainerUploadBlock" title: Content type: array model: allOf: - description: The model that handled the request. examples: - claude-sonnet-4-20250514 maxLength: 256 minLength: 1 title: Model type: string stop_reason: allOf: - anyOf: - enum: - end_turn - max_tokens - stop_sequence - tool_use - pause_turn - refusal type: string - type: "null" description: >- The reason that we stopped. This may be one the following values: * `"end_turn"`: the model reached a natural stopping point * `"max_tokens"`: we exceeded the requested `max_tokens` or the model's maximum * `"stop_sequence"`: one of your provided custom `stop_sequences` was generated * `"tool_use"`: the model invoked one or more tools * `"pause_turn"`: we paused a long-running turn. You may provide the response back as-is in a subsequent request to let the model continue. * `"refusal"`: when streaming classifiers intervene to handle potential policy violations In non-streaming mode this value is always non-null. In streaming mode, it is null in the `message_start` event and non-null otherwise. title: Stop Reason stop_sequence: allOf: - anyOf: - type: string - type: "null" default: null description: >- Which custom stop sequence was generated, if any. This value will be a non-null string if one of your custom stop sequences was generated. title: Stop Sequence usage: allOf: - $ref: "#/components/schemas/Usage" description: >- Billing and rate-limit usage. Anthropic's API bills and rate-limits by token counts, as tokens represent the underlying cost to our systems. Under the hood, the API transforms requests into a format suitable for the model. The model's output then goes through a parsing stage before becoming an API response. As a result, the token counts in `usage` will not match one-to-one with the exact visible content of an API request or response. For example, `output_tokens` will be non-zero, even for an empty string response from Claude. Total input tokens in a request is the summation of `input_tokens`, `cache_creation_input_tokens`, and `cache_read_input_tokens`. examples: - input_tokens: 2095 output_tokens: 503 container: allOf: - anyOf: - $ref: "#/components/schemas/Container" - type: "null" default: null description: >- Information about the container used in this request. This will be non-null if a container tool (e.g. code execution) was used. title: Message examples: - content: &ref_0 - text: Hi! My name is Claude. type: text id: msg_013Zva2CMHLNnXjNJJKqJ2EF model: claude-sonnet-4-20250514 role: assistant stop_reason: end_turn stop_sequence: null type: message usage: &ref_1 input_tokens: 2095 output_tokens: 503 requiredProperties: - id - type - role - content - model - stop_reason - stop_sequence - usage - container example: content: *ref_0 id: msg_013Zva2CMHLNnXjNJJKqJ2EF model: claude-sonnet-4-20250514 role: assistant stop_reason: end_turn stop_sequence: null type: message usage: *ref_1 examples: example: value: content: - text: Hi! My name is Claude. type: text id: msg_013Zva2CMHLNnXjNJJKqJ2EF model: claude-sonnet-4-20250514 role: assistant stop_reason: end_turn stop_sequence: null type: message usage: input_tokens: 2095 output_tokens: 503 description: Message object. 4XX: application/json: schemaArray: - type: object properties: error: allOf: - discriminator: mapping: api_error: "#/components/schemas/APIError" authentication_error: "#/components/schemas/AuthenticationError" billing_error: "#/components/schemas/BillingError" invalid_request_error: "#/components/schemas/InvalidRequestError" not_found_error: "#/components/schemas/NotFoundError" overloaded_error: "#/components/schemas/OverloadedError" permission_error: "#/components/schemas/PermissionError" rate_limit_error: "#/components/schemas/RateLimitError" timeout_error: "#/components/schemas/GatewayTimeoutError" propertyName: type oneOf: - $ref: "#/components/schemas/InvalidRequestError" - $ref: "#/components/schemas/AuthenticationError" - $ref: "#/components/schemas/BillingError" - $ref: "#/components/schemas/PermissionError" - $ref: "#/components/schemas/NotFoundError" - $ref: "#/components/schemas/RateLimitError" - $ref: "#/components/schemas/GatewayTimeoutError" - $ref: "#/components/schemas/APIError" - $ref: "#/components/schemas/OverloadedError" title: Error type: allOf: - const: error default: error enum: - error title: Type type: string title: ErrorResponse requiredProperties: - error - type examples: example: value: error: message: Invalid request type: invalid_request_error type: error description: >- Error response. See our [errors documentation](https://docs.anthropic.com/en/api/errors) for more details. deprecated: false type: path components: schemas: APIError: properties: message: default: Internal server error title: Message type: string type: const: api_error default: api_error enum: - api_error title: Type type: string required: - message - type title: APIError type: object AuthenticationError: properties: message: default: Authentication error title: Message type: string type: const: authentication_error default: authentication_error enum: - authentication_error title: Type type: string required: - message - type title: AuthenticationError type: object Base64ImageSource: additionalProperties: false properties: data: format: byte title: Data type: string media_type: enum: - image/jpeg - image/png - image/gif - image/webp title: Media Type type: string type: const: base64 enum: - base64 title: Type type: string required: - data - media_type - type title: Base64ImageSource type: object Base64PDFSource: additionalProperties: false properties: data: format: byte title: Data type: string media_type: const: application/pdf enum: - application/pdf title: Media Type type: string type: const: base64 enum: - base64 title: Type type: string required: - data - media_type - type title: PDF (base64) type: object BashTool_20241022: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control name: const: bash description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. enum: - bash title: Name type: string type: const: bash_20241022 enum: - bash_20241022 title: Type type: string required: - name - type title: Bash tool (2024-10-22) type: object BashTool_20250124: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control name: const: bash description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. enum: - bash title: Name type: string type: const: bash_20250124 enum: - bash_20250124 title: Type type: string required: - name - type title: Bash tool (2025-01-24) type: object BillingError: properties: message: default: Billing error title: Message type: string type: const: billing_error default: billing_error enum: - billing_error title: Type type: string required: - message - type title: BillingError type: object CacheControlEphemeral: additionalProperties: false properties: ttl: description: |- The time-to-live for the cache control breakpoint. This may be one the following values: - `5m`: 5 minutes - `1h`: 1 hour Defaults to `5m`. enum: - 5m - 1h title: Ttl type: string type: const: ephemeral enum: - ephemeral title: Type type: string required: - type title: CacheControlEphemeral type: object CacheCreation: properties: ephemeral_1h_input_tokens: default: 0 description: The number of input tokens used to create the 1 hour cache entry. minimum: 0 title: Ephemeral 1H Input Tokens type: integer ephemeral_5m_input_tokens: default: 0 description: The number of input tokens used to create the 5 minute cache entry. minimum: 0 title: Ephemeral 5M Input Tokens type: integer required: - ephemeral_1h_input_tokens - ephemeral_5m_input_tokens title: CacheCreation type: object CodeExecutionToolResultErrorCode: enum: - invalid_tool_input - unavailable - too_many_requests - execution_time_exceeded title: CodeExecutionToolResultErrorCode type: string CodeExecutionTool_20250522: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control name: const: code_execution description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. enum: - code_execution title: Name type: string type: const: code_execution_20250522 enum: - code_execution_20250522 title: Type type: string required: - name - type title: Code execution tool (2025-05-22) type: object ComputerUseTool_20241022: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control display_height_px: description: The height of the display in pixels. minimum: 1 title: Display Height Px type: integer display_number: anyOf: - minimum: 0 type: integer - type: "null" description: The X11 display number (e.g. 0, 1) for the display. title: Display Number display_width_px: description: The width of the display in pixels. minimum: 1 title: Display Width Px type: integer name: const: computer description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. enum: - computer title: Name type: string type: const: computer_20241022 enum: - computer_20241022 title: Type type: string required: - display_height_px - display_width_px - name - type title: Computer use tool (2024-01-22) type: object ComputerUseTool_20250124: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control display_height_px: description: The height of the display in pixels. minimum: 1 title: Display Height Px type: integer display_number: anyOf: - minimum: 0 type: integer - type: "null" description: The X11 display number (e.g. 0, 1) for the display. title: Display Number display_width_px: description: The width of the display in pixels. minimum: 1 title: Display Width Px type: integer name: const: computer description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. enum: - computer title: Name type: string type: const: computer_20250124 enum: - computer_20250124 title: Type type: string required: - display_height_px - display_width_px - name - type title: Computer use tool (2025-01-24) type: object Container: description: >- Information about the container used in the request (for the code execution tool) properties: expires_at: description: The time at which the container will expire. format: date-time title: Expires At type: string id: description: Identifier for the container used in this request title: Id type: string required: - expires_at - id title: Container type: object ContentBlockSource: additionalProperties: false properties: content: anyOf: - type: string - items: discriminator: mapping: image: "#/components/schemas/RequestImageBlock" text: "#/components/schemas/RequestTextBlock" propertyName: type oneOf: - $ref: "#/components/schemas/RequestTextBlock" - $ref: "#/components/schemas/RequestImageBlock" type: array title: Content type: const: content enum: - content title: Type type: string required: - content - type title: Content block type: object FileDocumentSource: additionalProperties: false properties: file_id: title: File Id type: string type: const: file enum: - file title: Type type: string required: - file_id - type title: File document type: object FileImageSource: additionalProperties: false properties: file_id: title: File Id type: string type: const: file enum: - file title: Type type: string required: - file_id - type title: FileImageSource type: object GatewayTimeoutError: properties: message: default: Request timeout title: Message type: string type: const: timeout_error default: timeout_error enum: - timeout_error title: Type type: string required: - message - type title: GatewayTimeoutError type: object InputMessage: additionalProperties: false properties: content: anyOf: - type: string - items: discriminator: mapping: code_execution_tool_result: "#/components/schemas/RequestCodeExecutionToolResultBlock" container_upload: "#/components/schemas/RequestContainerUploadBlock" document: "#/components/schemas/RequestDocumentBlock" image: "#/components/schemas/RequestImageBlock" mcp_tool_result: "#/components/schemas/RequestMCPToolResultBlock" mcp_tool_use: "#/components/schemas/RequestMCPToolUseBlock" redacted_thinking: "#/components/schemas/RequestRedactedThinkingBlock" search_result: "#/components/schemas/RequestSearchResultBlock" server_tool_use: "#/components/schemas/RequestServerToolUseBlock" text: "#/components/schemas/RequestTextBlock" thinking: "#/components/schemas/RequestThinkingBlock" tool_result: "#/components/schemas/RequestToolResultBlock" tool_use: "#/components/schemas/RequestToolUseBlock" web_search_tool_result: "#/components/schemas/RequestWebSearchToolResultBlock" propertyName: type oneOf: - $ref: "#/components/schemas/RequestTextBlock" description: Regular text content. - $ref: "#/components/schemas/RequestImageBlock" description: >- Image content specified directly as base64 data or as a reference via a URL. - $ref: "#/components/schemas/RequestDocumentBlock" description: >- Document content, either specified directly as base64 data, as text, or as a reference via a URL. - $ref: "#/components/schemas/RequestSearchResultBlock" description: >- A search result block containing source, title, and content from search operations. - $ref: "#/components/schemas/RequestThinkingBlock" description: A block specifying internal thinking by the model. - $ref: "#/components/schemas/RequestRedactedThinkingBlock" description: >- A block specifying internal, redacted thinking by the model. - $ref: "#/components/schemas/RequestToolUseBlock" description: A block indicating a tool use by the model. - $ref: "#/components/schemas/RequestToolResultBlock" description: A block specifying the results of a tool use by the model. - $ref: "#/components/schemas/RequestServerToolUseBlock" - $ref: "#/components/schemas/RequestWebSearchToolResultBlock" - $ref: "#/components/schemas/RequestCodeExecutionToolResultBlock" - $ref: "#/components/schemas/RequestMCPToolUseBlock" - $ref: "#/components/schemas/RequestMCPToolResultBlock" - $ref: "#/components/schemas/RequestContainerUploadBlock" type: array title: Content role: enum: - user - assistant title: Role type: string required: - content - role title: InputMessage type: object InputSchema: additionalProperties: true properties: properties: anyOf: - type: object - type: "null" title: Properties required: anyOf: - items: type: string type: array - type: "null" title: Required type: const: object enum: - object title: Type type: string required: - type title: InputSchema type: object InvalidRequestError: properties: message: default: Invalid request title: Message type: string type: const: invalid_request_error default: invalid_request_error enum: - invalid_request_error title: Type type: string required: - message - type title: InvalidRequestError type: object Metadata: additionalProperties: false properties: user_id: anyOf: - maxLength: 256 type: string - type: "null" description: >- An external identifier for the user who is associated with the request. This should be a uuid, hash value, or other opaque identifier. Anthropic may use this id to help detect abuse. Do not include any identifying information such as name, email address, or phone number. examples: - 13803d75-b4b5-4c3e-b2a2-6f21399b021b title: User Id title: Metadata type: object NotFoundError: properties: message: default: Not found title: Message type: string type: const: not_found_error default: not_found_error enum: - not_found_error title: Type type: string required: - message - type title: NotFoundError type: object OverloadedError: properties: message: default: Overloaded title: Message type: string type: const: overloaded_error default: overloaded_error enum: - overloaded_error title: Type type: string required: - message - type title: OverloadedError type: object PermissionError: properties: message: default: Permission denied title: Message type: string type: const: permission_error default: permission_error enum: - permission_error title: Type type: string required: - message - type title: PermissionError type: object PlainTextSource: additionalProperties: false properties: data: title: Data type: string media_type: const: text/plain enum: - text/plain title: Media Type type: string type: const: text enum: - text title: Type type: string required: - data - media_type - type title: Plain text type: object RateLimitError: properties: message: default: Rate limited title: Message type: string type: const: rate_limit_error default: rate_limit_error enum: - rate_limit_error title: Type type: string required: - message - type title: RateLimitError type: object RequestCharLocationCitation: additionalProperties: false properties: cited_text: title: Cited Text type: string document_index: minimum: 0 title: Document Index type: integer document_title: anyOf: - maxLength: 255 minLength: 1 type: string - type: "null" title: Document Title end_char_index: title: End Char Index type: integer start_char_index: minimum: 0 title: Start Char Index type: integer type: const: char_location enum: - char_location title: Type type: string required: - cited_text - document_index - document_title - end_char_index - start_char_index - type title: Character location type: object RequestCitationsConfig: additionalProperties: false properties: enabled: title: Enabled type: boolean title: RequestCitationsConfig type: object RequestCodeExecutionOutputBlock: additionalProperties: false properties: file_id: title: File Id type: string type: const: code_execution_output enum: - code_execution_output title: Type type: string required: - file_id - type title: RequestCodeExecutionOutputBlock type: object RequestCodeExecutionResultBlock: additionalProperties: false properties: content: items: $ref: "#/components/schemas/RequestCodeExecutionOutputBlock" title: Content type: array return_code: title: Return Code type: integer stderr: title: Stderr type: string stdout: title: Stdout type: string type: const: code_execution_result enum: - code_execution_result title: Type type: string required: - content - return_code - stderr - stdout - type title: Code execution result type: object RequestCodeExecutionToolResultBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control content: anyOf: - $ref: "#/components/schemas/RequestCodeExecutionToolResultError" - $ref: "#/components/schemas/RequestCodeExecutionResultBlock" title: Content tool_use_id: pattern: ^srvtoolu_[a-zA-Z0-9_]+$ title: Tool Use Id type: string type: const: code_execution_tool_result enum: - code_execution_tool_result title: Type type: string required: - content - tool_use_id - type title: Code execution tool result type: object RequestCodeExecutionToolResultError: additionalProperties: false properties: error_code: $ref: "#/components/schemas/CodeExecutionToolResultErrorCode" type: const: code_execution_tool_result_error enum: - code_execution_tool_result_error title: Type type: string required: - error_code - type title: Code execution tool error type: object RequestContainerUploadBlock: additionalProperties: false description: >- A content block that represents a file to be uploaded to the container Files uploaded via this block will be available in the container's input directory. properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control file_id: title: File Id type: string type: const: container_upload enum: - container_upload title: Type type: string required: - file_id - type title: Container upload type: object RequestContentBlockLocationCitation: additionalProperties: false properties: cited_text: title: Cited Text type: string document_index: minimum: 0 title: Document Index type: integer document_title: anyOf: - maxLength: 255 minLength: 1 type: string - type: "null" title: Document Title end_block_index: title: End Block Index type: integer start_block_index: minimum: 0 title: Start Block Index type: integer type: const: content_block_location enum: - content_block_location title: Type type: string required: - cited_text - document_index - document_title - end_block_index - start_block_index - type title: Content block location type: object RequestDocumentBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control citations: $ref: "#/components/schemas/RequestCitationsConfig" context: anyOf: - minLength: 1 type: string - type: "null" title: Context source: discriminator: mapping: base64: "#/components/schemas/Base64PDFSource" content: "#/components/schemas/ContentBlockSource" file: "#/components/schemas/FileDocumentSource" text: "#/components/schemas/PlainTextSource" url: "#/components/schemas/URLPDFSource" propertyName: type oneOf: - $ref: "#/components/schemas/Base64PDFSource" - $ref: "#/components/schemas/PlainTextSource" - $ref: "#/components/schemas/ContentBlockSource" - $ref: "#/components/schemas/URLPDFSource" - $ref: "#/components/schemas/FileDocumentSource" title: anyOf: - maxLength: 500 minLength: 1 type: string - type: "null" title: Title type: const: document enum: - document title: Type type: string required: - source - type title: Document type: object RequestImageBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control source: discriminator: mapping: base64: "#/components/schemas/Base64ImageSource" file: "#/components/schemas/FileImageSource" url: "#/components/schemas/URLImageSource" propertyName: type oneOf: - $ref: "#/components/schemas/Base64ImageSource" - $ref: "#/components/schemas/URLImageSource" - $ref: "#/components/schemas/FileImageSource" title: Source type: const: image enum: - image title: Type type: string required: - source - type title: Image type: object RequestMCPServerToolConfiguration: additionalProperties: false properties: allowed_tools: anyOf: - items: type: string type: array - type: "null" title: Allowed Tools enabled: anyOf: - type: boolean - type: "null" title: Enabled title: RequestMCPServerToolConfiguration type: object RequestMCPServerURLDefinition: additionalProperties: false properties: authorization_token: anyOf: - type: string - type: "null" title: Authorization Token name: title: Name type: string tool_configuration: anyOf: - $ref: "#/components/schemas/RequestMCPServerToolConfiguration" - type: "null" type: const: url enum: - url title: Type type: string url: title: Url type: string required: - name - type - url title: RequestMCPServerURLDefinition type: object RequestMCPToolResultBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control content: anyOf: - type: string - items: $ref: "#/components/schemas/RequestTextBlock" type: array title: Content is_error: title: Is Error type: boolean tool_use_id: pattern: ^[a-zA-Z0-9_-]+$ title: Tool Use Id type: string type: const: mcp_tool_result enum: - mcp_tool_result title: Type type: string required: - tool_use_id - type title: MCP tool result type: object RequestMCPToolUseBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control id: pattern: ^[a-zA-Z0-9_-]+$ title: Id type: string input: title: Input type: object name: title: Name type: string server_name: description: The name of the MCP server title: Server Name type: string type: const: mcp_tool_use enum: - mcp_tool_use title: Type type: string required: - id - input - name - server_name - type title: MCP tool use type: object RequestPageLocationCitation: additionalProperties: false properties: cited_text: title: Cited Text type: string document_index: minimum: 0 title: Document Index type: integer document_title: anyOf: - maxLength: 255 minLength: 1 type: string - type: "null" title: Document Title end_page_number: title: End Page Number type: integer start_page_number: minimum: 1 title: Start Page Number type: integer type: const: page_location enum: - page_location title: Type type: string required: - cited_text - document_index - document_title - end_page_number - start_page_number - type title: Page location type: object RequestRedactedThinkingBlock: additionalProperties: false properties: data: title: Data type: string type: const: redacted_thinking enum: - redacted_thinking title: Type type: string required: - data - type title: Redacted thinking type: object RequestSearchResultBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control citations: $ref: "#/components/schemas/RequestCitationsConfig" content: items: $ref: "#/components/schemas/RequestTextBlock" title: Content type: array source: title: Source type: string title: title: Title type: string type: const: search_result enum: - search_result title: Type type: string required: - content - source - title - type title: Search result type: object RequestSearchResultLocationCitation: additionalProperties: false properties: cited_text: title: Cited Text type: string end_block_index: title: End Block Index type: integer search_result_index: minimum: 0 title: Search Result Index type: integer source: title: Source type: string start_block_index: minimum: 0 title: Start Block Index type: integer title: anyOf: - type: string - type: "null" title: Title type: const: search_result_location enum: - search_result_location title: Type type: string required: - cited_text - end_block_index - search_result_index - source - start_block_index - title - type title: RequestSearchResultLocationCitation type: object RequestServerToolUseBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control id: pattern: ^srvtoolu_[a-zA-Z0-9_]+$ title: Id type: string input: title: Input type: object name: enum: - web_search - code_execution title: Name type: string type: const: server_tool_use enum: - server_tool_use title: Type type: string required: - id - input - name - type title: Server tool use type: object RequestTextBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control citations: anyOf: - items: discriminator: mapping: char_location: "#/components/schemas/RequestCharLocationCitation" content_block_location: "#/components/schemas/RequestContentBlockLocationCitation" page_location: "#/components/schemas/RequestPageLocationCitation" search_result_location: "#/components/schemas/RequestSearchResultLocationCitation" web_search_result_location: >- #/components/schemas/RequestWebSearchResultLocationCitation propertyName: type oneOf: - $ref: "#/components/schemas/RequestCharLocationCitation" - $ref: "#/components/schemas/RequestPageLocationCitation" - $ref: "#/components/schemas/RequestContentBlockLocationCitation" - $ref: >- #/components/schemas/RequestWebSearchResultLocationCitation - $ref: "#/components/schemas/RequestSearchResultLocationCitation" type: array - type: "null" title: Citations text: minLength: 1 title: Text type: string type: const: text enum: - text title: Type type: string required: - text - type title: Text type: object RequestThinkingBlock: additionalProperties: false properties: signature: title: Signature type: string thinking: title: Thinking type: string type: const: thinking enum: - thinking title: Type type: string required: - signature - thinking - type title: Thinking type: object RequestToolResultBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control content: anyOf: - type: string - items: discriminator: mapping: image: "#/components/schemas/RequestImageBlock" search_result: "#/components/schemas/RequestSearchResultBlock" text: "#/components/schemas/RequestTextBlock" propertyName: type oneOf: - $ref: "#/components/schemas/RequestTextBlock" - $ref: "#/components/schemas/RequestImageBlock" - $ref: "#/components/schemas/RequestSearchResultBlock" type: array title: Content is_error: title: Is Error type: boolean tool_use_id: pattern: ^[a-zA-Z0-9_-]+$ title: Tool Use Id type: string type: const: tool_result enum: - tool_result title: Type type: string required: - tool_use_id - type title: Tool result type: object RequestToolUseBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control id: pattern: ^[a-zA-Z0-9_-]+$ title: Id type: string input: title: Input type: object name: maxLength: 200 minLength: 1 title: Name type: string type: const: tool_use enum: - tool_use title: Type type: string required: - id - input - name - type title: Tool use type: object RequestWebSearchResultBlock: additionalProperties: false properties: encrypted_content: title: Encrypted Content type: string page_age: anyOf: - type: string - type: "null" title: Page Age title: title: Title type: string type: const: web_search_result enum: - web_search_result title: Type type: string url: title: Url type: string required: - encrypted_content - title - type - url title: RequestWebSearchResultBlock type: object RequestWebSearchResultLocationCitation: additionalProperties: false properties: cited_text: title: Cited Text type: string encrypted_index: title: Encrypted Index type: string title: anyOf: - maxLength: 512 minLength: 1 type: string - type: "null" title: Title type: const: web_search_result_location enum: - web_search_result_location title: Type type: string url: maxLength: 2048 minLength: 1 title: Url type: string required: - cited_text - encrypted_index - title - type - url title: RequestWebSearchResultLocationCitation type: object RequestWebSearchToolResultBlock: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control content: anyOf: - items: $ref: "#/components/schemas/RequestWebSearchResultBlock" type: array - $ref: "#/components/schemas/RequestWebSearchToolResultError" title: Content tool_use_id: pattern: ^srvtoolu_[a-zA-Z0-9_]+$ title: Tool Use Id type: string type: const: web_search_tool_result enum: - web_search_tool_result title: Type type: string required: - content - tool_use_id - type title: Web search tool result type: object RequestWebSearchToolResultError: additionalProperties: false properties: error_code: $ref: "#/components/schemas/WebSearchToolResultErrorCode" type: const: web_search_tool_result_error enum: - web_search_tool_result_error title: Type type: string required: - error_code - type title: RequestWebSearchToolResultError type: object ResponseCharLocationCitation: properties: cited_text: title: Cited Text type: string document_index: minimum: 0 title: Document Index type: integer document_title: anyOf: - type: string - type: "null" title: Document Title end_char_index: title: End Char Index type: integer file_id: anyOf: - type: string - type: "null" default: null title: File Id start_char_index: minimum: 0 title: Start Char Index type: integer type: const: char_location default: char_location enum: - char_location title: Type type: string required: - cited_text - document_index - document_title - end_char_index - file_id - start_char_index - type title: Character location type: object ResponseCodeExecutionOutputBlock: properties: file_id: title: File Id type: string type: const: code_execution_output default: code_execution_output enum: - code_execution_output title: Type type: string required: - file_id - type title: ResponseCodeExecutionOutputBlock type: object ResponseCodeExecutionResultBlock: properties: content: items: $ref: "#/components/schemas/ResponseCodeExecutionOutputBlock" title: Content type: array return_code: title: Return Code type: integer stderr: title: Stderr type: string stdout: title: Stdout type: string type: const: code_execution_result default: code_execution_result enum: - code_execution_result title: Type type: string required: - content - return_code - stderr - stdout - type title: Code execution result type: object ResponseCodeExecutionToolResultBlock: properties: content: anyOf: - $ref: "#/components/schemas/ResponseCodeExecutionToolResultError" - $ref: "#/components/schemas/ResponseCodeExecutionResultBlock" title: Content tool_use_id: pattern: ^srvtoolu_[a-zA-Z0-9_]+$ title: Tool Use Id type: string type: const: code_execution_tool_result default: code_execution_tool_result enum: - code_execution_tool_result title: Type type: string required: - content - tool_use_id - type title: Code execution tool result type: object ResponseCodeExecutionToolResultError: properties: error_code: $ref: "#/components/schemas/CodeExecutionToolResultErrorCode" type: const: code_execution_tool_result_error default: code_execution_tool_result_error enum: - code_execution_tool_result_error title: Type type: string required: - error_code - type title: Code execution tool error type: object ResponseContainerUploadBlock: description: Response model for a file uploaded to the container. properties: file_id: title: File Id type: string type: const: container_upload default: container_upload enum: - container_upload title: Type type: string required: - file_id - type title: Container upload type: object ResponseContentBlockLocationCitation: properties: cited_text: title: Cited Text type: string document_index: minimum: 0 title: Document Index type: integer document_title: anyOf: - type: string - type: "null" title: Document Title end_block_index: title: End Block Index type: integer file_id: anyOf: - type: string - type: "null" default: null title: File Id start_block_index: minimum: 0 title: Start Block Index type: integer type: const: content_block_location default: content_block_location enum: - content_block_location title: Type type: string required: - cited_text - document_index - document_title - end_block_index - file_id - start_block_index - type title: Content block location type: object ResponseMCPToolResultBlock: properties: content: anyOf: - type: string - items: $ref: "#/components/schemas/ResponseTextBlock" type: array title: Content is_error: default: false title: Is Error type: boolean tool_use_id: pattern: ^[a-zA-Z0-9_-]+$ title: Tool Use Id type: string type: const: mcp_tool_result default: mcp_tool_result enum: - mcp_tool_result title: Type type: string required: - content - is_error - tool_use_id - type title: MCP tool result type: object ResponseMCPToolUseBlock: properties: id: pattern: ^[a-zA-Z0-9_-]+$ title: Id type: string input: title: Input type: object name: description: The name of the MCP tool title: Name type: string server_name: description: The name of the MCP server title: Server Name type: string type: const: mcp_tool_use default: mcp_tool_use enum: - mcp_tool_use title: Type type: string required: - id - input - name - server_name - type title: MCP tool use type: object ResponsePageLocationCitation: properties: cited_text: title: Cited Text type: string document_index: minimum: 0 title: Document Index type: integer document_title: anyOf: - type: string - type: "null" title: Document Title end_page_number: title: End Page Number type: integer file_id: anyOf: - type: string - type: "null" default: null title: File Id start_page_number: minimum: 1 title: Start Page Number type: integer type: const: page_location default: page_location enum: - page_location title: Type type: string required: - cited_text - document_index - document_title - end_page_number - file_id - start_page_number - type title: Page location type: object ResponseRedactedThinkingBlock: properties: data: title: Data type: string type: const: redacted_thinking default: redacted_thinking enum: - redacted_thinking title: Type type: string required: - data - type title: Redacted thinking type: object ResponseSearchResultLocationCitation: properties: cited_text: title: Cited Text type: string end_block_index: title: End Block Index type: integer search_result_index: minimum: 0 title: Search Result Index type: integer source: title: Source type: string start_block_index: minimum: 0 title: Start Block Index type: integer title: anyOf: - type: string - type: "null" title: Title type: const: search_result_location default: search_result_location enum: - search_result_location title: Type type: string required: - cited_text - end_block_index - search_result_index - source - start_block_index - title - type title: ResponseSearchResultLocationCitation type: object ResponseServerToolUseBlock: properties: id: pattern: ^srvtoolu_[a-zA-Z0-9_]+$ title: Id type: string input: title: Input type: object name: enum: - web_search - code_execution title: Name type: string type: const: server_tool_use default: server_tool_use enum: - server_tool_use title: Type type: string required: - id - input - name - type title: Server tool use type: object ResponseTextBlock: properties: citations: anyOf: - items: discriminator: mapping: char_location: "#/components/schemas/ResponseCharLocationCitation" content_block_location: "#/components/schemas/ResponseContentBlockLocationCitation" page_location: "#/components/schemas/ResponsePageLocationCitation" search_result_location: "#/components/schemas/ResponseSearchResultLocationCitation" web_search_result_location: >- #/components/schemas/ResponseWebSearchResultLocationCitation propertyName: type oneOf: - $ref: "#/components/schemas/ResponseCharLocationCitation" - $ref: "#/components/schemas/ResponsePageLocationCitation" - $ref: "#/components/schemas/ResponseContentBlockLocationCitation" - $ref: >- #/components/schemas/ResponseWebSearchResultLocationCitation - $ref: "#/components/schemas/ResponseSearchResultLocationCitation" type: array - type: "null" default: null description: >- Citations supporting the text block. The type of citation returned will depend on the type of document being cited. Citing a PDF results in `page_location`, plain text results in `char_location`, and content document results in `content_block_location`. title: Citations text: maxLength: 5000000 minLength: 0 title: Text type: string type: const: text default: text enum: - text title: Type type: string required: - citations - text - type title: Text type: object ResponseThinkingBlock: properties: signature: title: Signature type: string thinking: title: Thinking type: string type: const: thinking default: thinking enum: - thinking title: Type type: string required: - signature - thinking - type title: Thinking type: object ResponseToolUseBlock: properties: id: pattern: ^[a-zA-Z0-9_-]+$ title: Id type: string input: title: Input type: object name: minLength: 1 title: Name type: string type: const: tool_use default: tool_use enum: - tool_use title: Type type: string required: - id - input - name - type title: Tool use type: object ResponseWebSearchResultBlock: properties: encrypted_content: title: Encrypted Content type: string page_age: anyOf: - type: string - type: "null" default: null title: Page Age title: title: Title type: string type: const: web_search_result default: web_search_result enum: - web_search_result title: Type type: string url: title: Url type: string required: - encrypted_content - page_age - title - type - url title: ResponseWebSearchResultBlock type: object ResponseWebSearchResultLocationCitation: properties: cited_text: title: Cited Text type: string encrypted_index: title: Encrypted Index type: string title: anyOf: - maxLength: 512 type: string - type: "null" title: Title type: const: web_search_result_location default: web_search_result_location enum: - web_search_result_location title: Type type: string url: title: Url type: string required: - cited_text - encrypted_index - title - type - url title: ResponseWebSearchResultLocationCitation type: object ResponseWebSearchToolResultBlock: properties: content: anyOf: - $ref: "#/components/schemas/ResponseWebSearchToolResultError" - items: $ref: "#/components/schemas/ResponseWebSearchResultBlock" type: array title: Content tool_use_id: pattern: ^srvtoolu_[a-zA-Z0-9_]+$ title: Tool Use Id type: string type: const: web_search_tool_result default: web_search_tool_result enum: - web_search_tool_result title: Type type: string required: - content - tool_use_id - type title: Web search tool result type: object ResponseWebSearchToolResultError: properties: error_code: $ref: "#/components/schemas/WebSearchToolResultErrorCode" type: const: web_search_tool_result_error default: web_search_tool_result_error enum: - web_search_tool_result_error title: Type type: string required: - error_code - type title: ResponseWebSearchToolResultError type: object ServerToolUsage: properties: web_search_requests: default: 0 description: The number of web search tool requests. examples: - 0 minimum: 0 title: Web Search Requests type: integer required: - web_search_requests title: ServerToolUsage type: object TextEditor_20241022: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control name: const: str_replace_editor description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. enum: - str_replace_editor title: Name type: string type: const: text_editor_20241022 enum: - text_editor_20241022 title: Type type: string required: - name - type title: Text editor tool (2024-10-22) type: object TextEditor_20250124: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control name: const: str_replace_editor description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. enum: - str_replace_editor title: Name type: string type: const: text_editor_20250124 enum: - text_editor_20250124 title: Type type: string required: - name - type title: Text editor tool (2025-01-24) type: object TextEditor_20250429: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control name: const: str_replace_based_edit_tool description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. enum: - str_replace_based_edit_tool title: Name type: string type: const: text_editor_20250429 enum: - text_editor_20250429 title: Type type: string required: - name - type title: Text editor tool (2025-04-29) type: object TextEditor_20250728: additionalProperties: false properties: cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control max_characters: anyOf: - minimum: 1 type: integer - type: "null" description: >- Maximum number of characters to display when viewing a file. If not specified, defaults to displaying the full file. title: Max Characters name: const: str_replace_based_edit_tool description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. enum: - str_replace_based_edit_tool title: Name type: string type: const: text_editor_20250728 enum: - text_editor_20250728 title: Type type: string required: - name - type title: TextEditor_20250728 type: object ThinkingConfigDisabled: additionalProperties: false properties: type: const: disabled enum: - disabled title: Type type: string required: - type title: Disabled type: object ThinkingConfigEnabled: additionalProperties: false properties: budget_tokens: description: >- Determines how many tokens Claude can use for its internal reasoning process. Larger budgets can enable more thorough analysis for complex problems, improving response quality. Must be ≥1024 and less than `max_tokens`. See [extended thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) for details. minimum: 1024 title: Budget Tokens type: integer type: const: enabled enum: - enabled title: Type type: string required: - budget_tokens - type title: Enabled type: object Tool: additionalProperties: false properties: type: anyOf: - type: "null" - const: custom enum: - custom type: string title: Type description: description: >- Description of what this tool does. Tool descriptions should be as detailed as possible. The more information that the model has about what the tool is and how to use it, the better it will perform. You can use natural language descriptions to reinforce important aspects of the tool input JSON schema. examples: - Get the current weather in a given location title: Description type: string name: description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. maxLength: 128 minLength: 1 pattern: ^[a-zA-Z0-9_-]{1,128}$ title: Name type: string input_schema: $ref: "#/components/schemas/InputSchema" description: >- [JSON schema](https://json-schema.org/draft/2020-12) for this tool's input. This defines the shape of the `input` that your tool accepts and that the model will produce. examples: - properties: location: description: The city and state, e.g. San Francisco, CA type: string unit: description: Unit for the output - one of (celsius, fahrenheit) type: string required: - location type: object cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control required: - name - input_schema title: Custom tool type: object ToolChoiceAny: additionalProperties: false description: The model will use any available tools. properties: disable_parallel_tool_use: description: >- Whether to disable parallel tool use. Defaults to `false`. If set to `true`, the model will output exactly one tool use. title: Disable Parallel Tool Use type: boolean type: const: any enum: - any title: Type type: string required: - type title: Any type: object ToolChoiceAuto: additionalProperties: false description: The model will automatically decide whether to use tools. properties: disable_parallel_tool_use: description: >- Whether to disable parallel tool use. Defaults to `false`. If set to `true`, the model will output at most one tool use. title: Disable Parallel Tool Use type: boolean type: const: auto enum: - auto title: Type type: string required: - type title: Auto type: object ToolChoiceNone: additionalProperties: false description: The model will not be allowed to use tools. properties: type: const: none enum: - none title: Type type: string required: - type title: None type: object ToolChoiceTool: additionalProperties: false description: The model will use the specified tool with `tool_choice.name`. properties: disable_parallel_tool_use: description: >- Whether to disable parallel tool use. Defaults to `false`. If set to `true`, the model will output exactly one tool use. title: Disable Parallel Tool Use type: boolean name: description: The name of the tool to use. title: Name type: string type: const: tool enum: - tool title: Type type: string required: - name - type title: Tool type: object URLImageSource: additionalProperties: false properties: type: const: url enum: - url title: Type type: string url: title: Url type: string required: - type - url title: URLImageSource type: object URLPDFSource: additionalProperties: false properties: type: const: url enum: - url title: Type type: string url: title: Url type: string required: - type - url title: PDF (URL) type: object Usage: properties: cache_creation: anyOf: - $ref: "#/components/schemas/CacheCreation" - type: "null" default: null description: Breakdown of cached tokens by TTL cache_creation_input_tokens: anyOf: - minimum: 0 type: integer - type: "null" default: null description: The number of input tokens used to create the cache entry. examples: - 2051 title: Cache Creation Input Tokens cache_read_input_tokens: anyOf: - minimum: 0 type: integer - type: "null" default: null description: The number of input tokens read from the cache. examples: - 2051 title: Cache Read Input Tokens input_tokens: description: The number of input tokens which were used. examples: - 2095 minimum: 0 title: Input Tokens type: integer output_tokens: description: The number of output tokens which were used. examples: - 503 minimum: 0 title: Output Tokens type: integer server_tool_use: anyOf: - $ref: "#/components/schemas/ServerToolUsage" - type: "null" default: null description: The number of server tool requests. service_tier: anyOf: - enum: - standard - priority - batch type: string - type: "null" default: null description: If the request used the priority, standard, or batch tier. title: Service Tier required: - cache_creation - cache_creation_input_tokens - cache_read_input_tokens - input_tokens - output_tokens - server_tool_use - service_tier title: Usage type: object UserLocation: additionalProperties: false properties: city: anyOf: - maxLength: 255 minLength: 1 type: string - type: "null" description: The city of the user. examples: - New York - Tokyo - Los Angeles title: City country: anyOf: - maxLength: 2 minLength: 2 type: string - type: "null" description: >- The two letter [ISO country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) of the user. examples: - US - JP - GB title: Country region: anyOf: - maxLength: 255 minLength: 1 type: string - type: "null" description: The region of the user. examples: - California - Ontario - Wales title: Region timezone: anyOf: - maxLength: 255 minLength: 1 type: string - type: "null" description: The [IANA timezone](https://nodatime.org/TimeZones) of the user. examples: - America/New_York - Asia/Tokyo - Europe/London title: Timezone type: const: approximate enum: - approximate title: Type type: string required: - type title: UserLocation type: object WebSearchToolResultErrorCode: enum: - invalid_tool_input - unavailable - max_uses_exceeded - too_many_requests - query_too_long title: WebSearchToolResultErrorCode type: string WebSearchTool_20250305: additionalProperties: false properties: allowed_domains: anyOf: - items: type: string type: array - type: "null" description: >- If provided, only these domains will be included in results. Cannot be used alongside `blocked_domains`. title: Allowed Domains blocked_domains: anyOf: - items: type: string type: array - type: "null" description: >- If provided, these domains will never appear in results. Cannot be used alongside `allowed_domains`. title: Blocked Domains cache_control: anyOf: - discriminator: mapping: ephemeral: "#/components/schemas/CacheControlEphemeral" propertyName: type oneOf: - $ref: "#/components/schemas/CacheControlEphemeral" - type: "null" description: Create a cache control breakpoint at this content block. title: Cache Control max_uses: anyOf: - exclusiveMinimum: 0 type: integer - type: "null" description: Maximum number of times the tool can be used in the API request. title: Max Uses name: const: web_search description: >- Name of the tool. This is how the tool will be called by the model and in `tool_use` blocks. enum: - web_search title: Name type: string type: const: web_search_20250305 enum: - web_search_20250305 title: Type type: string user_location: anyOf: - $ref: "#/components/schemas/UserLocation" - type: "null" description: >- Parameters for the user's location. Used to provide more relevant search results. required: - name - type title: Web search tool (2025-03-05) type: object ```` ================================================ FILE: aiprompts/anthropic-streaming.md ================================================ # Streaming Messages When creating a Message, you can set `"stream": true` to incrementally stream the response using [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent%5Fevents/Using%5Fserver-sent%5Fevents) (SSE). ## Streaming with SDKs Our [Python](https://github.com/anthropics/anthropic-sdk-python) and [TypeScript](https://github.com/anthropics/anthropic-sdk-typescript) SDKs offer multiple ways of streaming. The Python SDK allows both sync and async streams. See the documentation in each SDK for details. ```Python Python import anthropic client = anthropic.Anthropic() with client.messages.stream( max_tokens=1024, messages=[{"role": "user", "content": "Hello"}], model="claude-opus-4-1-20250805", ) as stream: for text in stream.text_stream: print(text, end="", flush=True) ```` ```TypeScript TypeScript import Anthropic from '@anthropic-ai/sdk'; const client = new Anthropic(); await client.messages.stream({ messages: [{role: 'user', content: "Hello"}], model: 'claude-opus-4-1-20250805', max_tokens: 1024, }).on('text', (text) => { console.log(text); }); ```` ## Event types Each server-sent event includes a named event type and associated JSON data. Each event will use an SSE event name (e.g. `event: message_stop`), and include the matching event `type` in its data. Each stream uses the following event flow: 1. `message_start`: contains a `Message` object with empty `content`. 2. A series of content blocks, each of which have a `content_block_start`, one or more `content_block_delta` events, and a `content_block_stop` event. Each content block will have an `index` that corresponds to its index in the final Message `content` array. 3. One or more `message_delta` events, indicating top-level changes to the final `Message` object. 4. A final `message_stop` event. The token counts shown in the `usage` field of the `message_delta` event are *cumulative*. ### Ping events Event streams may also include any number of `ping` events. ### Error events We may occasionally send [errors](/en/api/errors) in the event stream. For example, during periods of high usage, you may receive an `overloaded_error`, which would normally correspond to an HTTP 529 in a non-streaming context: ```json Example error event: error data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}} ``` ### Other events In accordance with our [versioning policy](/en/api/versioning), we may add new event types, and your code should handle unknown event types gracefully. ## Content block delta types Each `content_block_delta` event contains a `delta` of a type that updates the `content` block at a given `index`. ### Text delta A `text` content block delta looks like: ```JSON Text delta event: content_block_delta data: {"type": "content_block_delta","index": 0,"delta": {"type": "text_delta", "text": "ello frien"}} ``` ### Input JSON delta The deltas for `tool_use` content blocks correspond to updates for the `input` field of the block. To support maximum granularity, the deltas are _partial JSON strings_, whereas the final `tool_use.input` is always an _object_. You can accumulate the string deltas and parse the JSON once you receive a `content_block_stop` event, by using a library like [Pydantic](https://docs.pydantic.dev/latest/concepts/json/#partial-json-parsing) to do partial JSON parsing, or by using our [SDKs](https://docs.anthropic.com/en/api/client-sdks), which provide helpers to access parsed incremental values. A `tool_use` content block delta looks like: ```JSON Input JSON delta event: content_block_delta data: {"type": "content_block_delta","index": 1,"delta": {"type": "input_json_delta","partial_json": "{\"location\": \"San Fra"}}} ``` Note: Our current models only support emitting one complete key and value property from `input` at a time. As such, when using tools, there may be delays between streaming events while the model is working. Once an `input` key and value are accumulated, we emit them as multiple `content_block_delta` events with chunked partial json so that the format can automatically support finer granularity in future models. ### Thinking delta When using [extended thinking](/en/docs/build-with-claude/extended-thinking#streaming-thinking) with streaming enabled, you'll receive thinking content via `thinking_delta` events. These deltas correspond to the `thinking` field of the `thinking` content blocks. For thinking content, a special `signature_delta` event is sent just before the `content_block_stop` event. This signature is used to verify the integrity of the thinking block. A typical thinking delta looks like: ```JSON Thinking delta event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "Let me solve this step by step:\n\n1. First break down 27 * 453"}} ``` The signature delta looks like: ```JSON Signature delta event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds..."}} ``` ## Full HTTP Stream response We strongly recommend that you use our [client SDKs](/en/api/client-sdks) when using streaming mode. However, if you are building a direct API integration, you will need to handle these events yourself. A stream response is comprised of: 1. A `message_start` event 2. Potentially multiple content blocks, each of which contains: - A `content_block_start` event - Potentially multiple `content_block_delta` events - A `content_block_stop` event 3. A `message_delta` event 4. A `message_stop` event There may be `ping` events dispersed throughout the response as well. See [Event types](#event-types) for more details on the format. ### Basic streaming request ```bash Shell curl https://api.anthropic.com/v1/messages \ --header "anthropic-version: 2023-06-01" \ --header "content-type: application/json" \ --header "x-api-key: $ANTHROPIC_API_KEY" \ --data \ '{ "model": "claude-opus-4-1-20250805", "messages": [{"role": "user", "content": "Hello"}], "max_tokens": 256, "stream": true }' ``` ```python Python import anthropic client = anthropic.Anthropic() with client.messages.stream( model="claude-opus-4-1-20250805", messages=[{"role": "user", "content": "Hello"}], max_tokens=256, ) as stream: for text in stream.text_stream: print(text, end="", flush=True) ``` ```json Response event: message_start data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-opus-4-1-20250805", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} event: content_block_start data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} event: ping data: {"type": "ping"} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "!"}} event: content_block_stop data: {"type": "content_block_stop", "index": 0} event: message_delta data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null}, "usage": {"output_tokens": 15}} event: message_stop data: {"type": "message_stop"} ``` ### Streaming request with tool use Tool use now supports fine-grained streaming for parameter values as a beta feature. For more details, see [Fine-grained tool streaming](/en/docs/agents-and-tools/tool-use/fine-grained-tool-streaming). In this request, we ask Claude to use a tool to tell us the weather. ```bash Shell curl https://api.anthropic.com/v1/messages \ -H "content-type: application/json" \ -H "x-api-key: $ANTHROPIC_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -d '{ "model": "claude-opus-4-1-20250805", "max_tokens": 1024, "tools": [ { "name": "get_weather", "description": "Get the current weather in a given location", "input_schema": { "type": "object", "properties": { "location": { "type": "string", "description": "The city and state, e.g. San Francisco, CA" } }, "required": ["location"] } } ], "tool_choice": {"type": "any"}, "messages": [ { "role": "user", "content": "What is the weather like in San Francisco?" } ], "stream": true }' ``` ```python Python import anthropic client = anthropic.Anthropic() tools = [ { "name": "get_weather", "description": "Get the current weather in a given location", "input_schema": { "type": "object", "properties": { "location": { "type": "string", "description": "The city and state, e.g. San Francisco, CA" } }, "required": ["location"] } } ] with client.messages.stream( model="claude-opus-4-1-20250805", max_tokens=1024, tools=tools, tool_choice={"type": "any"}, messages=[ { "role": "user", "content": "What is the weather like in San Francisco?" } ], ) as stream: for text in stream.text_stream: print(text, end="", flush=True) ``` ```json Response event: message_start data: {"type":"message_start","message":{"id":"msg_014p7gG3wDgGV9EUtLvnow3U","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","stop_sequence":null,"usage":{"input_tokens":472,"output_tokens":2},"content":[],"stop_reason":null}} event: content_block_start data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: ping data: {"type": "ping"} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Okay"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":","}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" let"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" check"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" the"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" weather"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" for"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" San"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Francisco"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":","}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" CA"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":":"}} event: content_block_stop data: {"type":"content_block_stop","index":0} event: content_block_start data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01T1x1fJ34qAmk2tNTrN7Up6","name":"get_weather","input":{}}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"location\":"}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" \"San"}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" Francisc"}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"o,"}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" CA\""}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":", "}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"unit\": \"fah"}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"renheit\"}"}} event: content_block_stop data: {"type":"content_block_stop","index":1} event: message_delta data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":89}} event: message_stop data: {"type":"message_stop"} ``` ### Streaming request with extended thinking In this request, we enable extended thinking with streaming to see Claude's step-by-step reasoning. ```bash Shell curl https://api.anthropic.com/v1/messages \ --header "x-api-key: $ANTHROPIC_API_KEY" \ --header "anthropic-version: 2023-06-01" \ --header "content-type: application/json" \ --data \ '{ "model": "claude-opus-4-1-20250805", "max_tokens": 20000, "stream": true, "thinking": { "type": "enabled", "budget_tokens": 16000 }, "messages": [ { "role": "user", "content": "What is 27 * 453?" } ] }' ``` ```python Python import anthropic client = anthropic.Anthropic() with client.messages.stream( model="claude-opus-4-1-20250805", max_tokens=20000, thinking={ "type": "enabled", "budget_tokens": 16000 }, messages=[ { "role": "user", "content": "What is 27 * 453?" } ], ) as stream: for event in stream: if event.type == "content_block_delta": if event.delta.type == "thinking_delta": print(event.delta.thinking, end="", flush=True) elif event.delta.type == "text_delta": print(event.delta.text, end="", flush=True) ``` ```json Response event: message_start data: {"type": "message_start", "message": {"id": "msg_01...", "type": "message", "role": "assistant", "content": [], "model": "claude-opus-4-1-20250805", "stop_reason": null, "stop_sequence": null}} event: content_block_start data: {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "Let me solve this step by step:\n\n1. First break down 27 * 453"}} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\n2. 453 = 400 + 50 + 3"}} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\n3. 27 * 400 = 10,800"}} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\n4. 27 * 50 = 1,350"}} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\n5. 27 * 3 = 81"}} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\n6. 10,800 + 1,350 + 81 = 12,231"}} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds..."}} event: content_block_stop data: {"type": "content_block_stop", "index": 0} event: content_block_start data: {"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}} event: content_block_delta data: {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "27 * 453 = 12,231"}} event: content_block_stop data: {"type": "content_block_stop", "index": 1} event: message_delta data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null}} event: message_stop data: {"type": "message_stop"} ``` ### Streaming request with web search tool use In this request, we ask Claude to search the web for current weather information. ```bash Shell curl https://api.anthropic.com/v1/messages \ --header "x-api-key: $ANTHROPIC_API_KEY" \ --header "anthropic-version: 2023-06-01" \ --header "content-type: application/json" \ --data \ '{ "model": "claude-opus-4-1-20250805", "max_tokens": 1024, "stream": true, "tools": [ { "type": "web_search_20250305", "name": "web_search", "max_uses": 5 } ], "messages": [ { "role": "user", "content": "What is the weather like in New York City today?" } ] }' ``` ```python Python import anthropic client = anthropic.Anthropic() with client.messages.stream( model="claude-opus-4-1-20250805", max_tokens=1024, tools=[ { "type": "web_search_20250305", "name": "web_search", "max_uses": 5 } ], messages=[ { "role": "user", "content": "What is the weather like in New York City today?" } ], ) as stream: for text in stream.text_stream: print(text, end="", flush=True) ``` ```json Response event: message_start data: {"type":"message_start","message":{"id":"msg_01G...","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":2679,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":3}}} event: content_block_start data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'll check"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" the current weather in New York City for you"}} event: ping data: {"type": "ping"} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"."}} event: content_block_stop data: {"type":"content_block_stop","index":0} event: content_block_start data: {"type":"content_block_start","index":1,"content_block":{"type":"server_tool_use","id":"srvtoolu_014hJH82Qum7Td6UV8gDXThB","name":"web_search","input":{}}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"query"}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\":"}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" \"weather"}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" NY"}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"C to"}} event: content_block_delta data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"day\"}"}} event: content_block_stop data: {"type":"content_block_stop","index":1 } event: content_block_start data: {"type":"content_block_start","index":2,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_014hJH82Qum7Td6UV8gDXThB","content":[{"type":"web_search_result","title":"Weather in New York City in May 2025 (New York) - detailed Weather Forecast for a month","url":"https://world-weather.info/forecast/usa/new_york/may-2025/","encrypted_content":"Ev0DCioIAxgCIiQ3NmU4ZmI4OC1k...","page_age":null},...]}} event: content_block_stop data: {"type":"content_block_stop","index":2} event: content_block_start data: {"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"Here's the current weather information for New York"}} event: content_block_delta data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":" City:\n\n# Weather"}} event: content_block_delta data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":" in New York City"}} event: content_block_delta data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"\n\n"}} ... event: content_block_stop data: {"type":"content_block_stop","index":17} event: message_delta data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10682,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":510,"server_tool_use":{"web_search_requests":1}}} event: message_stop data: {"type":"message_stop"} ``` ## Error recovery When a streaming request is interrupted due to network issues, timeouts, or other errors, you can recover by resuming from where the stream was interrupted. This approach saves you from re-processing the entire response. The basic recovery strategy involves: 1. **Capture the partial response**: Save all content that was successfully received before the error occurred 2. **Construct a continuation request**: Create a new API request that includes the partial assistant response as the beginning of a new assistant message 3. **Resume streaming**: Continue receiving the rest of the response from where it was interrupted ### Error recovery best practices 1. **Use SDK features**: Leverage the SDK's built-in message accumulation and error handling capabilities 2. **Handle content types**: Be aware that messages can contain multiple content blocks (`text`, `tool_use`, `thinking`). Tool use and extended thinking blocks cannot be partially recovered. You can resume streaming from the most recent text block. ================================================ FILE: aiprompts/blockcontroller-lifecycle.md ================================================ # Block Controller Lifecycle ## Overview Block controllers manage the execution lifecycle of terminal shells, commands, and other interactive processes. **The frontend drives the controller lifecycle** - the backend is reactive, creating and managing controllers in response to frontend requests. ## Controller States Controllers have three primary states: - **`init`** - Controller exists but process is not running - **`running`** - Process is actively running - **`done`** - Process has exited ## Architecture Components ### Backend: Controller Registry Location: [`pkg/blockcontroller/blockcontroller.go`](pkg/blockcontroller/blockcontroller.go) The backend maintains a **global controller registry** that maps blockIds to controller instances: ```go var ( controllerRegistry = make(map[string]Controller) registryLock sync.RWMutex ) ``` Controllers implement the [`Controller` interface](pkg/blockcontroller/blockcontroller.go:64): - `Start(ctx, blockMeta, rtOpts, force)` - Start the controller process - `Stop(graceful, newStatus)` - Stop the controller process - `GetRuntimeStatus()` - Get current runtime status - `SendInput(input)` - Send input (data, signals, terminal size) to the process ### Frontend: View Model Location: [`frontend/app/view/term/term-model.ts`](frontend/app/view/term/term-model.ts) The [`TermViewModel`](frontend/app/view/term/term-model.ts:44) manages the frontend side of a terminal block: **Key Atoms:** - `shellProcFullStatus` - Holds the current controller status from backend - `shellProcStatus` - Derived atom for just the status string ("init", "running", "done") - `isRestarting` - UI state for restart animation **Event Subscription:** The constructor subscribes to controller status events (line 317-324): ```typescript this.shellProcStatusUnsubFn = waveEventSubscribe({ eventType: "controllerstatus", scope: WOS.makeORef("block", blockId), handler: (event) => { let bcRTS: BlockControllerRuntimeStatus = event.data; this.updateShellProcStatus(bcRTS); }, }); ``` This creates a **reactive data flow**: backend publishes status updates → frontend receives via WebSocket events → UI updates automatically via Jotai atoms. ## Lifecycle Flow ### 1. Frontend Triggers Controller Creation/Start **Entry Point:** [`ResyncController()`](pkg/blockcontroller/blockcontroller.go:120) RPC endpoint The frontend calls this via [`RpcApi.ControllerResyncCommand`](frontend/app/view/term/term-model.ts:661) when: 1. **Manual Restart** - User clicks restart button or presses Enter when process is done - Triggered by [`forceRestartController()`](frontend/app/view/term/term-model.ts:652) - Passes `forcerestart: true` flag - Includes current terminal size (`termsize: { rows, cols }`) 2. **Connection Status Changes** - Connection becomes available/unavailable - Monitored by [`TermResyncHandler`](frontend/app/view/term/term.tsx:34) component - Watches `connStatus` atom for changes - Calls `termRef.current?.resyncController("resync handler")` 3. **Block Meta Changes** - Configuration like controller type or connection changes - Happens when block metadata is updated - Backend detects changes and triggers resync ### 2. Backend Processes Resync Request The [`ResyncController()`](pkg/blockcontroller/blockcontroller.go:120) function: ```go func ResyncController(ctx context.Context, tabId, blockId string, rtOpts *waveobj.RuntimeOpts, force bool) error ``` **Steps:** 1. **Get Block Data** - Fetch block metadata from database 2. **Determine Controller Type** - Read `controller` meta key ("shell", "cmd", "tsunami") 3. **Check Existing Controller:** - If controller type changed → stop old, create new - If connection changed (for shell/cmd) → stop and restart - If `force=true` → stop existing 4. **Register Controller** - Add to registry (replaces existing if present) 5. **Check if Start Needed** - If status is "init" or "done": - For remote connections: verify connection status first - Call `controller.Start(ctx, blockMeta, rtOpts, force)` 6. **Publish Status** - Controller publishes runtime status updates **Important:** Registering a new controller automatically stops any existing controller for that blockId (line 95-98): ```go if existingController != nil { existingController.Stop(false, Status_Done) wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId)) } ``` ### 3. Backend Publishes Status Updates Controllers publish their status via the event system when: - Process starts - Process state changes - Process exits The status includes: - `shellprocstatus` - "init", "running", or "done" - `shellprocconnname` - Connection name being used - `shellprocexitcode` - Exit code when done - `version` - Incrementing version number for ordering ### 4. Frontend Receives and Processes Updates **Status Update Handler** (line 321-323): ```typescript handler: (event) => { let bcRTS: BlockControllerRuntimeStatus = event.data; this.updateShellProcStatus(bcRTS); } ``` **Status Update Logic** (line 430-438): ```typescript updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { if (fullStatus == null) return; const curStatus = globalStore.get(this.shellProcFullStatus); // Only update if newer version if (curStatus == null || curStatus.version < fullStatus.version) { globalStore.set(this.shellProcFullStatus, fullStatus); } } ``` The version check ensures out-of-order events don't cause issues. ### 5. UI Updates Reactively The UI reacts to status changes through Jotai atoms: **Header Buttons** (line 263-306): - Show "Play" icon when status is "init" - Show "Refresh" icon when status is "running" or "done" - Display exit code/status icons for cmd controller **Restart Behavior** (line 631-635 in term.tsx via term-model.ts): ```typescript const shellProcStatus = globalStore.get(this.shellProcStatus); if ((shellProcStatus == "done" || shellProcStatus == "init") && keyutil.checkKeyPressed(waveEvent, "Enter")) { this.forceRestartController(); return false; } ``` Pressing Enter when the process is done/init triggers a restart. ## Input Flow **Frontend → Backend:** When user types in terminal, data flows through [`sendDataToController()`](frontend/app/view/term/term-model.ts:408): ```typescript sendDataToController(data: string) { const b64data = stringToBase64(data); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } ``` This calls the backend [`SendInput()`](pkg/blockcontroller/blockcontroller.go:260) function which forwards to the controller's `SendInput()` method. The [`BlockInputUnion`](pkg/blockcontroller/blockcontroller.go:48) supports three types of input: - `inputdata` - Raw terminal input bytes - `signame` - Signal names (e.g., "SIGTERM", "SIGINT") - `termsize` - Terminal size changes (rows/cols) ## Key Design Principles ### 1. Frontend-Driven Architecture The frontend has full control over controller lifecycle: - **Creates** controllers by calling ResyncController - **Restarts** controllers via forcerestart flag - **Monitors** status via event subscriptions - **Sends input** via ControllerInput RPC The backend is stateless and reactive - it doesn't make lifecycle decisions autonomously. ### 2. Idempotent Resync `ResyncController()` is idempotent - calling it multiple times with the same state is safe: - If controller exists and is running with correct type/connection → no-op - If configuration changed → replaces controller - If force flag set → always restarts This makes it safe to call on various triggers (connection change, focus, etc.). ### 3. Versioned Status Updates Status includes a monotonically increasing version number: - Frontend can process events out-of-order - Only applies updates with newer versions - Prevents race conditions from concurrent updates ### 4. Automatic Cleanup When a controller is replaced: - Old controller is automatically stopped - Runtime info is cleaned up - Registry entry is updated atomically The `registerController()` function handles this automatically (line 84-99). ## Common Patterns ### Restarting a Controller ```typescript // In term-model.ts forceRestartController() { this.triggerRestartAtom(); // UI feedback const termsize = { rows: this.termRef.current?.terminal?.rows, cols: this.termRef.current?.terminal?.cols, }; RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: globalStore.get(atoms.staticTabId), blockid: this.blockId, forcerestart: true, rtopts: { termsize: termsize }, }); } ``` ### Handling Connection Changes ```typescript // In term.tsx - TermResyncHandler component React.useEffect(() => { const isConnected = connStatus?.status == "connected"; const wasConnected = lastConnStatus?.status == "connected"; if (isConnected == wasConnected && curConnName == lastConnName) { return; // No change } model.termRef.current?.resyncController("resync handler"); setLastConnStatus(connStatus); }, [connStatus]); ``` ### Monitoring Status ```typescript // Status is automatically available via atom const shellProcStatus = jotai.useAtomValue(model.shellProcStatus); // Use in UI if (shellProcStatus == "running") { // Show running state } else if (shellProcStatus == "done") { // Show restart button } ``` ## Summary The block controller lifecycle is **frontend-driven and event-reactive**: 1. **Frontend triggers** controller creation/restart via `ControllerResyncCommand` RPC 2. **Backend processes** the request in `ResyncController()`, creating/starting controllers as needed 3. **Backend publishes** status updates via WebSocket events 4. **Frontend receives** status updates and updates Jotai atoms 5. **UI reacts** automatically to atom changes via React components This architecture gives the frontend full control over when processes start/stop while keeping the backend focused on process management. The event-based status updates create a clean separation of concerns and enable real-time UI updates without polling. ================================================ FILE: aiprompts/config-system.md ================================================ # Wave Terminal Configuration System This document explains how Wave Terminal's configuration system works and provides step-by-step instructions for adding new configuration values. ## Overview Wave Terminal uses a hierarchical configuration system with the following components: 1. **Go Struct Definitions** - Type-safe configuration structure in Go 2. **JSON Schema** - Validation schema for configuration files 3. **Default Values** - Built-in default configuration 4. **User Configuration** - User-customizable settings in `~/.config/waveterm/settings.json` 5. **Documentation** - User-facing documentation ## Configuration File Structure Wave Terminal's configuration system is organized into several key directories and files: ``` waveterm/ ├── pkg/wconfig/ # Go configuration package │ ├── settingsconfig.go # Main settings struct definitions │ ├── defaultconfig/ # Default configuration files │ │ ├── settings.json # Default settings values │ │ ├── termthemes.json # Default terminal themes │ │ ├── presets.json # Default background presets │ │ └── widgets.json # Default widget configurations │ └── ... # Other config-related Go files ├── schema/ # JSON Schema definitions │ ├── settings.json # Settings validation schema │ └── ... # Other schema files ├── docs/docs/ # User documentation │ └── config.mdx # Configuration documentation └── ~/.config/waveterm/ # User config directory (runtime) ├── settings.json # User settings overrides ├── termthemes.json # User terminal themes ├── presets.json # User background presets ├── widgets.json # User widget configurations ├── bookmarks.json # Web bookmarks └── connections.json # SSH/remote connections ``` **Key Files:** - **[`pkg/wconfig/settingsconfig.go`](pkg/wconfig/settingsconfig.go)** - Defines the `SettingsType` struct with all configuration fields - **[`schema/settings.json`](schema/settings.json)** - JSON Schema for validation and type checking - **[`pkg/wconfig/defaultconfig/settings.json`](pkg/wconfig/defaultconfig/settings.json)** - Default values for all settings - **[`docs/docs/config.mdx`](docs/docs/config.mdx)** - User-facing documentation with descriptions and examples ## Configuration Architecture ### Configuration Hierarchy 1. **Built-in Defaults** (`pkg/wconfig/defaultconfig/settings.json`) 2. **User Settings** (`~/.config/waveterm/settings.json`) 3. **Block-level Overrides** (stored in block metadata) Settings cascade from defaults → user settings → block overrides. ### Block-Level Metadata Override System Wave Terminal supports block-level configuration overrides through the metadata system. This allows settings to be applied globally, per-connection, or per-block: 1. **Global Settings** (`~/.config/waveterm/settings.json`) - Apply to all blocks by default 2. **Connection Settings** (in connections config) - Apply to all blocks using a specific connection 3. **Block Metadata** - Override settings for individual blocks **Key Files for Block Overrides:** - **[`pkg/waveobj/wtypemeta.go`](pkg/waveobj/wtypemeta.go)** - Defines the `MetaTSType` struct for block-level metadata - Block metadata fields should match the corresponding settings fields for consistency **Frontend Usage:** ```typescript // Use getOverrideConfigAtom for hierarchical config resolution const settingValue = useAtomValue(getOverrideConfigAtom(blockId, "namespace:setting")); // This automatically resolves in order: block metadata → connection config → global settings → default ``` **Setting Block Metadata:** ```bash # Set for current block wsh setmeta namespace:setting=value # Set for specific block wsh setmeta --block BLOCK_ID namespace:setting=value ``` ## How to Add a New Configuration Value Follow these steps to add a new configuration setting: ### Step 1: Add to Go Struct Definition Edit [`pkg/wconfig/settingsconfig.go`](pkg/wconfig/settingsconfig.go) and add your new field to the `SettingsType` struct: ```go type SettingsType struct { // ... existing fields ... // Add your new field with appropriate JSON tag MyNewSetting string `json:"mynew:setting,omitempty"` // For different types: MyBoolSetting bool `json:"mynew:boolsetting,omitempty"` MyNumberSetting float64 `json:"mynew:numbersetting,omitempty"` MyIntSetting *int64 `json:"mynew:intsetting,omitempty"` // Use pointer for optional ints MyArraySetting []string `json:"mynew:arraysetting,omitempty"` } ``` **Naming Conventions:** - Use namespace prefixes (e.g., `term:`, `window:`, `ai:`, `web:`) - Use lowercase with colons as separators - Field names should be descriptive and follow Go naming conventions - Use `omitempty` tag to exclude empty values from JSON **Type Guidelines:** - Use `*int64` and `*float64` for optional numeric values - Use `*bool` for optional boolean values - Use `string` for text values - Use `[]string` for arrays - Use `float64` for numbers that can be decimals ### Step 1.5: Add to Block Metadata (Optional) If your setting should support block-level overrides, also add it to [`pkg/waveobj/wtypemeta.go`](pkg/waveobj/wtypemeta.go): ```go type MetaTSType struct { // ... existing fields ... // Add your new field with matching JSON tag and type MyNewSetting *string `json:"mynew:setting,omitempty"` // Use pointer for optional values // For different types: MyBoolSetting *bool `json:"mynew:boolsetting,omitempty"` MyNumberSetting *float64 `json:"mynew:numbersetting,omitempty"` MyIntSetting *int `json:"mynew:intsetting,omitempty"` MyArraySetting []string `json:"mynew:arraysetting,omitempty"` } ``` **Block Metadata Guidelines:** - Use pointer types (`*string`, `*bool`, `*int`, `*float64`) for optional overrides - JSON tags should exactly match the corresponding settings field - This enables the hierarchical config system: block metadata → connection config → global settings ### Step 2: Set Default Value (Optional) If your setting should have a default value, add it to [`pkg/wconfig/defaultconfig/settings.json`](pkg/wconfig/defaultconfig/settings.json): ```json { "ai:preset": "ai@global", "ai:model": "gpt-5-mini", // ... existing defaults ... "mynew:setting": "default value", "mynew:boolsetting": true, "mynew:numbersetting": 42.5, "mynew:intsetting": 100 } ``` **Default Value Guidelines:** - Only add defaults for settings that should have non-zero/non-empty initial values - Ensure defaults make sense for the typical user experience - Keep defaults conservative and safe ### Step 3: Update Documentation Add your new setting to the configuration table in [`docs/docs/config.mdx`](docs/docs/config.mdx): ```markdown | Key Name | Type | Function | | ------------------- | -------- | ----------------------------------------- | | mynew:setting | string | Description of what this setting controls | | mynew:boolsetting | bool | Enable/disable some feature | | mynew:numbersetting | float | Numeric setting for some parameter | | mynew:intsetting | int | Integer setting for some configuration | | mynew:arraysetting | string[] | Array of strings for multiple values | ``` Also update the default configuration example in the same file if you added defaults. ### Step 4: Regenerate Schema and TypeScript Types Run the generate task to automatically regenerate the JSON schema and TypeScript types: ```bash task generate ``` **What this does:** - Runs `task build:schema` (automatically generates JSON schema from Go structs) - Generates TypeScript type definitions in [`frontend/types/gotypes.d.ts`](frontend/types/gotypes.d.ts) - Generates RPC client APIs - Generates metadata constants **Note:** The JSON schema in [`schema/settings.json`](schema/settings.json) is **automatically generated** from the Go struct definitions - you don't need to edit it manually. ### Step 5: Use in Frontend Code Access your new setting in React components: ```typescript import { getOverrideConfigAtom, useAtomValue } from "@/store/global"; // In a React component const MyComponent = ({ blockId }: { blockId: string }) => { // Use override config atom for hierarchical resolution // This automatically checks: block metadata → connection config → global settings → default const mySettingAtom = getOverrideConfigAtom(blockId, "mynew:setting"); const mySetting = useAtomValue(mySettingAtom) ?? "fallback value"; // For global-only settings (no block overrides) const globalOnlySetting = useAtomValue(getSettingsKeyAtom("mynew:globalsetting")) ?? "fallback"; return
Setting value: {mySetting}
; }; ``` **Frontend Configuration Patterns:** ```typescript // 1. Settings with block-level overrides (recommended) const termFontSize = useAtomValue(getOverrideConfigAtom(blockId, "term:fontsize")) ?? 12; // 2. Global-only settings const appGlobalHotkey = useAtomValue(getSettingsKeyAtom("app:globalhotkey")) ?? ""; // 3. Connection-specific settings const connStatus = useAtomValue(getConnStatusAtom(connectionName)); ``` ### Step 6: Use in Backend Code Access settings in Go code: ```go // Get the full config fullConfig := wconfig.GetWatcher().GetFullConfig() // Access your setting myValue := fullConfig.Settings.MyNewSetting ``` ## Configuration Patterns ### Namespace Organization Settings are organized by namespace using colon separators: - `app:*` - Application-level settings - `term:*` - Terminal-specific settings - `window:*` - Window and UI settings - `ai:*` - AI-related settings - `web:*` - Web browser settings - `editor:*` - Code editor settings - `conn:*` - Connection settings ### Clear/Reset Pattern Each namespace can have a "clear" field for resetting all settings in that namespace: ```go AppClear bool `json:"app:*,omitempty"` TermClear bool `json:"term:*,omitempty"` ``` ### Optional vs Required Settings - Use pointer types (`*bool`, `*int64`, `*float64`) for truly optional settings - Use regular types for settings that should always have a value - Provide sensible defaults for important settings ### Block-Level Overrides Settings can be overridden at the block level using metadata: ```typescript // Set block-specific override await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { "mynew:setting": "block-specific value" }, }); ``` ## Example: Adding a New Terminal Setting Here's a complete example adding a new terminal setting `term:bellsound` with block-level override support: ### 1. Go Struct (settingsconfig.go) ```go type SettingsType struct { // ... existing fields ... TermBellSound string `json:"term:bellsound,omitempty"` } ``` ### 2. Block Metadata (wtypemeta.go) ```go type MetaTSType struct { // ... existing fields ... TermBellSound *string `json:"term:bellsound,omitempty"` // Pointer for optional override } ``` ### 3. Default Value (defaultconfig/settings.json - optional) ```json { "term:bellsound": "default" } ``` ### 4. Documentation (docs/config.mdx) ```markdown | term:bellsound | string | Sound to play for terminal bell ("default", "none", or custom sound file path) | ``` ### 5. Regenerate Types ```bash task generate ``` ### 6. Frontend Usage ```typescript // Use override config for hierarchical resolution const bellSoundAtom = getOverrideConfigAtom(blockId, "term:bellsound"); const bellSound = useAtomValue(bellSoundAtom) ?? "default"; ``` ### 7. Usage Examples ```bash # Set globally wsh setconfig term:bellsound="custom.wav" # Set for current block only wsh setmeta term:bellsound="none" # Set for specific block wsh setmeta --block BLOCK_ID term:bellsound="beep" ``` ## Testing Your Configuration 1. **Build and run** Wave Terminal with your changes 2. **Test default behavior** - Ensure the default value works 3. **Test user override** - Add your setting to `~/.config/waveterm/settings.json` 4. **Test block override** - Set block-specific metadata 5. **Verify schema validation** - Ensure invalid values are rejected ## Common Pitfalls ================================================ FILE: aiprompts/conn-arch.md ================================================ # Wave Terminal Connection Architecture ## Overview Wave Terminal's connection system is designed to provide a unified interface for running shell processes across local, SSH, and WSL environments. The architecture is built in layers, with clear separation of concerns between connection management, shell process execution, and block-level orchestration. ## Architecture Layers ``` ┌─────────────────────────────────────────────────────────────────┐ │ Block Controllers │ │ (blockcontroller/blockcontroller.go, shellcontroller.go) │ │ - Block lifecycle management │ │ - Controller registry and switching │ │ - Connection status verification │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ Connection Controllers (ConnUnion) │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Local │ │ SSH │ │ WSL │ │ │ │ │ │ (conncontrol │ │ (wslconn) │ │ │ │ │ │ ler) │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ - Connection lifecycle (init → connecting → connected) │ │ - WSH (Wave Shell Extensions) management │ │ - Domain socket setup for RPC communication │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ Shell Process Execution │ │ (shellexec/shellexec.go) │ │ - ShellProc wrapper for running processes │ │ - PTY management │ │ - Process lifecycle (start, wait, kill) │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ Low-Level Connection Implementation │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ os/exec │ │golang.org/x/ │ │ pkg/wsl │ │ │ │ │ │ crypto/ssh │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ - Local process spawning │ │ - SSH protocol implementation │ │ - WSL command execution │ └─────────────────────────────────────────────────────────────────┘ ``` ## Key Components ### 1. Block Controllers (`pkg/blockcontroller/`) **Primary Files:** - [`blockcontroller.go`](../pkg/blockcontroller/blockcontroller.go) - Controller registry and orchestration - [`shellcontroller.go`](../pkg/blockcontroller/shellcontroller.go) - Shell/terminal controller implementation **Responsibilities:** - **Controller Registry**: Maintains a global map of active block controllers (`controllerRegistry`) - **Lifecycle Management**: Handles controller creation, starting, stopping, and switching - **Connection Verification**: Checks connection status before starting shell processes ([`CheckConnStatus()`](../pkg/blockcontroller/blockcontroller.go:360)) - **Controller Types**: Supports different controller types (shell, cmd, tsunami) **Key Functions:** - [`ResyncController()`](../pkg/blockcontroller/blockcontroller.go:120) - Main entry point for synchronizing block state with desired controller - [`registerController()`](../pkg/blockcontroller/blockcontroller.go:84) - Registers a new controller, stopping any existing one - [`getController()`](../pkg/blockcontroller/blockcontroller.go:78) - Retrieves active controller for a block **ShellController Details:** - Implements the `Controller` interface - Manages shell processes via [`ShellProc`](../pkg/shellexec/shellexec.go:48) - Handles three connection types via `ConnUnion`: - **Local**: Direct process execution on local machine - **SSH**: Remote execution via SSH connections - **WSL**: Windows Subsystem for Linux execution - Key methods: - [`setupAndStartShellProcess()`](../pkg/blockcontroller/shellcontroller.go:364) - Sets up and starts shell process - [`getConnUnion()`](../pkg/blockcontroller/shellcontroller.go:321) - Determines connection type and retrieves connection object - [`manageRunningShellProcess()`](../pkg/blockcontroller/shellcontroller.go:500+) - Manages I/O for running process ### 2. Connection Controllers #### SSH Connections (`pkg/remote/conncontroller/`) **Primary File:** [`conncontroller.go`](../pkg/remote/conncontroller/conncontroller.go) **Architecture:** - **Global Registry**: `clientControllerMap` maintains all SSH connections - **Connection Lifecycle**: ``` init → connecting → connected → (running) → disconnected/error ``` - **Thread Safety**: Each connection has its own lock (`SSHConn.Lock`) **SSHConn Structure:** ```go type SSHConn struct { Lock *sync.Mutex Status string // Connection state WshEnabled *atomic.Bool // WSH availability flag Opts *remote.SSHOpts // Connection parameters Client *ssh.Client // Underlying SSH client DomainSockName string // Unix socket for RPC DomainSockListener net.Listener // Socket listener ConnController *ssh.Session // Runs "wsh connserver" Error string // Connection error WshError string // WSH-specific error WshVersion string // Installed WSH version // ... } ``` **Key Responsibilities:** 1. **SSH Client Management**: - Establishes SSH connections using [`golang.org/x/crypto/ssh`](https://pkg.go.dev/golang.org/x/crypto/ssh) - Handles authentication (pubkey, password, keyboard-interactive) - Supports ProxyJump for multi-hop connections 2. **Domain Socket Setup** ([`OpenDomainSocketListener()`](../pkg/remote/conncontroller/conncontroller.go:201)): - Creates Unix domain socket on remote host (`/tmp/waveterm-*.sock`) - Enables bidirectional RPC communication - Socket used by both connserver and shell processes 3. **WSH (Wave Shell Extensions) Management**: - **Version Check** ([`StartConnServer()`](../pkg/remote/conncontroller/conncontroller.go:277)): Runs `wsh version` to check installation - **Installation** ([`InstallWsh()`](../pkg/remote/conncontroller/conncontroller.go:478)): Copies appropriate WSH binary to remote - **Update** ([`UpdateWsh()`](../pkg/remote/conncontroller/conncontroller.go:417)): Updates existing WSH installation - **User Prompts** ([`getPermissionToInstallWsh()`](../pkg/remote/conncontroller/conncontroller.go:434)): Asks user for install permission 4. **Connection Server** (`wsh connserver`): - Long-running process on remote host - Provides RPC services for file operations, command execution, etc. - Communicates via domain socket - Template: [`ConnServerCmdTemplate`](../pkg/remote/conncontroller/conncontroller.go:74) **Connection Flow:** ``` 1. GetConn(opts) - Retrieve or create connection 2. Connect(ctx) - Initiate connection 3. CheckIfNeedsAuth() - Verify authentication needed 4. OpenDomainSocketListener() - Set up RPC channel 5. StartConnServer() - Launch wsh connserver 6. (Install/Update WSH if needed) 7. Status: Connected - Ready for shell processes ``` #### SSH Client (`pkg/remote/sshclient.go`) **Responsibilities:** - **Authentication Methods**: - Public key with optional passphrase ([`createPublicKeyCallback()`](../pkg/remote/sshclient.go:118)) - Password authentication ([`createPasswordCallbackPrompt()`](../pkg/remote/sshclient.go:227)) - Keyboard-interactive ([`createInteractiveKbdInteractiveChallenge()`](../pkg/remote/sshclient.go:264)) - SSH agent support - **Known Hosts Verification** ([`createHostKeyCallback()`](../pkg/remote/sshclient.go:429)): - Reads `~/.ssh/known_hosts` and global known_hosts - Prompts user for unknown hosts - Handles key changes/mismatches - **ProxyJump Support**: - Recursive connection through jump hosts - Max depth: `SshProxyJumpMaxDepth = 10` - **User Interaction**: - Integrates with Wave's [`userinput`](../pkg/userinput/) system - Non-blocking prompts for passwords, passphrases, host verification #### WSL Connections (`pkg/wslconn/`) **Primary File:** [`wslconn.go`](../pkg/wslconn/wslconn.go) **Architecture:** - **Similar to SSH**: Parallel structure to `conncontroller` but for WSL - **Global Registry**: `clientControllerMap` for WSL connections - **Connection Naming**: `wsl://[distro-name]` (e.g., `wsl://Ubuntu`) **WslConn Structure:** ```go type WslConn struct { Lock *sync.Mutex Status string WshEnabled *atomic.Bool Name wsl.WslName // Distro name Client *wsl.Distro // WSL distro interface DomainSockName string // Uses RemoteFullDomainSocketPath ConnController *wsl.WslCmd // Runs "wsh connserver" // ... similar to SSHConn } ``` **Key Differences from SSH:** - **No Network Socket**: WSL processes run locally, no SSH connection needed - **Domain Socket Path**: Uses predetermined path ([`wavebase.RemoteFullDomainSocketPath`](../pkg/wavebase/)) - **Command Execution**: Uses `wsl.exe` command-line tool - **Simpler Authentication**: No auth needed, user already logged into Windows **Connection Flow:** ``` 1. GetWslConn(distroName) - Get/create WSL connection 2. Connect(ctx) - Start connection process 3. OpenDomainSocketListener() - Set domain socket path (no actual listener) 4. StartConnServer() - Launch wsh connserver in WSL 5. (Install/Update WSH if needed) 6. Status: Connected - Ready for shell processes ``` ### 3. Shell Process Execution (`pkg/shellexec/`) **Primary File:** [`shellexec.go`](../pkg/shellexec/shellexec.go) **ShellProc Structure:** ```go type ShellProc struct { ConnName string // Connection identifier Cmd ConnInterface // Actual process interface CloseOnce *sync.Once // Ensures single close DoneCh chan any // Signals process completion WaitErr error // Process exit status } ``` **ConnInterface Implementations:** - **Local**: [`CombinedConnInterface`](../pkg/shellexec/) wraps `os/exec.Cmd` with PTY - **SSH**: [`RemoteConnInterface`](../pkg/shellexec/) wraps SSH session - **WSL**: [`WslConnInterface`](../pkg/shellexec/) wraps WSL command **Process Startup Functions:** - [`StartLocalShellProc()`](../pkg/shellexec/) - Local shell processes - [`StartRemoteShellProc()`](../pkg/shellexec/) - SSH remote shells (with WSH) - [`StartRemoteShellProcNoWsh()`](../pkg/shellexec/) - SSH remote shells (no WSH) - [`StartWslShellProc()`](../pkg/shellexec/) - WSL shells (with WSH) - [`StartWslShellProcNoWsh()`](../pkg/shellexec/) - WSL shells (no WSH) **Key Features:** - **PTY Management**: Pseudo-terminal for interactive shells - **Graceful Shutdown**: Sends SIGTERM, waits briefly, then SIGKILL - **Process Wrapping**: Abstracts differences between local/remote/WSL execution ### 4. Generic Connection Interface (`pkg/genconn/`) **Purpose**: Provides abstraction layer for running commands across different connection types **Primary File:** [`ssh-impl.go`](../pkg/genconn/ssh-impl.go) **Interface Hierarchy:** ```go ShellClient -> ShellProcessController ``` **SSHShellClient:** - Wraps `*ssh.Client` - Creates `SSHProcessController` for each command **SSHProcessController:** - Wraps `*ssh.Session` - Implements stdio piping (stdin, stdout, stderr) - Handles command lifecycle (Start, Wait, Kill) - Thread-safe with internal locking **Usage Pattern:** ```go client := genconn.MakeSSHShellClient(sshClient) proc, _ := client.MakeProcessController(cmdSpec) stdout, _ := proc.StdoutPipe() proc.Start() // Read from stdout... proc.Wait() ``` ### 5. Shell Utilities (`pkg/util/shellutil/`) **Primary File:** [`shellutil.go`](../pkg/util/shellutil/shellutil.go) **Responsibilities:** 1. **Shell Detection**: - [`DetectLocalShellPath()`](../pkg/util/shellutil/shellutil.go:87) - Finds user's default shell - [`GetShellTypeFromShellPath()`](../pkg/util/shellutil/shellutil.go:462) - Identifies shell type (bash, zsh, fish, pwsh) - [`DetectShellTypeAndVersion()`](../pkg/util/shellutil/shellutil.go:486) - Gets shell version info 2. **Shell Integration Files**: - [`InitCustomShellStartupFiles()`](../pkg/util/shellutil/shellutil.go:270) - Creates Wave's shell integration - Manages startup files for each shell type: - Bash: `.bashrc` in `shell/bash/` - Zsh: `.zshrc`, `.zprofile`, etc. in `shell/zsh/` - Fish: `wave.fish` in `shell/fish/` - PowerShell: `wavepwsh.ps1` in `shell/pwsh/` 3. **Environment Management**: - [`WaveshellLocalEnvVars()`](../pkg/util/shellutil/shellutil.go:218) - Wave-specific environment variables - [`UpdateCmdEnv()`](../pkg/util/shellutil/shellutil.go:231) - Updates command environment 4. **WSH Binary Management**: - [`GetLocalWshBinaryPath()`](../pkg/util/shellutil/shellutil.go:334) - Locates platform-specific WSH binary - Supports multiple OS/arch combinations 5. **Git Bash Detection** (Windows): - [`FindGitBash()`](../pkg/util/shellutil/shellutil.go:156) - Locates Git Bash installation - Checks multiple common installation paths ## Connection Types and Workflows ### Local Connections **Connection Name**: `"local"`, `"local:"`, or `""` (empty) **Workflow:** 1. Block controller checks connection type via [`IsLocalConnName()`](../pkg/remote/conncontroller/conncontroller.go:80) 2. No connection setup needed 3. Shell process started directly via [`StartLocalShellProc()`](../pkg/shellexec/) 4. Uses `os/exec.Cmd` with PTY 5. WSH integration via environment variables **Special Case - Git Bash (Windows):** - Variant: `"local:gitbash"` - Requires special shell path detection - Uses Git Bash binary instead of default shell ### SSH Connections **Connection Name**: `"user@host:port"` (parsed by [`remote.ParseOpts()`](../pkg/remote/)) **Full Connection Workflow:** ``` ┌─────────────────────────────────────────────────────────────────┐ │ 1. Connection Request (from Block Controller) │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 2. GetConn(opts) - Retrieve/Create SSHConn │ │ - Check global registry (clientControllerMap) │ │ - Create new SSHConn if needed │ │ - Status: "init" │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 3. conn.Connect(ctx) - Establish SSH Connection │ │ - Status: "connecting" │ │ - Read SSH config (~/.ssh/config) │ │ - Resolve ProxyJump if configured │ │ - Create SSH client auth methods: │ │ • Public key (with agent support) │ │ • Password │ │ • Keyboard-interactive │ │ - Establish SSH connection │ │ - Verify known_hosts │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 4. OpenDomainSocketListener(ctx) - Set Up RPC Channel │ │ - Create random socket path: /tmp/waveterm-[random].sock │ │ - Use ssh.Client.ListenUnix() for remote forwarding │ │ - Start RPC listener goroutine │ │ - Socket available for all subsequent operations │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 5. StartConnServer(ctx) - Launch Wave Shell Extensions │ │ - Run: "wsh version" to check installation │ │ - If not installed or outdated: │ │ a. Detect remote platform (OS/arch) │ │ b. Get user permission (if configured) │ │ c. InstallWsh() - Copy binary to remote │ │ d. Retry StartConnServer() │ │ - Run: "wsh connserver" on remote │ │ - Pass JWT token for authentication │ │ - Monitor connserver output │ │ - Wait for RPC route registration │ │ - Status: "connected" │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 6. Connection Ready - Can Start Shell Processes │ │ - SSHConn available in registry │ │ - Domain socket active for RPC │ │ - WSH connserver running │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 7. Start Shell Process (from ShellController) │ │ - setupAndStartShellProcess() │ │ - Create swap token (for shell integration) │ │ - StartRemoteShellProc() or StartRemoteShellProcNoWsh() │ │ - SSH session created for shell │ │ - PTY allocated │ │ - Shell starts with Wave integration │ └─────────────────────────────────────────────────────────────────┘ ``` **WSH (Wave Shell Extensions) Details:** **What is WSH?** - Binary program (`wsh`) that runs on remote hosts - Provides RPC services for Wave Terminal - Written in Go, cross-platform - Versioned to match Wave Terminal version **WSH Components:** 1. **wsh version**: Reports installed version 2. **wsh connserver**: Long-running RPC server - Handles file operations - Executes commands - Provides remote state information - Communicates over domain socket **WSH Installation Process:** 1. Check if wsh is installed: Run `wsh version` 2. If not installed: Detect platform with `uname -sm` 3. Get appropriate binary from local cache 4. Copy to remote: `~/.waveterm/bin/wsh` 5. Set executable permissions 6. Restart connection process **With vs Without WSH:** - **With WSH**: Full RPC support, better integration, file sync - **Without WSH**: Basic shell only, limited features - Fallback to no-WSH mode on installation failure ### WSL Connections **Connection Name**: `"wsl://[distro]"` (e.g., `"wsl://Ubuntu"`) **Workflow:** ``` 1. GetWslConn(distroName) - Get/create WslConn 2. conn.Connect(ctx) - Start connection 3. OpenDomainSocketListener() - Set socket path (no actual listener) 4. StartConnServer() - Launch "wsh connserver" via wsl.exe 5. Install/update WSH if needed (similar to SSH) 6. Status: "connected" 7. StartWslShellProc() - Create shell process in WSL ``` **Key Differences from SSH:** - Uses `wsl.exe` command-line tool - No network connection overhead - Predetermined domain socket path - Simpler authentication (inherited from Windows) ## Token Swap System **Purpose**: Pass connection-specific environment variables to shell processes **Implementation:** [`shellutil.TokenSwapEntry`](../pkg/util/shellutil/) **Flow:** 1. ShellController creates swap token before starting process 2. Token contains: - Socket name for RPC - JWT token for authentication - RPC context (TabId, BlockId, Conn) - Custom environment variables 3. Token stored in global swap map 4. Shell process receives token ID via environment 5. Shell integration scripts swap token for actual values 6. Token removed from map after use **Purpose:** - Avoid exposing JWT tokens in process listings - Enable shell integration without hardcoded values - Support multiple shells on same connection ## Error Handling and Recovery ### Connection Failures **SSH Connection Errors:** - Authentication failure → Prompt user (password, passphrase) - Host key mismatch → Prompt for verification - Network timeout → Status: "error", display error message - ProxyJump failure → Error shows which jump host failed **Recovery Mechanisms:** - [`conn.Reconnect(ctx)`](../pkg/remote/conncontroller/) - Close and re-establish connection - [`conn.WaitForConnect(ctx)`](../pkg/remote/conncontroller/) - Block until connected - Automatic fallback to no-WSH mode on installation failure ### Process Failures **Shell Process Errors:** - Process crash → WaitErr contains exit code - PTY failure → Captured in error message - I/O errors → Logged and surfaced to user **Cleanup:** - [`ShellProc.Close()`](../pkg/shellexec/shellexec.go:56) - Graceful then forceful kill - [`SSHConn.close_nolock()`](../pkg/remote/conncontroller/conncontroller.go:167) - Cleanup all resources - [`deleteController()`](../pkg/blockcontroller/blockcontroller.go:101) - Remove from registry ## Configuration Integration ### Connection Configuration **Source:** [`pkg/wconfig/`](../pkg/wconfig/) **Per-Connection Settings:** - `conn:wshenabled` - Enable/disable WSH - `conn:wshpath` - Custom WSH binary path - `conn:shellpath` - Custom shell path **Global Settings:** - `conn:askbeforewshinstall` - Prompt before WSH installation - Stored in `~/.waveterm/config/settings.json` - Per-connection overrides in `~/.waveterm/config/connections.json` ### SSH Configuration **Source:** `~/.ssh/config` **Supported Directives:** - `Host` - Connection matching - `HostName` - Target hostname - `Port` - SSH port - `User` - Username - `IdentityFile` - Private key paths - `ProxyJump` - Jump host specification - `UserKnownHostsFile` - Known hosts file - `GlobalKnownHostsFile` - System known hosts - `AddKeysToAgent` - Add keys to SSH agent **Library:** [`github.com/kevinburke/ssh_config`](https://github.com/kevinburke/ssh_config) ## Thread Safety ### Synchronization Patterns **SSHConn/WslConn:** ```go conn.Lock.Lock() defer conn.Lock.Unlock() // ... modify connection state ``` **Atomic Flags:** ```go conn.WshEnabled.Load() // Read WSH enabled status conn.WshEnabled.Store(v) // Update atomically ``` **Controller Registry:** ```go registryLock.RLock() // Read lock for lookups registryLock.Lock() // Write lock for modifications ``` **ShellProc Completion:** ```go sp.CloseOnce.Do(func() { // Ensure single execution sp.WaitErr = waitErr close(sp.DoneCh) // Signal completion }) ``` ## Event System Integration ### Connection Events **Published via:** [`pkg/wps/`](../pkg/wps/) (Wave Publish/Subscribe) **Event Types:** - `Event_ConnChange` - Connection status changed - `Event_ControllerStatus` - Block controller status update - `Event_BlockFile` - Block file operation (terminal output) **Example:** ```go wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_ConnChange, Scopes: []string{fmt.Sprintf("connection:%s", connName)}, Data: connStatus, }) ``` **Frontend Integration:** - Events received via WebSocket - Connection status updates UI - Real-time terminal output streaming ## Summary of Responsibilities | Component | Responsibilities | |-----------|-----------------| | **blockcontroller/** | Block lifecycle, controller registry, connection coordination | | **shellcontroller** | Shell process management, ConnUnion abstraction, I/O handling | | **conncontroller/** | SSH connection lifecycle, WSH management, domain socket setup | | **wslconn/** | WSL connection lifecycle, parallel to SSH but for WSL | | **sshclient.go** | Low-level SSH: auth, known_hosts, ProxyJump | | **shellexec/** | Process execution abstraction, PTY management | | **genconn/** | Generic command execution interface | | **shellutil/** | Shell detection, integration files, environment setup | ## Key Design Principles 1. **Layered Architecture**: Clear separation between block management, connection management, and process execution 2. **Connection Abstraction**: ConnUnion pattern allows uniform handling of Local/SSH/WSL 3. **WSH Optional**: System works with and without Wave Shell Extensions, degrading gracefully 4. **Thread Safety**: Defensive locking, atomic flags, singleton patterns prevent race conditions 5. **Error Recovery**: Multiple retry mechanisms, fallback modes, user prompts for resolution 6. **Configuration Hierarchy**: Global → Connection-Specific → Runtime overrides 7. **Event-Driven Updates**: Real-time status updates via pub/sub system 8. **User Interaction**: Non-blocking prompts for passwords, confirmations, installations This architecture provides a robust foundation for Wave Terminal's multi-environment shell capabilities, with clear extension points for adding new connection types or capabilities. ================================================ FILE: aiprompts/contextmenu.md ================================================ # Context Menu Quick Reference This guide provides a quick overview of how to create and display a context menu using our system. --- ## ContextMenuItem Type Define each menu item using the `ContextMenuItem` type: ```ts type ContextMenuItem = { label?: string; type?: "separator" | "normal" | "submenu" | "checkbox" | "radio"; role?: string; // Electron role (optional) click?: () => void; // Callback for item selection (not needed if role is set) submenu?: ContextMenuItem[]; // For nested menus checked?: boolean; // For checkbox or radio items visible?: boolean; enabled?: boolean; sublabel?: string; }; ``` --- ## Import and Show the Menu Import the context menu module: ```ts import { ContextMenuModel } from "@/app/store/contextmenu"; ``` To display the context menu, call: ```ts ContextMenuModel.showContextMenu(menu, event); ``` - **menu**: An array of `ContextMenuItem`. - **event**: The mouse event that triggered the context menu (typically from an onContextMenu handler). --- ## Basic Example A simple context menu with a separator: ```ts const menu: ContextMenuItem[] = [ { label: "New File", click: () => { /* create a new file */ }, }, { label: "New Folder", click: () => { /* create a new folder */ }, }, { type: "separator" }, { label: "Rename", click: () => { /* rename item */ }, }, ]; ContextMenuModel.showContextMenu(menu, e); ``` --- ## Example with Submenu and Checkboxes Toggle settings using a submenu with checkbox items: ```ts const isClearOnStart = true; // Example setting const menu: ContextMenuItem[] = [ { label: "Clear Output On Restart", submenu: [ { label: "On", type: "checkbox", checked: isClearOnStart, click: () => { // Set the config to enable clear on restart }, }, { label: "Off", type: "checkbox", checked: !isClearOnStart, click: () => { // Set the config to disable clear on restart }, }, ], }, ]; ContextMenuModel.showContextMenu(menu, e); ``` --- ## Editing a Config File Example Open a configuration file (e.g., `widgets.json`) in preview mode: ```ts { label: "Edit widgets.json", click: () => { fireAndForget(async () => { const path = `${getApi().getConfigDir()}/widgets.json`; const blockDef: BlockDef = { meta: { view: "preview", file: path }, }; await createBlock(blockDef, false, true); }); }, } ``` --- ## Summary - **Menu Definition**: Use the `ContextMenuItem` type. - **Actions**: Use `click` for actions; use `submenu` for nested options. - **Separators**: Use `type: "separator"` to group items. - **Toggles**: Use `type: "checkbox"` or `"radio"` with the `checked` property. - **Displaying**: Use `ContextMenuModel.showContextMenu(menu, event)` to render the menu. ================================================ FILE: aiprompts/fe-conn-arch.md ================================================ # Wave Terminal Frontend Connection Architecture ## Overview The frontend connection architecture provides a reactive interface for managing and interacting with connections (local, SSH, WSL, S3). It follows a unidirectional data flow pattern where the backend manages connection state, the frontend observes this state through Jotai atoms, and user interactions trigger backend operations via RPC commands. ## Architecture Pattern ``` ┌─────────────────────────────────────────────────────────────────┐ │ User Interface │ │ - ConnectionButton (displays status) │ │ - ChangeConnectionBlockModal (connection picker) │ │ - ConnStatusOverlay (error states) │ └─────────────────────────────────────────────────────────────────┘ ↕ ┌─────────────────────────────────────────────────────────────────┐ │ Jotai Reactive State │ │ - ConnStatusMapAtom (connection statuses) │ │ - View Model Atoms (derived connection state) │ │ - Block Metadata (connection selection) │ └─────────────────────────────────────────────────────────────────┘ ↕ ┌─────────────────────────────────────────────────────────────────┐ │ RPC Commands │ │ - ConnListCommand (list connections) │ │ - ConnEnsureCommand (ensure connected) │ │ - ConnConnectCommand/ConnDisconnectCommand │ │ - SetMetaCommand (change block connection) │ │ - ControllerInputCommand (send data to shell) │ └─────────────────────────────────────────────────────────────────┘ ↕ ┌─────────────────────────────────────────────────────────────────┐ │ Backend (see conn-arch.md) │ │ - Connection Controllers (SSHConn, WslConn) │ │ - Block Controllers (ShellController) │ │ - Shell Process Execution │ └─────────────────────────────────────────────────────────────────┘ ``` ## Key Components ### 1. Connection State Management ([`frontend/app/store/global.ts`](../frontend/app/store/global.ts)) **ConnStatusMapAtom** ```typescript const ConnStatusMapAtom = atom(new Map>()) ``` - Global registry of connection status atoms - One atom per connection (keyed by connection name) - Backend updates status via wave events - Frontend components subscribe to individual connection atoms **getConnStatusAtom()** ```typescript function getConnStatusAtom(connName: string): PrimitiveAtom ``` - Retrieves or creates status atom for a connection - Returns cached atom if exists - Creates new atom initialized to default if needed - Used by view models to track their connection **ConnStatus Structure** ```typescript interface ConnStatus { status: "init" | "connecting" | "connected" | "disconnected" | "error" connection: string // Connection name connected: boolean // Is currently connected activeconnnum: number // Color assignment number (1-8) wshenabled: boolean // WSH available on this connection error?: string // Error message if status is "error" wsherror?: string // WSH-specific error } ``` **allConnStatusAtom** ```typescript const allConnStatusAtom = atom((get) => { const connStatusMap = get(ConnStatusMapAtom) const connStatuses = Array.from(connStatusMap.values()).map((atom) => get(atom)) return connStatuses }) ``` - Provides array of all connection statuses - Used by connection modal to display all available connections - Automatically updates when any connection status changes ### 2. Connection Button UI ([`frontend/app/block/blockutil.tsx`](../frontend/app/block/blockutil.tsx)) **ConnectionButton Component** ```typescript export const ConnectionButton = React.memo( React.forwardRef( ({ connection, changeConnModalAtom }, ref) => { const connStatusAtom = getConnStatusAtom(connection) const connStatus = jotai.useAtomValue(connStatusAtom) // ... renders connection status with colored icon } ) ) ``` **Responsibilities:** - Displays connection name and status icon - Color-codes connections (8 colors, cycling) - Shows visual states: - **Local**: Laptop icon (grey) - **Connecting**: Animated dots (yellow/warning) - **Connected**: Arrow icon (colored by activeconnnum) - **Error**: Slashed arrow icon (red) - **Disconnected**: Slashed arrow icon (grey) - Opens connection modal on click **Color Assignment:** ```typescript function computeConnColorNum(connStatus: ConnStatus): number { const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors return connColorNum == 0 ? NumActiveConnColors : connColorNum } ``` - Backend assigns `activeconnnum` sequentially - Frontend cycles through 8 CSS color variables - `var(--conn-icon-color-1)` through `var(--conn-icon-color-8)` ### 3. Connection Selection Modal ([`frontend/app/modals/conntypeahead.tsx`](../frontend/app/modals/conntypeahead.tsx)) **ChangeConnectionBlockModal Component** **Data Fetching:** ```typescript useEffect(() => { if (!changeConnModalOpen) return // Fetch available connections RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 }) .then(setConnList) RpcApi.WslListCommand(TabRpcClient, { timeout: 2000 }) .then(setWslList) RpcApi.ConnListAWSCommand(TabRpcClient, { timeout: 2000 }) .then(setS3List) }, [changeConnModalOpen]) ``` **Connection Change Handler:** ```typescript const changeConnection = async (connName: string) => { // Update block metadata with new connection await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { connection: connName, file: newFile, // Reset file path for new connection "cmd:cwd": null // Clear working directory } }) // Ensure connection is established await RpcApi.ConnEnsureCommand(TabRpcClient, { connname: connName, logblockid: blockId }, { timeout: 60000 }) } ``` **Suggestion Categories:** 1. **Local Connections** - Local machine (`""` or `"local:"`) - Git Bash (Windows only: `"local:gitbash"`) - WSL distros (`"wsl://Ubuntu"`, etc.) 2. **Remote Connections** (SSH) - User-configured SSH connections - Format: `"user@host"` or `"user@host:port"` - Filtered by `display:hidden` config 3. **S3 Connections** (optional) - AWS S3 profiles - Format: `"aws:profile-name"` 4. **Actions** - Reconnect (if disconnected/error) - Disconnect (if connected) - Edit Connections (opens config editor) - New Connection (creates new SSH config) **Filtering Logic:** ```typescript function filterConnections( connList: Array, connSelected: string, fullConfig: FullConfigType, filterOutNowsh: boolean ): Array { const connectionsConfig = fullConfig.connections return connList.filter((conn) => { const hidden = connectionsConfig?.[conn]?.["display:hidden"] ?? false const wshEnabled = connectionsConfig?.[conn]?.["conn:wshenabled"] ?? true return conn.includes(connSelected) && !hidden && (wshEnabled || !filterOutNowsh) }) } ``` ### 4. Connection Status Overlay ([`frontend/app/block/blockframe.tsx`](../frontend/app/block/blockframe.tsx)) **ConnStatusOverlay Component** Displays over block content when: - Connection is disconnected or in error state - WSH installation/update errors occur - Not in layout mode (Ctrl+Shift held) - Connection modal is not open **Features:** - Shows connection status text - Displays error messages (scrollable) - Reconnect button (for disconnected/error) - "Always disable wsh" button (for WSH errors) - Adaptive layout based on width **Handlers:** ```typescript // Reconnect to failed connection const handleTryReconnect = () => { RpcApi.ConnConnectCommand(TabRpcClient, { host: connName, logblockid: nodeModel.blockId }, { timeout: 60000 }) } // Disable WSH for this connection const handleDisableWsh = async () => { await RpcApi.SetConnectionsConfigCommand(TabRpcClient, { host: connName, metamaptype: { "conn:wshenabled": false } }) } ``` ### 5. View Model Integration View models integrate connection state into their reactive data flow: #### Terminal View Model ([`frontend/app/view/term/term-model.ts`](../frontend/app/view/term/term-model.ts)) ```typescript class TermViewModel implements ViewModel { // Connection management flag manageConnection = atom((get) => { const termMode = get(this.termMode) if (termMode == "vdom") return false // VDOM mode doesn't show conn button const isCmd = get(this.isCmdController) if (isCmd) return false // Cmd controller doesn't manage connections return true // Standard terminals show connection button }) // Connection status for this block connStatus = atom((get) => { const blockData = get(this.blockAtom) const connName = blockData?.meta?.connection const connAtom = getConnStatusAtom(connName) return get(connAtom) }) // Filter connections without WSH filterOutNowsh = atom(false) } ``` **End Icon Button Logic:** ```typescript endIconButtons = atom((get) => { const connStatus = get(this.connStatus) const shellProcStatus = get(this.shellProcStatus) // Only show restart button if connected if (connStatus?.status != "connected") { return [] } // Show appropriate icon based on shell state if (shellProcStatus == "init") { return [{ icon: "play", title: "Click to Start Shell" }] } else if (shellProcStatus == "running") { return [{ icon: "refresh", title: "Shell Running. Click to Restart" }] } else if (shellProcStatus == "done") { return [{ icon: "refresh", title: "Shell Exited. Click to Restart" }] } }) ``` #### Preview View Model ([`frontend/app/view/preview/preview-model.tsx`](../frontend/app/view/preview/preview-model.tsx)) ```typescript class PreviewModel implements ViewModel { // Always manages connection manageConnection = atom(true) // Connection status connStatus = atom((get) => { const blockData = get(this.blockAtom) const connName = blockData?.meta?.connection const connAtom = getConnStatusAtom(connName) return get(connAtom) }) // Filter out connections without WSH (file ops require WSH) filterOutNowsh = atom(true) // Ensure connection before operations connection = atom>(async (get) => { const connName = get(this.blockAtom)?.meta?.connection try { await RpcApi.ConnEnsureCommand(TabRpcClient, { connname: connName }, { timeout: 60000 }) globalStore.set(this.connectionError, "") } catch (e) { globalStore.set(this.connectionError, e as string) } return connName }) } ``` **File Operations Over Connection:** ```typescript // Reads file from remote/local connection statFile = atom>(async (get) => { const fileName = get(this.metaFilePath) const path = await this.formatRemoteUri(fileName, get) return await RpcApi.FileInfoCommand(TabRpcClient, { info: { path } }) }) fullFile = atom>(async (get) => { const fileName = get(this.metaFilePath) const path = await this.formatRemoteUri(fileName, get) return await RpcApi.FileReadCommand(TabRpcClient, { info: { path } }) }) ``` ### 6. Block Controller Integration **View models do NOT directly manage shell processes.** They interact with block controllers via RPC: **Starting a Shell:** ```typescript // User clicks restart button in terminal forceRestartController() { // Backend handles connection verification and process startup RpcApi.ControllerRestartCommand(TabRpcClient, { blockid: this.blockId, force: true }) } ``` **Sending Input to Shell:** ```typescript sendDataToController(data: string) { const b64data = stringToBase64(data) RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }) } ``` **Backend Block Controller Flow:** 1. Frontend calls `ControllerRestartCommand` 2. Backend `ShellController.Run()` starts 3. `CheckConnStatus()` verifies connection is ready 4. If not connected, triggers connection attempt 5. Once connected, `setupAndStartShellProcess()` 6. `getConnUnion()` retrieves appropriate connection (Local/SSH/WSL) 7. `StartLocalShellProc()`, `StartRemoteShellProc()`, or `StartWslShellProc()` 8. Process I/O managed by `manageRunningShellProcess()` ## Connection Configuration ### Hierarchical Configuration System Wave uses a three-level config hierarchy for connections: 1. **Global Settings** (`settings`) 2. **Connection-Level Config** (`connections[connName]`) 3. **Block-Level Overrides** (`block.meta`) **Override Resolution:** ```typescript function getOverrideConfigAtom(blockId: string, key: T): Atom { return atom((get) => { // 1. Check block metadata const metaKeyVal = get(getBlockMetaKeyAtom(blockId, key)) if (metaKeyVal != null) return metaKeyVal // 2. Check connection config const connName = get(getBlockMetaKeyAtom(blockId, "connection")) const connConfigKeyVal = get(getConnConfigKeyAtom(connName, key)) if (connConfigKeyVal != null) return connConfigKeyVal // 3. Fall back to global settings const settingsVal = get(getSettingsKeyAtom(key)) return settingsVal ?? null }) } ``` ### Common Connection Settings **Connection Keywords** (apply to specific connections): - `conn:wshenabled` - Enable/disable WSH for this connection - `conn:wshpath` - Custom WSH binary path - `display:hidden` - Hide connection from selector - `display:order` - Sort order in connection list - `term:fontsize` - Font size for terminals on this connection - `term:theme` - Color theme for terminals on this connection **Example Usage in View Models:** ```typescript // Font size with connection override fontSizeAtom = atom((get) => { const blockData = get(this.blockAtom) const connName = blockData?.meta?.connection const fullConfig = get(atoms.fullConfigAtom) // Check: block meta > connection config > global settings const fontSize = blockData?.meta?.["term:fontsize"] ?? fullConfig?.connections?.[connName]?.["term:fontsize"] ?? get(getSettingsKeyAtom("term:fontsize")) ?? 12 return boundNumber(fontSize, 4, 64) }) ``` ## RPC Interface ### Connection Management Commands **ConnListCommand** ```typescript ConnListCommand(client: RpcClient): Promise ``` - Returns list of configured SSH connection names - Used by connection modal to populate remote connections - Filters by `display:hidden` config on frontend **WslListCommand** ```typescript WslListCommand(client: RpcClient): Promise ``` - Returns list of installed WSL distribution names - Windows only (silently fails on other platforms) - Connection names formatted as `wsl://[distro]` **ConnListAWSCommand** ```typescript ConnListAWSCommand(client: RpcClient): Promise ``` - Returns list of AWS profile names from config - Used for S3 preview connections - Connection names formatted as `aws:[profile]` **ConnEnsureCommand** ```typescript ConnEnsureCommand( client: RpcClient, data: { connname: string, logblockid?: string } ): Promise ``` - Ensures connection is in "connected" state - Triggers connection if not already connected - Waits for connection to complete or timeout - Used before file operations and by view models **ConnConnectCommand** ```typescript ConnConnectCommand( client: RpcClient, data: { host: string, logblockid?: string } ): Promise ``` - Explicitly connects to specified connection - Used by "Reconnect" action in overlay - Returns when connection succeeds or fails **ConnDisconnectCommand** ```typescript ConnDisconnectCommand( client: RpcClient, connName: string ): Promise ``` - Disconnects active connection - Used by "Disconnect" action in connection modal - Closes all shells/processes on that connection **SetMetaCommand** ```typescript SetMetaCommand( client: RpcClient, data: { oref: string, // WaveObject reference meta: MetaType // Metadata updates } ): Promise ``` - Updates block metadata (including connection) - Used when changing block's connection - Triggers backend to switch connection context **SetConnectionsConfigCommand** ```typescript SetConnectionsConfigCommand( client: RpcClient, data: { host: string, // Connection name metamaptype: any // Config updates } ): Promise ``` - Updates connection-level configuration - Used to disable WSH (`conn:wshenabled: false`) - Persists to config file ### File Operations (Connection-Aware) **FileInfoCommand** ```typescript FileInfoCommand( client: RpcClient, data: { info: { path: string } } ): Promise ``` - Gets file metadata (size, type, permissions, etc.) - Path format: `[connName]:[filepath]` (e.g., `user@host:~/file.txt`) - Uses connection's WSH for remote files **FileReadCommand** ```typescript FileReadCommand( client: RpcClient, data: { info: { path: string } } ): Promise ``` - Reads file content as base64 - Supports streaming for large files - Remote files read via connection's WSH ### Controller Commands (Indirect Connection Usage) **ControllerInputCommand** ```typescript ControllerInputCommand( client: RpcClient, data: { blockid: string, inputdata64: string } ): Promise ``` - Sends input to block's controller (shell) - Controller uses block's connection for execution - Base64-encoded to handle binary data **ControllerRestartCommand** ```typescript ControllerRestartCommand( client: RpcClient, data: { blockid: string, force?: boolean } ): Promise ``` - Restarts block's controller - Backend checks connection status before starting - If not connected, triggers connection first ## Event-Driven Updates ### Wave Event Subscriptions **Connection Status Updates:** ```typescript waveEventSubscribe({ eventType: "connstatus", handler: (event) => { const status: ConnStatus = event.data updateConnStatusAtom(status.connection, status) } }) ``` - Backend emits connection status changes - Frontend updates corresponding atom - All subscribed components re-render automatically **Configuration Updates:** ```typescript waveEventSubscribe({ eventType: "config", handler: (event) => { const fullConfig = event.data.fullconfig globalStore.set(atoms.fullConfigAtom, fullConfig) } }) ``` - Backend watches config files for changes - Pushes updates to all connected frontends - Connection configuration changes take effect immediately ## Data Flow Patterns ### Pattern 1: Changing Block Connection ``` User Action: Click connection button → select new connection ↓ ChangeConnectionBlockModal.changeConnection() ↓ RpcApi.SetMetaCommand({ connection: newConn }) ↓ Backend updates block metadata → emits waveobj:update ↓ Frontend WOS updates blockAtom ↓ View model connStatus atom recomputes ↓ ConnectionButton re-renders with new connection ↓ RpcApi.ConnEnsureCommand() ensures connected ↓ Backend triggers connection if needed ↓ Backend emits connstatus events as connection progresses ↓ Frontend updates ConnStatus atom ("connecting" → "connected") ↓ ConnectionButton shows connecting animation → connected state ``` ### Pattern 2: Shell Process Lifecycle ``` User Action: Press Enter in disconnected terminal ↓ View model detects shellProcStatus == "init" or "done" ↓ forceRestartController() called ↓ RpcApi.ControllerRestartCommand() ↓ Backend ShellController.Run() starts ↓ CheckConnStatus() verifies connection ↓ If not connected: trigger connection ↓ (Frontend shows ConnStatusOverlay with "connecting") ↓ Connection succeeds → WSH available ↓ setupAndStartShellProcess() ↓ StartRemoteShellProc() with connection's SSH client ↓ Backend emits controllerstatus event ↓ Frontend updates shellProcStatus atom ↓ View model endIconButtons recomputes (restart button) ↓ Terminal ready for input ``` ### Pattern 3: File Preview Over Connection ``` User Action: Open preview block with file path ↓ PreviewModel initialized with file path ↓ connection atom ensures connection ↓ RpcApi.ConnEnsureCommand(connName) ↓ Backend establishes connection if needed ↓ (Frontend shows ConnStatusOverlay if connecting) ↓ Connection ready ↓ statFile atom triggers FileInfoCommand ↓ Backend routes to connection's WSH ↓ WSH executes stat on remote file ↓ FileInfo returned to frontend ↓ PreviewModel determines if text/binary/streaming ↓ fullFile atom triggers FileReadCommand ↓ Backend streams file via WSH ↓ File content displayed in preview ``` ## Connection Types and Behaviors ### Local Connection **Connection Names:** - `""` (empty string) - `"local"` - `"local:"` - `"local:gitbash"` (Windows only) **Frontend Behavior:** - No connection modal interaction needed - ConnectionButton shows laptop icon (grey) - No ConnStatusOverlay shown (always "connected") - File paths used directly without connection prefix - Shell processes spawn locally via `os/exec` **View Model Configuration:** ```typescript connName = "" // or "local" or "local:gitbash" connStatus = { status: "connected", connection: "", connected: true, activeconnnum: 0, // No color assignment wshenabled: true // Local WSH always available } ``` ### SSH Connection **Connection Names:** - Format: `"user@host"`, `"user@host:port"`, or config name - Examples: `"ubuntu@192.168.1.10"`, `"myserver"`, `"deploy@prod:2222"` **Frontend Behavior:** - ConnectionButton shows arrow icon with color - Color cycles through 8 colors based on `activeconnnum` - ConnStatusOverlay shown during connecting/error states - File paths prefixed with connection: `user@host:~/file.txt` - Modal allows reconnect/disconnect actions **Connection States:** ```typescript // Connecting connStatus = { status: "connecting", connection: "user@host", connected: false, activeconnnum: 3, wshenabled: false // Not yet determined } // Connected with WSH connStatus = { status: "connected", connection: "user@host", connected: true, activeconnnum: 3, wshenabled: true } // Connected without WSH connStatus = { status: "connected", connection: "user@host", connected: true, activeconnnum: 3, wshenabled: false, wsherror: "wsh installation failed: permission denied" } // Error connStatus = { status: "error", connection: "user@host", connected: false, activeconnnum: 3, wshenabled: false, error: "ssh: connection refused" } ``` **WSH Errors:** - Shown in ConnStatusOverlay - "always disable wsh" button sets `conn:wshenabled: false` - Terminal still works without WSH (limited features) - Preview requires WSH (shows error if unavailable) ### WSL Connection **Connection Names:** - Format: `"wsl://[distro]"` - Examples: `"wsl://Ubuntu"`, `"wsl://Debian"`, `"wsl://Ubuntu-20.04"` **Frontend Behavior:** - Similar to SSH (colored arrow icon) - Listed under "Local" section in modal - No authentication prompts - File paths: `wsl://Ubuntu:~/file.txt` **Backend Differences:** - Uses `wsl.exe` instead of SSH - No network overhead - Predetermined domain socket path - Simpler error handling ### S3 Connection (Preview Only) **Connection Names:** - Format: `"aws:[profile]"` - Examples: `"aws:default"`, `"aws:production"` **Frontend Behavior:** - Database icon (accent color) - Only available in Preview view - No shell/terminal support - File paths: `aws:profile:/bucket/key` **View Model Settings:** ```typescript // Terminal: S3 not shown showS3 = atom(false) // Preview: S3 shown showS3 = atom(true) ``` ## Error Handling ### Connection Errors **Authentication Failures:** - Backend prompts for credentials via `userinput` events - Frontend shows UserInputModal - User enters password/passphrase - Connection retries automatically **Network Errors:** - ConnStatus.status becomes "error" - ConnStatus.error contains message - ConnStatusOverlay displays error - "Reconnect" button triggers `ConnConnectCommand` **WSH Installation Errors:** - ConnStatus.wsherror contains message - ConnStatusOverlay shows separate WSH error section - Options: - Dismiss error (temporary) - "always disable wsh" (permanent config change) ### View Model Error Handling **Terminal View:** ```typescript // Shell won't start if connection failed endIconButtons = atom((get) => { const connStatus = get(this.connStatus) if (connStatus?.status != "connected") { return [] // Hide restart button } // ... show restart button }) // ConnStatusOverlay blocks terminal interaction ``` **Preview View:** ```typescript // File operations return errors errorMsgAtom = atom(null) as PrimitiveAtom statFile = atom(async (get) => { try { const fileInfo = await RpcApi.FileInfoCommand(...) return fileInfo } catch (e) { globalStore.set(this.errorMsgAtom, { status: "File Read Failed", text: `${e}` }) throw e } }) // Error displayed in preview content area ``` ## Best Practices ### For View Model Authors 1. **Use Connection Atoms:** ```typescript connStatus = atom((get) => { const blockData = get(this.blockAtom) const connName = blockData?.meta?.connection return get(getConnStatusAtom(connName)) }) ``` 2. **Check Connection Before Operations:** ```typescript if (connStatus?.status != "connected") { return // Don't attempt operation } ``` 3. **Use ConnEnsureCommand for File Ops:** ```typescript await RpcApi.ConnEnsureCommand(TabRpcClient, { connname: connName, logblockid: blockId // For better logging }, { timeout: 60000 }) ``` 4. **Set manageConnection Appropriately:** ```typescript // Show connection button for views that need connections manageConnection = atom(true) // Hide for views that don't use connections manageConnection = atom(false) ``` 5. **Use filterOutNowsh for WSH Requirements:** ```typescript // Filter connections without WSH (file ops, etc.) filterOutNowsh = atom(true) // Allow all connections (basic shell) filterOutNowsh = atom(false) ``` ### For RPC Command Usage 1. **Always Handle Errors:** ```typescript try { await RpcApi.ConnConnectCommand(...) } catch (e) { console.error("Connection failed:", e) // Update UI to show error } ``` 2. **Use Appropriate Timeouts:** ```typescript // Connection operations: longer timeout { timeout: 60000 } // 60 seconds // List operations: shorter timeout { timeout: 2000 } // 2 seconds ``` 3. **Batch Related Operations:** ```typescript // Good: Single SetMetaCommand with all changes await RpcApi.SetMetaCommand(TabRpcClient, { oref: blockRef, meta: { connection: newConn, file: newPath, "cmd:cwd": null } }) // Bad: Multiple SetMetaCommand calls ``` ## Summary The frontend connection architecture is **reactive and declarative**: 1. **Backend owns connection state** - All connection management happens in Go 2. **Frontend observes state** - Jotai atoms mirror backend state 3. **User actions trigger backend** - RPC commands initiate backend operations 4. **Events flow back to frontend** - Backend pushes updates via wave events 5. **View models isolate concerns** - Each view manages its own connection needs 6. **Block controllers bridge the gap** - Backend controllers use connections for process execution This architecture ensures: - **Consistency** - Single source of truth (backend) - **Reactivity** - UI updates automatically with state changes - **Separation** - Frontend doesn't manage connection lifecycle - **Flexibility** - Views can easily add connection support - **Robustness** - Errors handled at appropriate layers ================================================ FILE: aiprompts/focus-layout.md ================================================ # Wave Terminal Focus System - Layout State Flow This document explains how focus state changes in the layout system propagate through the application to update both the visual focus ring and physical DOM focus. ## Overview When layout operations modify focus state, a straightforward chain of updates occurs: 1. **Visual feedback** - The focus ring updates immediately 2. **Physical DOM focus** - The terminal (or other view) receives actual browser focus The system uses local atoms as the source of truth with async persistence to the backend. ## The Flow ### 1. Setting Focus in Layout Operations Throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts), operations directly mutate `layoutState.focusedNodeId`: ```typescript // Example from insertNode if (action.magnified) { layoutState.magnifiedNodeId = action.node.id; layoutState.focusedNodeId = action.node.id; } if (action.focused) { layoutState.focusedNodeId = action.node.id; } ``` This happens in ~10 places: insertNode, insertNodeAtIndex, deleteNode, focusNode, magnifyNodeToggle, etc. ### 2. Committing to Local Atom The [`LayoutModel.treeReducer()`](../frontend/layout/lib/layoutModel.ts:547) commits changes: ```typescript treeReducer(action: LayoutTreeAction, setState = true): boolean { // Mutate tree state focusNode(this.treeState, action); if (setState) { this.updateTree(); // Compute leafOrder, etc. this.setter(this.localTreeStateAtom, { ...this.treeState }); // Sync update this.persistToBackend(); // Async persistence } } ``` The key is `{ ...this.treeState }` creates a new object reference, triggering Jotai reactivity. ### 3. Derived Atoms Recalculate Each block's `NodeModel` has an `isFocused` atom: ```typescript isFocused: atom((get) => { const treeState = get(this.localTreeStateAtom); const isFocused = treeState.focusedNodeId === nodeid; const waveAIFocused = get(atoms.waveAIFocusedAtom); return isFocused && !waveAIFocused; }) ``` When `localTreeStateAtom` updates, all `isFocused` atoms recalculate. Only the matching node returns `true`. ### 4. React Components Re-render **Visual Focus Ring** - Components subscribe to `isFocused`: ```typescript const isFocused = useAtomValue(nodeModel.isFocused); ``` CSS classes update immediately, showing the focus ring. **Physical DOM Focus** - Two-step effect chain: ```typescript // Step 1: isFocused → blockClicked useLayoutEffect(() => { setBlockClicked(isFocused); }, [isFocused]); // Step 2: blockClicked → physical focus useLayoutEffect(() => { if (!blockClicked) return; setBlockClicked(false); const focusWithin = focusedBlockId() == nodeModel.blockId; if (!focusWithin) { setFocusTarget(); // Calls viewModel.giveFocus() } }, [blockClicked, isFocused]); ``` The terminal's `giveFocus()` method grants actual browser focus: ```typescript giveFocus(): boolean { if (termMode == "term" && this.termRef?.current?.terminal) { this.termRef.current.terminal.focus(); return true; } return false; } ``` ### 5. Background Persistence While the UI updates synchronously, persistence happens asynchronously: ```typescript private persistToBackend() { // Debounced (100ms) to avoid excessive writes setTimeout(() => { waveObj.rootnode = this.treeState.rootNode; waveObj.focusednodeid = this.treeState.focusedNodeId; waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; waveObj.leaforder = this.treeState.leafOrder; this.setter(this.waveObjectAtom, waveObj); }, 100); } ``` The WaveObject is used purely for persistence (tab restore, uncaching). ## The Complete Chain ``` User action ↓ layoutState.focusedNodeId = nodeId ↓ setter(localTreeStateAtom, { ...treeState }) ↓ isFocused atoms recalculate ↓ React re-renders ↓ ┌────────────────────┬────────────────────┐ │ Visual Ring │ Physical Focus │ │ (immediate CSS) │ (2-step effect) │ └────────────────────┴────────────────────┘ ↓ persistToBackend() (async, debounced) ``` ## Key Points 1. **Local atoms** - `localTreeStateAtom` is the source of truth during runtime 2. **Synchronous updates** - UI changes happen immediately in one React tick 3. **Async persistence** - Backend writes are fire-and-forget with debouncing 4. **Two-step focus** - Separates visual (instant) from physical (coordinated) DOM focus 5. **View delegation** - Each view implements `giveFocus()` for custom focus behavior ## User-Initiated Focus When a user clicks a block: 1. **`onFocusCapture`** (mousedown) → calls `nodeModel.focusNode()` → visual focus ring appears 2. **`onClick`** → sets `blockClicked = true` → two-step effect chain → physical DOM focus This ensures visual feedback is instant while protecting selections. ## Backend Actions On initialization or backend updates, queued actions are processed: ```typescript if (initialState.pendingBackendActions?.length) { fireAndForget(() => this.processPendingBackendActions()); } ``` Backend can queue layout operations (create blocks, etc.) via `PendingBackendActions`. ================================================ FILE: aiprompts/focus.md ================================================ # Wave Terminal Focus System This document explains how the focus system works in Wave Terminal, particularly for terminal blocks. ## Overview Wave Terminal uses a multi-layered focus system that coordinates between: - **Layout Focus State**: Jotai atoms tracking which block is focused (`nodeModel.isFocused`) - **Visual Focus Ring**: CSS styling showing the focused block - **DOM Focus**: Actual browser focus on interactive elements - **View-Specific Focus**: Custom focus handling by view models (e.g., XTerm terminal focus) ## Focus Flow on Block Click When you click on a terminal block, this sequence occurs: ### 1. Click Handler Setup [`frontend/app/block/block.tsx:219-223`](frontend/app/block/block.tsx:219-223) ```typescript const blockModel: BlockComponentModel2 = { onClick: setBlockClickedTrue, onFocusCapture: handleChildFocus, blockRef: blockRef, }; ``` ### 2. Click Triggers State Change [`frontend/app/block/block.tsx:165-167`](frontend/app/block/block.tsx:165-167) When clicked, `setBlockClickedTrue` sets the `blockClicked` state to true. ### 3. useLayoutEffect Responds [`frontend/app/block/block.tsx:151-163`](frontend/app/block/block.tsx:151-163) ```typescript useLayoutEffect(() => { if (!blockClicked) { return; } setBlockClicked(false); const focusWithin = focusedBlockId() == nodeModel.blockId; if (!focusWithin) { setFocusTarget(); } if (!isFocused) { nodeModel.focusNode(); } }, [blockClicked, isFocused]); ``` ### 4. Focus Target Decision [`frontend/app/block/block.tsx:211-217`](frontend/app/block/block.tsx:211-217) ```typescript const setFocusTarget = useCallback(() => { const ok = viewModel?.giveFocus?.(); if (ok) { return; } focusElemRef.current?.focus({ preventScroll: true }); }, []); ``` The `setFocusTarget` function: 1. First attempts to call the view model's `giveFocus()` method 2. If that succeeds (returns true), we're done 3. Otherwise, falls back to focusing a dummy input element ### 5. Terminal-Specific Focus [`frontend/app/view/term/term.tsx:414-427`](frontend/app/view/term/term.tsx:414-427) ```typescript giveFocus(): boolean { if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) { return true; } let termMode = globalStore.get(this.termMode); if (termMode == "term") { if (this.termRef?.current?.terminal) { this.termRef.current.terminal.focus(); return true; } } return false; } ``` The terminal's `giveFocus()` calls XTerm's `terminal.focus()` to grant actual DOM focus. ## Selection Protection A critical feature is that text selections are preserved when clicking within the same block. ### The Protection Mechanism [`frontend/app/block/block.tsx:156-158`](frontend/app/block/block.tsx:156-158) ```typescript const focusWithin = focusedBlockId() == nodeModel.blockId; if (!focusWithin) { setFocusTarget(); } ``` The key is [`focusedBlockId()`](frontend/util/focusutil.ts:48-70) which checks: 1. **Active Element**: Is there a focused DOM element within this block? 2. **Selection**: Is there a text selection within this block? ```typescript export function focusedBlockId(): string { const focused = document.activeElement; if (focused instanceof HTMLElement) { const blockId = findBlockId(focused); if (blockId) { return blockId; } } const sel = document.getSelection(); if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { let anchor = sel.anchorNode; if (anchor instanceof Text) { anchor = anchor.parentElement; } if (anchor instanceof HTMLElement) { const blockId = findBlockId(anchor); if (blockId) { return blockId; } } } return null; } ``` **When making a text selection within a block:** - `focusWithin` returns true (selection exists in the block) - `setFocusTarget()` is **skipped** - Selection is preserved - Only `nodeModel.focusNode()` is called to update layout state ## Visual Focus vs DOM Focus There's an important separation between visual focus (the focus ring) and actual DOM focus. ### Visual Focus (Immediate) [`frontend/app/block/block.tsx:200-209`](frontend/app/block/block.tsx:200-209) ```typescript const handleChildFocus = useCallback( (event: React.FocusEvent) => { if (!isFocused) { nodeModel.focusNode(); // Updates layout state immediately } }, [isFocused] ); ``` This `onFocusCapture` handler fires on **mousedown** (capture phase), immediately updating the visual focus ring. ### DOM Focus (On Click Complete) The actual DOM focus via `giveFocus()` only happens after click completion, through the onClick → useLayoutEffect path. ### Selection Example: Two Terminals When making a selection in terminal 2 while terminal 1 is focused: 1. **Mousedown** → `onFocusCapture` fires → `nodeModel.focusNode()` updates focus ring - Terminal 2 now shows the focus ring - Layout state updated 2. **Drag** → Selection is made in terminal 2 3. **Mouseup** → Selection completes 4. **Click handler** → `onClick` fires → `setBlockClickedTrue` → triggers useLayoutEffect 5. **useLayoutEffect** → Checks `focusWithin` (now true because selection exists) 6. **Protected** → Skips `setFocusTarget()`, preserving the selection **Result:** Focus ring updates immediately, but DOM focus is only granted after the selection is made, and is protected by the `focusWithin` check. ## Terminal-Specific Focus Events The terminal view has three useEffects that call `giveFocus()`: ### 1. Search Close [`frontend/app/view/term/term.tsx:970-974`](frontend/app/view/term/term.tsx:970-974) When the search panel closes, focus returns to the terminal. ### 2. Terminal Recreation [`frontend/app/view/term/term.tsx:1035-1038`](frontend/app/view/term/term.tsx:1035-1038) When a terminal is recreated while focused (e.g., settings change), focus is restored. ### 3. Mode Switch [`frontend/app/view/term/term.tsx:1046-1052`](frontend/app/view/term/term.tsx:1046-1052) When switching from vdom mode back to term mode, the terminal receives focus. ## Key Components ### Block Component [`frontend/app/block/block.tsx`](frontend/app/block/block.tsx) - Manages the BlockFull component - Handles click and focus capture events - Coordinates between layout focus and DOM focus ### BlockNodeModel [`frontend/app/block/blocktypes.ts:7-12`](frontend/app/block/blocktypes.ts:7-12) ```typescript export interface BlockNodeModel { blockId: string; isFocused: Atom; onClose: () => void; focusNode: () => void; } ``` ### ViewModel Interface View models can implement `giveFocus(): boolean` to handle focus in a view-specific way. ### Focus Utilities [`frontend/util/focusutil.ts`](frontend/util/focusutil.ts) - `focusedBlockId()`: Determines which block has focus or selection - `hasSelection()`: Checks if there's an active text selection - `findBlockId()`: Traverses DOM to find containing block ## Summary The focus system elegantly separates concerns: - **Visual feedback** updates immediately on mousedown - **DOM focus** is deferred until after user interaction completes - **Selections are protected** by checking focus state before granting focus - **View-specific focus** is delegated to view models via `giveFocus()` This design allows for responsive UI (immediate focus ring updates) while preventing disruption of user interactions like text selection. ================================================ FILE: aiprompts/getsetconfigvar.md ================================================ # Setting and Reading Config Variables This document provides a quick reference for updating and reading configuration values in our system. --- ## Setting a Config Variable To update a configuration, use the `RpcApi.SetConfigCommand` function. The command takes an object with a key/value pair where the key is the config variable and the value is the new setting. **Example:** ```ts await RpcApi.SetConfigCommand(TabRpcClient, { "web:defaulturl": url }); ``` In this example, `"web:defaulturl"` is the key and `url` is the new value. Use this approach for any config key. --- ## Reading a Config Value To read a configuration value, retrieve the corresponding atom using `getSettingsKeyAtom` and then use `globalStore.get` to access its current value. getSettingsKeyAtom returns a jotai Atom. **Example:** ```ts const configAtom = getSettingsKeyAtom("app:defaultnewblock"); const configValue = globalStore.get(configAtom) ?? "default value"; ``` Here, `"app:defaultnewblock"` is the config key and `"default value"` serves as a fallback if the key isn't set. Inside of a react componet we should not use globalStore, instead we use useSettingsKeyAtom (this is just a jotai useAtomValue call wrapped around the getSettingsKeyAtom call) ```tsx const configValue = useSettingsKeyAtom("app:defaultnewblock") ?? "default value"; ``` --- ## Relevant Imports ```ts import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getSettingsKeyAtom, useSettingsKeyAtom, globalStore } from "@/app/store/global"; ``` Keep this guide handy for a quick reference when working with configuration values. ================================================ FILE: aiprompts/layout-simplification.md ================================================ # Wave Terminal Layout System - Simplification via Write Cache Pattern ## Executive Summary The current layout system uses a complex bidirectional atom architecture that forces every layout change to round-trip through the backend WaveObject, even though **the backend never reads this data** - it only queues actions via `PendingBackendActions`. By switching to a "write cache" pattern where local atoms are the source of truth and backend writes are fire-and-forget, we can eliminate ~70% of the complexity while maintaining full persistence. ## Current Architecture Problems ### The Unnecessary Round-Trip Every layout change (split, close, focus, magnify) currently follows this flow: ``` User action ↓ treeReducer() mutates layoutState ↓ layoutState.generation++ ← Only purpose: trigger the write ↓ Bidirectional atom setter (checks generation) ↓ Write to WaveObject {rootnode, focusednodeid, magnifiednodeid} ↓ WaveObject update notification ↓ Bidirectional atom getter runs ↓ ALL dependent atoms recalculate (every isFocused, etc.) ↓ React re-renders with updated state ``` **The critical insight**: The backend reads ONLY `leaforder` from the WaveObject (for block number resolution in commands like `wsh block:1`). The `rootnode`, `focusednodeid`, and `magnifiednodeid` fields exist **only for persistence** (tab restore, uncaching). ### What the Backend Actually Does **Backend Reads** (from [`pkg/wshrpc/wshserver/resolvers.go`](../pkg/wshrpc/wshserver/resolvers.go:196-206)): - **`LeafOrder`** - Used to resolve block numbers in commands (e.g., `wsh block:1` → blockId lookup) **Backend Writes** (from [`pkg/wcore/layout.go`](../pkg/wcore/layout.go)): - **`PendingBackendActions`** - Queued layout actions via [`QueueLayoutAction()`](../pkg/wcore/layout.go:101-118) **Backend NEVER touches**: - **`RootNode`** - Never read, only written by frontend for persistence - **`FocusedNodeId`** - Never read, only written by frontend for persistence - **`MagnifiedNodeId`** - Never read, only written by frontend for persistence **The key insight**: Only `LeafOrder` needs to be synced to backend (for command resolution). The tree structure fields (`rootnode`, `focusednodeid`, `magnifiednodeid`) are pure persistence! ### Complexity Symptoms 1. **Generation tracking**: [`layoutState.generation++`](../frontend/layout/lib/layoutTree.ts:294) appears in 10+ places, only to trigger atom writes 2. **Bidirectional atoms**: [`withLayoutTreeStateAtomFromTab()`](../frontend/layout/lib/layoutAtom.ts:18-60) has complex read/write logic 3. **Timing coordination**: The entire Section 8 of the WaveAI focus proposal exists only because of race conditions between focus updates and atom commits 4. **False reactivity**: Changes to `focusedNodeId` trigger full tree state propagation even though they're unrelated to tree structure ## Proposed "Write Cache" Architecture ### Core Concept ``` User action ↓ Update LOCAL atom (immediate, synchronous) ↓ React re-renders (single tick, all atoms see new state) ↓ [async, fire-and-forget] Persist to WaveObject ``` ### Key Principles 1. **Local atoms are source of truth** during runtime 2. **WaveObject is persistence layer** only (read on init, write async) 3. **Backend actions still work** via `PendingBackendActions` 4. **No generation tracking needed** (no need to trigger writes) ## Implementation Design ### 1. New LayoutModel Structure ```typescript // frontend/layout/lib/layoutModel.ts class LayoutModel { // BEFORE: Bidirectional atom with generation tracking // treeStateAtom: WritableLayoutTreeStateAtom // AFTER: Simple local atom (source of truth) private localTreeStateAtom: PrimitiveAtom; // Keep reference to WaveObject atom for persistence private waveObjectAtom: WritableWaveObjectAtom; constructor(tabAtom: Atom, ...) { this.waveObjectAtom = getLayoutStateAtomFromTab(tabAtom); // Initialize local atom (starts empty) this.localTreeStateAtom = atom({ rootNode: undefined, focusedNodeId: undefined, magnifiedNodeId: undefined, leafOrder: undefined, pendingBackendActions: undefined, generation: 0 // Can be removed entirely or kept for debugging }); // Read from WaveObject ONCE during initialization this.initializeFromWaveObject(); } private async initializeFromWaveObject() { const waveObjState = this.getter(this.waveObjectAtom); // Load persisted state into local atom const initialState: LayoutTreeState = { rootNode: waveObjState?.rootnode, focusedNodeId: waveObjState?.focusednodeid, magnifiedNodeId: waveObjState?.magnifiednodeid, leafOrder: undefined, // Computed by updateTree() pendingBackendActions: waveObjState?.pendingbackendactions, generation: 0 }; // Set local state this.treeState = initialState; this.setter(this.localTreeStateAtom, initialState); // Process any pending backend actions if (initialState.pendingBackendActions?.length) { await this.processPendingBackendActions(); } // Initialize tree (compute leafOrder, etc.) this.updateTree(); } // Process backend-queued actions (startup only) private async processPendingBackendActions() { const actions = this.treeState.pendingBackendActions; if (!actions?.length) return; this.treeState.pendingBackendActions = undefined; for (const action of actions) { // Convert backend action to frontend action and run through treeReducer // This code already exists in onTreeStateAtomUpdated() switch (action.actiontype) { case LayoutTreeActionType.InsertNode: this.treeReducer({ type: LayoutTreeActionType.InsertNode, node: newLayoutNode(undefined, undefined, undefined, { blockId: action.blockid }), magnified: action.magnified, focused: action.focused }, false); break; // ... other action types } } } } ``` ### 2. Simplified treeReducer ```typescript class LayoutModel { treeReducer(action: LayoutTreeAction, setState = true): boolean { // Run the tree operation (mutates this.treeState) switch (action.type) { case LayoutTreeActionType.InsertNode: insertNode(this.treeState, action); break; case LayoutTreeActionType.FocusNode: focusNode(this.treeState, action); break; case LayoutTreeActionType.DeleteNode: deleteNode(this.treeState, action); break; // ... all other cases unchanged } if (setState) { // Update tree (compute leafOrder, validate, etc.) this.updateTree(); // Update local atom IMMEDIATELY (synchronous) this.setter(this.localTreeStateAtom, { ...this.treeState }); // Persist to backend asynchronously (fire and forget) this.persistToBackend(); } return true; } // Fire-and-forget persistence private async persistToBackend() { const waveObj = this.getter(this.waveObjectAtom); if (!waveObj) return; // Update WaveObject fields waveObj.rootnode = this.treeState.rootNode; // Persistence only waveObj.focusednodeid = this.treeState.focusedNodeId; // Persistence only waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; // Persistence only waveObj.leaforder = this.treeState.leafOrder; // Backend reads this for command resolution! // Write to backend (don't await - fire and forget) this.setter(this.waveObjectAtom, waveObj); // Optional: Debounce if rapid changes are a concern } } ``` ### 3. Simplified NodeModel isFocused ```typescript class LayoutModel { getNodeModel(node: LayoutNode): NodeModel { return { // BEFORE: Complex dependency on bidirectional treeStateAtom // isFocused: atom((get) => { // const treeState = get(this.treeStateAtom); // Triggers on any tree change // ... // }) // AFTER: Simple dependency on local atom isFocused: atom((get) => { const treeState = get(this.localTreeStateAtom); // Simple read const focusType = get(focusManager.focusType); return treeState.focusedNodeId === node.id && focusType === "node"; }), // All other atoms similarly simplified... isMagnified: atom((get) => { const treeState = get(this.localTreeStateAtom); return treeState.magnifiedNodeId === node.id; }), // ... rest unchanged }; } } ``` ### 4. Remove Generation Tracking The `generation` field can be removed entirely from [`LayoutTreeState`](../frontend/layout/lib/types.ts): ```typescript // frontend/layout/lib/types.ts export interface LayoutTreeState { rootNode?: LayoutNode; focusedNodeId?: string; magnifiedNodeId?: string; leafOrder?: LayoutLeafEntry[]; pendingBackendActions?: LayoutActionData[]; // generation: number; ← DELETE THIS } ``` And remove all `generation++` calls from [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts) (appears in 10+ places). ### 5. Simplified layoutAtom.ts ```typescript // frontend/layout/lib/layoutAtom.ts // BEFORE: Complex bidirectional atom (60 lines) // AFTER: Can be deleted entirely or simplified to just helper for WaveObject access export function getLayoutStateAtomFromTab( tabAtom: Atom, get: Getter ): WritableWaveObjectAtom { const tabData = get(tabAtom); if (!tabData) return; const layoutStateOref = WOS.makeORef("layout", tabData.layoutstate); return WOS.getWaveObjectAtom(layoutStateOref); } // No more withLayoutTreeStateAtomFromTab() - not needed! ``` ## Benefits ### Immediate Benefits 1. **10x simpler reactivity**: Local atoms update synchronously, React sees complete state in one tick 2. **No generation tracking**: Eliminate 10+ `generation++` calls and all related logic 3. **No timing issues**: Everything happens synchronously, no coordination needed 4. **Faster updates**: No round-trip through WaveObject for every change 5. **Easier debugging**: Clear separation between runtime state (local atoms) and persistence (WaveObject) ### Impact on WaveAI Focus Proposal The entire Section 8 ("Layout Model Focus Integration - CRITICAL TIMING") **becomes unnecessary**: **BEFORE** (complex timing coordination): ```typescript treeReducer(action: LayoutTreeAction) { insertNode(this.treeState, action); // generation++ // CRITICAL: Must update focus manager BEFORE atom commits if (action.focused) { focusManager.requestNodeFocus(); // Synchronous! } // Then atom commits this.setter(this.treeStateAtom, ...); // Now isFocused sees correct focusType } ``` **AFTER** (trivial): ```typescript treeReducer(action: LayoutTreeAction) { insertNode(this.treeState, action); // Just mutates local state // Update local atom (synchronous) this.setter(this.localTreeStateAtom, { ...this.treeState }); // Update focus manager (order doesn't matter - both updated synchronously) if (action.focused) { focusManager.setBlockFocus(); } // Both updates happen in same tick, no race condition possible! } ``` ### Code Deletion **Can delete**: - `generation` field and all `generation++` calls (~15 places) - Complex bidirectional atom logic in [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts) (~40 lines) - `lastTreeStateGeneration` tracking in [`LayoutModel`](../frontend/layout/lib/layoutModel.ts) - All `generation > this.treeState.generation` checks **Total**: ~200-300 lines of complex coordination code deleted ## Edge Cases & Considerations ### 1. Rapid Changes **Concern**: Many layout changes in quick succession could cause many backend writes. **Solution**: Debounce the `persistToBackend()` call (e.g., 100ms). Users won't notice the delay in persistence. ```typescript private persistDebounceTimer: NodeJS.Timeout | null = null; private persistToBackend() { if (this.persistDebounceTimer) { clearTimeout(this.persistDebounceTimer); } this.persistDebounceTimer = setTimeout(() => { const waveObj = this.getter(this.waveObjectAtom); if (!waveObj) return; waveObj.rootnode = this.treeState.rootNode; waveObj.focusednodeid = this.treeState.focusedNodeId; waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; waveObj.leaforder = this.treeState.leafOrder; this.setter(this.waveObjectAtom, waveObj); this.persistDebounceTimer = null; }, 100); } ``` ### 2. Tab Switching **Current**: Each tab has its own `treeStateAtom` in a WeakMap. **After**: Each tab has its own `localTreeStateAtom` in the LayoutModel instance. No change needed - already isolated per tab. ### 3. Tab Uncaching (Electron Limit) **Current**: Tab gets uncached, needs to reload layout from WaveObject. **After**: Same - `initializeFromWaveObject()` reads persisted state. No change in behavior. ### 4. Backend Actions (New Blocks) ### 5. LeafOrder and CLI Commands **Concern**: The backend reads `LeafOrder` for CLI command resolution (e.g., `wsh block:1`). What if it's not synced yet? **Solution**: Fire-and-forget is perfectly fine! CLI commands aren't time-sensitive: - Commands are typed/run by users (human speed, not machine speed) - Even if `LeafOrder` is 100ms behind, no one will notice - By the time a user types `wsh block:1`, the async write has long since completed - Worst case: User types command during a split operation and gets previous block - extremely rare and not breaking ## Immutability and Jotai Atoms ### Question: Do we need deep copies for Jotai to detect changes? **Answer: NO - shallow copy is sufficient!** ✓ ### Current System (Already Uses Shallow Updates) Looking at the current code in [`layoutModel.ts:587`](../frontend/layout/lib/layoutModel.ts:587): ```typescript setTreeStateAtom(bumpGeneration = false) { if (bumpGeneration) { this.treeState.generation++; } this.lastTreeStateGeneration = this.treeState.generation; this.setter(this.treeStateAtom, this.treeState); // ← Sets same object! } ``` **The current system doesn't create new objects either!** It relies on `generation` changing to trigger the bidirectional atom's setter. ### Why Shallow Copy Works with Jotai ```typescript // In treeReducer after mutations this.setter(this.localTreeStateAtom, { ...this.treeState }); ``` **This works because**: 1. **Jotai checks reference equality** on the atom value itself (the `LayoutTreeState` object) 2. **`{ ...this.treeState }` creates a NEW object** with a different reference 3. **Nested structures don't matter** - Jotai doesn't do deep equality checks **Example**: ```typescript const oldState = { rootNode: someTree, focusedNodeId: "node1" }; const newState = { ...oldState }; oldState === newState // FALSE - different objects! oldState.rootNode === newState.rootNode // TRUE - same tree reference // But Jotai only checks the first comparison, so it detects the change! ``` ### Tree Mutations Don't Need Immutability All tree operations in [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts) **mutate in place**: - `insertNode()` - Mutates `layoutState.rootNode` ### Derived Atoms Will Update Correctly ✓ **Concern**: Will derived atoms like `isFocused` and `isMagnified` update when we change to local atoms? **Answer: YES - they will work perfectly!** ✓ ### How Derived Atoms Work The NodeModel creates derived atoms that depend on `treeStateAtom`: ```typescript // From layoutModel.ts:936-946 isFocused: atom((get) => { const treeState = get(this.treeStateAtom); // Subscribe to treeStateAtom const isFocused = treeState.focusedNodeId === nodeid; const waveAIFocused = get(atoms.waveAIFocusedAtom); return isFocused && !waveAIFocused; }), isMagnified: atom((get) => { const treeState = get(this.treeStateAtom); // Subscribe to treeStateAtom return treeState.magnifiedNodeId === nodeid; }), ``` ### Why They'll Still Work with Local Atoms **After the change**: ```typescript isFocused: atom((get) => { const treeState = get(this.localTreeStateAtom); // Subscribe to localTreeStateAtom const isFocused = treeState.focusedNodeId === nodeid; const waveAIFocused = get(atoms.waveAIFocusedAtom); return isFocused && !waveAIFocused; }), ``` **The update flow**: 1. User clicks block → `focusNode()` called 2. `treeReducer()` runs → mutates `this.treeState.focusedNodeId = newId` 3. `this.setter(this.localTreeStateAtom, { ...this.treeState })` ← **New reference!** 4. Jotai detects reference change in `localTreeStateAtom` 5. All derived atoms that call `get(this.localTreeStateAtom)` are notified 6. They re-run their getter functions 7. They see the new `focusedNodeId` value 8. React components re-render with correct values ✓ ### Key Insight **We're not mutating fields inside the atom** - we're replacing the entire state object: ```typescript // OLD way (current): // 1. Mutate this.treeState.focusedNodeId = newId // 2. Bump this.treeState.generation++ // 3. Set bidirectional atom (checks generation, writes to WaveObject, reads back, updates) // 4. Derived atoms see new state from the round-trip // NEW way (proposed): // 1. Mutate this.treeState.focusedNodeId = newId (same!) // 2. this.setter(localTreeStateAtom, { ...this.treeState }) (new object reference!) // 3. Derived atoms immediately see new state (no round-trip!) ``` **Both approaches create a new state object that triggers Jotai's reactivity!** The new way is actually **MORE reliable** because: - No round-trip delay - No generation checking - Direct, synchronous update - Same Jotai reactivity mechanism ### What About Nested Fields? **Question**: What if derived atoms access nested fields like `treeState.rootNode.children`? **Answer**: Still works! Example: ```typescript // Hypothetical derived atom someAtom: atom((get) => { const treeState = get(this.localTreeStateAtom); return treeState.rootNode.children.length; // Nested access }) ``` **This works because**: 1. We create new `LayoutTreeState` object: `{ ...this.treeState }` 2. Jotai sees new reference → notifies subscribers 3. Getter re-runs, calls `get(this.localTreeStateAtom)` 4. Gets the new state object 5. Accesses `newState.rootNode` (same reference as before, but that's OK!) 6. Returns correct value **The derived atom doesn't care that `rootNode` is the same object** - it just cares that the STATE object changed and it needs to re-evaluate. ### Verification All derived atoms in NodeModel: - ✅ `isFocused` - depends on `treeState.focusedNodeId` - ✅ `isMagnified` - depends on `treeState.magnifiedNodeId` - ✅ `blockNum` - depends on separate `this.leafOrder` atom (unaffected) - ✅ `isEphemeral` - depends on separate `this.ephemeralNode` atom (unaffected) All will update correctly with the new local atom approach! - `deleteNode()` - Mutates parent's children array - `focusNode()` - Mutates `layoutState.focusedNodeId` This is fine! We're not relying on immutability for change detection. We're relying on creating a new `LayoutTreeState` wrapper object via spread operator. ### Backend Round-Trip When reading from WaveObject on initialization: ```typescript const waveObjState = this.getter(this.waveObjectAtom); const initialState: LayoutTreeState = { rootNode: waveObjState?.rootnode, // New reference from backend focusedNodeId: waveObjState?.focusednodeid, // ... }; ``` This creates a **completely new object** with new references, which is even more immutable than necessary. No issues here. ### Summary ✅ **We're covered** - Shallow copy via spread operator is sufficient ✅ **Same as current system** - We're not making it worse, just simpler ✅ **Jotai only checks reference equality** on the atom value, not deep equality ✅ **Tree mutations are fine** - They've always worked this way **Current**: Backend queues actions via [`QueueLayoutAction()`](../pkg/wcore/layout.go:101), frontend processes via `pendingBackendActions`. **After**: Same - `initializeFromWaveObject()` processes pending actions. No change needed. ### 5. Write Failures **Concern**: What if the async write to WaveObject fails? **Solution**: 1. The app continues working (local state is fine) 2. On next persistence attempt, full state is written again 3. On tab reload, worst case is state from last successful write 4. Can add retry logic or error notification if needed ## Migration Path ### Phase 1: Preparation (No Breaking Changes) 1. Add `localTreeStateAtom` alongside existing `treeStateAtom` 2. Keep both in sync 3. Update a few `isFocused` atoms to use local atom 4. Test thoroughly ### Phase 2: Switch Over 1. Update `treeReducer` to write to local atom + fire-and-forget persist 2. Update all `isFocused` and other computed atoms to use local atom 3. Remove generation checks and tracking 4. Test all layout operations ### Phase 3: Cleanup 1. Delete bidirectional atom logic from [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts) 2. Remove `generation` field from `LayoutTreeState` 3. Simplify `onTreeStateAtomUpdated()` (only needed for `pendingBackendActions`) 4. Update documentation ### Testing Checklist - [ ] Split horizontal/vertical - [ ] Close blocks (focused and unfocused) - [ ] Focus changes via click, keyboard nav, tab switching - [ ] Magnify/unmagnify - [ ] Resize operations - [ ] Drag & drop - [ ] Tab switching (verify state persistence) - [ ] App restart (verify state restore) - [ ] Multiple windows - [ ] Rapid operations (verify debouncing works) ## Impact on Other Systems ### Focus Manager **Before**: Must coordinate timing with atom commits. **After**: Can update `focusType` atom independently. Order doesn't matter since both updates happen synchronously. ### Block Component **No change**: Blocks still subscribe to `nodeModel.isFocused`, which still reacts correctly (faster now). ### Keyboard Navigation **No change**: Still calls `layoutModel.focusNode()`, which updates local state immediately. ### Terminal/Views **No change**: Views don't interact with layout atoms directly. ## Performance Implications ### Improved 1. **Faster reactivity**: No round-trip through WaveObject (save ~1-2ms per operation) 2. **Fewer atom updates**: Only local atom updates, not bidirectional propagation 3. **Batched writes**: Debouncing reduces backend write frequency ### No Change 1. **Tree operations**: Same complexity (balance, walk, compute, etc.) 2. **React rendering**: Same render triggers, just faster 3. **Memory usage**: Same (local atom vs bidirectional atom is similar size) ## Conclusion The "write cache" pattern can simplify the layout system by ~70% while maintaining full functionality: - **Remove**: Generation tracking, bidirectional atoms, timing coordination - **Keep**: All tree logic, backend integration, persistence - **Gain**: Simpler code, faster updates, easier debugging This also makes the WaveAI focus integration trivial, eliminating the need for complex timing coordination. ## Recommendation Implement this simplification **before** adding WaveAI focus features. The cleaner foundation will make the focus work much easier and the codebase more maintainable long-term. # Wave Terminal Layout System - Simplification via Write Cache Pattern ## Risk Assessment: LOW RISK, Well-Contained Change ### Files to Modify: **4-5 files, all in `frontend/layout/`** 1. **`frontend/layout/lib/layoutModel.ts`** (~150 lines changed) - Add `localTreeStateAtom` field - Modify `treeReducer()` to update local atom + persist async - Add `initializeFromWaveObject()` method - Add `persistToBackend()` method - Update `getNodeModel()` atoms to use local atom 2. **`frontend/layout/lib/layoutTree.ts`** (~15 line deletions) - Remove all `layoutState.generation++` calls (appears 15 times) - No other changes needed 3. **`frontend/layout/lib/layoutAtom.ts`** (~40 lines deleted or simplified) - Can delete most of the bidirectional atom logic - Keep only `getLayoutStateAtomFromTab()` helper 4. **`frontend/layout/lib/types.ts`** (~1 line deletion) - Remove `generation: number` from `LayoutTreeState` 5. **`frontend/layout/tests/model.ts`** (~1 line change) - Remove generation from test fixtures **Total**: ~5 files, all within `frontend/layout/` directory. **No changes outside layout system!** ### Why This is Low Risk #### 1. **Fail-Fast Behavior** ✓ If we break something, it will be **immediately obvious**: - Split horizontal/vertical won't work → visible immediately - Block focus won't work → obvious when clicking - Close block won't work → obvious - Magnify won't work → obvious **No subtle corruption**: This change affects reactive state flow, not data persistence. If it breaks, the UI breaks obviously. We won't get "sometimes it works, sometimes it doesn't." #### 2. **Well-Contained Scope** ✓ - **All changes in one directory**: `frontend/layout/` - **No changes to**: - Block components (unchanged) - Terminal/views (unchanged) - Keyboard navigation (unchanged) - Focus manager (unchanged) - Backend Go code (unchanged) The **interface** to the layout system stays the same: - Blocks still call `nodeModel.focusNode()` - Blocks still subscribe to `nodeModel.isFocused` - Keyboard nav still calls `layoutModel.focusNode()` - Nothing outside the layout system needs to know about the change #### 3. **No Data Corruption Risk** ✓ This change affects **reactive state propagation**, not data storage: - WaveObject still stores the same data - Backend still queues actions the same way - Blocks still have the same IDs - Tab structure unchanged **Worst case**: Layout stops working, we revert the code. No data loss, no corruption. #### 4. **Incremental Implementation Possible** ✓ Can be done in safe phases: **Phase 1**: Add alongside existing (no breaking changes) ```typescript class LayoutModel { treeStateAtom: WritableLayoutTreeStateAtom; // Keep old localTreeStateAtom: PrimitiveAtom; // Add new // Keep both in sync temporarily } ``` **Phase 2**: Switch consumers one at a time ```typescript // Change this gradually isFocused: atom((get) => { // const treeState = get(this.treeStateAtom); // Old const treeState = get(this.localTreeStateAtom); // New ... }) ``` **Phase 3**: Remove old code once everything uses new atoms **Can test thoroughly at each phase before proceeding!** #### 5. **Easy to Test** ✓ Every layout operation is user-visible and testable: - [ ] Split horizontal → obvious if broken - [ ] Split vertical → obvious if broken - [ ] Close block → obvious if broken - [ ] Focus block → obvious if broken - [ ] Magnify/unmagnify → obvious if broken - [ ] Drag & drop → obvious if broken - [ ] Tab switch → obvious if broken - [ ] App restart → obvious if broken No subtle edge cases to hunt down. If it works in manual testing, it works. ### Comparison to High-Risk Changes **This change is NOT**: - ❌ Touching 20+ files across the codebase - ❌ Changing subtle timing in async operations - ❌ Modifying data storage formats - ❌ Affecting backend/frontend protocol - ❌ Requiring coordinated backend changes - ❌ Creating subtle race conditions **This change IS**: - ✅ Contained to 5 files in one directory - ✅ Synchronous state updates (simpler than current!) - ✅ Same data format, just different flow - ✅ Frontend-only - ✅ Backend unchanged - ✅ Eliminating race conditions (not creating them) ### What Could Go Wrong? (And How We'd Know) | Potential Issue | How We'd Detect | Recovery | |-----------------|-----------------|----------| | Local atom doesn't update | Layout frozen, nothing responds | Immediately obvious, revert | | Persistence fails silently | State doesn't survive restart | Caught in testing, add logging | | isFocused calculation wrong | Wrong focus ring | Immediately obvious, fix calculation | | Missing generation++ somewhere | Old code path tries to use generation | Compile error or immediate runtime error | | Tab switching breaks | Tabs don't load correctly | Immediately obvious | **All failure modes are immediate and obvious!** ### Difficulty Assessment **Conceptual Difficulty**: LOW - Replace bidirectional atom with simple atom - Add async persist function - Remove generation tracking - Very straightforward refactor **Code Difficulty**: LOW-MEDIUM - Changes are localized and mechanical - Most changes are deletions (always good!) - New code is simpler than old code - No complex algorithms to implement **Testing Difficulty**: LOW - All functionality is user-visible - No need for complex test scenarios - Manual testing catches everything - Can test incrementally ### Recommendation This is a **low-risk, high-reward change**: - **Risk**: LOW (contained, fail-fast, no corruption) - **Difficulty**: LOW-MEDIUM (straightforward refactor) - **Reward**: HIGH (70% less complexity, easier future work) **Suggested approach**: 1. Implement in a feature branch 2. Add local atom alongside existing system 3. Test thoroughly with both systems running 4. Switch over gradually 5. Remove old code 6. Merge when confident Total implementation time: **1-2 days for experienced developer**, including thorough testing. --- ================================================ FILE: aiprompts/layout.md ================================================ # Wave Terminal Layout System Architecture The Wave Terminal layout system is a sophisticated tile-based layout engine built with React, TypeScript, and Jotai state management. It provides a flexible, drag-and-drop interface for arranging terminal blocks and other content in complex layouts. ## Overview The layout system manages a tree of `LayoutNode` objects that represent the hierarchical structure of content. Each node can either be: - **Leaf node**: Contains actual content (block data) - **Container node**: Contains child nodes with a specific flex direction The system uses CSS Flexbox for positioning but maintains its own tree structure for state management, drag-and-drop operations, and complex layout manipulations. ## Core Architecture ### File Structure ``` frontend/layout/lib/ ├── TileLayout.tsx # Main React component ├── layoutAtom.ts # Jotai state management ├── layoutModel.ts # Core model class ├── layoutModelHooks.ts # React hooks for integration ├── layoutNode.ts # Node manipulation functions ├── layoutTree.ts # Tree operation functions ├── nodeRefMap.ts # DOM reference tracking ├── types.ts # Type definitions ├── utils.ts # Utility functions └── tilelayout.scss # Styling ``` ## Key Data Structures ### LayoutNode The fundamental building block of the layout system: ```typescript interface LayoutNode { id: string; // Unique identifier data?: TabLayoutData; // Content data (only for leaf nodes) children?: LayoutNode[]; // Child nodes (only for containers) flexDirection: FlexDirection; // "row" or "column" size: number; // Flex size (0-100) } ``` **Key Rules:** - Either `data` OR `children` must be defined, never both - Leaf nodes have `data`, container nodes have `children` - All nodes have a `flexDirection` that determines layout axis - `size` represents the relative flex size within the parent ### LayoutTreeState The complete state of the layout: ```typescript interface LayoutTreeState { rootNode: LayoutNode; // Root of the tree focusedNodeId?: string; // Currently focused node magnifiedNodeId?: string; // Currently magnified node leafOrder?: LeafOrderEntry[]; // Computed leaf ordering pendingBackendActions: LayoutActionData[]; // Actions from backend generation: number; // State version number } ``` **Generation System:** - Incremented on every state change - Used for optimistic updates and conflict resolution - Prevents stale state overwrites ### NodeModel Runtime model for individual nodes, providing React-friendly state: ```typescript interface NodeModel { additionalProps: Atom; innerRect: Atom; blockNum: Atom; nodeId: string; blockId: string; isFocused: Atom; isMagnified: Atom; isEphemeral: Atom; toggleMagnify: () => void; focusNode: () => void; onClose: () => void; dragHandleRef?: React.RefObject; // ... additional state and methods } ``` ## Core Classes ### LayoutModel The central orchestrator that manages the entire layout system: **Key Responsibilities:** - Maintains tree state through Jotai atoms - Processes layout actions (move, resize, insert, delete) - Computes layout positions and transforms - Manages drag-and-drop operations - Handles resize operations - Provides node models for React components **State Management:** ```typescript class LayoutModel { treeStateAtom: WritableLayoutTreeStateAtom; // Persistent state leafs: PrimitiveAtom; // Computed leaf nodes additionalProps: PrimitiveAtom>; pendingTreeAction: AtomWithThrottle; activeDrag: PrimitiveAtom; // ... many more atoms for different aspects } ``` **Action Processing:** The model uses a reducer pattern to process actions: ```typescript treeReducer(action: LayoutTreeAction) { switch (action.type) { case LayoutTreeActionType.Move: moveNode(this.treeState, action); break; case LayoutTreeActionType.InsertNode: insertNode(this.treeState, action); break; // ... handle all action types } this.updateTree(); // Recompute derived state } ``` ## Layout Actions The system uses a comprehensive action system for all modifications: ### Action Types ```typescript enum LayoutTreeActionType { ComputeMove = "computemove", // Preview move operation Move = "move", // Execute move Swap = "swap", // Swap two nodes ResizeNode = "resize", // Resize node(s) InsertNode = "insert", // Insert new node InsertNodeAtIndex = "insertatindex", // Insert at specific index DeleteNode = "delete", // Remove node FocusNode = "focus", // Change focus MagnifyNodeToggle = "magnify", // Toggle magnification SplitHorizontal = "splithorizontal", // Split horizontally SplitVertical = "splitvertical", // Split vertically // ... more actions } ``` ### Action Flow 1. **User Interaction** → Action triggered 2. **Action Validation** → Check if operation is valid 3. **Tree Modification** → Update `LayoutTreeState` 4. **State Propagation** → Update Jotai atoms 5. **Layout Computation** → Recalculate positions 6. **React Re-render** → Update UI ### Example: Move Operation ```typescript // 1. Compute operation during drag const computeAction: LayoutTreeComputeMoveNodeAction = { type: LayoutTreeActionType.ComputeMove, nodeId: targetNodeId, nodeToMoveId: draggedNodeId, direction: DropDirection.Right }; // 2. Execute on drop const moveAction: LayoutTreeMoveNodeAction = { type: LayoutTreeActionType.Move, parentId: newParentId, index: insertIndex, node: nodeToMove }; ``` ## Drag and Drop System The layout system implements a sophisticated drag-and-drop interface using `react-dnd`. ### Drop Direction Logic When dragging over a node, the system determines drop direction based on cursor position: ```typescript enum DropDirection { Top = 0, Right = 1, Bottom = 2, Left = 3, OuterTop = 4, OuterRight = 5, OuterBottom = 6, OuterLeft = 7, Center = 8 } ``` **Drop Zones:** - **Inner zones** (Top/Right/Bottom/Left): Insert within the target node - **Outer zones**: Insert in the target's parent - **Center**: Swap nodes ### Drag Preview The system generates drag previews by: 1. Rendering content to an off-screen element 2. Converting to PNG using `html-to-image` 3. Using the image as the drag preview ## Resize System ### Resize Handles Resize handles are dynamically positioned between adjacent nodes: ```typescript interface ResizeHandleProps { id: string; parentNodeId: string; parentIndex: number; centerPx: number; // Handle position transform: CSSProperties; // CSS positioning flexDirection: FlexDirection; // Handle orientation } ``` ### Resize Operation 1. **Handle Drag Start** → Store resize context 2. **Drag Move** → Compute new sizes based on cursor position 3. **Throttled Updates** → Update node sizes (10ms throttle) 4. **Drag End** → Commit final sizes ## Layout Computation The system computes absolute positions from the tree structure: ### Process 1. **Tree Walk** → Traverse from root to leaves 2. **Flexbox Simulation** → Calculate container and child sizes 3. **Position Calculation** → Compute absolute positions 4. **Transform Generation** → Create CSS transforms 5. **Handle Positioning** → Place resize handles between nodes ### Key Functions - [`updateTreeHelper()`](frontend/layout/lib/layoutModel.ts:638) - Main layout computation - [`computeNodeFromProps()`](frontend/layout/lib/layoutModel.ts:718) - Individual node positioning - [`setTransform()`](frontend/layout/lib/utils.ts:61) - CSS transform generation ## Node Management ### Node Operations The [`layoutNode.ts`](frontend/layout/lib/layoutNode.ts) file provides core node manipulation: ```typescript // Create new node newLayoutNode(flexDirection?, size?, children?, data?) // Tree traversal findNode(node, id) findParent(node, id) walkNodes(node, beforeCallback?, afterCallback?) // Modifications addChildAt(node, index, ...children) removeChild(parent, childToRemove) balanceNode(node) // Optimize tree structure ``` ### Tree Balancing The system automatically optimizes the tree structure: - Removes unnecessary intermediate nodes - Flattens single-child containers - Ensures valid flex directions ## State Synchronization ### Frontend ↔ Backend Sync The layout state synchronizes with the backend through: 1. **`layoutAtom.ts`** - Jotai atom that wraps backend state 2. **Generation tracking** - Prevents state conflicts 3. **Pending actions** - Backend-initiated changes 4. **Leaf order** - Frontend-computed ordering sent to backend ### Atom Structure ```typescript const layoutTreeStateAtom = atom( (get) => { // Read from backend const layoutState = get(backendLayoutStateAtom); return transformToTreeState(layoutState); }, (get, set, treeState) => { // Write to backend if (generationNewer(treeState)) { set(backendLayoutStateAtom, transformFromTreeState(treeState)); } } ); ``` ## Special Features ### Magnification Nodes can be magnified to take up the full layout space: - Magnified nodes appear above others (higher z-index) - Only one node can be magnified at a time - Animation smoothly transitions between normal and magnified states ### Ephemeral Nodes Temporary nodes that aren't part of the persistent tree: - Used for preview/temporary content - Automatically cleaned up - Appear above the normal layout ### Focus Management - One node can be focused at a time - Focus affects keyboard navigation - Integrates with the terminal's block focus system ## Integration Points ### React Integration **Hooks:** - [`useTileLayout()`](frontend/layout/lib/layoutModelHooks.ts:51) - Main hook for layout setup - [`useNodeModel()`](frontend/layout/lib/layoutModelHooks.ts:65) - Get node model for component - [`useDebouncedNodeInnerRect()`](frontend/layout/lib/layoutModelHooks.ts:69) - Animated positioning ### Content Rendering The layout system is content-agnostic through render callbacks: ```typescript interface TileLayoutContents { renderContent: (nodeModel: NodeModel) => React.ReactNode; renderPreview?: (nodeModel: NodeModel) => React.ReactElement; onNodeDelete?: (data: TabLayoutData) => Promise; } ``` ### Performance Optimizations 1. **Memoization** - Extensive use of `React.memo()` and `useMemo()` 2. **Throttling** - Resize and drag operations throttled to 10-50ms 3. **Transform-based positioning** - Uses CSS transforms for performance 4. **Split atoms** - Jotai `splitAtom()` for efficient array updates 5. **Selective re-rendering** - Only affected components re-render ## Common Patterns ### Adding New Actions 1. Define action type in [`types.ts`](frontend/layout/lib/types.ts) 2. Implement handler in [`layoutTree.ts`](frontend/layout/lib/layoutTree.ts) 3. Add case to [`LayoutModel.treeReducer()`](frontend/layout/lib/layoutModel.ts:330) 4. Update generation and call `updateTree()` ### Extending Node Properties 1. Add to `LayoutNodeAdditionalProps` in [`types.ts`](frontend/layout/lib/types.ts) 2. Compute in [`updateTreeHelper()`](frontend/layout/lib/layoutModel.ts:638) 3. Access via `nodeModel.additionalProps` ### Custom Layout Behaviors Override or extend layout computation by: 1. Modifying [`computeNodeFromProps()`](frontend/layout/lib/layoutModel.ts:718) 2. Adding custom CSS transforms 3. Implementing special handling in action reducers ## Error Handling The system includes extensive validation: - Node structure validation - Action parameter checking - Tree consistency checks - Graceful degradation on errors ## Testing The layout system includes comprehensive tests: - [`layoutNode.test.ts`](frontend/layout/tests/layoutNode.test.ts) - Node operations - [`layoutTree.test.ts`](frontend/layout/tests/layoutTree.test.ts) - Tree operations - [`utils.test.ts`](frontend/layout/tests/utils.test.ts) - Utility functions ## Debugging For debugging layout issues: 1. Check `treeState.generation` for state changes 2. Inspect `additionalProps` for computed layout data 3. Use browser dev tools to examine CSS transforms 4. Enable console logging in action reducers The layout system is complex but well-structured, providing a powerful foundation for Wave Terminal's dynamic layout capabilities. ================================================ FILE: aiprompts/monaco-v0.53.md ================================================ # Monaco 0.52 → 0.53 ESM Migration Plan (Vite/Electron) **Status:** Deferred to next release. **Current:** Pinned to `monaco-editor@0.52.x` (works with `@monaco-editor/loader`). **Target:** Switch to `monaco-editor@≥0.53` ESM build and drop `@monaco-editor/loader` + AMD path copy. --- ## Why this change - Monaco 0.53 deprecates the AMD build. The loader/AMD path mapping (`paths: { vs: "monaco" }`) becomes brittle. - ESM build uses **module workers**, which require explicit worker wiring. - Benefits: cleaner bundling with Vite, fewer legacy shims, better CSP/Electron compatibility. --- ## High‑level plan 1. **Remove AMD/loader**: uninstall `@monaco-editor/loader`; remove `viteStaticCopy` of `min/vs/*`; delete `loader.config/init` calls. 2. **Install Monaco ≥0.53** and **wire ESM workers** via `MonacoEnvironment.getWorker`. 3. **Keep main bundle slim**: lazy‑load the Monaco setup; optionally force a separate `monaco` chunk. 4. **Electron / build**: ensure `base: './'` in Vite for packaged apps. --- ## Step‑by‑step ### 1) Dependencies ```bash # next cycle: npm rm @monaco-editor/loader npm i monaco-editor@^0.53 ``` ### 2) Remove AMD-era build config - Delete `viteStaticCopy({ targets: [{ src: "node_modules/monaco-editor/min/vs/*", dest: "monaco" }] })`. - Delete: ```ts loader.config({ paths: { vs: "monaco" } }); await loader.init(); ``` ### 3) Add ESM setup module Create `monaco-setup.ts`: ```ts // monaco-setup.ts import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; import "monaco-editor/esm/vs/editor/editor.all.css"; (self as any).MonacoEnvironment = { getWorker(_moduleId: string, label: string) { switch (label) { case "json": return new Worker(new URL("monaco-editor/esm/vs/language/json/json.worker.js", import.meta.url), { type: "module", }); case "css": return new Worker(new URL("monaco-editor/esm/vs/language/css/css.worker.js", import.meta.url), { type: "module", }); case "html": return new Worker(new URL("monaco-editor/esm/vs/language/html/html.worker.js", import.meta.url), { type: "module", }); case "typescript": case "javascript": return new Worker(new URL("monaco-editor/esm/vs/language/typescript/ts.worker.js", import.meta.url), { type: "module", }); default: return new Worker(new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url), { type: "module" }); } }, }; export { monaco }; ``` ### 4) Import lazily where used ```ts // where the editor UI mounts const { monaco } = await import("./monaco-setup"); const editor = monaco.editor.create(container, { language: "javascript", value: "" }); ``` ### 5) Optional: isolate Monaco into its own chunk `vite.config.ts`: ```ts import { defineConfig } from "vite"; export default defineConfig({ base: "./", // important for Electron packaged apps build: { rollupOptions: { output: { manualChunks(id) { if (id.includes("node_modules/monaco-editor")) return "monaco"; }, }, }, }, }); ``` > Note: Workers created via `new URL(..., import.meta.url)` are emitted as **separate chunks** automatically. --- ## Bundle size controls (pick what you need) - Import `editor.api` instead of full `editor` (already done above). - Only include workers you use (drop `json/css/html` blocks if not needed). - Lazy‑load Monaco with `import()` behind the UI that needs it. - Optionally dynamic‑import language contributions on demand: ```ts if (lang === "json") { await import("monaco-editor/esm/vs/language/json/monaco.contribution"); } ``` --- ## Electron specifics - `base: './'` in `vite.config.ts` so worker URLs resolve under `file://` in packaged apps. - `{ type: 'module' }` is required for Monaco’s ESM workers. - This approach avoids blob URLs and works with stricter CSPs. --- ## Test checklist - Dev: editor renders; no 404s for worker scripts; language services active (TS hover/diagnostics, JSON schema). - Prod build: verify worker files emitted; open packaged Electron app and ensure workers load (no "Cannot use import statement outside a module"). - Hot paths: open/close editor repeatedly; memory doesn’t grow unbounded. --- ## Rollback plan If anything blocks the release, revert to: ```bash npm i monaco-editor@0.52.x npm i -D @monaco-editor/loader ``` Restore the `viteStaticCopy` block and `loader.config/init` calls. --- ## Open questions (optional) - Do we need JSON/CSS/HTML workers in the default bundle? (Decide before wiring.) - Any extra CSP limitations for production? (If so, confirm worker script allowances.) --- ## Snippet index (for quick copy) - `monaco-setup.ts` (ESM + workers): see above. - `vite.config.ts` (`base: './'` + `manualChunks`): see above. - Lazy import site: `const { monaco } = await import('./monaco-setup');` ================================================ FILE: aiprompts/newview.md ================================================ # Creating a New View in Wave Terminal This guide explains how to implement a new view type in Wave Terminal. Views are the core content components displayed within blocks in the terminal interface. ## Architecture Overview Wave Terminal uses a **Model-View architecture** where: - **ViewModel** - Contains all state, logic, and UI configuration as Jotai atoms - **ViewComponent** - Pure React component that renders the UI using the model - **BlockFrame** - Wraps views with a header, connection management, and standard controls The separation between model and component ensures: - Models can update state without React hooks - Components remain pure and testable - State is centralized in Jotai atoms for easy access ## ViewModel Interface Every view must implement the `ViewModel` interface defined in [`frontend/types/custom.d.ts`](../frontend/types/custom.d.ts:285-341): ```typescript interface ViewModel { // Required: The type identifier for this view (e.g., "term", "web", "preview") viewType: string; // Required: The React component that renders this view viewComponent: ViewComponent; // Optional: Icon shown in block header (FontAwesome icon name or IconButtonDecl) viewIcon?: jotai.Atom; // Optional: Display name shown in block header (e.g., "Terminal", "Web", "Preview") viewName?: jotai.Atom; // Optional: Additional header elements (text, buttons, inputs) shown after the name viewText?: jotai.Atom; // Optional: Icon button shown before the view name in header preIconButton?: jotai.Atom; // Optional: Icon buttons shown at the end of the header (before settings/close) endIconButtons?: jotai.Atom; // Optional: Custom background styling for the block blockBg?: jotai.Atom; // Optional: If true, completely hides the block header noHeader?: jotai.Atom; // Optional: If true, shows connection picker in header for remote connections manageConnection?: jotai.Atom; // Optional: If true, filters out 'nowsh' connections from connection picker filterOutNowsh?: jotai.Atom; // Optional: If true, shows S3 connections in connection picker showS3?: jotai.Atom; // Optional: If true, removes default padding from content area noPadding?: jotai.Atom; // Optional: Atoms for managing in-block search functionality searchAtoms?: SearchAtoms; // Optional: Returns whether this is a basic terminal (for multi-input feature) isBasicTerm?: (getFn: jotai.Getter) => boolean; // Optional: Returns context menu items for the settings dropdown getSettingsMenuItems?: () => ContextMenuItem[]; // Optional: Focuses the view when called, returns true if successful giveFocus?: () => boolean; // Optional: Handles keyboard events, returns true if handled keyDownHandler?: (e: WaveKeyboardEvent) => boolean; // Optional: Cleanup when block is closed dispose?: () => void; } ``` ### Key Concepts **Atoms**: All UI-related properties must be Jotai atoms. This enables: - Reactive updates when state changes - Access from anywhere via `globalStore.get()`/`globalStore.set()` - Derived atoms that compute values from other atoms **ViewComponent**: The React component receives these props: ```typescript type ViewComponentProps = { blockId: string; // Unique ID for this block blockRef: React.RefObject; // Ref to block container contentRef: React.RefObject; // Ref to content area model: T; // Your ViewModel instance }; ``` ## Step-by-Step Guide ### 1. Create the View Model Class Create a new file for your view model (e.g., `frontend/app/view/myview/myview-model.ts`): ```typescript import { BlockNodeModel } from "@/app/block/blocktypes"; import { globalStore } from "@/app/store/jotaiStore"; import { WOS, useBlockAtom } from "@/store/global"; import * as jotai from "jotai"; import { MyView } from "./myview"; export class MyViewModel implements ViewModel { viewType: string; blockId: string; nodeModel: BlockNodeModel; blockAtom: jotai.Atom; // Define your atoms (simple field initializers) viewIcon = jotai.atom("circle"); viewName = jotai.atom("My View"); noPadding = jotai.atom(true); // Derived atom (created in constructor) viewText!: jotai.Atom; constructor(blockId: string, nodeModel: BlockNodeModel) { this.viewType = "myview"; this.blockId = blockId; this.nodeModel = nodeModel; this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); // Create derived atoms that depend on block data or other atoms this.viewText = jotai.atom((get) => { const blockData = get(this.blockAtom); const rtn: HeaderElem[] = []; // Add header buttons/text based on state rtn.push({ elemtype: "iconbutton", icon: "refresh", title: "Refresh", click: () => this.refresh(), }); return rtn; }); } get viewComponent(): ViewComponent { return MyView; } refresh() { // Update state using globalStore // Never use React hooks in model methods console.log("refreshing..."); } giveFocus(): boolean { // Focus your view component return true; } dispose() { // Cleanup resources (unsubscribe from events, etc.) } } ``` ### 2. Create the View Component Create your React component (e.g., `frontend/app/view/myview/myview.tsx`): ```typescript import { ViewComponentProps } from "@/app/block/blocktypes"; import { MyViewModel } from "./myview-model"; import { useAtomValue } from "jotai"; import "./myview.scss"; export const MyView: React.FC> = ({ blockId, model, contentRef }) => { // Use atoms from the model (these are React hooks - call at top level!) const blockData = useAtomValue(model.blockAtom); return (
Block ID: {blockId}
View: {model.viewType}
{/* Your view content here */}
); }; ``` ### 3. Register the View Add your view to the `BlockRegistry` in [`frontend/app/block/block.tsx`](../frontend/app/block/block.tsx:42-55): ```typescript const BlockRegistry: Map = new Map(); BlockRegistry.set("term", TermViewModel); BlockRegistry.set("preview", PreviewModel); BlockRegistry.set("web", WebViewModel); // ... existing registrations ... BlockRegistry.set("myview", MyViewModel); // Add your view here ``` The registry key (e.g., `"myview"`) becomes the view type used in block metadata. ### 4. Create Blocks with Your View Users can create blocks with your view type: - Via CLI: `wsh view myview` - Via RPC: Use the block's `meta.view` field set to `"myview"` ## Real-World Examples ### Example 1: Terminal View ([`term-model.ts`](../frontend/app/view/term/term-model.ts)) The terminal view demonstrates: - **Connection management** via `manageConnection` atom - **Dynamic header buttons** showing shell status (play/restart) - **Mode switching** between terminal and vdom views - **Custom keyboard handling** for terminal-specific shortcuts - **Focus management** to focus the xterm.js instance - **Shell integration status** showing AI capability indicators Key features: ```typescript this.manageConnection = jotai.atom((get) => { const termMode = get(this.termMode); if (termMode == "vdom") return false; return true; // Show connection picker for regular terminal mode }); this.endIconButtons = jotai.atom((get) => { const shellProcStatus = get(this.shellProcStatus); const buttons: IconButtonDecl[] = []; if (shellProcStatus == "running") { buttons.push({ elemtype: "iconbutton", icon: "refresh", title: "Restart Shell", click: this.forceRestartController.bind(this), }); } return buttons; }); ``` ### Example 2: Web View ([`webview.tsx`](../frontend/app/view/webview/webview.tsx)) The web view shows: - **Complex header controls** (back/forward/home/URL input) - **State management** for loading, URL, and navigation - **Event handling** for webview navigation events - **Custom styling** with `noPadding` for full-bleed content - **Media controls** showing play/pause/mute when media is active Key features: ```typescript this.viewText = jotai.atom((get) => { const url = get(this.url); const rtn: HeaderElem[] = []; // Navigation buttons rtn.push({ elemtype: "iconbutton", icon: "chevron-left", click: this.handleBack.bind(this), disabled: this.shouldDisableBackButton(), }); // URL input with nested controls rtn.push({ elemtype: "div", className: "block-frame-div-url", children: [ { elemtype: "input", value: url, onChange: this.handleUrlChange.bind(this), onKeyDown: this.handleKeyDown.bind(this), }, { elemtype: "iconbutton", icon: "rotate-right", click: this.handleRefresh.bind(this), } ], }); return rtn; }); ``` ## Header Elements (`HeaderElem`) The `viewText` atom can return an array of these element types: ```typescript // Icon button { elemtype: "iconbutton", icon: "refresh", title: "Tooltip text", click: () => { /* handler */ }, disabled?: boolean, iconColor?: string, iconSpin?: boolean, noAction?: boolean, // Shows icon but no click action } // Text element { elemtype: "text", text: "Display text", className?: string, noGrow?: boolean, ref?: React.RefObject, onClick?: (e: React.MouseEvent) => void, } // Text button { elemtype: "textbutton", text: "Button text", className?: string, title: "Tooltip", onClick: (e: React.MouseEvent) => void, } // Input field { elemtype: "input", value: string, className?: string, onChange: (e: React.ChangeEvent) => void, onKeyDown?: (e: React.KeyboardEvent) => void, onFocus?: (e: React.FocusEvent) => void, onBlur?: (e: React.FocusEvent) => void, ref?: React.RefObject, } // Container with children { elemtype: "div", className?: string, children: HeaderElem[], onMouseOver?: (e: React.MouseEvent) => void, onMouseOut?: (e: React.MouseEvent) => void, } // Menu button (dropdown) { elemtype: "menubutton", // ... MenuButtonProps ... } ``` ## Best Practices ### Jotai Model Pattern Follow these rules for Jotai atoms in models: 1. **Simple atoms as field initializers**: ```typescript viewIcon = jotai.atom("circle"); noPadding = jotai.atom(true); ``` 2. **Derived atoms in constructor** (need dependency on other atoms): ```typescript constructor(blockId: string, nodeModel: BlockNodeModel) { this.viewText = jotai.atom((get) => { const blockData = get(this.blockAtom); return [/* computed based on blockData */]; }); } ``` 3. **Models never use React hooks** - Use `globalStore.get()`/`set()`: ```typescript refresh() { const currentData = globalStore.get(this.blockAtom); globalStore.set(this.dataAtom, newData); } ``` 4. **Components use hooks for atoms**: ```typescript const data = useAtomValue(model.dataAtom); const [value, setValue] = useAtom(model.valueAtom); ``` ### State Management - All view state should live in atoms on the model - Use `useBlockAtom()` helper for block-scoped atoms that persist - Use `globalStore` for imperative access outside React components - Subscribe to Wave events using `waveEventSubscribe()` ### Styling - Create a `.scss` file for your view styles - Use Tailwind utilities where possible (v4) - Add `noPadding: atom(true)` for full-bleed content - Use `blockBg` atom to customize block background ### Focus Management Implement `giveFocus()` to focus your view when: - Block gains focus via keyboard navigation - User clicks the block - Return `true` if successfully focused, `false` otherwise ### Keyboard Handling Implement `keyDownHandler(e: WaveKeyboardEvent)` for: - View-specific keyboard shortcuts - Return `true` if event was handled (prevents propagation) - Use `keyutil.checkKeyPressed(waveEvent, "Cmd:K")` for shortcut checks ### Cleanup Implement `dispose()` to: - Unsubscribe from Wave events - Unregister routes/handlers - Clear timers/intervals - Release resources ### Connection Management For views that need remote connections: ```typescript this.manageConnection = jotai.atom(true); // Show connection picker this.filterOutNowsh = jotai.atom(true); // Hide nowsh connections this.showS3 = jotai.atom(true); // Show S3 connections ``` Access connection status: ```typescript const connStatus = jotai.atom((get) => { const blockData = get(this.blockAtom); const connName = blockData?.meta?.connection; return get(getConnStatusAtom(connName)); }); ``` ## Common Patterns ### Reading Block Metadata ```typescript import { getBlockMetaKeyAtom } from "@/store/global"; // In constructor: this.someFlag = getBlockMetaKeyAtom(blockId, "myview:flag"); // In component: const flag = useAtomValue(model.someFlag); ``` ### Configuration Overrides Wave has a hierarchical config system (global → connection → block): ```typescript import { getOverrideConfigAtom } from "@/store/global"; this.settingAtom = jotai.atom((get) => { // Checks block meta, then connection config, then global settings return get(getOverrideConfigAtom(this.blockId, "myview:setting")) ?? defaultValue; }); ``` ### Updating Block Metadata ```typescript import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WOS } from "@/store/global"; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "myview:key": value }, }); ``` ### Search Integration To add in-block search: ```typescript import { useSearch } from "@/app/element/search"; // In model: this.searchAtoms = useSearch(); // Call in component, not model! // In component: const searchAtoms = useSearch(); // Pass to model or use directly ``` ## Testing Your View 1. Build the frontend: `task build:dev` or `task electron:dev` 2. Create a block with your view type 3. Test all interactive elements (buttons, inputs, etc.) 4. Test keyboard shortcuts 5. Test focus behavior 6. Test cleanup (close block and check console for errors) 7. Test with different block configurations via metadata ## Additional Resources - [`frontend/app/block/blockframe.tsx`](../frontend/app/block/blockframe.tsx) - Block header rendering - [`frontend/app/view/term/term-model.ts`](../frontend/app/view/term/term-model.ts) - Complex view example - [`frontend/app/view/webview/webview.tsx`](../frontend/app/view/webview/webview.tsx) - Navigation UI example - [`frontend/types/custom.d.ts`](../frontend/types/custom.d.ts) - Type definitions - Project coding rules in [`.roo/rules/`](../.roo/rules/) ================================================ FILE: aiprompts/openai-request.md ================================================ # OpenAI Request Input Field Structure (On-the-Wire Format) This document describes the actual JSON structure sent to the OpenAI API in the `input` field of [`OpenAIRequest`](../pkg/aiusechat/openai/openai-convertmessage.go:111). ## Overview The `input` field is a JSON array containing one of three object types: 1. **Messages** (user/assistant) - `OpenAIMessage` objects 2. **Function Calls** (tool invocations) - `OpenAIFunctionCallInput` objects 3. **Function Call Results** (tool outputs) - `OpenAIFunctionCallOutputInput` objects These are converted from [`OpenAIChatMessage`](../pkg/aiusechat/openai/openai-backend.go:46-52) internal format and cleaned before transmission ([see lines 485-494](../pkg/aiusechat/openai/openai-backend.go:485-494)). ## 1. Message Objects (User/Assistant) User and assistant messages sent as [`OpenAIMessage`](../pkg/aiusechat/openai/openai-backend.go:54-57): ```json { "role": "user", "content": [ { "type": "input_text", "text": "Hello, analyze this image" }, { "type": "input_image", "image_url": "data:image/png;base64,iVBORw0KG..." } ] } ``` **Key Points:** - `role`: Always `"user"` or `"assistant"` - `content`: **Always an array** of content blocks (never a plain string) ### Content Block Types #### Text Block ```json { "type": "input_text", "text": "message content here" } ``` #### Image Block ```json { "type": "input_image", "image_url": "data:image/png;base64,..." } ``` - Can be a data URL or https:// URL - `filename` field is **removed** during cleaning #### PDF File Block ```json { "type": "input_file", "file_data": "JVBERi0xLjQKJeLjz9M...", "filename": "document.pdf" } ``` - `file_data`: Base64-encoded PDF content #### Function Call Block (in assistant messages) ```json { "type": "function_call", "call_id": "call_abc123", "name": "search_files", "arguments": {"query": "test"} } ``` ## 2. Function Call Objects (Tool Invocations) Tool calls from the model sent as [`OpenAIFunctionCallInput`](../pkg/aiusechat/openai/openai-backend.go:59-67): ```json { "type": "function_call", "call_id": "call_abc123", "name": "search_files", "arguments": "{\"query\":\"test\",\"path\":\"./src\"}" } ``` **Key Points:** - `type`: Always `"function_call"` - `call_id`: Unique identifier generated by model - `name`: Function name to execute - `arguments`: JSON-encoded string of parameters - `status`: Optional (`"in_progress"`, `"completed"`, `"incomplete"`) - Internal `toolusedata` field is **removed** during cleaning ## 3. Function Call Output Objects (Tool Results) Tool execution results sent as [`OpenAIFunctionCallOutputInput`](../pkg/aiusechat/openai/openai-backend.go:69-75): ```json { "type": "function_call_output", "call_id": "call_abc123", "output": "Found 3 files matching query" } ``` **Key Points:** - `type`: Always `"function_call_output"` - `call_id`: Must match the original function call's `call_id` - `output`: Can be text, image array, or error object ### Output Value Types #### Text Output ```json { "type": "function_call_output", "call_id": "call_abc123", "output": "Result text here" } ``` #### Image Output ```json { "type": "function_call_output", "call_id": "call_abc123", "output": [ { "type": "input_image", "image_url": "data:image/png;base64,..." } ] } ``` #### Error Output ```json { "type": "function_call_output", "call_id": "call_abc123", "output": "{\"ok\":\"false\",\"error\":\"File not found\"}" } ``` - Error output is a JSON-encoded string containing `ok` and `error` fields ## Complete Example ```json { "model": "gpt-4o", "input": [ { "role": "user", "content": [ { "type": "input_text", "text": "What files are in src/?" } ] }, { "type": "function_call", "call_id": "call_xyz789", "name": "list_files", "arguments": "{\"path\":\"src/\"}" }, { "type": "function_call_output", "call_id": "call_xyz789", "output": "main.go\nutil.go\nconfig.go" }, { "role": "assistant", "content": [ { "type": "output_text", "text": "The src/ directory contains 3 files: main.go, util.go, and config.go" } ] } ], "stream": true, "max_output_tokens": 4096 } ``` ## Cleaning Process Before transmission, internal fields are removed ([cleanup code](../pkg/aiusechat/openai/openai-backend.go:485-494)): - **Messages**: `previewurl` field removed, `filename` removed from `input_image` blocks - **Function Calls**: `toolusedata` field removed - **Function Outputs**: Sent as-is (no cleaning needed) This ensures the API receives only the fields it expects. ================================================ FILE: aiprompts/openai-streaming-text.md ================================================ For **just text streaming**, you only need to handle these 3 core events: ## Essential Events ### 1. `response.created` ```json { "type": "response.created", "response": { "id": "resp_abc123", "created_at": 1640995200, "model": "gpt-5" } } ``` **Purpose**: Initialize response tracking (like Anthropic's `message_start`) ### 2. `response.output_text.delta` ```json { "type": "response.output_text.delta", "item_id": "msg_abc123", "delta": "Hello, how can I" } ``` **Purpose**: Stream text chunks (like Anthropic's `text_delta`) ### 3. `response.completed` ```json { "type": "response.completed", "response": { "usage": { "input_tokens": 100, "output_tokens": 200 } } } ``` **Purpose**: Finalize response (like Anthropic's `message_stop`) ## Optional but Recommended ### 4. `error` ```json { "type": "error", "code": "rate_limit_exceeded", "message": "Rate limit exceeded" } ``` **Purpose**: Handle errors gracefully --- That's it for basic text streaming! You can ignore all the `response.output_item.added/done`, tool calling, reasoning, and annotation events if you just want simple text responses. Your Go implementation would be: 1. Parse SSE stream 2. Switch on `event.type` 3. Handle these 4 event types 4. Accumulate text from `delta` fields 5. Emit to your existing SSE handler Much simpler than the full implementation. ================================================ FILE: aiprompts/openai-streaming.md ================================================ # OpenAI Responses API SSE Events Documentation This document outlines the Server-Sent Events (SSE) format used by OpenAI's Responses API for streaming chat completions, based on the Vercel AI SDK implementation. ## Core Event Types ### Response Lifecycle Events #### `response.created` Emitted when a new response begins. ```json { "type": "response.created", "response": { "id": "resp_abc123", "created_at": 1640995200, "model": "gpt-5", "service_tier": "default" } } ``` #### `response.completed` Emitted when the response completes successfully. ```json { "type": "response.completed", "response": { "incomplete_details": null, "usage": { "input_tokens": 100, "input_tokens_details": { "cached_tokens": 50 }, "output_tokens": 200, "output_tokens_details": { "reasoning_tokens": 150 } }, "service_tier": "default" } } ``` #### `response.incomplete` Emitted when the response is incomplete (e.g., due to length limits). ```json { "type": "response.incomplete", "response": { "incomplete_details": { "reason": "max_tokens" }, "usage": { "input_tokens": 100, "output_tokens": 4000 } } } ``` ### Content Block Events #### `response.output_item.added` Emitted when a new output item (content block) is added. ```json { "type": "response.output_item.added", "output_index": 0, "item": { "type": "message", "id": "msg_abc123" } } ``` Item types can be: - `message` - Text content - `reasoning` - Reasoning/thinking content - `function_call` - Tool call - `web_search_call` - Web search tool call - `computer_call` - Computer use tool call - `file_search_call` - File search tool call - `image_generation_call` - Image generation tool call - `code_interpreter_call` - Code interpreter tool call #### `response.output_item.done` Emitted when an output item is completed. ```json { "type": "response.output_item.done", "output_index": 0, "item": { "type": "message", "id": "msg_abc123" } } ``` For function calls, includes the complete arguments: ```json { "type": "response.output_item.done", "output_index": 1, "item": { "type": "function_call", "id": "call_abc123", "call_id": "call_abc123", "name": "get_weather", "arguments": "{\"location\": \"San Francisco\"}", "status": "completed" } } ``` ### Text Streaming Events #### `response.output_text.delta` Emitted for incremental text content. ```json { "type": "response.output_text.delta", "item_id": "msg_abc123", "delta": "Hello, how can I", "logprobs": [ { "token": "Hello", "logprob": -0.1, "top_logprobs": [ { "token": "Hello", "logprob": -0.1 }, { "token": "Hi", "logprob": -2.3 } ] } ] } ``` ### Tool Call Events #### `response.function_call_arguments.delta` Emitted for streaming function call arguments. ```json { "type": "response.function_call_arguments.delta", "item_id": "call_abc123", "output_index": 1, "delta": "\"location\": \"San" } ``` ### Reasoning Events #### `response.reasoning_summary_part.added` Emitted when a new reasoning summary part is added. ```json { "type": "response.reasoning_summary_part.added", "item_id": "reasoning_abc123", "summary_index": 0 } ``` #### `response.reasoning_summary_text.delta` Emitted for incremental reasoning text. ```json { "type": "response.reasoning_summary_text.delta", "item_id": "reasoning_abc123", "summary_index": 0, "delta": "Let me think about this step by step..." } ``` ### Annotation Events #### `response.output_text.annotation.added` Emitted when citations or annotations are added to text. ```json { "type": "response.output_text.annotation.added", "annotation": { "type": "url_citation", "url": "https://example.com/article", "title": "Example Article" } } ``` Or for file citations: ```json { "type": "response.output_text.annotation.added", "annotation": { "type": "file_citation", "file_id": "file_abc123", "filename": "document.pdf", "quote": "This is the relevant quote", "start_index": 100, "end_index": 150 } } ``` ### Error Events #### `error` Emitted when an error occurs. ```json { "type": "error", "code": "rate_limit_exceeded", "message": "Rate limit exceeded. Please try again later.", "param": null, "sequence_number": 5 } ``` ## Built-in Tool Call Schemas ### Web Search Call ```json { "type": "web_search_call", "id": "search_abc123", "status": "completed", "action": { "type": "search", "query": "OpenAI API documentation" } } ``` ### File Search Call ```json { "type": "file_search_call", "id": "search_abc123", "queries": ["OpenAI pricing", "API limits"], "results": [ { "attributes": {}, "file_id": "file_abc123", "filename": "pricing.pdf", "score": 0.85, "text": "OpenAI API pricing starts at..." } ] } ``` ### Code Interpreter Call ```json { "type": "code_interpreter_call", "id": "code_abc123", "code": "print('Hello, world!')", "container_id": "container_123", "outputs": [ { "type": "logs", "logs": "Hello, world!\n" } ] } ``` ### Image Generation Call ```json { "type": "image_generation_call", "id": "img_abc123", "result": "https://example.com/generated-image.png" } ``` ### Computer Use Call ```json { "type": "computer_call", "id": "computer_abc123", "status": "completed" } ``` ## Event Processing Flow 1. **Response Start**: `response.created` → Initialize response tracking 2. **Content Blocks**: `response.output_item.added` → Start tracking content block 3. **Streaming Content**: - `response.output_text.delta` → Accumulate text - `response.function_call_arguments.delta` → Accumulate tool arguments - `response.reasoning_summary_text.delta` → Accumulate reasoning 4. **Content Complete**: `response.output_item.done` → Finalize content block 5. **Response End**: `response.completed`/`response.incomplete` → Finalize response ## Key Differences from Anthropic | Aspect | OpenAI Responses API | Anthropic Messages API | | -------------- | ---------------------------------------- | ------------------------------------------------ | | Text streaming | `response.output_text.delta` | `content_block_delta` (type: `text_delta`) | | Tool arguments | `response.function_call_arguments.delta` | `content_block_delta` (type: `input_json_delta`) | | Reasoning | `response.reasoning_summary_text.delta` | `content_block_delta` (type: `thinking_delta`) | | Block tracking | `output_index` | `index` | | Response start | `response.created` | `message_start` | | Response end | `response.completed` | `message_stop` | ## Error Handling - Parse each SSE event with proper JSON validation - Handle unknown event types gracefully (forward as-is or ignore) - Track `sequence_number` for error events to maintain order - Use `output_index` to correlate events with specific content blocks - Handle partial JSON in tool argument deltas (accumulate until complete) ## Implementation Notes - Events may arrive out of order; use `output_index` and `item_id` for correlation - Multiple reasoning summary parts can exist; track by `summary_index` - Tool calls can be provider-executed (built-in tools) or require client execution - Logprobs are optional and only included when requested - Usage tokens are only available in completion events ================================================ FILE: aiprompts/tailwind-container-queries.md ================================================ ### Tailwind v4 Container Queries (Quick Overview) - **Viewport breakpoints**: `sm:`, `md:`, `lg:`, etc. → respond to **screen size**. - **Container queries**: `@sm:`, `@md:`, etc. → respond to **parent element size**. #### Enable No plugin needed in **v4** (built-in). In v3: install `@tailwindcss/container-queries`. #### Usage ```html ``` - `@container` marks the parent. - `@sm:` / `@md:` refer to **container width**, not viewport. #### Max-Width Container Queries For max-width queries, use `@max-` prefix: ```html
Only on containers < sm
Fixed overlay on small, normal on large
``` - `@max-sm:` = max-width query (container **below** sm breakpoint) - `@sm:` = min-width query (container **at or above** sm breakpoint) **IMPORTANT**: The syntax is `@max-w600:` NOT `max-@w600:` (prefix comes before the @) #### Notes - Based on native CSS container queries (well supported in modern browsers). - Breakpoints for container queries reuse Tailwind’s `sm`, `md`, `lg`, etc. scales. - Safe for modern webapps; no IE/legacy support. We have special breakpoints set up for panels: --container-w600: 600px; --container-w450: 450px; --container-xs: 300px; --container-xxs: 200px; --container-tiny: 120px; since often sm, md, and lg are too big for panels. Usage examples: ```html
``` ================================================ FILE: aiprompts/tsunami-builder.md ================================================ # Tsunami AI Builder - V1 Architecture ## Overview A split-screen builder for creating Tsunami applications: chat interface on left, tabbed preview/code/files on right. Users describe what they want, AI edits the code iteratively. ## UI Layout ### Left Panel - **💬 Chat** - Conversation with AI ### Right Panel **Top Section - Tabs:** - **👁️ Preview** (default) - Live preview of running Tsunami app, updates automatically after successful compilation - **📝 Code** - Monaco editor for manual edits to app.go - **📁 Files** - Static assets browser (images, etc) **Bottom Section - Build Panel (closable):** - Shows compilation status and output (like VSCode's terminal panel) - Displays success messages or errors with line numbers - Auto-runs after AI edits - For manual Code tab edits: auto-reruns or user clicks build button - Can be manually closed/reopened by user ### Top Bar - Current AppTitle (extracted from app.go) - **Publish** button - Moves draft → published version - **Revert** button - Copies published → draft (discards draft changes) ## Version Management **Draft mode**: Auto-saved on every edit, persists when builder closes **Published version**: What runs in main Wave Terminal, only updates on explicit "Publish" Flow: 1. Edit in builder (always editing draft) 2. Click "Publish" when ready (copies draft → published) 3. Continue editing draft OR click "Revert" to abandon changes ## Context Structure Every AI request includes: ``` [System Instructions] - General system prompt - Full system.md (Tsunami framework guide) [Conversation History] - Recent messages (with prompt caching) [Current Context] (injected fresh each turn, removed from previous turns) - Current app.go content - Compilation results (success or errors with line numbers) - Static files listing (e.g., "/static/logo.png") ``` **Context cleanup**: Old "current context" blocks are removed from previous messages and replaced with "[OLD CONTEXT REMOVED]" to save tokens. Only the latest app.go + compile results stay in context. ## AI Tools ### edit_appgo (str_replace) **Primary editing tool** - `old_str` - Unique string to find in app.go - `new_str` - Replacement string - `description` - What this change does **Backend behavior**: 1. Apply string replacement to app.go 2. Immediately run `go build` 3. Return tool result: - ✓ Success: "Edit applied, compilation successful" - ✗ Failure: "Edit applied, compilation failed: [error details]" AI can make multiple edits in one response, getting compile feedback after each. ### create_appgo **Bootstrap new apps** - `content` - Full app.go file content - Only used for initial app creation or total rewrites Same compilation behavior as str_replace. ### web_search **Look up APIs, docs, examples** - Implemented via provider backend (OpenAI/Anthropic) - AI can research before making edits ### read_file **Read user-provided documentation** - `path` - Path to file (e.g., "/docs/api-spec.md") - User can upload docs/examples for AI to reference ## User Actions (Not AI Tools) ### Manage Static Assets - Upload via drag & drop into Files tab or file picker - Delete files from Files tab - Rename files from Files tab - Appear in `/static/` directory - Auto-injected into AI context as available files ### Share Screenshot - User clicks "📷 Share preview with AI" button - Captures current preview state - Attaches to user's next message - Useful for debugging layout/visual issues ### Manual Code Editing - User can switch to Code tab - Edit app.go directly in Monaco editor - Changes auto-compile - AI sees manual edits in next chat turn ## Compilation Pipeline After every code change (AI or user): ``` 1. Write app.go to disk 2. Run: go build app.go 3. Show build output in build panel 4. If success: - Start/restart app process - Update preview iframe - Show success message in build panel 5. If failure: - Parse error output (line numbers, messages) - Show error in build panel (bottom of right side) - Inject into AI context for next turn ``` **Auto-retry**: AI can fix its own compilation errors within the same response (up to 3 attempts). ## Error Handling ### Compilation Errors Shown in build panel at bottom of right side. Format for AI: ``` COMPILATION FAILED Error at line 45: 43 | func(props TodoProps) any { 44 | return vdom.H("div", nil > 45 | vdom.H("span", nil, "test") | ^ missing closing parenthesis 46 | ) Message: expected ')', found 'vdom' ``` ### Runtime Errors - Shown in preview tab (not errors panel) - User can screenshot and report to AI - Not auto-injected (v1 simplification) ### Linting (Future) - Could add custom Tsunami-specific linting - Would inject warnings alongside compile results - Not required for v1 ## Secrets/Configuration Apps can declare secrets using Tsunami's ConfigAtom: ```go var apiKeyAtom = app.ConfigAtom("api_key", "", &app.AtomMeta{ Desc: "OpenAI API Key", Secret: true, }) ``` Builder detects these and shows input fields in UI for user to fill in. ## Conversation Limits **V1 approach**: No summarization, no smart handling. When context limit hit: Show message "You've hit the conversation limit. Click 'Start Fresh' to continue editing this app in a new chat." Starting fresh uses current app.go as the beginning state. ## Token Optimization - System.md + early messages benefit from prompt caching - Only pay per-turn for: current app.go + new messages - Old context blocks removed to prevent bloat - Estimated: 10-20k tokens per turn (very manageable) ## Example Flow ``` User: "Create a counter app" AI: [calls create_appgo with full counter app] Backend: ✓ Compiled successfully Preview: Shows counter app User: "Add a reset button" AI: [calls str_replace to add reset button] Backend: ✓ Compiled successfully Preview: Updates with reset button User: "Make buttons bigger" AI: [calls str_replace to update button classes] Backend: ✓ Compiled successfully Preview: Updates with larger buttons User: [switches to Code tab, tweaks color manually] Backend: ✓ Compiled successfully Preview: Updates User: "Add a chart showing count over time" AI: [calls web_search for "go charting library"] AI: [calls str_replace to add chart] Backend: ✗ Compilation failed - missing import AI: [calls str_replace to add import] Backend: ✓ Compiled successfully Preview: Shows chart ``` ## Out of Scope (V1) - Version history / snapshots - Multiple files / project structure - Collaboration / sharing - Advanced linting - Runtime error auto-injection - Conversation summarization - Component-specific editing tools These can be added in v2+ based on user feedback. ## Success Criteria - User can create functional Tsunami app through chat in <5 minutes - AI successfully fixes its own compilation errors 80%+ of the time - Iteration cycle (message → edit → preview) takes <10 seconds - Users can publish working apps to Wave Terminal - Draft state persists across sessions ================================================ FILE: aiprompts/usechat-backend-design.md ================================================ # useChat Compatible Backend Design for Wave Terminal ## Overview This document outlines how to create a `useChat()` compatible backend API using Go and Server-Sent Events (SSE) to replace the current complex RPC-based AI chat system. The goal is to leverage Vercel AI SDK's `useChat()` hook while maintaining all existing AI provider functionality. ## Current vs Target Architecture ### Current Architecture ``` Frontend (React) → Custom RPC → Go Backend → AI Providers - 10+ Jotai atoms for state management - Custom WaveAIStreamRequest/WaveAIPacketType - Complex configuration merging in frontend - Custom streaming protocol over WebSocket ``` ### Target Architecture ``` Frontend (useChat) → HTTP/SSE → Go Backend → AI Providers - Single useChat() hook manages all state - Standard HTTP POST + SSE streaming - Backend-driven configuration resolution - Standard AI SDK streaming format ``` ## API Design ### 1. Endpoint Structure **Chat Streaming Endpoint:** ``` POST /api/ai/chat/{blockId}?preset={presetKey} ``` **Conversation Persistence Endpoints:** ``` POST /api/ai/conversations/{blockId} # Save conversation GET /api/ai/conversations/{blockId} # Load conversation ``` **Why this approach:** - `blockId`: Identifies the conversation context (existing Wave concept) - `preset`: URL parameter for AI configuration preset - **Separate persistence**: Clean separation of streaming vs storage - **Fast localhost calls**: Frontend can call both endpoints quickly - **Simple backend**: Each endpoint has single responsibility ### 2. Request Format & Message Flow **Simplified Approach:** - Frontend manages **entire conversation state** (like all modern chat apps) - Frontend sends **complete message history** with each request - Backend just processes the messages and streams response - Frontend handles persistence via existing Wave file system **Standard useChat() Request:** ```json { "messages": [ { "id": "msg-1", "role": "user", "content": "Hello world" }, { "id": "msg-2", "role": "assistant", "content": "Hi there!" }, { "id": "msg-3", "role": "user", "content": "How are you?" // <- NEW message user just typed } ] } ``` **Backend Processing:** 1. **Receive complete conversation** from frontend 2. **Resolve AI configuration** (preset, model, etc.) 3. **Send messages directly** to AI provider 4. **Stream response** back to frontend 5. **Frontend calls separate persistence endpoint** when needed **Optional Extensions:** ```json { "messages": [...], "options": { "temperature": 0.7, "maxTokens": 1000, "model": "gpt-4" // Override preset model } } ``` ### 3. Configuration Resolution **Priority Order (backend resolves):** 1. **Request options** (highest priority) 2. **URL preset parameter** 3. **Block metadata** (`block.meta["ai:preset"]`) 4. **Global settings** (`settings["ai:preset"]`) 5. **Default preset** (lowest priority) **Backend Logic:** ```go func resolveAIConfig(blockId, presetKey string, requestOptions map[string]any) (*WaveAIOptsType, error) { // 1. Load block metadata block := getBlock(blockId) blockPreset := block.Meta["ai:preset"] // 2. Load global settings settings := getGlobalSettings() globalPreset := settings["ai:preset"] // 3. Resolve preset hierarchy finalPreset := presetKey if finalPreset == "" { finalPreset = blockPreset } if finalPreset == "" { finalPreset = globalPreset } if finalPreset == "" { finalPreset = "default" } // 4. Load and merge preset config presetConfig := loadPreset(finalPreset) // 5. Apply request overrides return mergeAIConfig(presetConfig, requestOptions), nil } ``` ### 4. Response Format (SSE) **Key Insight: Minimal Conversion** Most AI providers (OpenAI, Anthropic) already return SSE streams. Instead of converting to our custom format and back, we can **proxy/transform** their streams directly to useChat format. **Headers:** ``` Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive Access-Control-Allow-Origin: * ``` **useChat Expected Format:** ``` data: {"type":"text","text":"Hello"} data: {"type":"text","text":" world"} data: {"type":"text","text":"!"} data: {"type":"finish","finish_reason":"stop","usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}} data: [DONE] ``` **Provider Stream Transformation:** - **OpenAI**: Already SSE → direct proxy (no conversion needed) - **Anthropic**: Already SSE → direct proxy (minimal field mapping) - **Google**: Already streaming → direct proxy - **Perplexity**: OpenAI-compatible → direct proxy - **Wave Cloud**: WebSocket → **requires conversion** (only one needing transformation) **Error Format:** ``` data: {"type":"error","error":"API key invalid"} data: [DONE] ``` ## Implementation Plan ### Phase 1: HTTP Handler ```go // Simplified approach: Direct provider streaming with minimal transformation func (s *WshServer) HandleAIChat(w http.ResponseWriter, r *http.Request) { // 1. Parse URL parameters blockId := mux.Vars(r)["blockId"] presetKey := r.URL.Query().Get("preset") // 2. Parse request body var req struct { Messages []struct { Role string `json:"role"` Content string `json:"content"` } `json:"messages"` Options map[string]any `json:"options,omitempty"` } json.NewDecoder(r.Body).Decode(&req) // 3. Resolve configuration aiOpts, err := resolveAIConfig(blockId, presetKey, req.Options) if err != nil { http.Error(w, err.Error(), 400) return } // 4. Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") // 5. Route to provider and stream directly switch aiOpts.APIType { case "openai", "perplexity": // Direct proxy - these are already SSE compatible streamDirectSSE(w, r.Context(), aiOpts, req.Messages) case "anthropic": // Direct proxy with minimal field mapping streamAnthropicSSE(w, r.Context(), aiOpts, req.Messages) case "google": // Direct proxy streamGoogleSSE(w, r.Context(), aiOpts, req.Messages) default: // Wave Cloud - only one requiring conversion (WebSocket → SSE) if isCloudAIRequest(aiOpts) { streamWaveCloudToUseChat(w, r.Context(), aiOpts, req.Messages) } else { http.Error(w, "Unsupported provider", 400) } } } // Example: Direct OpenAI streaming (minimal conversion) func streamOpenAIToUseChat(w http.ResponseWriter, ctx context.Context, opts *WaveAIOptsType, messages []Message) { client := openai.NewClient(opts.APIToken) stream, err := client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ Model: opts.Model, Messages: convertToOpenAIMessages(messages), Stream: true, }) if err != nil { fmt.Fprintf(w, "data: {\"type\":\"error\",\"error\":%q}\n\n", err.Error()) fmt.Fprintf(w, "data: [DONE]\n\n") return } defer stream.Close() for { response, err := stream.Recv() if errors.Is(err, io.EOF) { fmt.Fprintf(w, "data: [DONE]\n\n") return } if err != nil { fmt.Fprintf(w, "data: {\"type\":\"error\",\"error\":%q}\n\n", err.Error()) fmt.Fprintf(w, "data: [DONE]\n\n") return } // Direct transformation: OpenAI format → useChat format for _, choice := range response.Choices { if choice.Delta.Content != "" { fmt.Fprintf(w, "data: {\"type\":\"text\",\"text\":%q}\n\n", choice.Delta.Content) } if choice.FinishReason != "" { fmt.Fprintf(w, "data: {\"type\":\"finish\",\"finish_reason\":%q}\n\n", choice.FinishReason) } } w.(http.Flusher).Flush() } } // Wave Cloud conversion (only provider needing transformation) func streamWaveCloudToUseChat(w http.ResponseWriter, ctx context.Context, opts *WaveAIOptsType, messages []Message) { // Use existing Wave Cloud WebSocket logic waveReq := wshrpc.WaveAIStreamRequest{ Opts: opts, Prompt: convertMessagesToPrompt(messages), } stream := waveai.RunAICommand(ctx, waveReq) // Returns WebSocket stream // Convert Wave Cloud packets to useChat SSE format for packet := range stream { if packet.Error != nil { fmt.Fprintf(w, "data: {\"type\":\"error\",\"error\":%q}\n\n", packet.Error.Error()) break } resp := packet.Response if resp.Text != "" { fmt.Fprintf(w, "data: {\"type\":\"text\",\"text\":%q}\n\n", resp.Text) } if resp.FinishReason != "" { usage := "" if resp.Usage != nil { usage = fmt.Sprintf(",\"usage\":{\"prompt_tokens\":%d,\"completion_tokens\":%d,\"total_tokens\":%d}", resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) } fmt.Fprintf(w, "data: {\"type\":\"finish\",\"finish_reason\":%q%s}\n\n", resp.FinishReason, usage) } w.(http.Flusher).Flush() } fmt.Fprintf(w, "data: [DONE]\n\n") } ``` ### Phase 2: Frontend Integration ```typescript import { useChat } from '@ai-sdk/react'; function WaveAI({ blockId }: { blockId: string }) { // Get current preset from block metadata or settings const preset = useAtomValue(currentPresetAtom); const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({ api: `/api/ai/chat/${blockId}?preset=${preset}`, initialMessages: [], // Load from existing aidata file onFinish: (message) => { // Save conversation to aidata file saveConversation(blockId, messages); } }); return (
{messages.map(message => (
))} {isLoading && } {error &&
{error.message}
}
); } ``` ### Phase 3: Advanced Features #### Multi-modal Support ```typescript // useChat supports multi-modal out of the box const { messages, append } = useChat({ api: `/api/ai/chat/${blockId}`, }); // Send image + text await append({ role: 'user', content: [ { type: 'text', text: 'What do you see in this image?' }, { type: 'image', image: imageFile } ] }); ``` #### Thinking Models ```go // Backend detects thinking models and formats appropriately if isThinkingModel(aiOpts.Model) { // Send thinking content separately fmt.Fprintf(w, "data: {\"type\":\"thinking\",\"text\":%q}\n\n", thinkingText) fmt.Fprintf(w, "data: {\"type\":\"text\",\"text\":%q}\n\n", responseText) } ``` #### Context Injection ```typescript // Add system messages or context via useChat options const { messages, append } = useChat({ api: `/api/ai/chat/${blockId}`, initialMessages: [ { role: 'system', content: 'You are a helpful terminal assistant...' } ] }); ``` ## Migration Strategy ### 1. Parallel Implementation - Keep existing RPC system running - Add new HTTP/SSE endpoint alongside - Feature flag to switch between systems ### 2. Gradual Migration - Start with new blocks using useChat - Migrate existing conversations on first interaction - Remove RPC system once stable ### 3. Backward Compatibility - Existing aidata files work unchanged - Same provider backends (OpenAI, Anthropic, etc.) - Same configuration system ## Benefits ### Complexity Reduction - **Frontend**: ~900 lines → ~100 lines (90% reduction) - **State Management**: 10+ atoms → 1 useChat hook - **Configuration**: Frontend merging → Backend resolution - **Streaming**: Custom protocol → Standard SSE ### Modern Features - **Multi-modal**: Images, files, audio support - **Thinking Models**: Built-in reasoning trace support - **Conversation Management**: Edit, retry, branch conversations - **Error Handling**: Automatic retry and error boundaries - **Performance**: Optimized streaming and batching ### Developer Experience - **Type Safety**: Full TypeScript support - **Testing**: Standard HTTP endpoints easier to test - **Debugging**: Standard browser dev tools work - **Documentation**: Leverage AI SDK docs and community ## Configuration Examples ### URL-based Configuration ``` POST /api/ai/chat/block-123?preset=claude-coding POST /api/ai/chat/block-456?preset=gpt4-creative ``` ### Header-based Overrides ``` POST /api/ai/chat/block-123 X-AI-Model: gpt-4-turbo X-AI-Temperature: 0.8 ``` ### Request Body Options ```json { "messages": [...], "options": { "model": "claude-3-sonnet", "temperature": 0.7, "maxTokens": 2000 } } ``` This design maintains all existing functionality while dramatically simplifying the implementation and adding modern AI chat capabilities. ================================================ FILE: aiprompts/view-prompt.md ================================================ # Wave Terminal ViewModel Guide ## Overview Wave Terminal uses a modular ViewModel system to define interactive blocks. Each block has a **ViewModel**, which manages its metadata, configuration, and state using **Jotai atoms**. The ViewModel also specifies a **React component (ViewComponent)** that renders the block. ### Key Concepts 1. **ViewModel Structure** - Implements the `ViewModel` interface. - Defines: - `viewType`: Unique block type identifier. - `viewIcon`, `viewName`, `viewText`: Atoms for UI metadata. - `preIconButton`, `endIconButtons`: Atoms for action buttons. - `blockBg`: Atom for background styling. - `manageConnection`, `noPadding`, `searchAtoms`. - `viewComponent`: React component rendering the block. - Lifecycle methods like `dispose()`, `giveFocus()`, `keyDownHandler()`. 2. **ViewComponent Structure** - A **React function component** implementing `ViewComponentProps`. - Uses `blockId`, `blockRef`, `contentRef`, and `model` as props. - Retrieves ViewModel state using Jotai atoms. - Returns JSX for rendering. 3. **Header Elements (`HeaderElem[]`)** - Can include: - **Icons (`IconButtonDecl`)**: Clickable buttons. - **Text (`HeaderText`)**: Metadata or status. - **Inputs (`HeaderInput`)**: Editable fields. - **Menu Buttons (`MenuButton`)**: Dropdowns. 4. **Jotai Atoms for State Management** - Use `atom`, `PrimitiveAtom`, `WritableAtom` for dynamic properties. - `splitAtom` for managing lists of atoms. - Read settings from `globalStore` and override with block metadata. 5. **Metadata vs. Global Config** - **Block Metadata (`SetMetaCommand`)**: Each block persists its **own configuration** in its metadata (`blockAtom.meta`). - **Global Config (`SetConfigCommand`)**: Provides **default settings** for all blocks, stored in config files. - **Cascading Behavior**: - Blocks first check their **own metadata** for settings. - If no override exists, they **fall back** to global config. - Updating a block's setting is done via `SetMetaCommand` (persisted per block). - Updating a global setting is done via `SetConfigCommand` (applies globally unless overridden). 6. **Useful Helper Functions** - To avoid repetitive boilerplate, use these global utilities from `global.ts`: - `useBlockMetaKeyAtom(blockId, key)`: Retrieves and updates block-specific metadata. - `useOverrideConfigAtom(blockId, key)`: Reads from global config but allows per-block overrides. - `useSettingsKeyAtom(key)`: Accesses global settings efficiently. 7. **Styling** - Use TailWind CSS to style components - Accent color is: text-accent, for a 50% transparent accent background use bg-accentbg - Hover background is: bg-hoverbg - Border color is "border", so use border-border - Colors are also defined for error, warning, and success (text-error, text-warning, text-sucess) ## Relevant TypeScript Types ```typescript type ViewComponentProps = { blockId: string; blockRef: React.RefObject; contentRef: React.RefObject; model: T; }; type ViewComponent = React.FC>; interface ViewModel { viewType: string; viewIcon?: jotai.Atom; viewName?: jotai.Atom; viewText?: jotai.Atom; preIconButton?: jotai.Atom; endIconButtons?: jotai.Atom; blockBg?: jotai.Atom; manageConnection?: jotai.Atom; noPadding?: jotai.Atom; searchAtoms?: SearchAtoms; viewComponent: ViewComponent; dispose?: () => void; giveFocus?: () => boolean; keyDownHandler?: (e: WaveKeyboardEvent) => boolean; } interface IconButtonDecl { elemtype: "iconbutton"; icon: string | React.ReactNode; click?: (e: React.MouseEvent) => void; } type HeaderElem = | IconButtonDecl | ToggleIconButtonDecl | HeaderText | HeaderInput | HeaderDiv | HeaderTextButton | ConnectionButton | MenuButton; type IconButtonCommon = { icon: string | React.ReactNode; iconColor?: string; iconSpin?: boolean; className?: string; title?: string; disabled?: boolean; noAction?: boolean; }; type IconButtonDecl = IconButtonCommon & { elemtype: "iconbutton"; click?: (e: React.MouseEvent) => void; longClick?: (e: React.MouseEvent) => void; }; type ToggleIconButtonDecl = IconButtonCommon & { elemtype: "toggleiconbutton"; active: jotai.WritableAtom; }; type HeaderTextButton = { elemtype: "textbutton"; text: string; className?: string; title?: string; onClick?: (e: React.MouseEvent) => void; }; type HeaderText = { elemtype: "text"; text: string; ref?: React.RefObject; className?: string; noGrow?: boolean; onClick?: (e: React.MouseEvent) => void; }; type HeaderInput = { elemtype: "input"; value: string; className?: string; isDisabled?: boolean; ref?: React.RefObject; onChange?: (e: React.ChangeEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onFocus?: (e: React.FocusEvent) => void; onBlur?: (e: React.FocusEvent) => void; }; type HeaderDiv = { elemtype: "div"; className?: string; children: HeaderElem[]; onMouseOver?: (e: React.MouseEvent) => void; onMouseOut?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void; }; type ConnectionButton = { elemtype: "connectionbutton"; icon: string; text: string; iconColor: string; onClick?: (e: React.MouseEvent) => void; connected: boolean; }; type MenuItem = { label: string; icon?: string | React.ReactNode; subItems?: MenuItem[]; onClick?: (e: React.MouseEvent) => void; }; type MenuButtonProps = { items: MenuItem[]; className?: string; text: string; title?: string; menuPlacement?: Placement; }; type MenuButton = { elemtype: "menubutton"; } & MenuButtonProps; ``` ## Minimal "Hello World" Example This example defines a simple ViewModel and ViewComponent for a block that displays "Hello, World!". ```typescript import * as jotai from "jotai"; import React from "react"; class HelloWorldModel implements ViewModel { viewType = "helloworld"; viewIcon = jotai.atom("smile"); viewName = jotai.atom("Hello World"); viewText = jotai.atom("A simple greeting block"); viewComponent = HelloWorldView; } const HelloWorldView: ViewComponent = ({ model }) => { return
Hello, World!
; }; export { HelloWorldModel }; ``` ## Instructions to AI 1. Generate a new **ViewModel** class for a block, following the structure above. 2. Generate a corresponding **ViewComponent**. 3. Use **Jotai atoms** to store all dynamic state. 4. Ensure the ViewModel defines **header elements** (`viewText`, `viewIcon`, `endIconButtons`). 5. Export the view model (to be registered in the BlockRegistry) 6. Use existing metadata patterns for config and settings. ## Other Notes - The types you see above don't need to be imported, they are global types (custom.d.ts) **Output Format:** - TypeScript code defining the **ViewModel**. - TypeScript code defining the **ViewComponent**. - Ensure alignment with the patterns in `waveai.tsx`, `preview.tsx`, `sysinfo.tsx`, and `term.tsx`. ================================================ FILE: aiprompts/wave-osc-16162.md ================================================ # Wave Terminal OSC 16162 Escape Sequences Wave Terminal uses a custom OSC (Operating System Command) escape sequence numbered **16162** for shell integration. This allows the shell to communicate its state and events to the terminal. ## Format All commands use this escape sequence format: ``` ESC ] 16162 ; command [;] BEL ``` Where: - `ESC` = `\033` (escape character) - `BEL` = `\007` (bell character) - `command` = Single letter (A, C, M, D, I, or R) - `` = Optional JSON payload (depends on command) ## Commands ### A - Prompt Start Marks the beginning of a new shell prompt. **Format:** `A` **When:** Sent in `precmd` hook (after previous command completes, before new prompt is displayed) **Purpose:** Signals to the terminal that a new prompt is being drawn. This helps Wave Terminal distinguish between prompt output and command output. **Example:** ```bash printf '\033]16162;A\007' ``` --- ### C - Command Execution Sent immediately before a command is executed, optionally including the command text. **Format:** `C[;]` **Data Type:** ```typescript { cmd64?: string; // base64-encoded command text } ``` **When:** Sent in `preexec` hook (after user presses Enter, before command runs) **Purpose:** Notifies the terminal that a command is about to execute. The command text is base64-encoded to handle special characters safely. **Example:** ```bash cmd64=$(printf '%s' "ls -la" | base64) printf '\033]16162;C;{"cmd64":"%s"}\007' "$cmd64" ``` --- ### M - Metadata Sends shell metadata information (typically only once at shell initialization). **Format:** `M;` **Data Type:** ```typescript { shell?: string; // Shell name (e.g., "zsh", "bash") shellversion?: string; // Version string of the shell uname?: string; // Output of "uname -smr" (e.g., "Darwin 23.0.0 arm64") integration?: boolean; // Whether shell integration is active (true) or disabled (false) } ``` **When:** Sent during first `precmd` hook (on shell startup) **Purpose:** Provides Wave Terminal with information about the shell environment and operating system. **Example:** ```bash uname_info=$(uname -smr 2>/dev/null) printf '\033]16162;M;{"shell":"zsh","shellversion":"5.9","uname":"%s"}\007' "$uname_info" ``` --- ### D - Done (Exit Status) Reports the exit status of the previously executed command. **Format:** `D;` **Data Type:** ```typescript { exitcode?: number; // Exit status code of the previous command } ``` **When:** Sent in `precmd` hook (after command completes) **Purpose:** Communicates whether the previous command succeeded or failed, allowing Wave Terminal to display success/failure indicators. **Example:** ```bash # After command exits with status 0 printf '\033]16162;D;{"exitcode":0}\007' # After command exits with status 1 printf '\033]16162;D;{"exitcode":1}\007' ``` --- ### I - Input Status Reports the current state of the command line input buffer. **Format:** `I;` **Data Type:** ```typescript { inputempty?: boolean; // Whether the command line buffer is empty } ``` **When:** Sent during ZLE (Zsh Line Editor) hooks when buffer state changes - `zle-line-init` - When line editor is initialized - `zle-line-pre-redraw` - Before line is redrawn **Purpose:** Allows Wave Terminal to track the state of the command line input. Currently reports whether the buffer is empty, but may be extended to include additional input state information in the future. **Example:** ```bash # When buffer is empty I;{"inputempty":true} # When buffer has content I;{"inputempty":false} ``` ### R - Reset Alternate Buffer Resets the terminal if it's in alternate buffer mode. **Format:** `R` **When:** Can be sent at any time to ensure terminal is not stuck in alternate buffer mode **Purpose:** If the terminal is currently displaying the alternate screen buffer, this command switches back to the normal buffer. This is useful for recovering from programs that crash without properly restoring the screen. **Behavior:** - Checks if terminal is in alternate buffer mode (`terminal.buffer.active.type === "alternate"`) - If in alternate mode, sends `ESC [ ? 1049 l` to exit alternate buffer - If not in alternate mode, does nothing **Example:** ```bash R ``` --- ## Typical Command Flow Here's the typical sequence during shell interaction: ``` 1. Shell starts → M; (metadata - shell info) 2. First prompt appears → A (prompt start) 3. User types command and presses Enter → I;{"inputempty":false} (input no longer empty - sent as user types) → C;{"cmd64":"..."} (command about to execute) 4. Command runs and completes → D;{"exitcode":} (exit status) → I;{"inputempty":true} (input empty again) → A (next prompt start) 5. Repeat from step 3... ``` ## Implementation Notes - Shell integration is **disabled** when running inside tmux or screen (`TMUX`, `STY` environment variables, or `tmux*`/`screen*` TERM values) - Commands are base64-encoded in the C sequence to safely handle special characters, newlines, and control characters - The I (input empty) command is only sent when the state changes (not on every keystroke) - The M (metadata) command is only sent once during the first precmd - The D (exit status) command is skipped during the first precmd (no previous command to report) ## Related Files - [`pkg/util/shellutil/shellintegration/zsh_zshrc.sh`](pkg/util/shellutil/shellintegration/zsh_zshrc.sh) - Zsh shell integration implementation - Similar integrations exist for bash and other shells ## Standard OSC 7 Wave Terminal also uses the standard **OSC 7** sequence for reporting the current working directory: **Format:** `7;file://` This is sent: - During first precmd (after metadata) - In the `chpwd` hook (whenever directory changes) The path is URL-encoded to safely handle special characters. ================================================ FILE: aiprompts/waveai-architecture.md ================================================ # Wave AI Architecture Documentation ## Overview Wave AI is a chat-based AI assistant feature integrated into Wave Terminal. It provides a conversational interface for interacting with various AI providers (OpenAI, Anthropic, Perplexity, Google, and Wave's cloud proxy) through a unified streaming architecture. The feature is implemented as a block view within Wave Terminal's modular system. ## Architecture Components ### Frontend Architecture (`frontend/app/view/waveai/`) #### Core Components **1. WaveAiModel Class** - **Purpose**: Main view model implementing the `ViewModel` interface - **Responsibilities**: - State management using Jotai atoms - Configuration management (presets, AI options) - Message handling and persistence - RPC communication with backend - UI state coordination **2. AiWshClient Class** - **Purpose**: Specialized WSH RPC client for AI operations - **Extends**: `WshClient` - **Responsibilities**: - Handle incoming `aisendmessage` RPC calls - Route messages to the model's `sendMessage` method **3. React Components** - **WaveAi**: Main container component - **ChatWindow**: Scrollable message display with auto-scroll behavior - **ChatItem**: Individual message renderer with role-based styling - **ChatInput**: Auto-resizing textarea with keyboard navigation #### State Management (Jotai Atoms) **Message State**: ```typescript messagesAtom: PrimitiveAtom> messagesSplitAtom: SplitAtom> latestMessageAtom: Atom addMessageAtom: WritableAtom updateLastMessageAtom: WritableAtom removeLastMessageAtom: WritableAtom ``` **Configuration State**: ```typescript presetKey: Atom // Current AI preset selection presetMap: Atom<{[k: string]: MetaType}> // Available AI presets mergedPresets: Atom // Merged configuration hierarchy aiOpts: Atom // Final AI options for requests ``` **UI State**: ```typescript locked: PrimitiveAtom // Prevents input during AI response viewIcon: Atom // Header icon viewName: Atom // Header title viewText: Atom // Dynamic header elements endIconButtons: Atom // Header action buttons ``` #### Configuration Hierarchy The AI configuration follows a three-tier hierarchy (lowest to highest priority): 1. **Global Settings**: `atoms.settingsAtom["ai:*"]` 2. **Preset Configuration**: `presets[presetKey]["ai:*"]` 3. **Block Metadata**: `block.meta["ai:*"]` Configuration is merged using `mergeMeta()` utility, allowing fine-grained overrides at each level. #### Data Flow - Frontend ``` User Input → sendMessage() → ├── Add user message to UI ├── Create WaveAIStreamRequest ├── Call RpcApi.StreamWaveAiCommand() ├── Add typing indicator └── Stream response handling: ├── Update message incrementally ├── Handle errors └── Save complete conversation ``` ### Backend Architecture (`pkg/waveai/`) #### Core Interface **AIBackend Interface**: ```go type AIBackend interface { StreamCompletion( ctx context.Context, request wshrpc.WaveAIStreamRequest, ) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] } ``` #### Backend Implementations **1. OpenAIBackend** (`openaibackend.go`) - **Providers**: OpenAI, Azure OpenAI, Cloudflare Azure - **Features**: - Reasoning model support (o1, o3, o4, gpt-5) - Proxy support - Multiple API types (OpenAI, Azure, AzureAD, CloudflareAzure) - **Streaming**: Uses `go-openai` library for SSE streaming **2. AnthropicBackend** (`anthropicbackend.go`) - **Provider**: Anthropic Claude - **Features**: - Custom SSE parser for Anthropic's event format - System message handling - Usage token tracking - **Events**: `message_start`, `content_block_delta`, `message_stop`, etc. **3. WaveAICloudBackend** (`cloudbackend.go`) - **Provider**: Wave's cloud proxy service - **Transport**: WebSocket connection to Wave cloud - **Features**: - Fallback when no API token/baseURL provided - Built-in rate limiting and abuse protection **4. PerplexityBackend** (`perplexitybackend.go`) - **Provider**: Perplexity AI - **Implementation**: Similar to OpenAI backend **5. GoogleBackend** (`googlebackend.go`) - **Provider**: Google AI (Gemini) - **Implementation**: Custom integration for Google's API #### Backend Routing Logic ```go func RunAICommand(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { // Route based on request.Opts.APIType: switch request.Opts.APIType { case "anthropic": backend = AnthropicBackend{} case "perplexity": backend = PerplexityBackend{} case "google": backend = GoogleBackend{} default: if IsCloudAIRequest(request.Opts) { backend = WaveAICloudBackend{} } else { backend = OpenAIBackend{} } } return backend.StreamCompletion(ctx, request) } ``` ### RPC Communication Layer #### WSH RPC Integration **Command**: `streamwaveai` **Type**: Response Stream (one request, multiple responses) **Request Type** (`WaveAIStreamRequest`): ```go type WaveAIStreamRequest struct { ClientId string `json:"clientid,omitempty"` Opts *WaveAIOptsType `json:"opts"` Prompt []WaveAIPromptMessageType `json:"prompt"` } ``` **Response Type** (`WaveAIPacketType`): ```go type WaveAIPacketType struct { Type string `json:"type"` Model string `json:"model,omitempty"` Created int64 `json:"created,omitempty"` FinishReason string `json:"finish_reason,omitempty"` Usage *WaveAIUsageType `json:"usage,omitempty"` Index int `json:"index,omitempty"` Text string `json:"text,omitempty"` Error string `json:"error,omitempty"` } ``` #### Configuration Types **AI Options** (`WaveAIOptsType`): ```go type WaveAIOptsType struct { Model string `json:"model"` APIType string `json:"apitype,omitempty"` APIToken string `json:"apitoken"` OrgID string `json:"orgid,omitempty"` APIVersion string `json:"apiversion,omitempty"` BaseURL string `json:"baseurl,omitempty"` ProxyURL string `json:"proxyurl,omitempty"` MaxTokens int `json:"maxtokens,omitempty"` MaxChoices int `json:"maxchoices,omitempty"` TimeoutMs int `json:"timeoutms,omitempty"` } ``` ### Data Persistence #### Chat History Storage **Frontend**: - **Method**: `fetchWaveFile(blockId, "aidata")` - **Format**: JSON array of `WaveAIPromptMessageType` - **Sliding Window**: Last 30 messages (`slidingWindowSize = 30`) **Backend**: - **Service**: `BlockService.SaveWaveAiData(blockId, history)` - **Storage**: Block-associated file storage - **Persistence**: Automatic save after each complete exchange #### Message Format **UI Messages** (`ChatMessageType`): ```typescript interface ChatMessageType { id: string; user: string; // "user" | "assistant" | "error" text: string; isUpdating?: boolean; } ``` **Stored Messages** (`WaveAIPromptMessageType`): ```go type WaveAIPromptMessageType struct { Role string `json:"role"` // "user" | "assistant" | "system" | "error" Content string `json:"content"` Name string `json:"name,omitempty"` } ``` ### Error Handling #### Frontend Error Handling 1. **Network Errors**: Caught in streaming loop, displayed as error messages 2. **Empty Responses**: Automatically remove typing indicator 3. **Cancellation**: User can cancel via stop button (`model.cancel = true`) 4. **Partial Responses**: Saved even if incomplete due to errors #### Backend Error Handling 1. **Panic Recovery**: All backends use `panichandler.PanicHandler()` 2. **Context Cancellation**: Proper cleanup on request cancellation 3. **Provider Errors**: Wrapped and forwarded to frontend 4. **Connection Errors**: Detailed error messages for debugging ### UI Features #### Message Rendering - **Markdown Support**: Full markdown rendering with syntax highlighting - **Role-based Styling**: Different colors/layouts for user/assistant/error messages - **Typing Indicator**: Animated dots during AI response - **Font Configuration**: Configurable font sizes via presets #### Input Handling - **Auto-resize**: Textarea grows/shrinks with content (max 5 lines) - **Keyboard Navigation**: - Enter to send - Cmd+L to clear history - Arrow keys for code block selection - **Code Block Selection**: Navigate through code blocks in responses #### Scroll Management - **Auto-scroll**: Automatically scrolls to new messages - **User Scroll Detection**: Pauses auto-scroll when user manually scrolls - **Smart Resume**: Resumes auto-scroll when near bottom ### Configuration Management #### Preset System **Preset Structure**: ```json { "ai@preset-name": { "display:name": "Preset Display Name", "display:order": 1, "ai:model": "gpt-4", "ai:apitype": "openai", "ai:apitoken": "sk-...", "ai:baseurl": "https://api.openai.com/v1", "ai:maxtokens": 4000, "ai:fontsize": "14px", "ai:fixedfontsize": "12px" } } ``` **Configuration Keys**: - `ai:model` - AI model name - `ai:apitype` - Provider type (openai, anthropic, perplexity, google) - `ai:apitoken` - API authentication token - `ai:baseurl` - Custom API endpoint - `ai:proxyurl` - HTTP proxy URL - `ai:maxtokens` - Maximum response tokens - `ai:timeoutms` - Request timeout - `ai:fontsize` - UI font size - `ai:fixedfontsize` - Code block font size #### Provider Detection The UI automatically detects and displays the active provider: - **Cloud**: Wave's proxy (no token/baseURL) - **Local**: localhost/127.0.0.1 endpoints - **Remote**: External API endpoints - **Provider-specific**: Anthropic, Perplexity with custom icons ### Performance Considerations #### Frontend Optimizations - **Jotai Atoms**: Granular reactivity, only re-render affected components - **Memo Components**: `ChatWindow` and `ChatItem` are memoized - **Throttled Scrolling**: Scroll events throttled to 100ms - **Debounced Scroll Detection**: User scroll detection debounced to 300ms #### Backend Optimizations - **Streaming**: All responses are streamed for immediate feedback - **Context Cancellation**: Proper cleanup prevents resource leaks - **Connection Pooling**: HTTP clients reuse connections - **Error Recovery**: Graceful degradation on provider failures ### Security Considerations #### API Token Handling - **Storage**: Tokens stored in encrypted configuration - **Transmission**: Tokens only sent to configured endpoints - **Validation**: Backend validates token format and permissions #### Request Validation - **Input Sanitization**: User input validated before sending - **Rate Limiting**: Cloud backend includes built-in rate limiting - **Error Filtering**: Sensitive error details filtered from UI ### Extension Points #### Adding New Providers 1. **Implement AIBackend Interface**: Create new backend struct 2. **Add Provider Detection**: Update `RunAICommand()` routing logic 3. **Add Configuration**: Define provider-specific config keys 4. **Update UI**: Add provider detection in `viewText` atom #### Custom Message Types 1. **Extend ChatMessageType**: Add new user types 2. **Update ChatItem Rendering**: Handle new message types 3. **Modify Storage**: Update persistence format if needed This architecture provides a flexible, extensible foundation for AI chat functionality while maintaining clean separation between UI, business logic, and provider integrations. ================================================ FILE: aiprompts/waveai-focus-updates.md ================================================ # Wave Terminal Focus System - Wave AI Integration ## Problem Wave AI focus handling is fragile compared to blocks: 1. Only watches textarea focus/blur, missing the multi-phase handling that blocks have 2. Selection handling breaks - selecting text causes blur → focus reverts to layout 3. Focus ring flashing - clicking Wave AI briefly shows focus ring on layout 4. Window blur sensitivity - `window.blur()` incorrectly assumes user wants to leave Wave AI 5. No capture phase - missing the immediate visual feedback that blocks get ## Solution Overview Extend the block focus system pattern to Wave AI: - Multi-phase handling (capture + click) - Selection protection - Focus manager coordination - View delegation ## Architecture ```mermaid graph TB User[User Interaction] FM[Focus Manager] Layout[Layout System] WaveAI[Wave AI Panel] User -->|click/key| FM FM -->|node focus| Layout FM -->|waveai focus| WaveAI Layout -->|request focus back| FM WaveAI -->|request focus back| FM FM -->|focusType atom| State[Global State] Layout -.->|checks| State WaveAI -.->|checks| State ``` ## Focus Manager Enhancements **File**: [`frontend/app/store/focusManager.ts`](frontend/app/store/focusManager.ts) Add selection-aware focus methods: ```typescript class FocusManager { // Existing focusType: PrimitiveAtom<"node" | "waveai">; // Single source of truth blockFocusAtom: Atom; // NEW: Selection-aware focus checking waveAIFocusWithin(): boolean; nodeFocusWithin(): boolean; // NEW: Focus transitions (INTENTIONALLY not defensive) requestNodeFocus(): void; // from Wave AI → node (BREAKS selections - that's the point!) requestWaveAIFocus(): void; // from node → Wave AI // NEW: Get current focus type getFocusType(): FocusStrType; // ENHANCED: Smart refocus based on focusType refocusNode(): void; // already handles both types } ``` **Critical Design Decision: `requestNodeFocus()` is NOT defensive** When `requestNodeFocus()` is called (e.g., Cmd+n, explicit focus change), it MUST take focus even if there's a selection in Wave AI. This is intentional - the user explicitly requested a focus change. Losing the selection is the correct behavior. **Focus Manager as Source of Truth** The `focusType` atom is the single source of truth. The old `waveAIFocusedAtom` will be kept in sync during migration but should eventually be removed. All components should read `focusManager.focusType` directly (via `useAtomValue`) to determine focus ring state - this ensures synchronized, reactive focus ring updates. ## Wave AI Focus Utilities **New File**: [`frontend/app/aipanel/waveai-focus-utils.ts`](frontend/app/aipanel/waveai-focus-utils.ts) Similar to [`focusutil.ts`](frontend/util/focusutil.ts) but for Wave AI: ```typescript // Find if element is within Wave AI panel export function findWaveAIPanel(element: HTMLElement): HTMLElement | null { let current: HTMLElement = element; while (current) { if (current.hasAttribute("data-waveai-panel")) { return current; } current = current.parentElement; } return null; } // Check if Wave AI panel has focus or selection (like focusedBlockId()) export function waveAIHasFocusWithin(): boolean { // Check if activeElement is within Wave AI panel const focused = document.activeElement; if (focused instanceof HTMLElement) { const waveAIPanel = findWaveAIPanel(focused); if (waveAIPanel) return true; } // Check if selection is within Wave AI panel const sel = document.getSelection(); if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { let anchor = sel.anchorNode; if (anchor instanceof Text) { anchor = anchor.parentElement; } if (anchor instanceof HTMLElement) { const waveAIPanel = findWaveAIPanel(anchor); if (waveAIPanel) return true; } } return false; } // Check if there's an active selection in Wave AI export function waveAIHasSelection(): boolean { const sel = document.getSelection(); if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { return false; } let anchor = sel.anchorNode; if (anchor instanceof Text) { anchor = anchor.parentElement; } if (anchor instanceof HTMLElement) { return findWaveAIPanel(anchor) != null; } return false; } ``` ## Wave AI Panel Integration **File**: [`frontend/app/aipanel/aipanel.tsx`](frontend/app/aipanel/aipanel.tsx) Add capture phase and selection protection: ```typescript // ADD: Capture phase handler (like blocks) const handleFocusCapture = useCallback((event: React.FocusEvent) => { console.log("Wave AI focus capture", getElemAsStr(event.target)); focusManager.requestWaveAIFocus(); // Sets visual state immediately }, []); // MODIFY: Click handler with selection protection const handleClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); if (isInteractive) { return; } // NEW: Check for selection protection const hasSelection = waveAIHasSelection(); if (hasSelection) { // Just update visual focus, don't move DOM focus focusManager.requestWaveAIFocus(); return; } // No selection, safe to move DOM focus setTimeout(() => { if (!waveAIHasSelection()) { // Double-check after timeout model.focusInput(); } }, 0); }; // Add data attribute and onFocusCapture to the div
``` ## Wave AI Input Focus Handling **File**: [`frontend/app/aipanel/aipanelinput.tsx`](frontend/app/aipanel/aipanelinput.tsx) Smart blur handling: ```typescript // MODIFY: handleFocus - advisory only const handleFocus = useCallback(() => { focusManager.requestWaveAIFocus(); }, []); // MODIFY: handleBlur - simplified with waveAIHasFocusWithin() const handleBlur = useCallback((e: React.FocusEvent) => { // Window blur - preserve state if (e.relatedTarget === null) { return; } // Still within Wave AI (focus or selection) - don't revert if (waveAIHasFocusWithin()) { return; } // Focus truly leaving Wave AI, revert to node focus focusManager.requestNodeFocus(); }, []); ``` **Note:** `waveAIHasFocusWithin()` checks both: 1. If `relatedTarget` is within Wave AI panel (handles context menus, buttons) 2. If there's an active selection in Wave AI (handles text selection clicks) This combines both checks from the original implementation into a single utility call. ## Block Focus Integration **File**: [`frontend/app/block/block.tsx`](frontend/app/block/block.tsx) **No changes needed in block.tsx** - the block code works perfectly as-is! **How it works:** When a block child gets focus (input field, terminal click, tab navigation): ``` 1. handleChildFocus fires (capture phase) ↓ 2. nodeModel.focusNode() ↓ 3. layoutModel.focusNode(nodeId) ↓ 4. treeReducer(FocusNodeAction) ↓ 5. focusManager.requestNodeFocus() (see Layout Focus Coordination section) ↓ 6. Updates localTreeStateAtom (synchronous) ↓ 7. isFocused recalculates (sees focusType = "node") ↓ 8. Two-step effect grants physical DOM focus ``` The focus manager update happens automatically in the treeReducer for all focus-claiming operations. ## Layout Focus Integration **File**: [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts) The `isFocused` atom already checks Wave AI state: ```typescript isFocused: atom((get) => { const treeState = get(this.localTreeStateAtom); const isFocused = treeState.focusedNodeId === nodeid; const waveAIFocused = get(atoms.waveAIFocusedAtom); return isFocused && !waveAIFocused; }); ``` **Update to use focus manager:** ```typescript isFocused: atom((get) => { const treeState = get(this.localTreeStateAtom); const isFocused = treeState.focusedNodeId === nodeid; const focusType = get(focusManager.focusType); return isFocused && focusType === "node"; }); ``` This single change coordinates the entire system: - Layout can set `focusedNodeId` freely - The reactive chain runs normally - But `isFocused` returns `false` if focus manager says "waveai" - Block's two-step effect doesn't run - Physical DOM focus stays with Wave AI ## Layout Focus Coordination **File**: [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts) **Critical Integration**: When layout operations claim focus, they must update the focus manager synchronously. ```typescript treeReducer(action: LayoutTreeAction, setState = true): boolean { // Process the action (mutates this.treeState) switch (action.type) { case LayoutTreeActionType.InsertNode: insertNode(this.treeState, action); // If inserting with focus, claim focus from Wave AI if ((action as LayoutTreeInsertNodeAction).focused) { focusManager.requestNodeFocus(); } break; case LayoutTreeActionType.InsertNodeAtIndex: insertNodeAtIndex(this.treeState, action); if ((action as LayoutTreeInsertNodeAtIndexAction).focused) { focusManager.requestNodeFocus(); } break; case LayoutTreeActionType.FocusNode: focusNode(this.treeState, action); // Explicit focus change always claims focus focusManager.requestNodeFocus(); break; case LayoutTreeActionType.MagnifyNodeToggle: magnifyNodeToggle(this.treeState, action); // Magnifying also focuses the node focusManager.requestNodeFocus(); break; // ... other cases don't affect focus } if (setState) { this.updateTree(); this.setter(this.localTreeStateAtom, { ...this.treeState }); this.persistToBackend(); } return true; } ``` **Why This Works:** 1. `focusManager.requestNodeFocus()` updates `focusType` synchronously 2. Called BEFORE atoms commit (still in same function) 3. When `localTreeStateAtom` commits, `isFocused` sees the new `focusType` 4. Both updates happen in same tick → React sees consistent state 5. No race conditions, no flash **Order of Operations:** ``` Cmd+n pressed ↓ treeReducer() executes ↓ 1. insertNode() mutates layoutState.focusedNodeId 2. focusManager.requestNodeFocus() updates focusType 3. setter(localTreeStateAtom) commits tree state ↓ [All synchronous - single call stack] ↓ React re-renders with both updates applied ↓ isFocused sees: focusedNodeId = newNode AND focusType = "node" ↓ Two-step effect grants physical focus ``` ## Keyboard Navigation Integration **File**: [`frontend/app/store/keymodel.ts`](frontend/app/store/keymodel.ts) Use focus manager instead of direct atom checks: ```typescript function switchBlockInDirection(tabId: string, direction: NavigateDirection) { const layoutModel = getLayoutModelForTabById(tabId); const focusType = focusManager.getFocusType(); if (direction === NavigateDirection.Left) { const numBlocks = globalStore.get(layoutModel.numLeafs); if (focusType === "waveai") { return; } if (numBlocks === 1) { focusManager.requestWaveAIFocus(); return; } } // For right navigation, switch from Wave AI to blocks if (direction === NavigateDirection.Right && focusType === "waveai") { focusManager.requestNodeFocus(); return; } // Rest of navigation logic... } ``` ## Focus Flow ### Complete Flow (Single Tick, No Flash) ``` User presses Cmd+n ↓ treeReducer() called ↓ 1. insertNode(focused: true) - SYNCHRONOUS - layoutState.focusedNodeId = newNode ↓ 2. setter(localTreeStateAtom, { ...treeState }) - SYNCHRONOUS - Atom updated immediately ↓ 3. persistToBackend() - ASYNC (fire-and-forget) ↓ [All in same tick - no intermediate renders] ↓ React re-renders (batched update) ↓ isFocused recalculates: - get(localTreeStateAtom) → focusedNodeId = newNode ✓ - get(focusType) → checks current focus type - Returns TRUE if focusType === "node" ↓ useLayoutEffect #1: setBlockClicked(true) ↓ useLayoutEffect #2: setFocusTarget() ↓ Physical DOM focus granted ✓ ``` **Why there's no flash:** - Local atoms update synchronously - React batches the updates - Everything sees consistent state in one render ## Edge Cases ### 1. Window Blur (⌘+Tab to other app) - Textarea loses focus, triggers `handleBlur` - `relatedTarget` is null → detected as window blur - Focus state preserved ### 2. Selection in Wave AI - User selects text - Clicks elsewhere in Wave AI - `waveAIHasSelection()` returns true - Only visual focus updates, no DOM focus change - Selection preserved ### 3. Copy/Paste Context Menu - Right-click causes blur - `relatedTarget` within Wave AI panel - `handleBlur` detects this, doesn't revert focus ### 4. Modal Dialogs - Modal opens, steals focus - Modal closes → `globalRefocus()` - Focus manager restores correct focus based on `focusType` ## Implementation Steps ### 1. Focus Manager Foundation - Implement enhanced `focusManager.ts` with new methods - Create `waveai-focus-utils.ts` with selection utilities - Add data attributes to Wave AI panel ### 2. Wave AI Integration - Add `onFocusCapture` to Wave AI panel - Update `handleBlur` with simplified `waveAIHasFocusWithin()` check - Update `handleClick` with selection awareness - Components read `focusManager.focusType` directly via `useAtomValue` for focus ring display ### 3. Layout Integration - Update `isFocused` atom to check `focusManager.focusType` - Add `focusManager.requestNodeFocus()` calls in `treeReducer` for focus-claiming operations - Update keyboard navigation to use `focusManager.getFocusType()` ### 4. Testing - Test all transitions and edge cases - Verify selection protection works - Confirm no focus ring flashing - Verify focus rings are synchronized through focus manager ## Files to Create/Modify ### New Files - `frontend/app/aipanel/waveai-focus-utils.ts` - Focus utilities for Wave AI ### Modified Files - [`frontend/app/store/focusManager.ts`](frontend/app/store/focusManager.ts) - Enhanced with new methods - [`frontend/app/aipanel/aipanel.tsx`](frontend/app/aipanel/aipanel.tsx) - Add capture phase, improve click handler - [`frontend/app/aipanel/aipanelinput.tsx`](frontend/app/aipanel/aipanelinput.tsx) - Smart blur handling - [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts) - Update isFocused atom AND add focus manager calls in treeReducer - [`frontend/app/store/keymodel.ts`](frontend/app/store/keymodel.ts) - Use focus manager for navigation ## Testing Checklist - [ ] Select text in Wave AI, click elsewhere in Wave AI → selection preserved - [ ] Click Wave AI panel (not input) → focus moves to Wave AI - [ ] Click block while in Wave AI (no selection) → focus moves to block - [ ] Press Left arrow in single block → Wave AI focused - [ ] Press Right arrow in Wave AI → block focused - [ ] Window blur (⌘+Tab) → focus state preserved - [ ] Open context menu in Wave AI → doesn't lose focus - [ ] Modal opens/closes → focus restores correctly ## Benefits 1. **Selection protection** - Wave AI selections preserved like blocks 2. **No focus flash** - Capture phase provides immediate visual feedback 3. **Robust blur handling** - Smart detection of where focus is going 4. **Unified model** - Single source of truth simplifies reasoning 5. **Simple reactivity** - Everything updates synchronously in one tick 6. **No timing issues** - Local atoms eliminate race conditions ## Phased Implementation Approach The changes can be broken into safe, independently testable phases. Each phase can be shipped and tested before proceeding to the next. ### Phase 1: Foundation (Non-Breaking, Fully Testable) **Add focus manager methods WITHOUT changing existing code** ```typescript // In focusManager.ts - ADD these methods class FocusManager { // NEW methods that ALSO update the old waveAIFocusedAtom during migration requestWaveAIFocus(): void { globalStore.set(this.focusType, "waveai"); globalStore.set(atoms.waveAIFocusedAtom, true); // ← Keep old atom in sync during migration! } requestNodeFocus(): void { // NO defensive checks - when called, we TAKE focus (selections may be lost) globalStore.set(this.focusType, "node"); globalStore.set(atoms.waveAIFocusedAtom, false); // ← Keep old atom in sync during migration! } getFocusType(): FocusStrType { return globalStore.get(this.focusType); } waveAIFocusWithin(): boolean { return waveAIHasFocusWithin(); } nodeFocusWithin(): boolean { return focusedBlockId() != null; } } ``` **Why this is safe:** - Doesn't change any existing code - Focus manager updates BOTH new `focusType` AND old `waveAIFocusedAtom` during migration - Everything keeps working exactly as before - Can test focus manager methods in isolation - Components can read `focusType` directly via `useAtomValue` for reactive updates - No user-visible changes **Testing:** - Call the new methods manually in console - Verify both atoms update correctly - Verify existing focus behavior unchanged --- ### Phase 2: Wave AI Improvements (Testable in Isolation) **Add utilities and improve Wave AI focus handling** 1. Create `waveai-focus-utils.ts` with selection checking utilities 2. Update `aipanel.tsx`: - Add `data-waveai-panel` attribute - Add `onFocusCapture` handler - Improve click handler with selection protection - Call `focusManager.requestWaveAIFocus()` instead of setting atom directly 3. Update `aipanelinput.tsx`: - Smart blur handling with selection checks - Call `focusManager.requestNodeFocus()` instead of setting atom directly **Why this is safe:** - Wave AI now uses focus manager, but focus manager keeps old atom in sync - Blocks still read `waveAIFocusedAtom` directly - still works! - Can test Wave AI selection protection independently - If there's a bug, only Wave AI is affected - Blocks remain completely unchanged **Testing:** - Wave AI selection preservation when clicking within panel - Wave AI blur handling (window blur, context menus, etc.) - Verify blocks still work normally (unchanged) - Test transitions between Wave AI and blocks **User-visible improvements:** - Wave AI text selections no longer lost when clicking in panel - No focus ring flashing - Better window blur handling --- ### Phase 3: Layout isFocused Migration (Single Critical Change) **Update isFocused atom to use focus manager** ```typescript // In layoutModel.ts - CHANGE isFocused atom isFocused: atom((get) => { const treeState = get(this.localTreeStateAtom); const isFocused = treeState.focusedNodeId === nodeid; const focusType = get(focusManager.focusType); // ← Use focus manager return isFocused && focusType === "node"; }); ``` **Why this is safe:** - Focus manager already keeps `waveAIFocusedAtom` in sync (Phase 1) - Wave AI already uses focus manager (Phase 2) - Blocks read the new `focusType` but it's always consistent with old atom - Should be completely transparent - Single file change - easy to revert if issues **Testing:** - Focus transitions between blocks still work - Wave AI → block transitions work - Block → Wave AI transitions work - Keyboard navigation still works - All existing functionality preserved **No user-visible changes** - just internal refactoring --- ### Phase 4: Layout Focus Coordination (Completes the System) **Add focus manager calls to treeReducer** ```typescript // In layoutModel.ts treeReducer - ADD focus manager calls case LayoutTreeActionType.FocusNode: focusNode(this.treeState, action); focusManager.requestNodeFocus(); // ← NEW break; case LayoutTreeActionType.InsertNode: insertNode(this.treeState, action); if ((action as LayoutTreeInsertNodeAction).focused) { focusManager.requestNodeFocus(); // ← NEW } break; case LayoutTreeActionType.MagnifyNodeToggle: magnifyNodeToggle(this.treeState, action); focusManager.requestNodeFocus(); // ← NEW break; ``` **Why this is safe:** - Just makes explicit what was already happening via Wave AI's blur handler - Ensures focus manager is updated even when layout programmatically changes focus - Makes the system more robust - Small, focused changes in one file **Testing:** - Cmd+n creates new block with correct focus - Magnify toggle works correctly - Programmatic focus changes work - Focus stays consistent during rapid operations **User-visible improvements:** - More robust focus handling during programmatic layout changes - Edge cases with rapid focus changes handled better --- ### Phase 5: Keyboard Nav & Cleanup (Optional Polish) **Use focus manager in keyboard navigation, remove old atom usage** 1. Update `keymodel.ts` to use `focusManager.getFocusType()` 2. Remove direct `atoms.waveAIFocusedAtom` usage throughout codebase 3. (Optional) Stop syncing `waveAIFocusedAtom` in focus manager - can be deprecated **Why this is safe:** - Everything already using focus manager under the hood - Just cleanup/optimization - Can be done incrementally **Testing:** - Keyboard navigation between blocks - Left/Right arrow to/from Wave AI - All keyboard shortcuts still work --- ## Key Insight: Dual Atom Sync **Phase 1 is the enabler**: By having the focus manager update BOTH the new `focusType` atom AND the old `waveAIFocusedAtom`, we create a safe transition period where: - New code can use focus manager - Old code continues reading the old atom - Everything stays consistent - Each phase is independently testable - Can ship and test after each phase This dual-sync approach eliminates the "all or nothing" problem. You can stop at any phase and have a working, tested system. ## Testing Between Phases After each phase, you can ship and test: - **Phase 1** → No user-visible changes, foundation in place - **Phase 2** → Wave AI improvements only, blocks unchanged - **Phase 3** → Complete system working with new architecture - **Phase 4** → More robust edge case handling - **Phase 5** → Code cleanup and optimization Each phase builds on the previous one but can be independently verified. ================================================ FILE: aiprompts/wps-events.md ================================================ # WPS Events Guide ## Overview WPS (Wave PubSub) is Wave Terminal's publish-subscribe event system that enables different parts of the application to communicate asynchronously. The system uses a broker pattern to route events from publishers to subscribers based on event types and scopes. ## Key Files - [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go) - Event type constants and data structures - [`pkg/wps/wps.go`](../pkg/wps/wps.go) - Broker implementation and core logic - [`pkg/wcore/wcore.go`](../pkg/wcore/wcore.go) - Example usage patterns ## Event Structure Events in WPS have the following structure: ```go type WaveEvent struct { Event string `json:"event"` // Event type constant Scopes []string `json:"scopes,omitempty"` // Optional scopes for targeted delivery Sender string `json:"sender,omitempty"` // Optional sender identifier Persist int `json:"persist,omitempty"` // Number of events to persist in history Data any `json:"data,omitempty"` // Event payload } ``` ## Adding a New Event Type ### Step 1: Define the Event Constant Add your event type constant to [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go:8-19): ```go const ( Event_BlockClose = "blockclose" Event_ConnChange = "connchange" // ... other events ... Event_YourNewEvent = "your:newevent" // Use colon notation for namespacing ) ``` **Naming Convention:** - Use descriptive PascalCase for the constant name with `Event_` prefix - Use lowercase with colons for the string value (e.g., "namespace:eventname") - Group related events with the same namespace prefix ### Step 2: Define Event Data Structure (Optional) If your event carries structured data, define a type for it: ```go type YourEventData struct { Field1 string `json:"field1"` Field2 int `json:"field2"` } ``` ### Step 3: Expose Type to Frontend (If Needed) If your event data type isn't already exposed via an RPC call, you need to add it to [`pkg/tsgen/tsgen.go`](../pkg/tsgen/tsgen.go:29-56) so TypeScript types are generated: ```go // add extra types to generate here var ExtraTypes = []any{ waveobj.ORef{}, // ... other types ... uctypes.RateLimitInfo{}, // Example: already added YourEventData{}, // Add your new type here } ``` Then run code generation: ```bash task generate ``` This will update [`frontend/types/gotypes.d.ts`](../frontend/types/gotypes.d.ts) with TypeScript definitions for your type, ensuring type safety in the frontend when handling these events. ## Publishing Events ### Basic Publishing To publish an event, use the global broker: ```go import "github.com/wavetermdev/waveterm/pkg/wps" wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_YourNewEvent, Data: yourData, }) ``` ### Publishing with Scopes Scopes allow targeted event delivery. Subscribers can filter events by scope: ```go wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveObjUpdate, Scopes: []string{oref.String()}, // Target specific object Data: updateData, }) ``` ### Publishing in a Goroutine To avoid blocking the caller, publish events asynchronously: ```go go func() { wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_YourNewEvent, Data: data, }) }() ``` **When to use goroutines:** - When publishing from performance-critical code paths - When the event is informational and doesn't need immediate delivery - When publishing from code that holds locks (to prevent deadlocks) ### Event Persistence Events can be persisted in memory for late subscribers: ```go wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_YourNewEvent, Persist: 100, // Keep last 100 events Data: data, }) ``` ## Complete Example: Rate Limit Updates This example shows how rate limit information is published when AI chat responses include rate limit headers. ### 1. Define the Event Type In [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go:19): ```go const ( // ... other events ... Event_WaveAIRateLimit = "waveai:ratelimit" ) ``` ### 2. Publish the Event In [`pkg/aiusechat/usechat.go`](../pkg/aiusechat/usechat.go:94-108): ```go import "github.com/wavetermdev/waveterm/pkg/wps" func updateRateLimit(info *uctypes.RateLimitInfo) { if info == nil { return } rateLimitLock.Lock() defer rateLimitLock.Unlock() globalRateLimitInfo = info // Publish event in goroutine to avoid blocking go func() { wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveAIRateLimit, Data: info, // RateLimitInfo struct }) }() } ``` ### 3. Subscribe to the Event (Frontend) In the frontend, subscribe to events via WebSocket: ```typescript // Subscribe to rate limit updates const subscription = { event: "waveai:ratelimit", allscopes: true, // Receive all rate limit events }; ``` ## Subscribing to Events ### From Go Code ```go // Subscribe to all events of a type wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ Event: wps.Event_YourNewEvent, AllScopes: true, }) // Subscribe to specific scopes wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ Event: wps.Event_WaveObjUpdate, Scopes: []string{"workspace:123"}, }) // Unsubscribe wps.Broker.Unsubscribe(routeId, wps.Event_YourNewEvent) ``` ### Scope Matching Scopes support wildcard matching: - `*` matches a single scope segment - `**` matches multiple scope segments ```go // Subscribe to all workspace events wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ Event: wps.Event_WaveObjUpdate, Scopes: []string{"workspace:*"}, }) ``` ## Best Practices 1. **Use Namespaces**: Prefix event names with a namespace (e.g., `waveai:`, `workspace:`, `block:`) 2. **Don't Block**: Use goroutines when publishing from performance-critical code or while holding locks 3. **Type-Safe Data**: Define struct types for event data rather than using maps 4. **Scope Wisely**: Use scopes to limit event delivery and reduce unnecessary processing 5. **Document Events**: Add comments explaining when events are fired and what data they carry 6. **Consider Persistence**: Use `Persist` for events that late subscribers might need (like status updates). This is normally not used. We normally do a live RPC call to get the current value and then subscribe for updates. ## Common Event Patterns ### Status Updates ```go wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_ControllerStatus, Scopes: []string{blockId}, Persist: 1, // Keep only latest status Data: statusData, }) ``` ### Object Updates ```go wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveObjUpdate, Scopes: []string{oref.String()}, Data: waveobj.WaveObjUpdate{ UpdateType: waveobj.UpdateType_Update, OType: obj.GetOType(), OID: waveobj.GetOID(obj), Obj: obj, }, }) ``` ### Batch Updates ```go // Helper function for multiple updates func (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) { for _, update := range updates { b.Publish(WaveEvent{ Event: Event_WaveObjUpdate, Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()}, Data: update, }) } } ``` ## Debugging To debug event flow: 1. Check broker subscription map: `wps.Broker.SubMap` 2. View persisted events: `wps.Broker.ReadEventHistory(eventType, scope, maxItems)` 3. Add logging in publish/subscribe methods 4. Monitor WebSocket traffic in browser dev tools ## Related Documentation - [Configuration System](config-system.md) - Uses WPS events for config updates - [Wave AI Architecture](waveai-architecture.md) - AI-related events ================================================ FILE: build/deb-postinstall.tpl ================================================ #!/bin/bash if type update-alternatives 2>/dev/null >&1; then # Remove previous link if it doesn't use update-alternatives if [ -L '/usr/bin/waveterm' -a -e '/usr/bin/waveterm' -a "`readlink '/usr/bin/waveterm'`" != '/etc/alternatives/waveterm' ]; then rm -f '/usr/bin/waveterm' fi update-alternatives --install '/usr/bin/waveterm' 'waveterm' '/opt/Wave/waveterm' 100 || ln -sf '/opt/Wave/waveterm' '/usr/bin/waveterm' else ln -sf '/opt/Wave/waveterm' '/usr/bin/waveterm' fi chmod 4755 '/opt/Wave/chrome-sandbox' || true if hash update-mime-database 2>/dev/null; then update-mime-database /usr/share/mime || true fi if hash update-desktop-database 2>/dev/null; then update-desktop-database /usr/share/applications || true fi ================================================ FILE: build/entitlements.mac.plist ================================================ com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation com.apple.security.device.audio-input com.apple.security.device.camera com.apple.security.personal-information.addressbook com.apple.security.personal-information.calendars com.apple.security.personal-information.location com.apple.security.personal-information.photos-library ================================================ FILE: cmd/generatego/main-generatego.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "fmt" "os" "reflect" "strings" "github.com/wavetermdev/waveterm/pkg/gogen" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const WshClientFileName = "pkg/wshrpc/wshclient/wshclient.go" const WaveObjMetaConstsFileName = "pkg/waveobj/metaconsts.go" const SettingsMetaConstsFileName = "pkg/wconfig/metaconsts.go" func GenerateWshClient() error { fmt.Fprintf(os.Stderr, "generating wshclient file to %s\n", WshClientFileName) var buf strings.Builder gogen.GenerateBoilerplate(&buf, "wshclient", []string{ "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", "github.com/wavetermdev/waveterm/pkg/baseds", "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata", "github.com/wavetermdev/waveterm/pkg/vdom", "github.com/wavetermdev/waveterm/pkg/waveobj", "github.com/wavetermdev/waveterm/pkg/wconfig", "github.com/wavetermdev/waveterm/pkg/wps", "github.com/wavetermdev/waveterm/pkg/wshrpc", "github.com/wavetermdev/waveterm/pkg/wshutil", }) wshDeclMap := wshrpc.GenerateWshCommandDeclMap() for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { methodDecl := wshDeclMap[key] if methodDecl.CommandType == wshrpc.RpcType_ResponseStream { gogen.GenMethod_ResponseStream(&buf, methodDecl) } else if methodDecl.CommandType == wshrpc.RpcType_Call { gogen.GenMethod_Call(&buf, methodDecl) } else { panic("unsupported command type " + methodDecl.CommandType) } } buf.WriteString("\n") written, err := utilfn.WriteFileIfDifferent(WshClientFileName, []byte(buf.String())) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", WshClientFileName) } return err } func GenerateWaveObjMetaConsts() error { fmt.Fprintf(os.Stderr, "generating waveobj meta consts file to %s\n", WaveObjMetaConstsFileName) var buf strings.Builder gogen.GenerateBoilerplate(&buf, "waveobj", []string{}) gogen.GenerateMetaMapConsts(&buf, "MetaKey_", reflect.TypeOf(waveobj.MetaTSType{}), false) buf.WriteString("\n") written, err := utilfn.WriteFileIfDifferent(WaveObjMetaConstsFileName, []byte(buf.String())) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", WaveObjMetaConstsFileName) } return err } func GenerateSettingsMetaConsts() error { fmt.Fprintf(os.Stderr, "generating settings meta consts file to %s\n", SettingsMetaConstsFileName) var buf strings.Builder gogen.GenerateBoilerplate(&buf, "wconfig", []string{}) gogen.GenerateMetaMapConsts(&buf, "ConfigKey_", reflect.TypeOf(wconfig.SettingsType{}), false) buf.WriteString("\n") written, err := utilfn.WriteFileIfDifferent(SettingsMetaConstsFileName, []byte(buf.String())) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", SettingsMetaConstsFileName) } return err } func main() { err := GenerateWshClient() if err != nil { fmt.Fprintf(os.Stderr, "error generating wshclient: %v\n", err) return } err = GenerateWaveObjMetaConsts() if err != nil { fmt.Fprintf(os.Stderr, "error generating waveobj meta consts: %v\n", err) return } err = GenerateSettingsMetaConsts() if err != nil { fmt.Fprintf(os.Stderr, "error generating settings meta consts: %v\n", err) return } } ================================================ FILE: cmd/generateschema/main-generateschema.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "encoding/json" "fmt" "log" "os" "reflect" "github.com/invopop/jsonschema" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" ) const WaveSchemaSettingsFileName = "schema/settings.json" const WaveSchemaConnectionsFileName = "schema/connections.json" const WaveSchemaAiPresetsFileName = "schema/aipresets.json" const WaveSchemaWidgetsFileName = "schema/widgets.json" const WaveSchemaBgPresetsFileName = "schema/bgpresets.json" const WaveSchemaWaveAIFileName = "schema/waveai.json" // ViewNameType is a string type whose JSON Schema offers enum suggestions for the most // common widget view names while still accepting any arbitrary string value. type ViewNameType string func (ViewNameType) JSONSchema() *jsonschema.Schema { return &jsonschema.Schema{ AnyOf: []*jsonschema.Schema{ { Enum: []any{"term", "preview", "web", "sysinfo", "launcher"}, }, { Type: "string", }, }, } } // ControllerNameType is a string type whose JSON Schema offers enum suggestions for the // known block controller names while still accepting any arbitrary string value. type ControllerNameType string func (ControllerNameType) JSONSchema() *jsonschema.Schema { return &jsonschema.Schema{ AnyOf: []*jsonschema.Schema{ { Enum: []any{"shell", "cmd"}, }, { Type: "string", }, }, } } // WidgetsMetaSchemaHints provides schema hints for the blockdef.meta field in widget configs. // It covers the most common keys used when defining widgets: view, file, url, controller, // cmd and cmd:* options, and term:* options. type WidgetsMetaSchemaHints struct { View ViewNameType `json:"view,omitempty"` File string `json:"file,omitempty"` Url string `json:"url,omitempty"` Controller ControllerNameType `json:"controller,omitempty"` Cmd string `json:"cmd,omitempty"` CmdInteractive bool `json:"cmd:interactive,omitempty"` CmdLogin bool `json:"cmd:login,omitempty"` CmdPersistent bool `json:"cmd:persistent,omitempty"` CmdRunOnStart bool `json:"cmd:runonstart,omitempty"` CmdClearOnStart bool `json:"cmd:clearonstart,omitempty"` CmdRunOnce bool `json:"cmd:runonce,omitempty"` CmdCloseOnExit bool `json:"cmd:closeonexit,omitempty"` CmdCloseOnExitForce bool `json:"cmd:closeonexitforce,omitempty"` CmdCloseOnExitDelay float64 `json:"cmd:closeonexitdelay,omitempty"` CmdNoWsh bool `json:"cmd:nowsh,omitempty"` CmdArgs []string `json:"cmd:args,omitempty"` CmdShell bool `json:"cmd:shell,omitempty"` CmdAllowConnChange bool `json:"cmd:allowconnchange,omitempty"` CmdEnv map[string]string `json:"cmd:env,omitempty"` CmdCwd string `json:"cmd:cwd,omitempty"` CmdInitScript string `json:"cmd:initscript,omitempty"` CmdInitScriptSh string `json:"cmd:initscript.sh,omitempty"` CmdInitScriptBash string `json:"cmd:initscript.bash,omitempty"` CmdInitScriptZsh string `json:"cmd:initscript.zsh,omitempty"` CmdInitScriptPwsh string `json:"cmd:initscript.pwsh,omitempty"` CmdInitScriptFish string `json:"cmd:initscript.fish,omitempty"` TermFontSize int `json:"term:fontsize,omitempty"` TermFontFamily string `json:"term:fontfamily,omitempty"` TermMode string `json:"term:mode,omitempty"` TermTheme string `json:"term:theme,omitempty"` TermLocalShellPath string `json:"term:localshellpath,omitempty"` TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` TermScrollback *int `json:"term:scrollback,omitempty"` TermTransparency *float64 `json:"term:transparency,omitempty"` TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"` TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"` TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"` TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` TermDurable *bool `json:"term:durable,omitempty"` } func generateSchema(template any, dir string) error { settingsSchema := jsonschema.Reflect(template) jsonSettingsSchema, err := json.MarshalIndent(settingsSchema, "", " ") if err != nil { return fmt.Errorf("failed to parse local schema: %w", err) } written, err := utilfn.WriteFileIfDifferent(dir, jsonSettingsSchema) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", dir) } if err != nil { return fmt.Errorf("failed to write local schema: %w", err) } return nil } func generateWidgetsSchema(dir string) error { metaT := reflect.TypeOf(waveobj.MetaMapType(nil)) // Build the hints schema once using an expanded reflector hr := &jsonschema.Reflector{ DoNotReference: true, ExpandedStruct: true, AllowAdditionalProperties: true, } hintSchema := hr.Reflect(&WidgetsMetaSchemaHints{}) r := &jsonschema.Reflector{} r.Mapper = func(t reflect.Type) *jsonschema.Schema { if t == metaT { return &jsonschema.Schema{ Type: "object", Properties: hintSchema.Properties, AdditionalProperties: jsonschema.TrueSchema, } } return nil } widgetsTemplate := make(map[string]wconfig.WidgetConfigType) widgetsSchema := r.Reflect(&widgetsTemplate) jsonWidgetsSchema, err := json.MarshalIndent(widgetsSchema, "", " ") if err != nil { return fmt.Errorf("failed to parse widgets schema: %w", err) } written, err := utilfn.WriteFileIfDifferent(dir, jsonWidgetsSchema) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", dir) } if err != nil { return fmt.Errorf("failed to write widgets schema: %w", err) } return nil } func main() { err := generateSchema(&wconfig.SettingsType{}, WaveSchemaSettingsFileName) if err != nil { log.Fatalf("settings schema error: %v", err) } connectionTemplate := make(map[string]wconfig.ConnKeywords) err = generateSchema(&connectionTemplate, WaveSchemaConnectionsFileName) if err != nil { log.Fatalf("connections schema error: %v", err) } aiPresetsTemplate := make(map[string]wconfig.AiSettingsType) err = generateSchema(&aiPresetsTemplate, WaveSchemaAiPresetsFileName) if err != nil { log.Fatalf("ai presets schema error: %v", err) } err = generateWidgetsSchema(WaveSchemaWidgetsFileName) if err != nil { log.Fatalf("widgets schema error: %v", err) } bgPresetsTemplate := make(map[string]wconfig.BgPresetsType) err = generateSchema(&bgPresetsTemplate, WaveSchemaBgPresetsFileName) if err != nil { log.Fatalf("bg presets schema error: %v", err) } waveAITemplate := make(map[string]wconfig.AIModeConfigType) err = generateSchema(&waveAITemplate, WaveSchemaWaveAIFileName) if err != nil { log.Fatalf("waveai schema error: %v", err) } } ================================================ FILE: cmd/generatets/main-generatets.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "bytes" "fmt" "os" "reflect" "sort" "strings" "github.com/wavetermdev/waveterm/pkg/service" "github.com/wavetermdev/waveterm/pkg/tsgen" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) func generateTypesFile(tsTypesMap map[reflect.Type]string) error { fileName := "frontend/types/gotypes.d.ts" fmt.Fprintf(os.Stderr, "generating types file to %s\n", fileName) tsgen.GenerateWaveObjTypes(tsTypesMap) tsgen.GenerateWaveEventTypes(tsTypesMap) err := tsgen.GenerateServiceTypes(tsTypesMap) if err != nil { fmt.Fprintf(os.Stderr, "Error generating service types: %v\n", err) os.Exit(1) } err = tsgen.GenerateWshServerTypes(tsTypesMap) if err != nil { return fmt.Errorf("error generating wsh server types: %w", err) } var buf bytes.Buffer fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") fmt.Fprintf(&buf, "declare global {\n\n") var keys []reflect.Type for key := range tsTypesMap { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { iname, _ := tsgen.TypeToTSType(keys[i], tsTypesMap) jname, _ := tsgen.TypeToTSType(keys[j], tsTypesMap) return iname < jname }) for _, key := range keys { // don't output generic types if strings.Contains(key.Name(), "[") { continue } tsCode := tsTypesMap[key] istr := utilfn.IndentString(" ", tsCode) fmt.Fprint(&buf, istr) } fmt.Fprintf(&buf, "}\n\n") fmt.Fprintf(&buf, "export {}\n") written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) } return err } func generateWaveEventFile(tsTypesMap map[reflect.Type]string) error { fileName := "frontend/types/waveevent.d.ts" fmt.Fprintf(os.Stderr, "generating waveevent file to %s\n", fileName) var buf bytes.Buffer fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") fmt.Fprintf(&buf, "declare global {\n\n") fmt.Fprint(&buf, utilfn.IndentString(" ", tsgen.GenerateWaveEventTypes(tsTypesMap))) fmt.Fprintf(&buf, "}\n\n") fmt.Fprintf(&buf, "export {}\n") written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) } return err } func generateServicesFile(tsTypesMap map[reflect.Type]string) error { fileName := "frontend/app/store/services.ts" var buf bytes.Buffer fmt.Fprintf(os.Stderr, "generating services file to %s\n", fileName) fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n") fmt.Fprintf(&buf, "import type { WaveEnv } from \"@/app/waveenv/waveenv\";\n\n") fmt.Fprintf(&buf, "function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise {\n") fmt.Fprintf(&buf, " if (waveEnv != null) {\n") fmt.Fprintf(&buf, " return waveEnv.callBackendService(service, method, args, noUIContext)\n") fmt.Fprintf(&buf, " }\n") fmt.Fprintf(&buf, " return WOS.callBackendService(service, method, args, noUIContext);\n") fmt.Fprintf(&buf, "}\n\n") orderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap) for _, serviceName := range orderedKeys { serviceObj := service.ServiceMap[serviceName] svcStr := tsgen.GenerateServiceClass(serviceName, serviceObj, tsTypesMap) fmt.Fprint(&buf, svcStr) fmt.Fprint(&buf, "\n") } fmt.Fprintf(&buf, "export const AllServiceTypes = {\n") for _, serviceName := range orderedKeys { serviceObj := service.ServiceMap[serviceName] serviceType := reflect.TypeOf(serviceObj) tsServiceName := serviceType.Elem().Name() fmt.Fprintf(&buf, " %q: %sType,\n", serviceName, tsServiceName) } fmt.Fprintf(&buf, "};\n\n") fmt.Fprintf(&buf, "export const AllServiceImpls = {\n") for _, serviceName := range orderedKeys { serviceObj := service.ServiceMap[serviceName] serviceType := reflect.TypeOf(serviceObj) tsServiceName := serviceType.Elem().Name() fmt.Fprintf(&buf, " %q: %s,\n", serviceName, tsServiceName) } fmt.Fprintf(&buf, "};\n") written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) } return err } func generateWshClientApiFile(tsTypeMap map[reflect.Type]string) error { fileName := "frontend/app/store/wshclientapi.ts" var buf bytes.Buffer declMap := wshrpc.GenerateWshCommandDeclMap() fmt.Fprintf(os.Stderr, "generating wshclientapi file to %s\n", fileName) fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n") fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n") fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n") fmt.Fprintf(&buf, "import { WshClient } from \"./wshclient\";\n\n") fmt.Fprintf(&buf, "export interface MockRpcClient {\n") fmt.Fprintf(&buf, " mockWshRpcCall(client: WshClient, command: string, data: any, opts?: RpcOpts): Promise;\n") fmt.Fprintf(&buf, " mockWshRpcStream(client: WshClient, command: string, data: any, opts?: RpcOpts): AsyncGenerator;\n") fmt.Fprintf(&buf, "}\n\n") orderedKeys := utilfn.GetOrderedMapKeys(declMap) fmt.Fprintf(&buf, "// WshServerCommandToDeclMap\n") fmt.Fprintf(&buf, "export class RpcApiType {\n") fmt.Fprintf(&buf, " mockClient: MockRpcClient = null;\n\n") fmt.Fprintf(&buf, " setMockRpcClient(client: MockRpcClient): void {\n") fmt.Fprintf(&buf, " this.mockClient = client;\n") fmt.Fprintf(&buf, " }\n\n") for _, methodDecl := range orderedKeys { methodDecl := declMap[methodDecl] methodStr := tsgen.GenerateWshClientApiMethod(methodDecl, tsTypeMap) fmt.Fprint(&buf, methodStr) fmt.Fprintf(&buf, "\n") } fmt.Fprintf(&buf, "}\n\n") fmt.Fprintf(&buf, "export const RpcApi = new RpcApiType();\n") written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes()) if !written { fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName) } return err } func main() { err := service.ValidateServiceMap() if err != nil { fmt.Fprintf(os.Stderr, "Error validating service map: %v\n", err) os.Exit(1) } tsTypesMap := make(map[reflect.Type]string) err = generateTypesFile(tsTypesMap) if err != nil { fmt.Fprintf(os.Stderr, "Error generating types file: %v\n", err) os.Exit(1) } err = generateServicesFile(tsTypesMap) if err != nil { fmt.Fprintf(os.Stderr, "Error generating services file: %v\n", err) os.Exit(1) } err = generateWaveEventFile(tsTypesMap) if err != nil { fmt.Fprintf(os.Stderr, "Error generating wave event file: %v\n", err) os.Exit(1) } err = generateWshClientApiFile(tsTypesMap) if err != nil { fmt.Fprintf(os.Stderr, "Error generating wshserver file: %v\n", err) os.Exit(1) } } ================================================ FILE: cmd/packfiles/main-packfiles.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "bufio" "fmt" "io" "os" "path/filepath" ) func main() { // Ensure at least one argument is provided if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "Usage: go run main.go ...") os.Exit(1) } // Get the current working directory cwd, err := os.Getwd() if err != nil { fmt.Fprintf(os.Stderr, "Error getting current working directory: %v\n", err) os.Exit(1) } for _, filePath := range os.Args[1:] { if filePath == "" || filePath == "--" { continue } // Convert file path to an absolute path absPath, err := filepath.Abs(filePath) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving absolute path for %q: %v\n", filePath, err) continue } finfo, err := os.Stat(absPath) if err != nil { fmt.Fprintf(os.Stderr, "Error getting file info for %q: %v\n", absPath, err) continue } if finfo.IsDir() { fmt.Fprintf(os.Stderr, "%q is a directory, skipping\n", absPath) continue } // Get the path relative to the current working directory relPath, err := filepath.Rel(cwd, absPath) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving relative path for %q: %v\n", absPath, err) continue } // Open the file file, err := os.Open(absPath) if err != nil { fmt.Fprintf(os.Stderr, "Error opening file %q: %v\n", absPath, err) continue } defer file.Close() // Print start delimiter with quoted relative path fmt.Printf("@@@start file %q\n", relPath) // Copy file contents to stdout reader := bufio.NewReader(file) for { line, err := reader.ReadString('\n') fmt.Print(line) // Print each line if err == io.EOF { break } if err != nil { fmt.Fprintf(os.Stderr, "Error reading file %q: %v\n", relPath, err) break } } // Print end delimiter with quoted relative path fmt.Printf("@@@end file %q\n", relPath) } } ================================================ FILE: cmd/server/main-server.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "context" "fmt" "log" "os" "runtime" "sync" "time" "github.com/joho/godotenv" "github.com/wavetermdev/waveterm/pkg/aiusechat" "github.com/wavetermdev/waveterm/pkg/authkey" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/filebackup" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/service" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/sigutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcloud" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/web" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wslconn" "github.com/wavetermdev/waveterm/pkg/wstore" "net/http" _ "net/http/pprof" ) // these are set at build time var WaveVersion = "0.0.0" var BuildTime = "0" const InitialTelemetryWait = 10 * time.Second const TelemetryTick = 2 * time.Minute const TelemetryInterval = 4 * time.Hour const TelemetryInitialCountsWait = 5 * time.Second const TelemetryCountsInterval = 1 * time.Hour const BackupCleanupTick = 2 * time.Minute const BackupCleanupInterval = 4 * time.Hour const InitialDiagnosticWait = 5 * time.Minute const DiagnosticTick = 10 * time.Minute var shutdownOnce sync.Once func init() { envFilePath := os.Getenv("WAVETERM_ENVFILE") if envFilePath != "" { log.Printf("applying env file: %s\n", envFilePath) _ = godotenv.Load(envFilePath) } } func doShutdown(reason string) { shutdownOnce.Do(func() { log.Printf("shutting down: %s\n", reason) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() go blockcontroller.StopAllBlockControllersForShutdown() shutdownActivityUpdate() sendTelemetryWrapper() // TODO deal with flush in progress clearTempFiles() filestore.WFS.FlushCache(ctx) watcher := wconfig.GetWatcher() if watcher != nil { watcher.Close() } time.Sleep(500 * time.Millisecond) log.Printf("shutdown complete\n") os.Exit(0) }) } // watch stdin, kill server if stdin is closed func stdinReadWatch() { defer func() { panichandler.PanicHandler("stdinReadWatch", recover()) }() buf := make([]byte, 1024) for { _, err := os.Stdin.Read(buf) if err != nil { doShutdown(fmt.Sprintf("stdin closed/error (%v)", err)) break } } } func startConfigWatcher() { watcher := wconfig.GetWatcher() if watcher != nil { watcher.Start() } } func telemetryLoop() { defer func() { panichandler.PanicHandler("telemetryLoop", recover()) }() var nextSend int64 time.Sleep(InitialTelemetryWait) for { if time.Now().Unix() > nextSend { nextSend = time.Now().Add(TelemetryInterval).Unix() sendTelemetryWrapper() } time.Sleep(TelemetryTick) } } func diagnosticLoop() { defer func() { panichandler.PanicHandler("diagnosticLoop", recover()) }() if os.Getenv("WAVETERM_NOPING") != "" { log.Printf("WAVETERM_NOPING set, disabling diagnostic ping\n") return } var lastSentDate string time.Sleep(InitialDiagnosticWait) for { currentDate := time.Now().Format("2006-01-02") if lastSentDate == "" || lastSentDate != currentDate { if sendDiagnosticPing() { lastSentDate = currentDate } } time.Sleep(DiagnosticTick) } } func sendDiagnosticPing() bool { ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() rpcClient := wshclient.GetBareRpcClient() isOnline, err := wshclient.NetworkOnlineCommand(rpcClient, &wshrpc.RpcOpts{Route: "electron", Timeout: 2000}) if err != nil || !isOnline { return false } clientId := wstore.GetClientId() usageTelemetry := telemetry.IsTelemetryEnabled() wcloud.SendDiagnosticPing(ctx, clientId, usageTelemetry) return true } func setupTelemetryConfigHandler() { watcher := wconfig.GetWatcher() if watcher == nil { return } currentConfig := watcher.GetFullConfig() currentTelemetryEnabled := currentConfig.Settings.TelemetryEnabled watcher.RegisterUpdateHandler(func(newConfig wconfig.FullConfigType) { newTelemetryEnabled := newConfig.Settings.TelemetryEnabled if newTelemetryEnabled != currentTelemetryEnabled { currentTelemetryEnabled = newTelemetryEnabled wcore.GoSendNoTelemetryUpdate(newTelemetryEnabled) } }) } func backupCleanupLoop() { defer func() { panichandler.PanicHandler("backupCleanupLoop", recover()) }() var nextCleanup int64 for { if time.Now().Unix() > nextCleanup { nextCleanup = time.Now().Add(BackupCleanupInterval).Unix() err := filebackup.CleanupOldBackups() if err != nil { log.Printf("error cleaning up old backups: %v\n", err) } } time.Sleep(BackupCleanupTick) } } func panicTelemetryHandler(panicName string) { activity := wshrpc.ActivityUpdate{NumPanics: 1} err := telemetry.UpdateActivity(context.Background(), activity) if err != nil { log.Printf("error updating activity (panicTelemetryHandler): %v\n", err) } telemetry.RecordTEvent(context.Background(), telemetrydata.MakeTEvent("debug:panic", telemetrydata.TEventProps{ PanicType: panicName, })) } func sendTelemetryWrapper() { defer func() { panichandler.PanicHandler("sendTelemetryWrapper", recover()) }() ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second) defer cancelFn() beforeSendActivityUpdate(ctx) clientId := wstore.GetClientId() err := wcloud.SendAllTelemetry(clientId) if err != nil { log.Printf("[error] sending telemetry: %v\n", err) } } func updateTelemetryCounts(lastCounts telemetrydata.TEventProps) telemetrydata.TEventProps { ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() var props telemetrydata.TEventProps props.CountBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx) props.CountTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) props.CountWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx) props.CountWorkspaces, _, _ = wstore.DBGetWSCounts(ctx) props.CountSSHConn = conncontroller.GetNumSSHHasConnected() props.CountWSLConn = wslconn.GetNumWSLHasConnected() props.CountJobs = jobcontroller.GetNumJobsRunning() props.CountJobsConnected = jobcontroller.GetNumJobsConnected() props.CountViews, _ = wstore.DBGetBlockViewCounts(ctx) fullConfig := wconfig.GetWatcher().GetFullConfig() customWidgets := fullConfig.CountCustomWidgets() customAIPresets := fullConfig.CountCustomAIPresets() customSettings := wconfig.CountCustomSettings() customAIModes := fullConfig.CountCustomAIModes() props.UserSet = &telemetrydata.TEventUserProps{ SettingsCustomWidgets: customWidgets, SettingsCustomAIPresets: customAIPresets, SettingsCustomSettings: customSettings, SettingsCustomAIModes: customAIModes, } secretsCount, err := secretstore.CountSecrets() if err == nil { props.UserSet.SettingsSecretsCount = secretsCount } if utilfn.CompareAsMarshaledJson(props, lastCounts) { return lastCounts } tevent := telemetrydata.MakeTEvent("app:counts", props) err = telemetry.RecordTEvent(ctx, tevent) if err != nil { log.Printf("error recording counts tevent: %v\n", err) } return props } func updateTelemetryCountsLoop() { defer func() { panichandler.PanicHandler("updateTelemetryCountsLoop", recover()) }() var nextSend int64 var lastCounts telemetrydata.TEventProps time.Sleep(TelemetryInitialCountsWait) for { if time.Now().Unix() > nextSend { nextSend = time.Now().Add(TelemetryCountsInterval).Unix() lastCounts = updateTelemetryCounts(lastCounts) } time.Sleep(TelemetryTick) } } func beforeSendActivityUpdate(ctx context.Context) { activity := wshrpc.ActivityUpdate{} activity.NumTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) activity.NumBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx) activity.Blocks, _ = wstore.DBGetBlockViewCounts(ctx) activity.NumWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx) activity.NumSSHConn = conncontroller.GetNumSSHHasConnected() activity.NumWSLConn = wslconn.GetNumWSLHasConnected() activity.NumWSNamed, activity.NumWS, _ = wstore.DBGetWSCounts(ctx) err := telemetry.UpdateActivity(ctx, activity) if err != nil { log.Printf("error updating before activity: %v\n", err) } } func startupActivityUpdate(firstLaunch bool) { defer func() { panichandler.PanicHandler("startupActivityUpdate", recover()) }() ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() activity := wshrpc.ActivityUpdate{Startup: 1} err := telemetry.UpdateActivity(ctx, activity) // set at least one record into activity (don't use go routine wrap here) if err != nil { log.Printf("error updating startup activity: %v\n", err) } autoUpdateChannel := telemetry.AutoUpdateChannel() autoUpdateEnabled := telemetry.IsAutoUpdateEnabled() shellType, shellVersion, shellErr := shellutil.DetectShellTypeAndVersion() if shellErr != nil { shellType = "error" shellVersion = "" } userSetOnce := &telemetrydata.TEventUserProps{ ClientInitialVersion: "v" + WaveVersion, } tosTs := telemetry.GetTosAgreedTs() var cohortTime time.Time if tosTs > 0 { cohortTime = time.UnixMilli(tosTs) } else { cohortTime = time.Now() } cohortMonth := cohortTime.Format("2006-01") year, week := cohortTime.ISOWeek() cohortISOWeek := fmt.Sprintf("%04d-W%02d", year, week) userSetOnce.CohortMonth = cohortMonth userSetOnce.CohortISOWeek = cohortISOWeek fullConfig := wconfig.GetWatcher().GetFullConfig() props := telemetrydata.TEventProps{ UserSet: &telemetrydata.TEventUserProps{ ClientVersion: "v" + wavebase.WaveVersion, ClientBuildTime: wavebase.BuildTime, ClientArch: wavebase.ClientArch(), ClientOSRelease: wavebase.UnameKernelRelease(), ClientIsDev: wavebase.IsDevMode(), ClientPackageType: wavebase.ClientPackageType(), ClientMacOSVersion: wavebase.ClientMacOSVersion(), AutoUpdateChannel: autoUpdateChannel, AutoUpdateEnabled: autoUpdateEnabled, LocalShellType: shellType, LocalShellVersion: shellVersion, SettingsTransparent: fullConfig.Settings.WindowTransparent, }, UserSetOnce: userSetOnce, } if firstLaunch { props.AppFirstLaunch = true } tevent := telemetrydata.MakeTEvent("app:startup", props) err = telemetry.RecordTEvent(ctx, tevent) if err != nil { log.Printf("error recording startup event: %v\n", err) } } func shutdownActivityUpdate() { ctx, cancelFn := context.WithTimeout(context.Background(), 1*time.Second) defer cancelFn() activity := wshrpc.ActivityUpdate{Shutdown: 1} err := telemetry.UpdateActivity(ctx, activity) // do NOT use the go routine wrap here (this needs to be synchronous) if err != nil { log.Printf("error updating shutdown activity: %v\n", err) } err = telemetry.TruncateActivityTEventForShutdown(ctx) if err != nil { log.Printf("error truncating activity t-event for shutdown: %v\n", err) } tevent := telemetrydata.MakeTEvent("app:shutdown", telemetrydata.TEventProps{}) err = telemetry.RecordTEvent(ctx, tevent) if err != nil { log.Printf("error recording shutdown event: %v\n", err) } } func createMainWshClient() { rpc := wshserver.GetMainRpcClient() wshfs.RpcClient = rpc wshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute) wps.Broker.SetClient(wshutil.DefaultRouter) localInitialEnv := envutil.PruneInitialEnv(envutil.SliceToMap(os.Environ())) sockName := wavebase.GetDomainSocketName() remoteImpl := wshremote.MakeRemoteRpcServerImpl(nil, wshutil.DefaultRouter, wshclient.GetBareRpcClient(), true, localInitialEnv, sockName) localConnWsh := wshutil.MakeWshRpc(wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, remoteImpl, "conn:local") go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName) wshutil.DefaultRouter.RegisterTrustedLeaf(localConnWsh, wshutil.MakeConnectionRouteId(wshrpc.LocalConnName)) } func grabAndRemoveEnvVars() error { err := authkey.SetAuthKeyFromEnv() if err != nil { return fmt.Errorf("setting auth key: %v", err) } err = wavebase.CacheAndRemoveEnvVars() if err != nil { return err } err = wcloud.CacheAndRemoveEnvVars() if err != nil { return err } // Remove WAVETERM env vars that leak from prod => dev os.Unsetenv("WAVETERM_CLIENTID") os.Unsetenv("WAVETERM_WORKSPACEID") os.Unsetenv("WAVETERM_TABID") os.Unsetenv("WAVETERM_BLOCKID") os.Unsetenv("WAVETERM_CONN") os.Unsetenv("WAVETERM_JWT") os.Unsetenv("WAVETERM_VERSION") return nil } func clearTempFiles() error { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) if err != nil { return fmt.Errorf("error getting client: %v", err) } filestore.WFS.DeleteZone(ctx, client.TempOID) return nil } func maybeStartPprofServer() { settings := wconfig.GetWatcher().GetFullConfig().Settings if settings.DebugPprofMemProfileRate != nil { runtime.MemProfileRate = *settings.DebugPprofMemProfileRate log.Printf("set runtime.MemProfileRate to %d\n", runtime.MemProfileRate) } if settings.DebugPprofPort == nil { return } pprofPort := *settings.DebugPprofPort if pprofPort < 1 || pprofPort > 65535 { log.Printf("[error] debug:pprofport must be between 1 and 65535, got %d\n", pprofPort) return } go func() { addr := fmt.Sprintf("localhost:%d", pprofPort) log.Printf("starting pprof server on %s\n", addr) if err := http.ListenAndServe(addr, nil); err != nil { log.Printf("[error] pprof server failed: %v\n", err) } }() } func main() { log.SetFlags(0) // disable timestamp since electron's winston logger already wraps with timestamp log.SetPrefix("[wavesrv] ") wavebase.WaveVersion = WaveVersion wavebase.BuildTime = BuildTime wshutil.DefaultRouter = wshutil.NewWshRouter() wshutil.DefaultRouter.SetAsRootRouter() err := grabAndRemoveEnvVars() if err != nil { log.Printf("[error] %v\n", err) return } err = service.ValidateServiceMap() if err != nil { log.Printf("error validating service map: %v\n", err) return } err = wavebase.EnsureWaveDataDir() if err != nil { log.Printf("error ensuring wave home dir: %v\n", err) return } err = wavebase.EnsureWaveDBDir() if err != nil { log.Printf("error ensuring wave db dir: %v\n", err) return } err = wavebase.EnsureWaveConfigDir() if err != nil { log.Printf("error ensuring wave config dir: %v\n", err) return } // TODO: rather than ensure this dir exists, we should let the editor recursively create parent dirs on save err = wavebase.EnsureWavePresetsDir() if err != nil { log.Printf("error ensuring wave presets dir: %v\n", err) return } err = wavebase.EnsureWaveCachesDir() if err != nil { log.Printf("error ensuring wave caches dir: %v\n", err) return } waveLock, err := wavebase.AcquireWaveLock() if err != nil { log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err) return } defer func() { err = waveLock.Close() if err != nil { log.Printf("error releasing wave lock: %v\n", err) } }() log.Printf("wave version: %s (%s)\n", WaveVersion, BuildTime) log.Printf("wave data dir: %s\n", wavebase.GetWaveDataDir()) log.Printf("wave config dir: %s\n", wavebase.GetWaveConfigDir()) err = filestore.InitFilestore() if err != nil { log.Printf("error initializing filestore: %v\n", err) return } err = wstore.InitWStore() if err != nil { log.Printf("error initializing wstore: %v\n", err) return } panichandler.PanicTelemetryHandler = panicTelemetryHandler go func() { defer func() { panichandler.PanicHandler("InitCustomShellStartupFiles", recover()) }() err := shellutil.InitCustomShellStartupFiles() if err != nil { log.Printf("error initializing wsh and shell-integration files: %v\n", err) } }() firstLaunch, err := wcore.EnsureInitialData() if err != nil { log.Printf("error ensuring initial data: %v\n", err) return } if firstLaunch { log.Printf("first launch detected") } err = clearTempFiles() if err != nil { log.Printf("error clearing temp files: %v\n", err) return } err = wcore.InitMainServer() if err != nil { log.Printf("error initializing mainserver: %v\n", err) return } err = shellutil.FixupWaveZshHistory() if err != nil { log.Printf("error fixing up wave zsh history: %v\n", err) } createMainWshClient() sigutil.InstallShutdownSignalHandlers(doShutdown) sigutil.InstallSIGUSR1Handler() startConfigWatcher() aiusechat.InitAIModeConfigWatcher() maybeStartPprofServer() go stdinReadWatch() go telemetryLoop() go diagnosticLoop() setupTelemetryConfigHandler() go updateTelemetryCountsLoop() go backupCleanupLoop() go startupActivityUpdate(firstLaunch) // must be after startConfigWatcher() blocklogger.InitBlockLogger() jobcontroller.InitJobController() blockcontroller.InitBlockController() err = wcore.InitBadgeStore() if err != nil { log.Printf("error initializing badge store: %v\n", err) return } go func() { defer func() { panichandler.PanicHandler("GetSystemSummary", recover()) }() wavebase.GetSystemSummary() }() webListener, err := web.MakeTCPListener("web") if err != nil { log.Printf("error creating web listener: %v\n", err) return } wsListener, err := web.MakeTCPListener("websocket") if err != nil { log.Printf("error creating websocket listener: %v\n", err) return } go web.RunWebSocketServer(wsListener) unixListener, err := web.MakeUnixListener() if err != nil { log.Printf("error creating unix listener: %v\n", err) return } go func() { if BuildTime == "" { BuildTime = "0" } // use fmt instead of log here to make sure it goes directly to stderr fmt.Fprintf(os.Stderr, "WAVESRV-ESTART ws:%s web:%s version:%s buildtime:%s\n", wsListener.Addr(), webListener.Addr(), WaveVersion, BuildTime) }() go wshutil.RunWshRpcOverListener(unixListener, nil) web.RunWebServer(webListener) // blocking runtime.KeepAlive(waveLock) } ================================================ FILE: cmd/test/test-main.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main func main() { } ================================================ FILE: cmd/test-conn/cliprovider.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "bufio" "context" "fmt" "os" "strings" "github.com/wavetermdev/waveterm/pkg/userinput" ) type CLIProvider struct { AutoAccept bool } func (p *CLIProvider) GetUserInput(ctx context.Context, request *userinput.UserInputRequest) (*userinput.UserInputResponse, error) { response := &userinput.UserInputResponse{ Type: request.ResponseType, RequestId: request.RequestId, } if request.Title != "" { fmt.Printf("\n=== %s ===\n", request.Title) } fmt.Printf("%s\n", request.QueryText) if p.AutoAccept { fmt.Printf("Auto-accepting (use -i for interactive mode)\n") response.Confirm = true response.Text = "yes" return response, nil } reader := bufio.NewReader(os.Stdin) fmt.Printf("Accept? [y/n]: ") text, err := reader.ReadString('\n') if err != nil { response.ErrorMsg = fmt.Sprintf("error reading input: %v", err) return response, err } text = strings.TrimSpace(strings.ToLower(text)) if text == "y" || text == "yes" { response.Confirm = true response.Text = "yes" } else { response.Confirm = false response.Text = "no" } return response, nil } ================================================ FILE: cmd/test-conn/main-test-conn.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "flag" "fmt" "log" "os" "time" ) var ( WaveVersion = "0.0.0" BuildTime = "0" ) func usage() { fmt.Fprintf(os.Stderr, `Test Harness for SSH Connection Flows Usage: test-conn [flags] [args...] Commands: connect - Test basic SSH connection with wsh ssh - Test basic SSH connection exec - Execute command and show output (no wsh) wshexec - Execute command with wsh enabled shell - Start interactive shell session Flags: -t duration Connection timeout (default: 60s) -i Interactive mode (prompt for user input instead of auto-accept) -v Show version and exit Examples: test-conn ssh user@example.com test-conn exec user@example.com "ls -la" test-conn wshexec user@example.com "wsh version" test-conn -i connect user@example.com test-conn shell user@example.com `) os.Exit(1) } func main() { timeoutFlag := flag.Duration("t", 60*time.Second, "connection timeout") interactiveFlag := flag.Bool("i", false, "interactive mode (prompt for user input)") versionFlag := flag.Bool("v", false, "show version") flag.Usage = usage flag.Parse() if *versionFlag { fmt.Printf("test-conn version %s (built %s)\n", WaveVersion, BuildTime) os.Exit(0) } args := flag.Args() if len(args) < 2 { usage() } command := args[0] connName := args[1] autoAccept := !*interactiveFlag err := initTestHarness(autoAccept) if err != nil { log.Fatalf("Failed to initialize: %v", err) } switch command { case "ssh", "connect": err = testBasicConnect(connName, *timeoutFlag) case "exec": if len(args) < 3 { log.Fatalf("exec command requires a command argument") } cmd := args[2] err = testShellWithCommand(connName, cmd, *timeoutFlag) case "wshexec": if len(args) < 3 { log.Fatalf("wshexec command requires a command argument") } cmd := args[2] err = testWshExec(connName, cmd, *timeoutFlag) case "shell": err = testInteractiveShell(connName, *timeoutFlag) default: log.Fatalf("Unknown command: %s", command) } if err != nil { log.Fatalf("Error: %v", err) } } ================================================ FILE: cmd/test-conn/testutil.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "context" "fmt" "log" "os" "path/filepath" "runtime" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/shellexec" "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wstore" ) func setupWaveEnvVars() error { homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get home directory: %w", err) } isDev := os.Getenv("WAVETERM_DEV") != "" devSuffix := "" if isDev { devSuffix = "-dev" } configHome := os.Getenv("WAVETERM_CONFIG_HOME") if configHome == "" { configHome = filepath.Join(homeDir, ".config", "waveterm"+devSuffix) os.Setenv("WAVETERM_CONFIG_HOME", configHome) } log.Printf("Using config directory: %s", configHome) dataHome := os.Getenv("WAVETERM_DATA_HOME") if dataHome == "" { if runtime.GOOS == "darwin" { dataHome = filepath.Join(homeDir, "Library", "Application Support", "waveterm"+devSuffix) os.Setenv("WAVETERM_DATA_HOME", dataHome) } else { return fmt.Errorf("WAVETERM_DATA_HOME must be set on non-macOS systems") } } log.Printf("Using data directory: %s", dataHome) return nil } func initTestHarness(autoAccept bool) error { log.Printf("Initializing test harness...") err := setupWaveEnvVars() if err != nil { return fmt.Errorf("failed to setup wave env vars: %w", err) } err = wavebase.CacheAndRemoveEnvVars() if err != nil { return fmt.Errorf("failed to cache env vars: %w", err) } wshutil.DefaultRouter = wshutil.NewWshRouter() wshutil.DefaultRouter.SetAsRootRouter() wstore.SetClientId("test-client-" + fmt.Sprintf("%d", time.Now().Unix())) userinput.SetUserInputProvider(&CLIProvider{AutoAccept: autoAccept}) keyPair, err := wavejwt.GenerateKeyPair() if err != nil { return fmt.Errorf("failed to generate JWT key pair: %w", err) } err = wavejwt.SetPrivateKey(keyPair.PrivateKey) if err != nil { return fmt.Errorf("failed to set JWT private key: %w", err) } err = wavejwt.SetPublicKey(keyPair.PublicKey) if err != nil { return fmt.Errorf("failed to set JWT public key: %w", err) } rpc := wshserver.GetMainRpcClient() wshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute) wconfig.GetWatcher().Start() log.Printf("Test harness initialized") return nil } func testBasicConnect(connName string, timeout time.Duration) error { opts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("failed to parse connection string: %w", err) } log.Printf("Connecting to %s...", opts.String()) conn := conncontroller.GetConn(opts) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() err = conn.Connect(ctx, &wconfig.ConnKeywords{}) if err != nil { return fmt.Errorf("connection failed: %w", err) } status := conn.DeriveConnStatus() log.Printf("✓ Connected!") log.Printf(" Status: %s", status.Status) log.Printf(" WshEnabled: %v", status.WshEnabled) log.Printf(" Connection: %s", status.Connection) if status.WshVersion != "" { log.Printf(" WshVersion: %s", status.WshVersion) } if status.WshError != "" { log.Printf(" WshError: %s", status.WshError) } if status.NoWshReason != "" { log.Printf(" NoWshReason: %s", status.NoWshReason) } return nil } func testShellWithCommand(connName string, cmd string, timeout time.Duration) error { opts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("failed to parse connection string: %w", err) } log.Printf("Connecting to %s...", opts.String()) conn := conncontroller.GetConn(opts) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() err = conn.Connect(ctx, &wconfig.ConnKeywords{}) if err != nil { return fmt.Errorf("connection failed: %w", err) } log.Printf("✓ Connected! Starting shell...") termSize := waveobj.TermSize{Rows: 24, Cols: 80} shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn) if err != nil { return fmt.Errorf("failed to start shell: %w", err) } defer shellProc.Close() log.Printf("✓ Shell started! Executing: %s", cmd) _, err = shellProc.Cmd.Write([]byte(cmd + "\n")) if err != nil { return fmt.Errorf("failed to write command: %w", err) } time.Sleep(500 * time.Millisecond) buf := make([]byte, 8192) n, err := shellProc.Cmd.Read(buf) if err != nil { log.Printf("Warning: read error (may be expected): %v", err) } if n > 0 { log.Printf("\n--- Output ---\n%s\n--- End Output ---", string(buf[:n])) } else { log.Printf("No output received (timeout or no data)") } return nil } func testWshExec(connName string, cmd string, timeout time.Duration) error { opts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("failed to parse connection string: %w", err) } log.Printf("Connecting to %s with wsh enabled...", opts.String()) conn := conncontroller.GetConn(opts) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() wshEnabled := true err = conn.Connect(ctx, &wconfig.ConnKeywords{ ConnWshEnabled: &wshEnabled, }) if err != nil { return fmt.Errorf("connection failed: %w", err) } status := conn.DeriveConnStatus() log.Printf("✓ Connected! (wsh enabled: %v)", status.WshEnabled) if status.WshVersion != "" { log.Printf(" wsh version: %s", status.WshVersion) } if !status.WshEnabled { log.Printf(" WARNING: wsh not enabled - reason: %s", status.NoWshReason) } log.Printf("Starting wsh-enabled shell...") swapToken := &shellutil.TokenSwapEntry{ Token: uuid.New().String(), Env: make(map[string]string), Exp: time.Now().Add(5 * time.Minute), } swapToken.Env["TERM_PROGRAM"] = "waveterm" swapToken.Env["WAVETERM"] = "1" swapToken.Env["WAVETERM_VERSION"] = wavebase.WaveVersion swapToken.Env["WAVETERM_CONN"] = connName cmdOpts := shellexec.CommandOptsType{ SwapToken: swapToken, } termSize := waveobj.TermSize{Rows: 24, Cols: 80} shellProc, err := shellexec.StartRemoteShellProc(ctx, ctx, termSize, "", cmdOpts, conn) if err != nil { return fmt.Errorf("failed to start shell: %w", err) } defer shellProc.Close() log.Printf("✓ Shell started! Executing: %s", cmd) _, err = shellProc.Cmd.Write([]byte(cmd + "\n")) if err != nil { return fmt.Errorf("failed to write command: %w", err) } time.Sleep(500 * time.Millisecond) buf := make([]byte, 8192) n, err := shellProc.Cmd.Read(buf) if err != nil { log.Printf("Warning: read error (may be expected): %v", err) } if n > 0 { log.Printf("\n--- Output ---\n%s\n--- End Output ---", string(buf[:n])) } else { log.Printf("No output received (timeout or no data)") } return nil } func testInteractiveShell(connName string, timeout time.Duration) error { opts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("failed to parse connection string: %w", err) } log.Printf("Connecting to %s...", opts.String()) conn := conncontroller.GetConn(opts) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() err = conn.Connect(ctx, &wconfig.ConnKeywords{}) if err != nil { return fmt.Errorf("connection failed: %w", err) } log.Printf("✓ Connected! Starting interactive shell...") log.Printf("Note: This is a simple test - output may be mixed with prompts") log.Printf("Type commands and press Enter. Type 'exit' to quit.\n") termSize := waveobj.TermSize{Rows: 24, Cols: 80} shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn) if err != nil { return fmt.Errorf("failed to start shell: %w", err) } defer shellProc.Close() go func() { buf := make([]byte, 8192) for { n, err := shellProc.Cmd.Read(buf) if err != nil { return } if n > 0 { fmt.Print(string(buf[:n])) } } }() go func() { buf := make([]byte, 1) for { n, err := os.Stdin.Read(buf) if err != nil { return } if n > 0 { shellProc.Cmd.Write(buf[:n]) } } }() shellProc.Wait() log.Printf("\nShell exited") return nil } ================================================ FILE: cmd/test-streammanager/bridge.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "fmt" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) // WriterBridge - used by the writer broker // Sends data to the pipe, receives acks from the pipe type WriterBridge struct { pipe *DeliveryPipe } func (b *WriterBridge) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error { b.pipe.EnqueueData(data) return nil } func (b *WriterBridge) StreamDataAckCommand(ack wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error { return fmt.Errorf("writer bridge should not send acks") } // ReaderBridge - used by the reader broker // Sends acks to the pipe, receives data from the pipe type ReaderBridge struct { pipe *DeliveryPipe } func (b *ReaderBridge) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error { return fmt.Errorf("reader bridge should not send data") } func (b *ReaderBridge) StreamDataAckCommand(ack wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error { b.pipe.EnqueueAck(ack) return nil } ================================================ FILE: cmd/test-streammanager/deliverypipe.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "encoding/base64" "math/rand" "sort" "sync" "time" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type DeliveryConfig struct { Delay time.Duration Skew time.Duration } type taggedPacket struct { seq uint64 deliveryTime time.Time isData bool dataPk wshrpc.CommandStreamData ackPk wshrpc.CommandStreamAckData dataSize int } type DeliveryPipe struct { lock sync.Mutex config DeliveryConfig // Sequence counters (separate for data and ack) dataSeq uint64 ackSeq uint64 // Pending packets sorted by (deliveryTime, seq) dataPending []taggedPacket ackPending []taggedPacket // Delivery targets dataTarget func(wshrpc.CommandStreamData) ackTarget func(wshrpc.CommandStreamAckData) // Control closed bool wg sync.WaitGroup // Metrics metrics *Metrics lastDataSeqNum int64 lastAckSeqNum int64 // Byte tracking for high water mark currentBytes int64 } func NewDeliveryPipe(config DeliveryConfig, metrics *Metrics) *DeliveryPipe { return &DeliveryPipe{ config: config, metrics: metrics, lastDataSeqNum: -1, lastAckSeqNum: -1, } } func (dp *DeliveryPipe) SetDataTarget(fn func(wshrpc.CommandStreamData)) { dp.lock.Lock() defer dp.lock.Unlock() dp.dataTarget = fn } func (dp *DeliveryPipe) SetAckTarget(fn func(wshrpc.CommandStreamAckData)) { dp.lock.Lock() defer dp.lock.Unlock() dp.ackTarget = fn } func (dp *DeliveryPipe) EnqueueData(pkt wshrpc.CommandStreamData) { dp.lock.Lock() defer dp.lock.Unlock() if dp.closed { return } dataSize := base64.StdEncoding.DecodedLen(len(pkt.Data64)) dp.dataSeq++ tagged := taggedPacket{ seq: dp.dataSeq, deliveryTime: dp.computeDeliveryTime(), isData: true, dataPk: pkt, dataSize: dataSize, } dp.dataPending = append(dp.dataPending, tagged) dp.sortPending(&dp.dataPending) dp.currentBytes += int64(dataSize) if dp.metrics != nil { dp.metrics.AddDataPacket() dp.metrics.UpdatePipeHighWaterMark(dp.currentBytes) } } func (dp *DeliveryPipe) EnqueueAck(pkt wshrpc.CommandStreamAckData) { dp.lock.Lock() defer dp.lock.Unlock() if dp.closed { return } dp.ackSeq++ tagged := taggedPacket{ seq: dp.ackSeq, deliveryTime: dp.computeDeliveryTime(), isData: false, ackPk: pkt, } dp.ackPending = append(dp.ackPending, tagged) dp.sortPending(&dp.ackPending) if dp.metrics != nil { dp.metrics.AddAckPacket() } } func (dp *DeliveryPipe) computeDeliveryTime() time.Time { base := time.Now().Add(dp.config.Delay) if dp.config.Skew == 0 { return base } // Random skew: -skew to +skew skewNs := dp.config.Skew.Nanoseconds() randomSkew := time.Duration(rand.Int63n(2*skewNs+1) - skewNs) return base.Add(randomSkew) } func (dp *DeliveryPipe) sortPending(pending *[]taggedPacket) { sort.Slice(*pending, func(i, j int) bool { pi, pj := (*pending)[i], (*pending)[j] if pi.deliveryTime.Equal(pj.deliveryTime) { return pi.seq < pj.seq } return pi.deliveryTime.Before(pj.deliveryTime) }) } func (dp *DeliveryPipe) Start() { dp.wg.Add(2) go dp.dataDeliveryLoop() go dp.ackDeliveryLoop() } func (dp *DeliveryPipe) dataDeliveryLoop() { defer dp.wg.Done() dp.deliveryLoop( func() *[]taggedPacket { return &dp.dataPending }, func(pkt taggedPacket) { if dp.dataTarget != nil { // Track out-of-order packets if dp.metrics != nil && dp.lastDataSeqNum != -1 { if pkt.dataPk.Seq < dp.lastDataSeqNum { dp.metrics.AddOOOPacket() } } dp.lastDataSeqNum = pkt.dataPk.Seq dp.dataTarget(pkt.dataPk) dp.lock.Lock() dp.currentBytes -= int64(pkt.dataSize) dp.lock.Unlock() } }, ) } func (dp *DeliveryPipe) ackDeliveryLoop() { defer dp.wg.Done() dp.deliveryLoop( func() *[]taggedPacket { return &dp.ackPending }, func(pkt taggedPacket) { if dp.ackTarget != nil { // Track out-of-order acks if dp.metrics != nil && dp.lastAckSeqNum != -1 { if pkt.ackPk.Seq < dp.lastAckSeqNum { dp.metrics.AddOOOPacket() } } dp.lastAckSeqNum = pkt.ackPk.Seq dp.ackTarget(pkt.ackPk) } }, ) } func (dp *DeliveryPipe) deliveryLoop( getPending func() *[]taggedPacket, deliver func(taggedPacket), ) { for { dp.lock.Lock() if dp.closed { dp.lock.Unlock() return } pending := getPending() now := time.Now() // Find all packets ready for delivery (deliveryTime <= now) readyCount := 0 for _, pkt := range *pending { if pkt.deliveryTime.After(now) { break } readyCount++ } // Extract ready packets ready := make([]taggedPacket, readyCount) copy(ready, (*pending)[:readyCount]) *pending = (*pending)[readyCount:] dp.lock.Unlock() // Deliver all ready packets (outside lock) for _, pkt := range ready { deliver(pkt) } // Always sleep 1ms - simple busy loop time.Sleep(1 * time.Millisecond) } } func (dp *DeliveryPipe) Close() { dp.lock.Lock() dp.closed = true dp.lock.Unlock() dp.wg.Wait() } ================================================ FILE: cmd/test-streammanager/generator.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "io" ) // Base64 charset: all printable, easy to inspect manually const Base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" type TestDataGenerator struct { totalBytes int64 generated int64 } func NewTestDataGenerator(totalBytes int64) *TestDataGenerator { return &TestDataGenerator{totalBytes: totalBytes} } func (g *TestDataGenerator) Read(p []byte) (n int, err error) { if g.generated >= g.totalBytes { return 0, io.EOF } remaining := g.totalBytes - g.generated toRead := int64(len(p)) if toRead > remaining { toRead = remaining } // Sequential pattern using base64 chars (0-63 cycling) for i := int64(0); i < toRead; i++ { p[i] = Base64Chars[(g.generated+i)%64] } g.generated += toRead return int(toRead), nil } ================================================ FILE: cmd/test-streammanager/main-test-streammanager.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "fmt" "io" "log" "os" "time" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/jobmanager" "github.com/wavetermdev/waveterm/pkg/streamclient" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type TestConfig struct { Mode string DataSize int64 Delay time.Duration Skew time.Duration WindowSize int SlowReader int Verbose bool } var config TestConfig var rootCmd = &cobra.Command{ Use: "test-streammanager", Short: "Integration test for StreamManager streaming system", RunE: func(cmd *cobra.Command, args []string) error { return runTest(config) }, } func init() { rootCmd.Flags().StringVar(&config.Mode, "mode", "streammanager", "Writer mode: 'streammanager' or 'writer'") rootCmd.Flags().Int64Var(&config.DataSize, "size", 10*1024*1024, "Total data to transfer (bytes)") rootCmd.Flags().DurationVar(&config.Delay, "delay", 0, "Base delivery delay (e.g., 10ms)") rootCmd.Flags().DurationVar(&config.Skew, "skew", 0, "Delivery skew +/- (e.g., 5ms)") rootCmd.Flags().IntVar(&config.WindowSize, "windowsize", 64*1024, "Window size for both sender and receiver") rootCmd.Flags().IntVar(&config.SlowReader, "slowreader", 0, "Slow reader mode: bytes per second (0=disabled, e.g., 1024)") rootCmd.Flags().BoolVar(&config.Verbose, "verbose", false, "Enable verbose logging") } func main() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } } func runTest(config TestConfig) error { if config.Mode != "streammanager" && config.Mode != "writer" { return fmt.Errorf("invalid mode: %s (must be 'streammanager' or 'writer')", config.Mode) } fmt.Printf("Starting Streaming Integration Test\n") fmt.Printf(" Mode: %s\n", config.Mode) fmt.Printf(" Data Size: %d bytes\n", config.DataSize) fmt.Printf(" Delay: %v, Skew: %v\n", config.Delay, config.Skew) fmt.Printf(" Window Size: %d\n", config.WindowSize) if config.SlowReader > 0 { fmt.Printf(" Slow Reader: %d bytes/sec\n", config.SlowReader) } // 1. Create metrics metrics := NewMetrics() // 2. Create the delivery pipe pipe := NewDeliveryPipe(DeliveryConfig{ Delay: config.Delay, Skew: config.Skew, }, metrics) // 3. Create brokers with bridges writerBridge := &WriterBridge{pipe: pipe} readerBridge := &ReaderBridge{pipe: pipe} writerBroker := streamclient.NewBroker(writerBridge) readerBroker := streamclient.NewBroker(readerBridge) // 4. Wire up delivery targets pipe.SetDataTarget(readerBroker.RecvData) pipe.SetAckTarget(writerBroker.RecvAck) // 5. Start the delivery pipe pipe.Start() // 6. Create the reader side reader, streamMeta := readerBroker.CreateStreamReader("reader-route", "writer-route", int64(config.WindowSize)) // 7. Set up writer side based on mode var writerDone chan error if config.Mode == "streammanager" { writerDone = runStreamManagerMode(config, writerBroker, streamMeta) } else { writerDone = runWriterMode(config, writerBroker, streamMeta) } // 8. Create verifier verifier := NewVerifier(config.DataSize) // 9. Create metrics writer wrapper metricsWriter := &MetricsWriter{ writer: verifier, metrics: metrics, } // 10. Wrap reader with slow reader if configured var actualReader io.Reader = reader if config.SlowReader > 0 { actualReader = NewSlowReader(reader, config.SlowReader) } // 11. Start reading from stream reader and writing to verifier metrics.Start() readerDone := make(chan error) go func() { _, err := io.Copy(metricsWriter, actualReader) readerDone <- err }() // 12. Wait for completion var writerErr, readerErr error if writerDone != nil { writerErr = <-writerDone } readerErr = <-readerDone metrics.End() // 13. Cleanup pipe.Close() writerBroker.Close() readerBroker.Close() // 14. Report results fmt.Println(metrics.Report()) fmt.Printf("Verification: received=%d, mismatches=%d\n", verifier.TotalReceived(), verifier.Mismatches()) if writerErr != nil && writerErr != io.EOF { return fmt.Errorf("writer error: %w", writerErr) } if readerErr != nil && readerErr != io.EOF { return fmt.Errorf("reader error: %w", readerErr) } if verifier.Mismatches() > 0 { return fmt.Errorf("data corruption: %d mismatches, first at byte %d", verifier.Mismatches(), verifier.FirstMismatch()) } fmt.Println("TEST PASSED") return nil } func runStreamManagerMode(config TestConfig, writerBroker *streamclient.Broker, streamMeta *wshrpc.StreamMeta) chan error { streamManager := jobmanager.MakeStreamManagerWithSizes(config.WindowSize, 2*1024*1024) writerBroker.AttachStreamWriter(streamMeta, streamManager) dataSender := &BrokerDataSender{broker: writerBroker} startSeq, err := streamManager.ClientConnected(streamMeta.Id, dataSender, config.WindowSize, 0) if err != nil { fmt.Printf("failed to connect stream manager: %v\n", err) return nil } fmt.Printf(" Stream connected, startSeq: %d\n", startSeq) generator := NewTestDataGenerator(config.DataSize) if err := streamManager.AttachReader(generator); err != nil { fmt.Printf("failed to attach reader: %v\n", err) return nil } return nil } func runWriterMode(config TestConfig, writerBroker *streamclient.Broker, streamMeta *wshrpc.StreamMeta) chan error { writer, err := writerBroker.CreateStreamWriter(streamMeta) if err != nil { fmt.Printf("failed to create stream writer: %v\n", err) return nil } fmt.Printf(" Stream writer created\n") generator := NewTestDataGenerator(config.DataSize) done := make(chan error, 1) go func() { _, copyErr := io.Copy(writer, generator) closeErr := writer.Close() if copyErr != nil && copyErr != io.EOF { done <- copyErr } else { done <- closeErr } }() return done } // BrokerDataSender implements DataSender interface type BrokerDataSender struct { broker *streamclient.Broker } func (s *BrokerDataSender) SendData(dataPk wshrpc.CommandStreamData) { s.broker.SendData(dataPk) } // MetricsWriter wraps an io.Writer and records bytes written to metrics type MetricsWriter struct { writer io.Writer metrics *Metrics } func (mw *MetricsWriter) Write(p []byte) (n int, err error) { n, err = mw.writer.Write(p) if n > 0 { mw.metrics.AddBytes(int64(n)) } return n, err } // SlowReader wraps an io.Reader and rate-limits reads to a specified bytes/sec type SlowReader struct { reader io.Reader bytesPerSec int } func NewSlowReader(reader io.Reader, bytesPerSec int) *SlowReader { return &SlowReader{ reader: reader, bytesPerSec: bytesPerSec, } } func (sr *SlowReader) Read(p []byte) (n int, err error) { time.Sleep(1 * time.Second) readSize := sr.bytesPerSec if readSize > len(p) { readSize = len(p) } n, err = sr.reader.Read(p[:readSize]) log.Printf("SlowReader: read %d bytes, err=%v", n, err) return n, err } ================================================ FILE: cmd/test-streammanager/metrics.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "fmt" "sync" "time" ) type Metrics struct { lock sync.Mutex // Timing startTime time.Time endTime time.Time // Data transfer totalBytes int64 // Packet counts dataPackets int64 ackPackets int64 // Out of order tracking oooPackets int64 // High water mark for pipe bytes pipeHighWaterMark int64 } func NewMetrics() *Metrics { return &Metrics{} } func (m *Metrics) Start() { m.lock.Lock() defer m.lock.Unlock() m.startTime = time.Now() } func (m *Metrics) End() { m.lock.Lock() defer m.lock.Unlock() m.endTime = time.Now() } func (m *Metrics) AddDataPacket() { m.lock.Lock() defer m.lock.Unlock() m.dataPackets++ } func (m *Metrics) AddAckPacket() { m.lock.Lock() defer m.lock.Unlock() m.ackPackets++ } func (m *Metrics) AddOOOPacket() { m.lock.Lock() defer m.lock.Unlock() m.oooPackets++ } func (m *Metrics) AddBytes(n int64) { m.lock.Lock() defer m.lock.Unlock() m.totalBytes += n } func (m *Metrics) UpdatePipeHighWaterMark(currentBytes int64) { m.lock.Lock() defer m.lock.Unlock() if currentBytes > m.pipeHighWaterMark { m.pipeHighWaterMark = currentBytes } } func (m *Metrics) GetPipeHighWaterMark() int64 { m.lock.Lock() defer m.lock.Unlock() return m.pipeHighWaterMark } func (m *Metrics) Report() string { m.lock.Lock() defer m.lock.Unlock() duration := m.endTime.Sub(m.startTime) durationSecs := duration.Seconds() if durationSecs == 0 { durationSecs = 1.0 } throughput := float64(m.totalBytes) / durationSecs / 1024 / 1024 return fmt.Sprintf(` StreamManager Integration Test Results ====================================== Duration: %v Total Bytes: %d Throughput: %.2f MB/s Data Packets: %d Ack Packets: %d OOO Packets: %d Pipe High Water: %d bytes (%.2f KB) `, duration, m.totalBytes, throughput, m.dataPackets, m.ackPackets, m.oooPackets, m.pipeHighWaterMark, float64(m.pipeHighWaterMark)/1024) } ================================================ FILE: cmd/test-streammanager/verifier.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "sync" ) type Verifier struct { lock sync.Mutex expectedGen *TestDataGenerator totalReceived int64 mismatches int firstMismatch int64 } func NewVerifier(totalBytes int64) *Verifier { return &Verifier{ expectedGen: NewTestDataGenerator(totalBytes), firstMismatch: -1, } } func (v *Verifier) Write(p []byte) (n int, err error) { v.lock.Lock() defer v.lock.Unlock() expected := make([]byte, len(p)) // expectedGen.Read() error ignored: TestDataGenerator is deterministic and won't fail, // and any data length mismatch will be caught by byte comparison below v.expectedGen.Read(expected) for i := 0; i < len(p); i++ { if p[i] != expected[i] { v.mismatches++ if v.firstMismatch == -1 { v.firstMismatch = v.totalReceived + int64(i) } } } v.totalReceived += int64(len(p)) return len(p), nil } func (v *Verifier) TotalReceived() int64 { v.lock.Lock() defer v.lock.Unlock() return v.totalReceived } func (v *Verifier) Mismatches() int { v.lock.Lock() defer v.lock.Unlock() return v.mismatches } func (v *Verifier) FirstMismatch() int64 { v.lock.Lock() defer v.lock.Unlock() return v.firstMismatch } ================================================ FILE: cmd/testai/main-testai.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "context" _ "embed" "encoding/json" "flag" "fmt" "log" "net/http" "os" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/web/sse" ) //go:embed testschema.json var testSchemaJSON string const ( DefaultAnthropicModel = "claude-sonnet-4-5" DefaultOpenAIModel = "gpt-5.1" DefaultOpenRouterModel = "mistralai/mistral-small-3.2-24b-instruct" DefaultNanoGPTModel = "zai-org/glm-4.7" DefaultGeminiModel = "gemini-3-pro-preview" ) // TestResponseWriter implements http.ResponseWriter and additional interfaces for testing type TestResponseWriter struct { header http.Header } func (w *TestResponseWriter) Header() http.Header { if w.header == nil { w.header = make(http.Header) } return w.header } func (w *TestResponseWriter) Write(data []byte) (int, error) { fmt.Printf("SSE: %s", string(data)) return len(data), nil } func (w *TestResponseWriter) WriteHeader(statusCode int) { fmt.Printf("Status: %d\n", statusCode) } // Implement http.Flusher interface func (w *TestResponseWriter) Flush() { // No-op for testing } // Implement interfaces needed by http.ResponseController func (w *TestResponseWriter) SetWriteDeadline(deadline time.Time) error { // No-op for testing return nil } func (w *TestResponseWriter) SetReadDeadline(deadline time.Time) error { // No-op for testing return nil } func getToolDefinitions() []uctypes.ToolDefinition { var schemas map[string]any if err := json.Unmarshal([]byte(testSchemaJSON), &schemas); err != nil { log.Printf("Error parsing schema: %v\n", err) return nil } var configSchema map[string]any if rawSchema, ok := schemas["config"]; ok && rawSchema != nil { if schema, ok := rawSchema.(map[string]any); ok { configSchema = schema } } if configSchema == nil { configSchema = map[string]any{"type": "object"} } return []uctypes.ToolDefinition{ { Name: "get_config", Description: "Get the current GitHub Actions Monitor configuration settings including repository, workflow, polling interval, and max workflow runs", InputSchema: map[string]any{ "type": "object", }, }, { Name: "update_config", Description: "Update GitHub Actions Monitor configuration settings", InputSchema: configSchema, }, { Name: "get_data", Description: "Get the current GitHub Actions workflow run data including workflow runs, loading state, and errors", InputSchema: map[string]any{ "type": "object", }, }, } } func testOpenAI(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { apiKey := os.Getenv("OPENAI_APIKEY") if apiKey == "" { fmt.Println("Error: OPENAI_APIKEY environment variable not set") os.Exit(1) } opts := &uctypes.AIOptsType{ APIType: uctypes.APIType_OpenAIResponses, APIToken: apiKey, Model: model, MaxTokens: 4096, ThinkingLevel: uctypes.ThinkingLevelMedium, } // Generate a chat ID chatID := uuid.New().String() // Convert to AIMessage format for WaveAIPostMessageWrap aiMessage := &uctypes.AIMessage{ MessageId: uuid.New().String(), Parts: []uctypes.AIMessagePart{ { Type: uctypes.AIMessagePartTypeText, Text: message, }, }, } fmt.Printf("Testing OpenAI streaming with WaveAIPostMessageWrap, model: %s\n", model) fmt.Printf("Message: %s\n", message) fmt.Printf("Chat ID: %s\n", chatID) fmt.Println("---") testWriter := &TestResponseWriter{} sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) defer sseHandler.Close() chatOpts := uctypes.WaveChatOpts{ ChatId: chatID, ClientId: uuid.New().String(), Config: *opts, Tools: tools, } err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) if err != nil { fmt.Printf("OpenAI streaming error: %v\n", err) } } func testOpenAIComp(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { apiKey := os.Getenv("OPENAI_APIKEY") if apiKey == "" { fmt.Println("Error: OPENAI_APIKEY environment variable not set") os.Exit(1) } opts := &uctypes.AIOptsType{ APIType: uctypes.APIType_OpenAIChat, APIToken: apiKey, Endpoint: "https://api.openai.com/v1/chat/completions", Model: model, MaxTokens: 4096, ThinkingLevel: uctypes.ThinkingLevelMedium, } chatID := uuid.New().String() aiMessage := &uctypes.AIMessage{ MessageId: uuid.New().String(), Parts: []uctypes.AIMessagePart{ { Type: uctypes.AIMessagePartTypeText, Text: message, }, }, } fmt.Printf("Testing OpenAI Completions API with WaveAIPostMessageWrap, model: %s\n", model) fmt.Printf("Message: %s\n", message) fmt.Printf("Chat ID: %s\n", chatID) fmt.Println("---") testWriter := &TestResponseWriter{} sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) defer sseHandler.Close() chatOpts := uctypes.WaveChatOpts{ ChatId: chatID, ClientId: uuid.New().String(), Config: *opts, Tools: tools, SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, } err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) if err != nil { fmt.Printf("OpenAI Completions API streaming error: %v\n", err) } } func testOpenRouter(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { apiKey := os.Getenv("OPENROUTER_APIKEY") if apiKey == "" { fmt.Println("Error: OPENROUTER_APIKEY environment variable not set") os.Exit(1) } opts := &uctypes.AIOptsType{ APIType: uctypes.APIType_OpenAIChat, APIToken: apiKey, Endpoint: "https://openrouter.ai/api/v1/chat/completions", Model: model, MaxTokens: 4096, ThinkingLevel: uctypes.ThinkingLevelMedium, } chatID := uuid.New().String() aiMessage := &uctypes.AIMessage{ MessageId: uuid.New().String(), Parts: []uctypes.AIMessagePart{ { Type: uctypes.AIMessagePartTypeText, Text: message, }, }, } fmt.Printf("Testing OpenRouter with WaveAIPostMessageWrap, model: %s\n", model) fmt.Printf("Message: %s\n", message) fmt.Printf("Chat ID: %s\n", chatID) fmt.Println("---") testWriter := &TestResponseWriter{} sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) defer sseHandler.Close() chatOpts := uctypes.WaveChatOpts{ ChatId: chatID, ClientId: uuid.New().String(), Config: *opts, Tools: tools, SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, } err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) if err != nil { fmt.Printf("OpenRouter streaming error: %v\n", err) } } func testNanoGPT(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { apiKey := os.Getenv("NANOGPT_KEY") if apiKey == "" { fmt.Println("Error: NANOGPT_KEY environment variable not set") os.Exit(1) } opts := &uctypes.AIOptsType{ APIType: uctypes.APIType_OpenAIChat, APIToken: apiKey, Endpoint: "https://nano-gpt.com/api/v1/chat/completions", Model: model, MaxTokens: 4096, } chatID := uuid.New().String() aiMessage := &uctypes.AIMessage{ MessageId: uuid.New().String(), Parts: []uctypes.AIMessagePart{ { Type: uctypes.AIMessagePartTypeText, Text: message, }, }, } fmt.Printf("Testing NanoGPT with WaveAIPostMessageWrap, model: %s\n", model) fmt.Printf("Message: %s\n", message) fmt.Printf("Chat ID: %s\n", chatID) fmt.Println("---") testWriter := &TestResponseWriter{} sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) defer sseHandler.Close() chatOpts := uctypes.WaveChatOpts{ ChatId: chatID, ClientId: uuid.New().String(), Config: *opts, Tools: tools, SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, } err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) if err != nil { fmt.Printf("NanoGPT streaming error: %v\n", err) } } func testAnthropic(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { apiKey := os.Getenv("ANTHROPIC_APIKEY") if apiKey == "" { fmt.Println("Error: ANTHROPIC_APIKEY environment variable not set") os.Exit(1) } opts := &uctypes.AIOptsType{ APIType: uctypes.APIType_AnthropicMessages, APIToken: apiKey, Model: model, MaxTokens: 4096, ThinkingLevel: uctypes.ThinkingLevelMedium, } // Generate a chat ID chatID := uuid.New().String() // Convert to AIMessage format for WaveAIPostMessageWrap aiMessage := &uctypes.AIMessage{ MessageId: uuid.New().String(), Parts: []uctypes.AIMessagePart{ { Type: uctypes.AIMessagePartTypeText, Text: message, }, }, } fmt.Printf("Testing Anthropic streaming with WaveAIPostMessageWrap, model: %s\n", model) fmt.Printf("Message: %s\n", message) fmt.Printf("Chat ID: %s\n", chatID) fmt.Println("---") testWriter := &TestResponseWriter{} sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) defer sseHandler.Close() chatOpts := uctypes.WaveChatOpts{ ChatId: chatID, ClientId: uuid.New().String(), Config: *opts, Tools: tools, } err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) if err != nil { fmt.Printf("Anthropic streaming error: %v\n", err) } } func testGemini(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { apiKey := os.Getenv("GOOGLE_APIKEY") if apiKey == "" { fmt.Println("Error: GOOGLE_APIKEY environment variable not set") os.Exit(1) } opts := &uctypes.AIOptsType{ APIType: uctypes.APIType_GoogleGemini, APIToken: apiKey, Model: model, MaxTokens: 8192, Capabilities: []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs}, } // Generate a chat ID chatID := uuid.New().String() // Convert to AIMessage format for WaveAIPostMessageWrap aiMessage := &uctypes.AIMessage{ MessageId: uuid.New().String(), Parts: []uctypes.AIMessagePart{ { Type: uctypes.AIMessagePartTypeText, Text: message, }, }, } fmt.Printf("Testing Google Gemini streaming with WaveAIPostMessageWrap, model: %s\n", model) fmt.Printf("Message: %s\n", message) fmt.Printf("Chat ID: %s\n", chatID) fmt.Println("---") testWriter := &TestResponseWriter{} sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) defer sseHandler.Close() chatOpts := uctypes.WaveChatOpts{ ChatId: chatID, ClientId: uuid.New().String(), Config: *opts, Tools: tools, SystemPrompt: []string{"You are a helpful assistant. Be concise and clear in your responses."}, } err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) if err != nil { fmt.Printf("Google Gemini streaming error: %v\n", err) } } func testT1(ctx context.Context) { tool := aiusechat.GetAdderToolDefinition() tools := []uctypes.ToolDefinition{tool} testAnthropic(ctx, DefaultAnthropicModel, "what is 2+2, use the provider adder tool", tools) } func testT2(ctx context.Context) { tool := aiusechat.GetAdderToolDefinition() tools := []uctypes.ToolDefinition{tool} testOpenAI(ctx, DefaultOpenAIModel, "what is 2+2+8, use the provider adder tool", tools) } func testT3(ctx context.Context) { testOpenAIComp(ctx, "gpt-4o", "what is 2+2? please be brief", nil) } func testT4(ctx context.Context) { tool := aiusechat.GetAdderToolDefinition() tools := []uctypes.ToolDefinition{tool} testGemini(ctx, DefaultGeminiModel, "what is 2+2+8, use the provider adder tool", tools) } func printUsage() { fmt.Println("Usage: go run main-testai.go [--anthropic|--openaicomp|--openrouter|--nanogpt|--gemini] [--tools] [--model ] [message]") fmt.Println("Examples:") fmt.Println(" go run main-testai.go 'What is 2+2?'") fmt.Println(" go run main-testai.go --model o4-mini 'What is 2+2?'") fmt.Println(" go run main-testai.go --anthropic 'What is 2+2?'") fmt.Println(" go run main-testai.go --anthropic --model claude-3-5-sonnet-20241022 'What is 2+2?'") fmt.Println(" go run main-testai.go --openaicomp --model gpt-4o 'What is 2+2?'") fmt.Println(" go run main-testai.go --openrouter 'What is 2+2?'") fmt.Println(" go run main-testai.go --openrouter --model anthropic/claude-3.5-sonnet 'What is 2+2?'") fmt.Println(" go run main-testai.go --nanogpt 'What is 2+2?'") fmt.Println(" go run main-testai.go --nanogpt --model gpt-4o 'What is 2+2?'") fmt.Println(" go run main-testai.go --gemini 'What is 2+2?'") fmt.Println(" go run main-testai.go --gemini --model gemini-1.5-pro 'What is 2+2?'") fmt.Println(" go run main-testai.go --tools 'Help me configure GitHub Actions monitoring'") fmt.Println("") fmt.Println("Default models:") fmt.Printf(" OpenAI: %s\n", DefaultOpenAIModel) fmt.Printf(" Anthropic: %s\n", DefaultAnthropicModel) fmt.Printf(" OpenAI Completions: gpt-4o\n") fmt.Printf(" OpenRouter: %s\n", DefaultOpenRouterModel) fmt.Printf(" NanoGPT: %s\n", DefaultNanoGPTModel) fmt.Printf(" Google Gemini: %s\n", DefaultGeminiModel) fmt.Println("") fmt.Println("Environment variables:") fmt.Println(" OPENAI_APIKEY (for OpenAI models)") fmt.Println(" ANTHROPIC_APIKEY (for Anthropic models)") fmt.Println(" OPENROUTER_APIKEY (for OpenRouter models)") fmt.Println(" NANOGPT_KEY (for NanoGPT models)") fmt.Println(" GOOGLE_APIKEY (for Google Gemini models)") } func main() { var anthropic, openaicomp, openrouter, nanogpt, gemini, tools, help, t1, t2, t3, t4 bool var model string flag.BoolVar(&anthropic, "anthropic", false, "Use Anthropic API instead of OpenAI") flag.BoolVar(&openaicomp, "openaicomp", false, "Use OpenAI Completions API") flag.BoolVar(&openrouter, "openrouter", false, "Use OpenRouter API") flag.BoolVar(&nanogpt, "nanogpt", false, "Use NanoGPT API") flag.BoolVar(&gemini, "gemini", false, "Use Google Gemini API") flag.BoolVar(&tools, "tools", false, "Enable GitHub Actions Monitor tools for testing") flag.StringVar(&model, "model", "", fmt.Sprintf("AI model to use (defaults: %s for OpenAI, %s for Anthropic, %s for OpenRouter, %s for NanoGPT, %s for Gemini)", DefaultOpenAIModel, DefaultAnthropicModel, DefaultOpenRouterModel, DefaultNanoGPTModel, DefaultGeminiModel)) flag.BoolVar(&help, "help", false, "Show usage information") flag.BoolVar(&t1, "t1", false, fmt.Sprintf("Run preset T1 test (%s with 'what is 2+2')", DefaultAnthropicModel)) flag.BoolVar(&t2, "t2", false, fmt.Sprintf("Run preset T2 test (%s with 'what is 2+2')", DefaultOpenAIModel)) flag.BoolVar(&t3, "t3", false, "Run preset T3 test (OpenAI Completions API with gpt-5.1)") flag.BoolVar(&t4, "t4", false, "Run preset T4 test (OpenAI Completions API with gemini-3-pro-preview)") flag.Parse() if help { printUsage() os.Exit(0) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() if t1 { testT1(ctx) return } if t2 { testT2(ctx) return } if t3 { testT3(ctx) return } if t4 { testT4(ctx) return } // Set default model based on API type if not provided if model == "" { if anthropic { model = DefaultAnthropicModel } else if openaicomp { model = "gpt-4o" } else if openrouter { model = DefaultOpenRouterModel } else if nanogpt { model = DefaultNanoGPTModel } else if gemini { model = DefaultGeminiModel } else { model = DefaultOpenAIModel } } args := flag.Args() message := "What is 2+2?" if len(args) > 0 { message = args[0] } var toolDefs []uctypes.ToolDefinition if tools { toolDefs = getToolDefinitions() } if anthropic { testAnthropic(ctx, model, message, toolDefs) } else if openaicomp { testOpenAIComp(ctx, model, message, toolDefs) } else if openrouter { testOpenRouter(ctx, model, message, toolDefs) } else if nanogpt { testNanoGPT(ctx, model, message, toolDefs) } else if gemini { testGemini(ctx, model, message, toolDefs) } else { testOpenAI(ctx, model, message, toolDefs) } } ================================================ FILE: cmd/testai/testschema.json ================================================ { "config": { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "Application configuration settings", "properties": { "maxWorkflowRuns": { "description": "Maximum number of workflow runs to fetch", "maximum": 100, "minimum": 1, "type": "integer" }, "pollInterval": { "description": "Polling interval for GitHub API requests", "maximum": 300, "minimum": 1, "type": "integer", "units": "s" }, "repository": { "description": "GitHub repository in owner/repo format", "pattern": "^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$", "type": "string" }, "workflow": { "description": "GitHub Actions workflow file name", "pattern": "^.+\\.(yml|yaml)$", "type": "string" } }, "title": "Application Configuration", "type": "object" }, "data": { "$schema": "https://json-schema.org/draft/2020-12/schema", "definitions": { "WorkflowRun": { "properties": { "conclusion": { "type": "string" }, "created_at": { "format": "date-time", "type": "string" }, "html_url": { "type": "string" }, "id": { "type": "integer" }, "name": { "type": "string" }, "run_number": { "type": "integer" }, "status": { "type": "string" }, "updated_at": { "format": "date-time", "type": "string" } }, "required": [ "id", "name", "status", "conclusion", "created_at", "updated_at", "html_url", "run_number" ], "type": "object" } }, "description": "Application data schema", "properties": { "isLoading": { "description": "Loading state for workflow data fetch", "type": "boolean" }, "lastError": { "description": "Last error message from GitHub API", "type": "string" }, "lastRefreshTime": { "description": "Timestamp of last successful data refresh", "format": "date-time", "type": "string" }, "workflowRuns": { "description": "List of GitHub Actions workflow runs", "items": { "$ref": "#/definitions/WorkflowRun" }, "type": "array" } }, "title": "Application Data", "type": "object" } } ================================================ FILE: cmd/testopenai/main-testopenai.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "bufio" "bytes" "context" "encoding/json" "flag" "fmt" "io" "net/http" "os" "time" "github.com/wavetermdev/waveterm/pkg/aiusechat" "github.com/wavetermdev/waveterm/pkg/aiusechat/openai" ) func makeOpenAIRequest(ctx context.Context, apiKey, model, message string, tools bool) error { reqBody := openai.OpenAIRequest{ Model: model, Input: []any{ openai.OpenAIMessage{ Role: "user", Content: []openai.OpenAIMessageContent{ { Type: "input_text", Text: message, }, }, }, }, Stream: true, StreamOptions: &openai.StreamOptionsType{IncludeObfuscation: false}, Reasoning: &openai.ReasoningType{Effort: "medium"}, } if tools { reqBody.Tools = []openai.OpenAIRequestTool{ openai.ConvertToolDefinitionToOpenAI(aiusechat.GetAdderToolDefinition()), } } jsonData, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("error marshaling request: %v", err) } // Pretty print the request JSON for debugging prettyJSON, err := json.MarshalIndent(reqBody, "", " ") if err == nil { fmt.Printf("Request JSON:\n%s\n", string(prettyJSON)) } req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/responses", bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("error creating request: %v", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Accept", "text/event-stream") client := &http.Client{ Timeout: 60 * time.Second, } resp, err := client.Do(req) if err != nil { return fmt.Errorf("error making request: %v", err) } defer resp.Body.Close() fmt.Printf("Response Status: %s\n", resp.Status) fmt.Printf("Response Headers:\n") for name, values := range resp.Header { for _, value := range values { fmt.Printf(" %s: %s\n", name, value) } } fmt.Println("---") if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) } return processSSEStream(resp.Body) } func processSSEStream(reader io.Reader) error { scanner := bufio.NewScanner(reader) fmt.Println("SSE Stream:") fmt.Println("---") for scanner.Scan() { line := scanner.Text() fmt.Println(line) } if err := scanner.Err(); err != nil { return fmt.Errorf("error reading stream: %v", err) } return nil } func printUsage() { fmt.Println("Usage: go run main-testopenai.go [--model ] [--tools] [message]") fmt.Println("Examples:") fmt.Println(" go run main-testopenai.go 'Stream me a limerick about gophers coding in Go.'") fmt.Println(" go run main-testopenai.go --model gpt-4 'What is 2+2?'") fmt.Println(" go run main-testopenai.go --tools 'What is 2+2? Use the adder tool.'") fmt.Println("") fmt.Println("Default model: gpt-5-mini") fmt.Println("") fmt.Println("Environment variables:") fmt.Println(" OPENAI_APIKEY (required)") } func main() { var model string var showHelp bool var tools bool flag.StringVar(&model, "model", "gpt-5-mini", "OpenAI model to use") flag.BoolVar(&showHelp, "help", false, "Show usage information") flag.BoolVar(&tools, "tools", false, "Enable tools for testing") flag.Parse() if showHelp { printUsage() os.Exit(0) } apiKey := os.Getenv("OPENAI_APIKEY") if apiKey == "" { fmt.Println("Error: OPENAI_APIKEY environment variable not set") printUsage() os.Exit(1) } args := flag.Args() message := "Stream me a limerick about gophers coding in Go." if len(args) > 0 { message = args[0] } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() fmt.Printf("Testing OpenAI Responses API\n") fmt.Printf("Model: %s\n", model) fmt.Printf("Message: %s\n", message) fmt.Println("===") if err := makeOpenAIRequest(ctx, apiKey, model, message, tools); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } } ================================================ FILE: cmd/testsummarize/main-testsummarize.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "context" "flag" "fmt" "os" "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/google" ) func printUsage() { fmt.Println("Usage: go run main-testsummarize.go [--help] [--mode MODE] ") fmt.Println("Examples:") fmt.Println(" go run main-testsummarize.go README.md") fmt.Println(" go run main-testsummarize.go --mode useful /path/to/image.png") fmt.Println(" go run main-testsummarize.go -m publiccode document.pdf") fmt.Println("") fmt.Println("Supported file types:") fmt.Println(" - Text files (up to 200KB)") fmt.Println(" - Images (up to 7MB)") fmt.Println(" - PDFs (up to 5MB)") fmt.Println("") fmt.Println("Flags:") fmt.Println(" --mode, -m Summarization mode (default: quick)") fmt.Println(" Options: quick, useful, publiccode, htmlcontent, htmlfull") fmt.Println("") fmt.Println("Environment variables:") fmt.Println(" GOOGLE_APIKEY (required)") } func main() { var showHelp bool var mode string flag.BoolVar(&showHelp, "help", false, "Show usage information") flag.StringVar(&mode, "mode", "quick", "Summarization mode") flag.StringVar(&mode, "m", "quick", "Summarization mode (shorthand)") flag.Parse() if showHelp { printUsage() os.Exit(0) } apiKey := os.Getenv("GOOGLE_APIKEY") if apiKey == "" { fmt.Println("Error: GOOGLE_APIKEY environment variable not set") printUsage() os.Exit(1) } args := flag.Args() if len(args) == 0 { fmt.Println("Error: filename required") printUsage() os.Exit(1) } filename := args[0] // Check if file exists if _, err := os.Stat(filename); os.IsNotExist(err) { fmt.Printf("Error: file '%s' does not exist\n", filename) os.Exit(1) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() fmt.Printf("Summarizing file: %s\n", filename) fmt.Printf("Model: %s\n", google.SummarizeModel) fmt.Printf("Mode: %s\n", mode) startTime := time.Now() summary, usage, err := google.SummarizeFile(ctx, filename, google.SummarizeOpts{ APIKey: apiKey, Mode: mode, }) latency := time.Since(startTime) fmt.Printf("Latency: %d ms\n", latency.Milliseconds()) fmt.Println("===") if err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } fmt.Println("\nSummary:") fmt.Println("---") fmt.Println(summary) fmt.Println("---") if usage != nil { fmt.Println("\nUsage Statistics:") fmt.Printf(" Prompt tokens: %d\n", usage.PromptTokenCount) fmt.Printf(" Cached tokens: %d\n", usage.CachedContentTokenCount) fmt.Printf(" Response tokens: %d\n", usage.CandidatesTokenCount) fmt.Printf(" Total tokens: %d\n", usage.TotalTokenCount) } } ================================================ FILE: cmd/wsh/cmd/csscolormap.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd var CssColorNames = map[string]bool{ "aliceblue": true, "antiquewhite": true, "aqua": true, "aquamarine": true, "azure": true, "beige": true, "bisque": true, "black": true, "blanchedalmond": true, "blue": true, "blueviolet": true, "brown": true, "burlywood": true, "cadetblue": true, "chartreuse": true, "chocolate": true, "coral": true, "cornflowerblue": true, "cornsilk": true, "crimson": true, "cyan": true, "darkblue": true, "darkcyan": true, "darkgoldenrod": true, "darkgray": true, "darkgreen": true, "darkkhaki": true, "darkmagenta": true, "darkolivegreen": true, "darkorange": true, "darkorchid": true, "darkred": true, "darksalmon": true, "darkseagreen": true, "darkslateblue": true, "darkslategray": true, "darkturquoise": true, "darkviolet": true, "deeppink": true, "deepskyblue": true, "dimgray": true, "dodgerblue": true, "firebrick": true, "floralwhite": true, "forestgreen": true, "fuchsia": true, "gainsboro": true, "ghostwhite": true, "gold": true, "goldenrod": true, "gray": true, "green": true, "greenyellow": true, "honeydew": true, "hotpink": true, "indianred": true, "indigo": true, "ivory": true, "khaki": true, "lavender": true, "lavenderblush": true, "lawngreen": true, "lemonchiffon": true, "lightblue": true, "lightcoral": true, "lightcyan": true, "lightgoldenrodyellow": true, "lightgray": true, "lightgreen": true, "lightpink": true, "lightsalmon": true, "lightseagreen": true, "lightskyblue": true, "lightslategray": true, "lightsteelblue": true, "lightyellow": true, "lime": true, "limegreen": true, "linen": true, "magenta": true, "maroon": true, "mediumaquamarine": true, "mediumblue": true, "mediumorchid": true, "mediumpurple": true, "mediumseagreen": true, "mediumslateblue": true, "mediumspringgreen": true, "mediumturquoise": true, "mediumvioletred": true, "midnightblue": true, "mintcream": true, "mistyrose": true, "moccasin": true, "navajowhite": true, "navy": true, "oldlace": true, "olive": true, "olivedrab": true, "orange": true, "orangered": true, "orchid": true, "palegoldenrod": true, "palegreen": true, "paleturquoise": true, "palevioletred": true, "papayawhip": true, "peachpuff": true, "peru": true, "pink": true, "plum": true, "powderblue": true, "purple": true, "red": true, "rosybrown": true, "royalblue": true, "saddlebrown": true, "salmon": true, "sandybrown": true, "seagreen": true, "seashell": true, "sienna": true, "silver": true, "skyblue": true, "slateblue": true, "slategray": true, "snow": true, "springgreen": true, "steelblue": true, "tan": true, "teal": true, "thistle": true, "tomato": true, "turquoise": true, "violet": true, "wheat": true, "white": true, "whitesmoke": true, "yellow": true, "yellowgreen": true, } ================================================ FILE: cmd/wsh/cmd/setmeta_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "reflect" "testing" ) func TestParseMetaSets(t *testing.T) { tests := []struct { name string input []string want map[string]any wantErr bool }{ { name: "basic types", input: []string{"str=hello", "num=42", "float=3.14", "bool=true", "null=null"}, want: map[string]any{ "str": "hello", "num": int64(42), "float": float64(3.14), "bool": true, "null": nil, }, }, { name: "json values", input: []string{ `arr=[1,2,3]`, `obj={"foo":"bar"}`, `str="quoted"`, }, want: map[string]any{ "arr": []any{float64(1), float64(2), float64(3)}, "obj": map[string]any{"foo": "bar"}, "str": "quoted", }, }, { name: "nested paths", input: []string{ "a/b=55", "a/c=2", }, want: map[string]any{ "a": map[string]any{ "b": int64(55), "c": int64(2), }, }, }, { name: "deep nesting", input: []string{ "a/b/c/d=hello", }, want: map[string]any{ "a": map[string]any{ "b": map[string]any{ "c": map[string]any{ "d": "hello", }, }, }, }, }, { name: "override nested value", input: []string{ "a/b/c=1", "a/b=2", }, want: map[string]any{ "a": map[string]any{ "b": int64(2), }, }, }, { name: "override with null", input: []string{ "a/b=1", "a/c=2", "a=null", }, want: map[string]any{ "a": nil, }, }, { name: "mixed types in path", input: []string{ "a/b=1", "a/c=[1,2,3]", "a/d/e=true", }, want: map[string]any{ "a": map[string]any{ "b": int64(1), "c": []any{float64(1), float64(2), float64(3)}, "d": map[string]any{ "e": true, }, }, }, }, { name: "invalid format", input: []string{"invalid"}, wantErr: true, }, { name: "invalid json", input: []string{`a={"invalid`}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseMetaSets(tt.input) if (err != nil) != tt.wantErr { t.Errorf("parseMetaSets() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { t.Errorf("parseMetaSets() = %v, want %v", got, tt.want) } }) } } func TestParseMetaValue(t *testing.T) { tests := []struct { name string input string want any wantErr bool }{ {"empty string", "", nil, false}, {"null", "null", nil, false}, {"true", "true", true, false}, {"false", "false", false, false}, {"integer", "42", int64(42), false}, {"negative integer", "-42", int64(-42), false}, {"hex integer", "0xff", int64(255), false}, {"float", "3.14", float64(3.14), false}, {"string", "hello", "hello", false}, {"json array", "[1,2,3]", []any{float64(1), float64(2), float64(3)}, false}, {"json object", `{"foo":"bar"}`, map[string]any{"foo": "bar"}, false}, {"quoted string", `"quoted"`, "quoted", false}, {"invalid json", `{"invalid`, nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseMetaValue(tt.input) if (err != nil) != tt.wantErr { t.Errorf("parseMetaValue() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { t.Errorf("parseMetaValue() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: cmd/wsh/cmd/wshcmd-ai.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/base64" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var aiCmd = &cobra.Command{ Use: "ai [options] [files...]", Short: "Append content to Wave AI sidebar prompt", Long: `Append content to Wave AI sidebar prompt (does not auto-submit by default) Arguments: files... Files to attach (use '-' for stdin) Examples: git diff | wsh ai - # Pipe diff to AI, ask question in UI wsh ai main.go # Attach file, ask question in UI wsh ai *.go -m "find bugs" # Attach files with message wsh ai -s - -m "review" < log.txt # Stdin + message, auto-submit wsh ai -n config.json # New chat with file attached`, RunE: aiRun, PreRunE: preRunSetupRpcClient, DisableFlagsInUseLine: true, } var aiMessageFlag string var aiSubmitFlag bool var aiNewBlockFlag bool func init() { rootCmd.AddCommand(aiCmd) aiCmd.Flags().StringVarP(&aiMessageFlag, "message", "m", "", "optional message/question to append after files") aiCmd.Flags().BoolVarP(&aiSubmitFlag, "submit", "s", false, "submit the prompt immediately after appending") aiCmd.Flags().BoolVarP(&aiNewBlockFlag, "new", "n", false, "create a new AI chat instead of using existing") } func detectMimeType(data []byte) string { mimeType := http.DetectContentType(data) return strings.Split(mimeType, ";")[0] } func getMaxFileSize(mimeType string) (int, string) { if mimeType == "application/pdf" { return 5 * 1024 * 1024, "5MB" } if strings.HasPrefix(mimeType, "image/") { return 7 * 1024 * 1024, "7MB" } return 200 * 1024, "200KB" } func aiRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("ai", rtnErr == nil) }() if len(args) == 0 && aiMessageFlag == "" { OutputHelpMessage(cmd) return fmt.Errorf("no files or message provided") } const maxFileCount = 15 const rpcTimeout = 30000 var allFiles []wshrpc.AIAttachedFile var stdinUsed bool if len(args) > maxFileCount { return fmt.Errorf("too many files (maximum %d files allowed)", maxFileCount) } for _, filePath := range args { var data []byte var fileName string var mimeType string var err error if filePath == "-" { if stdinUsed { return fmt.Errorf("stdin (-) can only be used once") } stdinUsed = true data, err = io.ReadAll(os.Stdin) if err != nil { return fmt.Errorf("reading from stdin: %w", err) } fileName = "stdin" mimeType = "text/plain" } else { fileInfo, err := os.Stat(filePath) if err != nil { return fmt.Errorf("accessing file %s: %w", filePath, err) } absPath, err := filepath.Abs(filePath) if err != nil { return fmt.Errorf("getting absolute path for %s: %w", filePath, err) } if fileInfo.IsDir() { result, err := fileutil.ReadDir(filePath, 500) if err != nil { return fmt.Errorf("reading directory %s: %w", filePath, err) } jsonData, err := json.Marshal(result) if err != nil { return fmt.Errorf("marshaling directory listing for %s: %w", filePath, err) } data = jsonData fileName = absPath mimeType = "directory" } else { data, err = os.ReadFile(filePath) if err != nil { return fmt.Errorf("reading file %s: %w", filePath, err) } fileName = absPath mimeType = detectMimeType(data) } } isPDF := mimeType == "application/pdf" isImage := strings.HasPrefix(mimeType, "image/") isDirectory := mimeType == "directory" if !isPDF && !isImage && !isDirectory { mimeType = "text/plain" if utilfn.ContainsBinaryData(data) { return fmt.Errorf("file %s contains binary data and cannot be uploaded as text", fileName) } } maxSize, sizeStr := getMaxFileSize(mimeType) if len(data) > maxSize { return fmt.Errorf("file %s exceeds maximum size of %s for %s files", fileName, sizeStr, mimeType) } allFiles = append(allFiles, wshrpc.AIAttachedFile{ Name: fileName, Type: mimeType, Size: len(data), Data64: base64.StdEncoding.EncodeToString(data), }) } tabId := os.Getenv("WAVETERM_TABID") if tabId == "" { return fmt.Errorf("WAVETERM_TABID environment variable not set") } route := wshutil.MakeTabRouteId(tabId) if aiNewBlockFlag { newChatData := wshrpc.CommandWaveAIAddContextData{ NewChat: true, } err := wshclient.WaveAIAddContextCommand(RpcClient, newChatData, &wshrpc.RpcOpts{ Route: route, Timeout: rpcTimeout, }) if err != nil { return fmt.Errorf("creating new chat: %w", err) } } for _, file := range allFiles { contextData := wshrpc.CommandWaveAIAddContextData{ Files: []wshrpc.AIAttachedFile{file}, } err := wshclient.WaveAIAddContextCommand(RpcClient, contextData, &wshrpc.RpcOpts{ Route: route, Timeout: rpcTimeout, }) if err != nil { return fmt.Errorf("adding file %s: %w", file.Name, err) } } if aiMessageFlag != "" || aiSubmitFlag { finalContextData := wshrpc.CommandWaveAIAddContextData{ Text: aiMessageFlag, Submit: aiSubmitFlag, } err := wshclient.WaveAIAddContextCommand(RpcClient, finalContextData, &wshrpc.RpcOpts{ Route: route, Timeout: rpcTimeout, }) if err != nil { return fmt.Errorf("adding context: %w", err) } } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-badge.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "runtime" "github.com/google/uuid" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var badgeCmd = &cobra.Command{ Use: "badge [icon]", Short: "set or clear a block badge", Args: cobra.MaximumNArgs(1), RunE: badgeRun, PreRunE: preRunSetupRpcClient, } var ( badgeColor string badgePriority float64 badgeClear bool badgeBeep bool badgePid int ) func init() { rootCmd.AddCommand(badgeCmd) badgeCmd.Flags().StringVar(&badgeColor, "color", "", "badge color") badgeCmd.Flags().Float64Var(&badgePriority, "priority", 10, "badge priority") badgeCmd.Flags().BoolVar(&badgeClear, "clear", false, "clear the badge") badgeCmd.Flags().BoolVar(&badgeBeep, "beep", false, "play system bell sound") badgeCmd.Flags().IntVar(&badgePid, "pid", 0, "watch a pid and automatically clear the badge when it exits (default priority 5)") } func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("badge", rtnErr == nil) }() if badgePid > 0 && runtime.GOOS == "windows" { return fmt.Errorf("--pid flag is not supported on Windows") } if badgePid > 0 && !cmd.Flags().Changed("priority") { badgePriority = 5 } oref, err := resolveBlockArg() if err != nil { return fmt.Errorf("resolving block: %v", err) } if oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab { return fmt.Errorf("badge oref must be a block or tab (got %q)", oref.OType) } var eventData baseds.BadgeEvent eventData.ORef = oref.String() if badgeClear { eventData.Clear = true } else { icon := "circle-small" if len(args) > 0 { icon = args[0] } badgeId, err := uuid.NewV7() if err != nil { return fmt.Errorf("generating badge id: %v", err) } eventData.Badge = &baseds.Badge{ BadgeId: badgeId.String(), Icon: icon, Color: badgeColor, Priority: badgePriority, PidLinked: badgePid > 0, } } event := wps.WaveEvent{ Event: wps.Event_Badge, Scopes: []string{oref.String()}, Data: eventData, } err = wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) if err != nil { return fmt.Errorf("publishing badge event: %v", err) } if badgeBeep { err = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: "electron"}) if err != nil { return fmt.Errorf("playing system bell: %v", err) } } if badgePid > 0 && eventData.Badge != nil { conn := RpcContext.Conn if conn == "" { conn = wshrpc.LocalConnName } connRoute := wshutil.MakeConnectionRouteId(conn) watchData := wshrpc.CommandBadgeWatchPidData{ Pid: badgePid, ORef: *oref, BadgeId: eventData.Badge.BadgeId, } err = wshclient.BadgeWatchPidCommand(RpcClient, watchData, &wshrpc.RpcOpts{Route: connRoute}) if err != nil { return fmt.Errorf("watching pid: %v", err) } } if badgeClear { fmt.Printf("badge cleared\n") } else { fmt.Printf("badge set\n") } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-blocks.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/json" "fmt" "sort" "strings" "text/tabwriter" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) // Command-line flags for the blocks commands var ( blocksWindowId string // Window ID to filter blocks by blocksWorkspaceId string // Workspace ID to filter blocks by blocksTabId string // Tab ID to filter blocks by blocksView string // View type to filter blocks by (term, web, etc.) blocksJSON bool // Whether to output as JSON blocksTimeout int // Timeout in milliseconds for RPC calls ) // BlockDetails represents the information about a block returned by the list command type BlockDetails struct { BlockId string `json:"blockid"` // Unique identifier for the block WorkspaceId string `json:"workspaceid"` // ID of the workspace containing the block TabId string `json:"tabid"` // ID of the tab containing the block View string `json:"view"` // Canonical view type (term, web, preview, edit, sysinfo, waveai) Meta waveobj.MetaMapType `json:"meta"` // Block metadata including view type } // blocksListCmd represents the 'blocks list' command var blocksListCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls", "get"}, Short: "List blocks in workspaces/windows", Long: `List blocks with optional filtering by workspace, window, tab, or view type. Examples: # List blocks from all workspaces wsh blocks list # List only terminal blocks wsh blocks list --view=term # Filter by window ID (get IDs from 'wsh workspace list') wsh blocks list --window=dbca23b5-f89b-4780-a0fe-452f5bc7d900 # Filter by workspace ID wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114 # Filter by tab ID wsh blocks list --tab=a0459921-cc1a-48cc-ae7b-5f4821e1c9e1 # Output as JSON for scripting wsh blocks list --json # Set a different timeout (in milliseconds) wsh blocks list --timeout=10000`, RunE: blocksListRun, PreRunE: preRunSetupRpcClient, SilenceUsage: true, } // init registers the blocks commands with the root command // It configures all the flags and command options func init() { blocksListCmd.Flags().StringVar(&blocksWindowId, "window", "", "restrict to window id") blocksListCmd.Flags().StringVar(&blocksWorkspaceId, "workspace", "", "restrict to workspace id") blocksListCmd.Flags().StringVar(&blocksTabId, "tab", "", "restrict to specific tab id") blocksListCmd.Flags().StringVar(&blocksView, "view", "", "restrict to view type (term/terminal, web/browser, preview/edit, sysinfo, waveai)") blocksListCmd.Flags().BoolVar(&blocksJSON, "json", false, "output as JSON") blocksListCmd.Flags().IntVar(&blocksTimeout, "timeout", 5000, "timeout in milliseconds for RPC calls (default: 5000)") for _, cmd := range rootCmd.Commands() { if cmd.Use == "blocks" { cmd.AddCommand(blocksListCmd) return } } blocksCmd := &cobra.Command{ Use: "blocks", Short: "Manage blocks", Long: "Commands for working with blocks", } blocksCmd.AddCommand(blocksListCmd) rootCmd.AddCommand(blocksCmd) } // blocksListRun implements the 'blocks list' command // It retrieves and displays blocks with optional filtering by workspace, window, tab, or view type func blocksListRun(cmd *cobra.Command, args []string) error { if v := strings.TrimSpace(blocksView); v != "" { if !isKnownViewFilter(v) { return fmt.Errorf("unknown --view %q; try one of: term, web, preview, edit, sysinfo, waveai", v) } } var allBlocks []BlockDetails workspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)}) if err != nil { return fmt.Errorf("failed to list workspaces: %v", err) } if len(workspaces) == 0 { return fmt.Errorf("no workspaces found") } var workspaceIdsToQuery []string // Determine which workspaces to query if blocksWorkspaceId != "" && blocksWindowId != "" { return fmt.Errorf("--workspace and --window are mutually exclusive; specify only one") } if blocksWorkspaceId != "" { workspaceIdsToQuery = []string{blocksWorkspaceId} } else if blocksWindowId != "" { // Find workspace ID for this window windowFound := false for _, ws := range workspaces { if ws.WindowId == blocksWindowId { workspaceIdsToQuery = []string{ws.WorkspaceData.OID} windowFound = true break } } if !windowFound { return fmt.Errorf("window %s not found", blocksWindowId) } } else { // Default to all workspaces for _, ws := range workspaces { workspaceIdsToQuery = append(workspaceIdsToQuery, ws.WorkspaceData.OID) } } // Query each selected workspace hadSuccess := false for _, wsId := range workspaceIdsToQuery { req := wshrpc.BlocksListRequest{WorkspaceId: wsId} if blocksWindowId != "" { req.WindowId = blocksWindowId } blocks, err := wshclient.BlocksListCommand(RpcClient, req, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)}) if err != nil { WriteStderr("Warning: couldn't list blocks for workspace %s: %v\n", wsId, err) continue } hadSuccess = true // Apply filters for _, b := range blocks { if blocksTabId != "" && b.TabId != blocksTabId { continue } if blocksView != "" { view := b.Meta.GetString(waveobj.MetaKey_View, "") // Support view type aliases if !matchesViewType(view, blocksView) { continue } } v := b.Meta.GetString(waveobj.MetaKey_View, "") allBlocks = append(allBlocks, BlockDetails{ BlockId: b.BlockId, WorkspaceId: b.WorkspaceId, TabId: b.TabId, View: v, Meta: b.Meta, }) } } // No blocks found check if len(allBlocks) == 0 { if !hadSuccess { return fmt.Errorf("failed to list blocks from all %d workspace(s)", len(workspaceIdsToQuery)) } WriteStdout("No blocks found\n") return nil } // Stable ordering for both JSON and table output sort.SliceStable(allBlocks, func(i, j int) bool { if allBlocks[i].WorkspaceId != allBlocks[j].WorkspaceId { return allBlocks[i].WorkspaceId < allBlocks[j].WorkspaceId } if allBlocks[i].TabId != allBlocks[j].TabId { return allBlocks[i].TabId < allBlocks[j].TabId } return allBlocks[i].BlockId < allBlocks[j].BlockId }) // Output results if blocksJSON { bytes, err := json.MarshalIndent(allBlocks, "", " ") if err != nil { return fmt.Errorf("failed to marshal JSON: %v", err) } WriteStdout("%s\n", string(bytes)) return nil } w := tabwriter.NewWriter(WrappedStdout, 0, 0, 2, ' ', 0) defer w.Flush() fmt.Fprintf(w, "BLOCK ID\tWORKSPACE\tTAB ID\tVIEW\tCONTENT\n") for _, b := range allBlocks { blockID := b.BlockId if len(blockID) > 36 { blockID = blockID[:34] + ".." } view := strings.ToLower(b.View) if view == "" { view = "" } var content string switch view { case "preview", "edit": content = b.Meta.GetString(waveobj.MetaKey_File, "") case "web": content = b.Meta.GetString(waveobj.MetaKey_Url, "") case "term": content = b.Meta.GetString(waveobj.MetaKey_CmdCwd, "") default: content = "" } wsID := b.WorkspaceId if len(wsID) > 36 { wsID = wsID[:34] + ".." } tabID := b.TabId if len(tabID) > 36 { tabID = tabID[0:34] + ".." } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", blockID, wsID, tabID, view, content) } return nil } // matchesViewType checks if a view type matches a filter, supporting aliases func matchesViewType(actual, filter string) bool { // Direct match (case insensitive) if strings.EqualFold(actual, filter) { return true } // Handle aliases switch strings.ToLower(filter) { case "preview", "edit": return strings.EqualFold(actual, "preview") || strings.EqualFold(actual, "edit") case "terminal", "term", "shell", "console": return strings.EqualFold(actual, "term") case "web", "browser", "url": return strings.EqualFold(actual, "web") case "ai", "waveai", "assistant": return strings.EqualFold(actual, "waveai") case "sys", "sysinfo", "system": return strings.EqualFold(actual, "sysinfo") } return false } // isKnownViewFilter checks if a filter value is recognized func isKnownViewFilter(f string) bool { switch strings.ToLower(strings.TrimSpace(f)) { case "term", "terminal", "shell", "console", "web", "browser", "url", "preview", "edit", "sysinfo", "sys", "system", "waveai", "ai", "assistant": return true default: return false } } ================================================ FILE: cmd/wsh/cmd/wshcmd-conn.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var connCmd = &cobra.Command{ Use: "conn", Short: "manage Wave Terminal connections", Long: "Commands to manage Wave Terminal SSH and WSL connections", } var connStatusCmd = &cobra.Command{ Use: "status", Short: "show status of all connections", Args: cobra.NoArgs, RunE: connStatusRun, PreRunE: preRunSetupRpcClient, } var connReinstallCmd = &cobra.Command{ Use: "reinstall CONNECTION", Short: "reinstall wsh on a connection", RunE: connReinstallRun, PreRunE: preRunSetupRpcClient, } var connDisconnectCmd = &cobra.Command{ Use: "disconnect CONNECTION", Short: "disconnect a connection", Args: cobra.ExactArgs(1), RunE: connDisconnectRun, PreRunE: preRunSetupRpcClient, } var connDisconnectAllCmd = &cobra.Command{ Use: "disconnectall", Short: "disconnect all connections", Args: cobra.NoArgs, RunE: connDisconnectAllRun, PreRunE: preRunSetupRpcClient, } var connConnectCmd = &cobra.Command{ Use: "connect CONNECTION", Short: "connect to a connection", Args: cobra.ExactArgs(1), RunE: connConnectRun, PreRunE: preRunSetupRpcClient, } var connEnsureCmd = &cobra.Command{ Use: "ensure CONNECTION", Short: "ensure wsh is installed on a connection", Args: cobra.ExactArgs(1), RunE: connEnsureRun, PreRunE: preRunSetupRpcClient, } func init() { rootCmd.AddCommand(connCmd) connCmd.AddCommand(connStatusCmd) connCmd.AddCommand(connReinstallCmd) connCmd.AddCommand(connDisconnectCmd) connCmd.AddCommand(connDisconnectAllCmd) connCmd.AddCommand(connConnectCmd) connCmd.AddCommand(connEnsureCmd) } func validateConnectionName(name string) error { if !strings.HasPrefix(name, "wsl://") { _, err := remote.ParseOpts(name) if err != nil { return fmt.Errorf("cannot parse connection name: %w", err) } } return nil } func getAllConnStatus() ([]wshrpc.ConnStatus, error) { var allResp []wshrpc.ConnStatus sshResp, err := wshclient.ConnStatusCommand(RpcClient, nil) if err != nil { return nil, fmt.Errorf("getting ssh connection status: %w", err) } allResp = append(allResp, sshResp...) wslResp, err := wshclient.WslStatusCommand(RpcClient, nil) if err != nil { return nil, fmt.Errorf("getting wsl connection status: %w", err) } allResp = append(allResp, wslResp...) return allResp, nil } func connStatusRun(cmd *cobra.Command, args []string) error { allResp, err := getAllConnStatus() if err != nil { return err } if len(allResp) == 0 { WriteStdout("no connections\n") return nil } WriteStdout("%-30s %-12s\n", "connection", "status") WriteStdout("----------------------------------------------\n") for _, conn := range allResp { str := fmt.Sprintf("%-30s %-12s", conn.Connection, conn.Status) if conn.Error != "" { str += fmt.Sprintf(" (%s)", conn.Error) } WriteStdout("%s\n", str) } return nil } func connReinstallRun(cmd *cobra.Command, args []string) error { if len(args) != 1 { if RpcContext.Conn == "" { return fmt.Errorf("no connection specified") } args = []string{RpcContext.Conn} } connName := args[0] if err := validateConnectionName(connName); err != nil { return err } data := wshrpc.ConnExtData{ ConnName: connName, LogBlockId: RpcContext.BlockId, } err := wshclient.ConnReinstallWshCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000}) if err != nil { return fmt.Errorf("reinstalling connection: %w", err) } WriteStdout("wsh reinstalled on connection %q\n", connName) return nil } func connDisconnectRun(cmd *cobra.Command, args []string) error { connName := args[0] if err := validateConnectionName(connName); err != nil { return err } err := wshclient.ConnDisconnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 10000}) if err != nil { return fmt.Errorf("disconnecting %q error: %w", connName, err) } WriteStdout("disconnected %q\n", connName) return nil } func connDisconnectAllRun(cmd *cobra.Command, args []string) error { allConns, err := getAllConnStatus() if err != nil { return err } for _, conn := range allConns { if conn.Status != "connected" { continue } err := wshclient.ConnDisconnectCommand(RpcClient, conn.Connection, &wshrpc.RpcOpts{Timeout: 10000}) if err != nil { WriteStdout("error disconnecting %q: %v\n", conn.Connection, err) } else { WriteStdout("disconnected %q\n", conn.Connection) } } return nil } func connConnectRun(cmd *cobra.Command, args []string) error { connName := args[0] if err := validateConnectionName(connName); err != nil { return err } data := wshrpc.ConnRequest{ Host: connName, LogBlockId: RpcContext.BlockId, } err := wshclient.ConnConnectCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000}) if err != nil { return fmt.Errorf("connecting connection: %w", err) } WriteStdout("connected connection %q\n", connName) return nil } func connEnsureRun(cmd *cobra.Command, args []string) error { connName := args[0] if err := validateConnectionName(connName); err != nil { return err } data := wshrpc.ConnExtData{ ConnName: connName, LogBlockId: RpcContext.BlockId, } err := wshclient.ConnEnsureCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 60000}) if err != nil { return fmt.Errorf("ensuring connection: %w", err) } WriteStdout("wsh ensured on connection %q\n", connName) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-connserver.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/base64" "fmt" "io" "log" "net" "os" "path/filepath" "strings" "sync/atomic" "time" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/packetparser" "github.com/wavetermdev/waveterm/pkg/util/sigutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var serverCmd = &cobra.Command{ Use: "connserver", Hidden: true, Short: "remote server to power wave blocks", Args: cobra.NoArgs, RunE: serverRun, } const ( JobLogRetentionTime = 48 * time.Hour JobLogCleanupDelay = 10 * time.Second JobLogCleanupInterval = 1 * time.Hour ) var connServerRouter bool var connServerRouterDomainSocket bool var connServerConnName string var connServerDev bool var ConnServerWshRouter *wshutil.WshRouter var connServerInitialEnv map[string]string func init() { serverCmd.Flags().BoolVar(&connServerRouter, "router", false, "run in local router mode (stdio upstream)") serverCmd.Flags().BoolVar(&connServerRouterDomainSocket, "router-domainsocket", false, "run in local router mode (domain socket upstream)") serverCmd.Flags().StringVar(&connServerConnName, "conn", "", "connection name") serverCmd.Flags().BoolVar(&connServerDev, "dev", false, "enable dev mode with file logging and PID in logs") rootCmd.AddCommand(serverCmd) } func cleanupOldJobLogs() { jobDir := wavebase.GetRemoteJobLogDir() entries, err := os.ReadDir(jobDir) if err != nil { return } cutoffTime := time.Now().Add(-JobLogRetentionTime) for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if !strings.HasSuffix(name, ".log") { continue } info, err := entry.Info() if err != nil { continue } if info.ModTime().Before(cutoffTime) { filePath := filepath.Join(jobDir, name) err := os.Remove(filePath) if err != nil { log.Printf("error removing old job log file %s: %v", filePath, err) } else { log.Printf("removed old job log file: %s", filePath) } } } } func startJobLogCleanup() { go func() { defer func() { panichandler.PanicHandler("startJobLogCleanup", recover()) }() time.Sleep(JobLogCleanupDelay) cleanupOldJobLogs() ticker := time.NewTicker(JobLogCleanupInterval) defer ticker.Stop() for range ticker.C { cleanupOldJobLogs() } }() } func getRemoteDomainSocketName() string { homeDir := wavebase.GetHomeDir() return filepath.Join(homeDir, wavebase.RemoteWaveHomeDirName, wavebase.RemoteDomainSocketBaseName) } func MakeRemoteUnixListener() (net.Listener, error) { serverAddr := getRemoteDomainSocketName() os.Remove(serverAddr) // ignore error rtn, err := net.Listen("unix", serverAddr) if err != nil { return nil, fmt.Errorf("error creating listener at %v: %v", serverAddr, err) } os.Chmod(serverAddr, 0700) log.Printf("Server [unix-domain] listening on %s\n", serverAddr) return rtn, nil } func handleNewListenerConn(conn net.Conn, router *wshutil.WshRouter) { defer func() { panichandler.PanicHandler("handleNewListenerConn", recover()) }() var linkIdContainer atomic.Int32 proxy := wshutil.MakeRpcProxy(fmt.Sprintf("connserver:%s", conn.RemoteAddr().String())) go func() { defer func() { panichandler.PanicHandler("handleNewListenerConn:AdaptOutputChToStream", recover()) }() writeErr := wshutil.AdaptOutputChToStream(proxy.ToRemoteCh, conn) if writeErr != nil { log.Printf("error writing to domain socket: %v\n", writeErr) } }() go func() { // when input is closed, close the connection defer func() { panichandler.PanicHandler("handleNewListenerConn:AdaptStreamToMsgCh", recover()) }() defer func() { conn.Close() linkId := linkIdContainer.Load() if linkId != baseds.NoLinkId { router.UnregisterLink(baseds.LinkId(linkId)) } }() wshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh, nil) }() linkId := router.RegisterUntrustedLink(proxy) linkIdContainer.Store(int32(linkId)) } func runListener(listener net.Listener, router *wshutil.WshRouter) { defer func() { log.Printf("listener closed, exiting\n") time.Sleep(500 * time.Millisecond) wshutil.DoShutdown("", 1, true) }() for { conn, err := listener.Accept() if err == io.EOF { break } if err != nil { log.Printf("error accepting connection: %v\n", err) continue } go handleNewListenerConn(conn, router) } } func setupConnServerRpcClientWithRouter(router *wshutil.WshRouter, sockName string) (*wshutil.WshRpc, error) { routeId := wshutil.MakeConnectionRouteId(connServerConnName) rpcCtx := wshrpc.RpcContext{ RouteId: routeId, Conn: connServerConnName, } bareRouteId := wshutil.MakeRandomProcRouteId() bareClient := wshutil.MakeWshRpc(wshrpc.RpcContext{}, &wshclient.WshServer{}, bareRouteId) router.RegisterTrustedLeaf(bareClient, bareRouteId) connServerClient := wshutil.MakeWshRpc(rpcCtx, wshremote.MakeRemoteRpcServerImpl(os.Stdout, router, bareClient, false, connServerInitialEnv, sockName), routeId) router.RegisterTrustedLeaf(connServerClient, routeId) return connServerClient, nil } func serverRunRouter() error { log.Printf("starting connserver router") router := wshutil.NewWshRouter() ConnServerWshRouter = router termProxy := wshutil.MakeRpcProxy("connserver-term") rawCh := make(chan []byte, wshutil.DefaultOutputChSize) go func() { defer func() { panichandler.PanicHandler("serverRunRouter:Parse", recover()) }() packetparser.Parse(os.Stdin, termProxy.FromRemoteCh, rawCh) }() go func() { defer func() { panichandler.PanicHandler("serverRunRouter:WritePackets", recover()) }() for msg := range termProxy.ToRemoteCh { packetparser.WritePacket(os.Stdout, msg) } }() go func() { defer func() { panichandler.PanicHandler("serverRunRouter:drainRawCh", recover()) }() defer func() { log.Printf("stdin closed, shutting down") wshutil.DoShutdown("", 0, true) }() for range rawCh { // ignore } }() router.RegisterUpstream(termProxy) sockName := getRemoteDomainSocketName() // setup the connserver rpc client first client, err := setupConnServerRpcClientWithRouter(router, sockName) if err != nil { return fmt.Errorf("error setting up connserver rpc client: %v", err) } wshfs.RpcClient = client log.Printf("trying to get JWT public key") // fetch and set JWT public key jwtPublicKeyB64, err := wshclient.GetJwtPublicKeyCommand(client, nil) if err != nil { return fmt.Errorf("error getting jwt public key: %v", err) } jwtPublicKeyBytes, err := base64.StdEncoding.DecodeString(jwtPublicKeyB64) if err != nil { return fmt.Errorf("error decoding jwt public key: %v", err) } err = wavejwt.SetPublicKey(jwtPublicKeyBytes) if err != nil { return fmt.Errorf("error setting jwt public key: %v", err) } log.Printf("got JWT public key") // now set up the domain socket unixListener, err := MakeRemoteUnixListener() if err != nil { return fmt.Errorf("cannot create unix listener: %v", err) } log.Printf("unix listener started") go func() { defer func() { panichandler.PanicHandler("serverRunRouter:runListener", recover()) }() runListener(unixListener, router) }() // run the sysinfo loop go func() { defer func() { panichandler.PanicHandler("serverRunRouter:RunSysInfoLoop", recover()) }() wshremote.RunSysInfoLoop(client, connServerConnName) }() startJobLogCleanup() log.Printf("running server, successfully started") select {} } func serverRunRouterDomainSocket(jwtToken string) error { log.Printf("starting connserver router (domain socket upstream)") // extract socket name from JWT token (unverified - we're on the client side) sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) if err != nil { return fmt.Errorf("error extracting socket name from JWT: %v", err) } // connect to the forwarded domain socket sockName = wavebase.ExpandHomeDirSafe(sockName) conn, err := net.Dial("unix", sockName) if err != nil { return fmt.Errorf("error connecting to domain socket %s: %v", sockName, err) } // create router router := wshutil.NewWshRouter() ConnServerWshRouter = router // create proxy for the domain socket connection upstreamProxy := wshutil.MakeRpcProxy("connserver-upstream") // goroutine to write to the domain socket go func() { defer func() { panichandler.PanicHandler("serverRunRouterDomainSocket:WriteLoop", recover()) }() writeErr := wshutil.AdaptOutputChToStream(upstreamProxy.ToRemoteCh, conn) if writeErr != nil { log.Printf("error writing to upstream domain socket: %v\n", writeErr) } }() // goroutine to read from the domain socket go func() { defer func() { panichandler.PanicHandler("serverRunRouterDomainSocket:ReadLoop", recover()) }() defer func() { log.Printf("upstream domain socket closed, shutting down") wshutil.DoShutdown("", 0, true) }() wshutil.AdaptStreamToMsgCh(conn, upstreamProxy.FromRemoteCh, nil) }() // register the domain socket connection as upstream router.RegisterUpstream(upstreamProxy) // use the router's control RPC to authenticate with upstream controlRpc := router.GetControlRpc() // authenticate with the upstream router using the JWT _, err = wshclient.AuthenticateCommand(controlRpc, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRootRoute}) if err != nil { return fmt.Errorf("error authenticating with upstream: %v", err) } log.Printf("authenticated with upstream router") // fetch and set JWT public key log.Printf("trying to get JWT public key") jwtPublicKeyB64, err := wshclient.GetJwtPublicKeyCommand(controlRpc, nil) if err != nil { return fmt.Errorf("error getting jwt public key: %v", err) } jwtPublicKeyBytes, err := base64.StdEncoding.DecodeString(jwtPublicKeyB64) if err != nil { return fmt.Errorf("error decoding jwt public key: %v", err) } err = wavejwt.SetPublicKey(jwtPublicKeyBytes) if err != nil { return fmt.Errorf("error setting jwt public key: %v", err) } log.Printf("got JWT public key") // now setup the connserver rpc client client, err := setupConnServerRpcClientWithRouter(router, sockName) if err != nil { return fmt.Errorf("error setting up connserver rpc client: %v", err) } wshfs.RpcClient = client // set up the local domain socket listener for local wsh commands unixListener, err := MakeRemoteUnixListener() if err != nil { return fmt.Errorf("cannot create unix listener: %v", err) } log.Printf("unix listener started") go func() { defer func() { panichandler.PanicHandler("serverRunRouterDomainSocket:runListener", recover()) }() runListener(unixListener, router) }() // run the sysinfo loop go func() { defer func() { panichandler.PanicHandler("serverRunRouterDomainSocket:RunSysInfoLoop", recover()) }() wshremote.RunSysInfoLoop(client, connServerConnName) }() startJobLogCleanup() log.Printf("running server (router-domainsocket mode), successfully started") select {} } func serverRunNormal(jwtToken string) error { sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) if err != nil { return fmt.Errorf("error extracting socket name from JWT: %v", err) } err = setupRpcClient(wshremote.MakeRemoteRpcServerImpl(os.Stdout, nil, nil, false, connServerInitialEnv, sockName), jwtToken) if err != nil { return err } wshfs.RpcClient = RpcClient WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn) go func() { defer func() { panichandler.PanicHandler("serverRunNormal:RunSysInfoLoop", recover()) }() wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn) }() startJobLogCleanup() select {} // run forever } func askForJwtToken() (string, error) { // if it already exists in the environment, great, use it jwtToken := os.Getenv(wavebase.WaveJwtTokenVarName) if jwtToken != "" { fmt.Printf("HAVE-JWT\n") return jwtToken, nil } // otherwise, ask for it fmt.Printf("%s\n", wavebase.NeedJwtConst) // read a single line from stdin var line string _, err := fmt.Fscanln(os.Stdin, &line) if err != nil { return "", fmt.Errorf("failed to read JWT token from stdin: %w", err) } return strings.TrimSpace(line), nil } func serverRun(cmd *cobra.Command, args []string) error { connServerInitialEnv = envutil.PruneInitialEnv(envutil.SliceToMap(os.Environ())) var logFile *os.File if connServerDev { var err error logFilePath := fmt.Sprintf("/tmp/waveterm-connserver-%d.log", os.Getuid()) logFile, err = os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err) log.SetFlags(log.LstdFlags | log.Lmicroseconds) log.SetPrefix(fmt.Sprintf("[PID:%d] ", os.Getpid())) } else { defer logFile.Close() logWriter := io.MultiWriter(os.Stderr, logFile) log.SetOutput(logWriter) log.SetFlags(log.LstdFlags | log.Lmicroseconds) log.SetPrefix(fmt.Sprintf("[PID:%d] ", os.Getpid())) } } if connServerConnName == "" { if logFile != nil { fmt.Fprintf(logFile, "--conn parameter is required\n") } return fmt.Errorf("--conn parameter is required") } installErr := wshutil.InstallRcFiles() if installErr != nil { if logFile != nil { fmt.Fprintf(logFile, "error installing rc files: %v\n", installErr) } log.Printf("error installing rc files: %v", installErr) } sigutil.InstallSIGUSR1Handler() if connServerRouter { err := serverRunRouter() if err != nil && logFile != nil { fmt.Fprintf(logFile, "serverRunRouter error: %v\n", err) } return err } if connServerRouterDomainSocket { jwtToken, err := askForJwtToken() if err != nil { if logFile != nil { fmt.Fprintf(logFile, "askForJwtToken error: %v\n", err) } return err } err = serverRunRouterDomainSocket(jwtToken) if err != nil && logFile != nil { fmt.Fprintf(logFile, "serverRunRouterDomainSocket error: %v\n", err) } return err } jwtToken, err := askForJwtToken() if err != nil { if logFile != nil { fmt.Fprintf(logFile, "askForJwtToken error: %v\n", err) } return err } err = serverRunNormal(jwtToken) if err != nil && logFile != nil { fmt.Fprintf(logFile, "serverRunNormal error: %v\n", err) } return err } ================================================ FILE: cmd/wsh/cmd/wshcmd-createblock.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var createBlockMagnified bool var createBlockCmd = &cobra.Command{ Use: "createblock viewname key=value ...", Short: "create a new block", Args: cobra.MinimumNArgs(1), RunE: createBlockRun, PreRunE: preRunSetupRpcClient, Hidden: true, } func init() { createBlockCmd.Flags().BoolVarP(&createBlockMagnified, "magnified", "m", false, "create block in magnified mode") rootCmd.AddCommand(createBlockCmd) } func createBlockRun(cmd *cobra.Command, args []string) error { viewName := args[0] var metaSetStrs []string if len(args) > 1 { metaSetStrs = args[1:] } tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } meta, err := parseMetaSets(metaSetStrs) if err != nil { return err } meta["view"] = viewName data := wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &waveobj.BlockDef{ Meta: meta, }, Magnified: createBlockMagnified, Focused: true, } oref, err := wshclient.CreateBlockCommand(RpcClient, data, nil) if err != nil { return fmt.Errorf("create block failed: %v", err) } fmt.Printf("created block %s\n", oref.OID) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-debug.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/json" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var debugCmd = &cobra.Command{ Use: "debug", Short: "debug commands", PersistentPreRunE: preRunSetupRpcClient, Hidden: true, } var debugBlockIdsCmd = &cobra.Command{ Use: "block", Short: "list sub-blockids for block", RunE: debugBlockIdsRun, Hidden: true, } var debugSendTelemetryCmd = &cobra.Command{ Use: "send-telemetry", Short: "send telemetry", RunE: debugSendTelemetryRun, Hidden: true, } func init() { debugCmd.AddCommand(debugBlockIdsCmd) debugCmd.AddCommand(debugSendTelemetryCmd) rootCmd.AddCommand(debugCmd) } func debugSendTelemetryRun(cmd *cobra.Command, args []string) error { err := wshclient.SendTelemetryCommand(RpcClient, nil) return err } func debugBlockIdsRun(cmd *cobra.Command, args []string) error { oref, err := resolveBlockArg() if err != nil { return err } blockInfo, err := wshclient.BlockInfoCommand(RpcClient, oref.OID, nil) if err != nil { return err } barr, err := json.MarshalIndent(blockInfo, "", " ") if err != nil { return err } WriteStdout("%s\n", string(barr)) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-debugterm.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "os" "strconv" "strings" "unicode/utf8" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) const ( DebugTermModeHex = "hex" DebugTermModeDecode = "decode" ) var debugTermCmd = &cobra.Command{ Use: "debugterm", Short: "inspect recent terminal output bytes", RunE: debugTermRun, PreRunE: debugTermPreRun, DisableFlagsInUseLine: true, Hidden: true, } var ( debugTermSize int64 debugTermMode string debugTermStdin bool debugTermInput string ) func init() { rootCmd.AddCommand(debugTermCmd) debugTermCmd.Flags().Int64Var(&debugTermSize, "size", 1000, "number of terminal bytes to read") debugTermCmd.Flags().StringVar(&debugTermMode, "mode", DebugTermModeHex, "output mode: hex or decode") debugTermCmd.Flags().BoolVar(&debugTermStdin, "stdin", false, "read input from stdin instead of rpc call") debugTermCmd.Flags().StringVar(&debugTermInput, "input", "", "read input from file instead of rpc call") } func debugTermRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("debugterm", rtnErr == nil) }() mode, err := getDebugTermMode() if err != nil { return err } if debugTermStdin { stdinData, err := io.ReadAll(WrappedStdin) if err != nil { return fmt.Errorf("reading stdin: %w", err) } termData, err := parseDebugTermStdinData(stdinData) if err != nil { return err } if mode == DebugTermModeDecode { WriteStdout("%s", formatDebugTermDecode(termData)) } else { WriteStdout("%s", formatDebugTermHex(termData)) } return nil } if debugTermInput != "" { fileData, err := os.ReadFile(debugTermInput) if err != nil { return fmt.Errorf("reading input file: %w", err) } termData, err := parseDebugTermStdinData(fileData) if err != nil { return err } if mode == DebugTermModeDecode { WriteStdout("%s", formatDebugTermDecode(termData)) } else { WriteStdout("%s", formatDebugTermHex(termData)) } return nil } if debugTermSize <= 0 { return fmt.Errorf("size must be greater than 0") } fullORef, err := resolveBlockArg() if err != nil { return err } rtn, err := wshclient.DebugTermCommand(RpcClient, wshrpc.CommandDebugTermData{ BlockId: fullORef.OID, Size: debugTermSize, }, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("reading terminal output: %w", err) } termData, err := base64.StdEncoding.DecodeString(rtn.Data64) if err != nil { return fmt.Errorf("decoding terminal output: %w", err) } var output string if mode == DebugTermModeDecode { output = formatDebugTermDecode(termData) } else { output = formatDebugTermHex(termData) } WriteStdout("%s", output) return nil } func debugTermPreRun(cmd *cobra.Command, args []string) error { if debugTermStdin || debugTermInput != "" { return nil } return preRunSetupRpcClient(cmd, args) } func getDebugTermMode() (string, error) { mode := strings.ToLower(debugTermMode) if mode != DebugTermModeHex && mode != DebugTermModeDecode { return "", fmt.Errorf("invalid mode %q (expected %q or %q)", debugTermMode, DebugTermModeHex, DebugTermModeDecode) } return mode, nil } type debugTermStdinEntry struct { Data string `json:"data"` } func parseDebugTermStdinData(data []byte) ([]byte, error) { trimmed := strings.TrimSpace(string(data)) if len(trimmed) == 0 { return data, nil } if trimmed[0] == '[' { // try array of structs first var structArr []debugTermStdinEntry err := json.Unmarshal(data, &structArr) if err == nil { parts := make([]string, len(structArr)) for i, entry := range structArr { parts[i] = entry.Data } return []byte(strings.Join(parts, "")), nil } fmt.Fprintf(os.Stderr, "json read err %v\n", err) // try array of strings var strArr []string err = json.Unmarshal(data, &strArr) if err == nil { return []byte(strings.Join(strArr, "")), nil } } return data, nil } func formatDebugTermHex(data []byte) string { return hex.Dump(data) } func parseCursorForwardN(seq []byte) (int, bool) { if len(seq) < 3 || seq[len(seq)-1] != 'C' { return 0, false } params := string(seq[2 : len(seq)-1]) if params == "" { return 1, true } n, err := strconv.Atoi(params) if err != nil || n <= 0 { return 0, false } return n, true } // splitOnCRLFRuns splits s at the end of each run of \r and \n characters. // Each segment includes its trailing CR/LF run. The last segment may have no such run. func splitOnCRLFRuns(s string) []string { var result []string for len(s) > 0 { // find start of next CR/LF run i := 0 for i < len(s) && s[i] != '\r' && s[i] != '\n' { i++ } if i == len(s) { break } // consume the CR/LF run j := i for j < len(s) && (s[j] == '\r' || s[j] == '\n') { j++ } result = append(result, s[:j]) s = s[j:] } if len(s) > 0 { result = append(result, s) } return result } func formatDebugTermDecode(data []byte) string { if len(data) == 0 { return "" } lines := make([]string, 0) // textBuf accumulates text across CSI-C (cursor forward) sequences so consecutive // "word CSI-C word" runs collapse into a single TXT line. The // NC annotation goes // on the last segment only. textBuf := "" totalCSpaces := 0 flushText := func() { if textBuf == "" && totalCSpaces == 0 { return } segs := splitOnCRLFRuns(textBuf) if len(segs) == 0 { segs = []string{textBuf} } for i, seg := range segs { if i == len(segs)-1 && totalCSpaces > 0 { lines = append(lines, fmt.Sprintf("TXT %s // %dC", strconv.Quote(seg), totalCSpaces)) } else { lines = append(lines, "TXT "+strconv.Quote(seg)) } } textBuf = "" totalCSpaces = 0 } for i := 0; i < len(data); { b := data[i] if b == 0x1b { if i+1 >= len(data) { flushText() lines = append(lines, "ESC") i++ continue } next := data[i+1] switch next { case '[': seq, end := consumeDebugTermCSI(data, i) if n, ok := parseCursorForwardN(seq); ok { textBuf += strings.Repeat(" ", n) totalCSpaces += n } else { flushText() lines = append(lines, formatDebugTermCSILine(seq)) } i = end case ']': flushText() seq, end := consumeDebugTermOSC(data, i) lines = append(lines, formatDebugTermOSCLine(seq)) i = end case 'P': flushText() seq, end := consumeDebugTermST(data, i) lines = append(lines, "DCS "+strconv.QuoteToASCII(string(seq))) i = end case '^': flushText() seq, end := consumeDebugTermST(data, i) lines = append(lines, "PM "+strconv.QuoteToASCII(string(seq))) i = end case '_': flushText() seq, end := consumeDebugTermST(data, i) lines = append(lines, "APC "+strconv.QuoteToASCII(string(seq))) i = end default: flushText() seq := data[i : i+2] lines = append(lines, "ESC "+strconv.QuoteToASCII(string(seq))) i += 2 } continue } if b == 0x07 { flushText() lines = append(lines, "BEL") i++ continue } start, end := consumeDebugTermText(data, i) if end > start { textBuf += string(data[start:end]) i = end continue } flushText() lines = append(lines, fmt.Sprintf("CTL 0x%02x", b)) i++ } flushText() return strings.Join(lines, "\n") + "\n" } var csiCommandDescriptions = map[byte]string{ '@': "insert character", 'A': "cursor up", 'B': "cursor down", 'C': "cursor forward", 'D': "cursor back", 'E': "cursor next line", 'F': "cursor prev line", 'G': "cursor horizontal absolute", 'H': "cursor position", 'I': "cursor horizontal tab", 'J': "erase display", 'K': "erase line", 'L': "insert line", 'M': "delete line", 'P': "delete character", 'S': "scroll up", 'T': "scroll down", 'X': "erase character", 'Z': "cursor backward tab", 'a': "cursor horizontal relative", 'b': "repeat character", 'c': "device attributes", 'd': "cursor vertical absolute", 'e': "cursor vertical relative", 'f': "horizontal vertical position", 'g': "tab clear", 'h': "set mode", 'l': "reset mode", 'm': "SGR", 'n': "device status report", 'r': "set scrolling region", 's': "save cursor", 'u': "restore cursor", } var decModeDescriptions = map[string]string{ "1": "application cursor keys", "3": "132 column mode", "6": "origin mode", "7": "auto wrap", "12": "blinking cursor", "25": "show cursor", "47": "alternate screen", "1000": "mouse X10 tracking", "1002": "mouse button events", "1003": "mouse all events", "1004": "focus events", "1006": "SGR mouse mode", "1049": "alt screen + save cursor", "2004": "bracketed paste", "2026": "synchronized output", } var sgrSingleDescriptions = map[int]string{ 0: "reset all", 1: "bold", 2: "dim", 3: "italic", 4: "underline", 5: "blink", 7: "reverse", 8: "hidden", 9: "strikethrough", 21: "doubly underlined", 22: "normal intensity", 23: "not italic", 24: "not underlined", 25: "not blinking", 27: "not reversed", 28: "not hidden", 29: "not strikethrough", 39: "default fg", 49: "default bg", } func describeSGR(params string) string { if params == "" { return "reset all" } parts := strings.Split(params, ";") if len(parts) >= 5 && parts[0] == "38" && parts[1] == "2" { return fmt.Sprintf("fg rgb(%s,%s,%s)", parts[2], parts[3], parts[4]) } if len(parts) >= 5 && parts[0] == "48" && parts[1] == "2" { return fmt.Sprintf("bg rgb(%s,%s,%s)", parts[2], parts[3], parts[4]) } if len(parts) == 3 && parts[0] == "38" && parts[1] == "5" { return fmt.Sprintf("fg color256(%s)", parts[2]) } if len(parts) == 3 && parts[0] == "48" && parts[1] == "5" { return fmt.Sprintf("bg color256(%s)", parts[2]) } if len(parts) != 1 { return "" } n, err := strconv.Atoi(parts[0]) if err != nil { return "" } if desc, ok := sgrSingleDescriptions[n]; ok { return desc } if n >= 30 && n <= 37 { return fmt.Sprintf("fg ansi color %d", n-30) } if n >= 40 && n <= 47 { return fmt.Sprintf("bg ansi color %d", n-40) } if n >= 90 && n <= 97 { return fmt.Sprintf("fg bright color %d", n-90) } if n >= 100 && n <= 107 { return fmt.Sprintf("bg bright color %d", n-100) } return "" } func formatDebugTermCSILine(seq []byte) string { // seq is the full sequence starting with ESC [ if len(seq) < 3 { return "CSI " + strconv.QuoteToASCII(string(seq)) } inner := seq[2:] finalByte := inner[len(inner)-1] params := string(inner[:len(inner)-1]) // DEC private mode: params starts with "?" and final byte is 'h' (set) or 'l' (reset) if strings.HasPrefix(params, "?") && (finalByte == 'h' || finalByte == 'l') { modeStr := params[1:] var line string if finalByte == 'h' { line = "DEC SET " + modeStr } else { line = "DEC RST " + modeStr } if desc, ok := decModeDescriptions[modeStr]; ok { line += " // " + desc } return line } finalStr := string([]byte{finalByte}) var line string if params == "" { line = "CSI " + finalStr } else { line = "CSI " + finalStr + " " + params } if finalByte == 'm' { if desc := describeSGR(params); desc != "" { line += " // " + desc } } else if desc, ok := csiCommandDescriptions[finalByte]; ok { line += " // " + desc } return line } func consumeDebugTermCSI(data []byte, start int) ([]byte, int) { i := start + 2 for i < len(data) { if data[i] >= 0x40 && data[i] <= 0x7e { return data[start : i+1], i + 1 } i++ } return data[start:], len(data) } func formatDebugTermOSCLine(seq []byte) string { // seq is the full sequence starting with ESC ] if len(seq) < 3 { return "OSC " + strconv.QuoteToASCII(string(seq)) } // strip ESC ] prefix inner := string(seq[2:]) // strip trailing BEL or ST (ESC \) inner = strings.TrimSuffix(inner, "\x07") inner = strings.TrimSuffix(inner, "\x1b\\") // split code from data on first ; if idx := strings.IndexByte(inner, ';'); idx >= 0 { code := inner[:idx] data := inner[idx+1:] return "OSC " + code + " " + strconv.QuoteToASCII(data) } return "OSC " + strconv.QuoteToASCII(inner) } func consumeDebugTermOSC(data []byte, start int) ([]byte, int) { i := start + 2 for i < len(data) { if data[i] == 0x07 { return data[start : i+1], i + 1 } if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { return data[start : i+2], i + 2 } i++ } return data[start:], len(data) } func consumeDebugTermST(data []byte, start int) ([]byte, int) { i := start + 2 for i < len(data) { if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { return data[start : i+2], i + 2 } i++ } return data[start:], len(data) } func isDebugTermC0Control(b byte) bool { return b < 0x20 || b == 0x7f } func consumeDebugTermText(data []byte, i int) (start, end int) { start = i for i < len(data) { b := data[i] if b == 0x1b || b == 0x07 { break } if b == '\n' || b == '\r' || b == '\t' { i++ continue } if isDebugTermC0Control(b) { break } if b < 0x80 { i++ continue } _, sz := utf8.DecodeRune(data[i:]) if sz == 1 { break } i += sz } return start, i } ================================================ FILE: cmd/wsh/cmd/wshcmd-debugterm_test.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "strings" "testing" ) func TestFormatDebugTermHex(t *testing.T) { output := formatDebugTermHex([]byte("abc")) if !strings.Contains(output, "61 62 63") { t.Fatalf("unexpected hex output: %q", output) } } func TestFormatDebugTermDecode(t *testing.T) { data := []byte("abc\x1b[31mred\x1b[0m\x07\x1b]0;title\x07\x00") output := formatDebugTermDecode(data) expected := []string{ `TXT "abc"`, `CSI m 31`, `TXT "red"`, `CSI m 0`, `BEL`, `OSC 0 "title"`, `CTL 0x00`, } for _, line := range expected { if !strings.Contains(output, line) { t.Fatalf("missing decode line %q in output %q", line, output) } } } func TestParseDebugTermStdinData(t *testing.T) { data, err := parseDebugTermStdinData([]byte(`["abc","\u001b[31mred","\u001b[0m"]`)) if err != nil { t.Fatalf("parseDebugTermStdinData() error: %v", err) } output := formatDebugTermDecode(data) expected := []string{ `TXT "abc"`, `CSI m 31`, `TXT "red"`, `CSI m 0`, } for _, line := range expected { if !strings.Contains(output, line) { t.Fatalf("missing decode line %q in output %q", line, output) } } } func TestParseDebugTermStdinDataStructs(t *testing.T) { data, err := parseDebugTermStdinData([]byte(`[{"data":"abc"},{"data":"\u001b[31mred"},{"data":"\u001b[0m"}]`)) if err != nil { t.Fatalf("parseDebugTermStdinData() error: %v", err) } output := formatDebugTermDecode(data) expected := []string{ `TXT "abc"`, `CSI m 31`, `TXT "red"`, `CSI m 0`, } for _, line := range expected { if !strings.Contains(output, line) { t.Fatalf("missing decode line %q in output %q", line, output) } } } func TestFormatDebugTermDecodeCursorForward(t *testing.T) { // CSI C sequences collapse into adjacent text; all consecutive text+CSI-C runs merge into one TXT line. // The run is split into separate TXT lines at CR/LF run boundaries; // NC appears on the last line. data := []byte("hi\x1b[1Cworld\x1b[3Cfoo\r\nbar") output := formatDebugTermDecode(data) expected := []string{ `TXT "hi world foo\r\n"`, `TXT "bar" // 4C`, } for _, line := range expected { if !strings.Contains(output, line) { t.Fatalf("missing decode line %q in output:\n%s", line, output) } } } func TestParseDebugTermStdinDataRaw(t *testing.T) { raw := []byte("hello\x1b[31mworld") data, err := parseDebugTermStdinData(raw) if err != nil { t.Fatalf("parseDebugTermStdinData() error: %v", err) } if string(data) != string(raw) { t.Fatalf("expected raw passthrough, got %q", data) } } ================================================ FILE: cmd/wsh/cmd/wshcmd-deleteblock.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var deleteBlockCmd = &cobra.Command{ Use: "deleteblock", Short: "delete a block", RunE: deleteBlockRun, PreRunE: preRunSetupRpcClient, } func init() { rootCmd.AddCommand(deleteBlockCmd) } func deleteBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("deleteblock", rtnErr == nil) }() fullORef, err := resolveBlockArg() if err != nil { return err } if fullORef.OType != "block" { return fmt.Errorf("object reference is not a block") } deleteBlockData := &wshrpc.CommandDeleteBlockData{ BlockId: fullORef.OID, } err = wshclient.DeleteBlockCommand(RpcClient, *deleteBlockData, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("delete block failed: %v", err) } WriteStdout("block deleted\n") return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-editconfig.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var editConfigMagnified bool var editConfigCmd = &cobra.Command{ Use: "editconfig [configfile]", Short: "edit Wave configuration files", Long: "Edit Wave configuration files. Defaults to settings.json if no file specified. Common files: settings.json, presets.json, widgets.json", Args: cobra.MaximumNArgs(1), RunE: editConfigRun, PreRunE: preRunSetupRpcClient, } func init() { editConfigCmd.Flags().BoolVarP(&editConfigMagnified, "magnified", "m", false, "open config in magnified mode") rootCmd.AddCommand(editConfigCmd) } func editConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("editconfig", rtnErr == nil) }() configFile := "settings.json" // default if len(args) > 0 { configFile = args[0] } tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } wshCmd := &wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &waveobj.BlockDef{ Meta: map[string]interface{}{ waveobj.MetaKey_View: "waveconfig", waveobj.MetaKey_File: configFile, }, }, Magnified: editConfigMagnified, Focused: true, } _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("opening config file: %w", err) } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-editor.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "io/fs" "os" "path/filepath" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var editMagnified bool var editorCmd = &cobra.Command{ Use: "editor", Short: "edit a file (blocks until editor is closed)", RunE: editorRun, PreRunE: preRunSetupRpcClient, } func init() { editorCmd.Flags().BoolVarP(&editMagnified, "magnified", "m", false, "open view in magnified mode") rootCmd.AddCommand(editorCmd) } func editorRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("editor", rtnErr == nil) }() if len(args) == 0 { OutputHelpMessage(cmd) return fmt.Errorf("no arguments. wsh editor requires a file or URL as an argument argument") } if len(args) > 1 { OutputHelpMessage(cmd) return fmt.Errorf("too many arguments. wsh editor requires exactly one argument") } fileArg := args[0] absFile, err := filepath.Abs(fileArg) if err != nil { return fmt.Errorf("getting absolute path: %w", err) } _, err = os.Stat(absFile) if err == fs.ErrNotExist { return fmt.Errorf("file does not exist: %q", absFile) } if err != nil { return fmt.Errorf("getting file info: %w", err) } tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } wshCmd := wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &waveobj.BlockDef{ Meta: map[string]any{ waveobj.MetaKey_View: "preview", waveobj.MetaKey_File: absFile, waveobj.MetaKey_Edit: true, }, }, Magnified: editMagnified, Focused: true, } if RpcContext.Conn != "" { wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn } blockRef, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("running view command: %w", err) } doneCh := make(chan bool) RpcClient.EventListener.On(wps.Event_BlockClose, func(event *wps.WaveEvent) { if event.HasScope(blockRef.String()) { close(doneCh) } }) wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{blockRef.String()}}, nil) <-doneCh return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-file-util.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "context" "encoding/base64" "fmt" "io" "io/fs" "strings" "github.com/wavetermdev/waveterm/pkg/remote/connparse" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/fsutil" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) func convertNotFoundErr(err error) error { if err == nil { return nil } if strings.HasPrefix(err.Error(), "NOTFOUND:") { return fs.ErrNotExist } return err } func ensureFile(fileData wshrpc.FileData) (*wshrpc.FileInfo, error) { info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) err = convertNotFoundErr(err) if err == fs.ErrNotExist { err = wshclient.FileCreateCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) if err != nil { return nil, fmt.Errorf("creating file: %w", err) } info, err = wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) if err != nil { return nil, fmt.Errorf("getting file info: %w", err) } return info, err } if err != nil { return nil, fmt.Errorf("getting file info: %w", err) } return info, nil } func streamWriteToFile(fileData wshrpc.FileData, reader io.Reader) error { // First truncate the file with an empty write emptyWrite := fileData emptyWrite.Data64 = "" err := wshclient.FileWriteCommand(RpcClient, emptyWrite, &wshrpc.RpcOpts{Timeout: fileTimeout}) if err != nil { return fmt.Errorf("initializing file with empty write: %w", err) } const chunkSize = wshrpc.FileChunkSize // 32KB chunks buf := make([]byte, chunkSize) totalWritten := int64(0) for { n, err := reader.Read(buf) if err == io.EOF { break } if err != nil { return fmt.Errorf("reading input: %w", err) } // Check total size totalWritten += int64(n) if totalWritten > MaxFileSize { return fmt.Errorf("input exceeds maximum file size of %d bytes", MaxFileSize) } // Prepare and send chunk chunk := buf[:n] appendData := fileData appendData.Data64 = base64.StdEncoding.EncodeToString(chunk) err = wshclient.FileAppendCommand(RpcClient, appendData, &wshrpc.RpcOpts{Timeout: int64(fileTimeout)}) if err != nil { return fmt.Errorf("appending chunk to file: %w", err) } } return nil } func streamReadFromFile(ctx context.Context, fileData wshrpc.FileData, writer io.Writer) error { ch := wshclient.FileReadStreamCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) return fsutil.ReadFileStreamToWriter(ctx, ch, writer) } func fixRelativePaths(path string) (string, error) { conn, err := connparse.ParseURI(path) if err != nil { return "", err } if conn.Scheme == connparse.ConnectionTypeWsh { if conn.Host == connparse.ConnHostCurrent { conn.Host = RpcContext.Conn fixedPath, err := fileutil.FixPath(conn.Path) if err != nil { return "", err } conn.Path = fixedPath } if conn.Host == "" { conn.Host = wshrpc.LocalConnName } } return conn.GetFullURI(), nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-file.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "bufio" "bytes" "encoding/base64" "fmt" "io" "log" "os" "strings" "text/tabwriter" "time" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "golang.org/x/term" ) const ( MaxFileSize = 10 * 1024 * 1024 // 10MB TimeoutYear = int64(365) * 24 * 60 * 60 * 1000 UriHelpText = ` URI format: [profile]:[uri-scheme]://[connection]/[path] Supported URI schemes: wsh: Used to access files on remote hosts over SSH via the WSH helper. Allows for file streaming to Wave and other remotes. Profiles are optional for WSH URIs, provided that you have configured the remote host in your "connections.json" or "~/.ssh/config" file. If a profile is provided, it must be defined in "profiles.json" in the Wave configuration directory. Format: wsh://[remote]/[path] Shorthands can be used for the current remote and your local computer: [path] a relative or absolute path on the current remote //[remote]/[path] a path on a remote /~/[path] a path relative to the home directory on your local computer` ) var fileCmd = &cobra.Command{ Use: "file", Short: "manage files across local and remote systems", Long: `Manage files across local and remote systems. Wave Terminal is capable of managing files from remote SSH hosts and your local computer. Files are addressed via URIs.` + UriHelpText} var fileTimeout int64 func init() { rootCmd.AddCommand(fileCmd) fileCmd.PersistentFlags().Int64VarP(&fileTimeout, "timeout", "t", 15000, "timeout in milliseconds for long operations") fileListCmd.Flags().BoolP("long", "l", false, "use long listing format") fileListCmd.Flags().BoolP("one", "1", false, "list one file per line") fileListCmd.Flags().BoolP("files", "f", false, "list files only") fileCmd.AddCommand(fileListCmd) fileCmd.AddCommand(fileCatCmd) fileCmd.AddCommand(fileWriteCmd) fileRmCmd.Flags().BoolP("recursive", "r", false, "remove directories recursively") fileCmd.AddCommand(fileRmCmd) fileCmd.AddCommand(fileInfoCmd) fileCmd.AddCommand(fileAppendCmd) fileCpCmd.Flags().BoolP("merge", "m", false, "merge directories") fileCpCmd.Flags().BoolP("force", "f", false, "force overwrite of existing files") fileCmd.AddCommand(fileCpCmd) fileMvCmd.Flags().BoolP("force", "f", false, "force overwrite of existing files") fileCmd.AddCommand(fileMvCmd) } var fileListCmd = &cobra.Command{ Use: "ls [uri]", Aliases: []string{"list"}, Short: "list files", Long: "List files in a directory. By default, lists files in the current directory." + UriHelpText, Example: " wsh file ls wsh://user@ec2/home/user/", RunE: activityWrap("file", fileListRun), PreRunE: preRunSetupRpcClient, } var fileCatCmd = &cobra.Command{ Use: "cat [uri]", Short: "display contents of a file", Long: "Display the contents of a file." + UriHelpText, Example: " wsh file cat wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(1), RunE: activityWrap("file", fileCatRun), PreRunE: preRunSetupRpcClient, } var fileInfoCmd = &cobra.Command{ Use: "info [uri]", Short: "show wave file information", Long: "Show information about a file." + UriHelpText, Example: " wsh file info wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(1), RunE: activityWrap("file", fileInfoRun), PreRunE: preRunSetupRpcClient, } var fileRmCmd = &cobra.Command{ Use: "rm [uri]", Short: "remove a file", Long: "Remove a file." + UriHelpText, Example: " wsh file rm wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(1), RunE: activityWrap("file", fileRmRun), PreRunE: preRunSetupRpcClient, } var fileWriteCmd = &cobra.Command{ Use: "write [uri]", Short: "write stdin into a file (up to 10MB)", Long: "Write stdin into a file, buffering input (10MB total file size limit)." + UriHelpText, Example: " echo 'hello' | wsh file write ./greeting.txt", Args: cobra.ExactArgs(1), RunE: activityWrap("file", fileWriteRun), PreRunE: preRunSetupRpcClient, } var fileAppendCmd = &cobra.Command{ Use: "append [uri]", Short: "append stdin to a file", Long: "Append stdin to a file, buffering input (10MB total file size limit)." + UriHelpText, Example: " tail -f log.txt | wsh file append ./app.log", Args: cobra.ExactArgs(1), RunE: activityWrap("file", fileAppendRun), PreRunE: preRunSetupRpcClient, } var fileCpCmd = &cobra.Command{ Use: "cp [source-uri] [destination-uri]" + UriHelpText, Aliases: []string{"copy"}, Short: "copy files between storage systems, recursively if needed", Long: "Copy files between different storage systems." + UriHelpText, Example: " wsh file cp wsh://user@ec2/home/user/config.txt ./local-config.txt\n wsh file cp ./local-config.txt wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(2), RunE: activityWrap("file", fileCpRun), PreRunE: preRunSetupRpcClient, } var fileMvCmd = &cobra.Command{ Use: "mv [source-uri] [destination-uri]" + UriHelpText, Aliases: []string{"move"}, Short: "move files between storage systems", Long: "Move files between different storage systems. The source file will be deleted once the operation completes successfully." + UriHelpText, Example: " wsh file mv wsh://user@ec2/home/user/config.txt ./local-config.txt\n wsh file mv ./local-config.txt wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(2), RunE: activityWrap("file", fileMvRun), PreRunE: preRunSetupRpcClient, } func fileCatRun(cmd *cobra.Command, args []string) error { path, err := fixRelativePaths(args[0]) if err != nil { return err } _, err = checkFileSize(path, MaxFileSize) if err != nil { return err } fileData := wshrpc.FileData{ Info: &wshrpc.FileInfo{ Path: path}} err = streamReadFromFile(cmd.Context(), fileData, os.Stdout) if err != nil { return fmt.Errorf("reading file: %w", err) } return nil } func fileInfoRun(cmd *cobra.Command, args []string) error { path, err := fixRelativePaths(args[0]) if err != nil { return err } fileData := wshrpc.FileData{ Info: &wshrpc.FileInfo{ Path: path}} info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) err = convertNotFoundErr(err) if err != nil { return fmt.Errorf("getting file info: %w", err) } if info.NotFound { return fmt.Errorf("%s: no such file", path) } WriteStdout("name:\t%s\n", info.Name) if info.Mode != 0 { WriteStdout("mode:\t%s\n", info.Mode.String()) } WriteStdout("mtime:\t%s\n", time.Unix(info.ModTime/1000, 0).Format(time.DateTime)) if !info.IsDir { WriteStdout("size:\t%d\n", info.Size) } if info.Meta != nil && len(*info.Meta) > 0 { WriteStdout("metadata:\n") for k, v := range *info.Meta { WriteStdout("\t\t\t%s: %v\n", k, v) } } return nil } func fileRmRun(cmd *cobra.Command, args []string) error { path, err := fixRelativePaths(args[0]) if err != nil { return err } recursive, err := cmd.Flags().GetBool("recursive") if err != nil { return err } err = wshclient.FileDeleteCommand(RpcClient, wshrpc.CommandDeleteFileData{Path: path, Recursive: recursive}, &wshrpc.RpcOpts{Timeout: fileTimeout}) if err != nil { return fmt.Errorf("removing file: %w", err) } return nil } func fileWriteRun(cmd *cobra.Command, args []string) error { path, err := fixRelativePaths(args[0]) if err != nil { return err } fileData := wshrpc.FileData{ Info: &wshrpc.FileInfo{ Path: path}} limitReader := io.LimitReader(WrappedStdin, MaxFileSize+1) data, err := io.ReadAll(limitReader) if err != nil { return fmt.Errorf("reading input: %w", err) } if len(data) > MaxFileSize { return fmt.Errorf("input exceeds maximum file size of %d bytes", MaxFileSize) } fileData.Data64 = base64.StdEncoding.EncodeToString(data) err = wshclient.FileWriteCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) if err != nil { return fmt.Errorf("writing file: %w", err) } return nil } func fileAppendRun(cmd *cobra.Command, args []string) error { path, err := fixRelativePaths(args[0]) if err != nil { return err } fileData := wshrpc.FileData{ Info: &wshrpc.FileInfo{ Path: path}} info, err := ensureFile(fileData) if err != nil { return err } if info.Size >= MaxFileSize { return fmt.Errorf("file already at maximum size (%d bytes)", MaxFileSize) } reader := bufio.NewReader(WrappedStdin) var buf bytes.Buffer remainingSpace := MaxFileSize - info.Size for { chunk := make([]byte, 8192) n, err := reader.Read(chunk) if err == io.EOF { break } if err != nil { return fmt.Errorf("reading input: %w", err) } if int64(buf.Len()+n) > remainingSpace { return fmt.Errorf("append would exceed maximum file size of %d bytes", MaxFileSize) } buf.Write(chunk[:n]) if buf.Len() >= 8192 { // 8KB batch size fileData.Data64 = base64.StdEncoding.EncodeToString(buf.Bytes()) err = wshclient.FileAppendCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) if err != nil { return fmt.Errorf("appending to file: %w", err) } remainingSpace -= int64(buf.Len()) buf.Reset() } } if buf.Len() > 0 { fileData.Data64 = base64.StdEncoding.EncodeToString(buf.Bytes()) err = wshclient.FileAppendCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) if err != nil { return fmt.Errorf("appending to file: %w", err) } } return nil } func checkFileSize(path string, maxSize int64) (*wshrpc.FileInfo, error) { fileData := wshrpc.FileData{ Info: &wshrpc.FileInfo{ Path: path}} info, err := wshclient.FileInfoCommand(RpcClient, fileData, &wshrpc.RpcOpts{Timeout: fileTimeout}) err = convertNotFoundErr(err) if err != nil { return nil, fmt.Errorf("getting file info: %w", err) } if info.NotFound { return nil, fmt.Errorf("%s: no such file", path) } if info.IsDir { return nil, fmt.Errorf("%s: is a directory", path) } if info.Size > maxSize { return nil, fmt.Errorf("file size (%d bytes) exceeds maximum of %d bytes", info.Size, maxSize) } return info, nil } func fileCpRun(cmd *cobra.Command, args []string) error { src, dst := args[0], args[1] merge, err := cmd.Flags().GetBool("merge") if err != nil { return err } force, err := cmd.Flags().GetBool("force") if err != nil { return err } srcPath, err := fixRelativePaths(src) if err != nil { return fmt.Errorf("unable to parse src path: %w", err) } _, err = checkFileSize(srcPath, MaxFileSize) if err != nil { return err } destPath, err := fixRelativePaths(dst) if err != nil { return fmt.Errorf("unable to parse dest path: %w", err) } log.Printf("Copying %s to %s; merge: %v, force: %v", srcPath, destPath, merge, force) rpcOpts := &wshrpc.RpcOpts{Timeout: TimeoutYear} err = wshclient.FileCopyCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcPath, DestUri: destPath, Opts: &wshrpc.FileCopyOpts{Merge: merge, Overwrite: force, Timeout: TimeoutYear}}, rpcOpts) if err != nil { return fmt.Errorf("copying file: %w", err) } return nil } func fileMvRun(cmd *cobra.Command, args []string) error { src, dst := args[0], args[1] force, err := cmd.Flags().GetBool("force") if err != nil { return err } srcPath, err := fixRelativePaths(src) if err != nil { return fmt.Errorf("unable to parse src path: %w", err) } _, err = checkFileSize(srcPath, MaxFileSize) if err != nil { return err } destPath, err := fixRelativePaths(dst) if err != nil { return fmt.Errorf("unable to parse dest path: %w", err) } log.Printf("Moving %s to %s; force: %v", srcPath, destPath, force) rpcOpts := &wshrpc.RpcOpts{Timeout: TimeoutYear} err = wshclient.FileMoveCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcPath, DestUri: destPath, Opts: &wshrpc.FileCopyOpts{Overwrite: force, Timeout: TimeoutYear}}, rpcOpts) if err != nil { return fmt.Errorf("moving file: %w", err) } return nil } func filePrintColumns(filesChan <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]) error { width := 80 w, _, err := term.GetSize(int(os.Stdout.Fd())) if err == nil { width = w } var allNames []string maxLen := 0 for respUnion := range filesChan { if respUnion.Error != nil { return respUnion.Error } for _, f := range respUnion.Response.FileInfo { allNames = append(allNames, f.Name) if len(f.Name) > maxLen { maxLen = len(f.Name) } } } colWidth := maxLen + 2 numCols := width / colWidth if numCols < 1 { numCols = 1 } col := 0 for _, name := range allNames { fmt.Fprintf(os.Stdout, "%-*s", colWidth, name) col++ if col >= numCols { fmt.Fprintln(os.Stdout) col = 0 } } if col > 0 { fmt.Fprintln(os.Stdout) } return nil } func filePrintLong(filesChan <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]) error { var allFiles []*wshrpc.FileInfo for respUnion := range filesChan { if respUnion.Error != nil { return respUnion.Error } resp := respUnion.Response allFiles = append(allFiles, resp.FileInfo...) } maxNameLen := 0 for _, fi := range allFiles { if len(fi.Name) > maxNameLen { maxNameLen = len(fi.Name) } } nameWidth := maxNameLen + 2 if nameWidth > 60 { nameWidth = 60 } writer := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) for _, f := range allFiles { name := f.Name t := time.Unix(f.ModTime/1000, 0) timestamp := utilfn.FormatLsTime(t) if f.Size == 0 && strings.HasSuffix(name, "/") { fmt.Fprintf(writer, "%-*s\t%8s\t%s\n", nameWidth, name, "-", timestamp) } else { fmt.Fprintf(writer, "%-*s\t%8d\t%s\n", nameWidth, name, f.Size, timestamp) } } writer.Flush() return nil } func fileListRun(cmd *cobra.Command, args []string) error { longForm, _ := cmd.Flags().GetBool("long") onePerLine, _ := cmd.Flags().GetBool("one") // Check if we're in a pipe stat, _ := os.Stdout.Stat() isPipe := (stat.Mode() & os.ModeCharDevice) == 0 if isPipe { onePerLine = true } if len(args) == 0 { args = []string{"."} } path, err := fixRelativePaths(args[0]) if err != nil { return err } filesChan := wshclient.FileListStreamCommand(RpcClient, wshrpc.FileListData{Path: path, Opts: &wshrpc.FileListOpts{All: false}}, &wshrpc.RpcOpts{Timeout: 2000}) // Drain the channel when done defer utilfn.DrainChannelSafe(filesChan, "fileListRun") if longForm { return filePrintLong(filesChan) } if onePerLine { for respUnion := range filesChan { if respUnion.Error != nil { log.Printf("error: %v", respUnion.Error) return respUnion.Error } for _, f := range respUnion.Response.FileInfo { fmt.Fprintln(os.Stdout, f.Name) } } return nil } return filePrintColumns(filesChan) } ================================================ FILE: cmd/wsh/cmd/wshcmd-focusblock.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "os" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var focusBlockCmd = &cobra.Command{ Use: "focusblock [-b {blockid|blocknum|this}]", Short: "focus a block in the current tab", Args: cobra.NoArgs, RunE: focusBlockRun, PreRunE: preRunSetupRpcClient, } func init() { rootCmd.AddCommand(focusBlockCmd) } func focusBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("focusblock", rtnErr == nil) }() tabId := os.Getenv("WAVETERM_TABID") if tabId == "" { return fmt.Errorf("no tab id specified (set WAVETERM_TABID environment variable)") } fullORef, err := resolveBlockArg() if err != nil { return err } route := fmt.Sprintf("tab:%s", tabId) err = wshclient.SetBlockFocusCommand(RpcClient, fullORef.OID, &wshrpc.RpcOpts{ Route: route, Timeout: 2000, }) if err != nil { return fmt.Errorf("focusing block: %v", err) } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-getmeta.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/json" "fmt" "os" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var getMetaCmd = &cobra.Command{ Use: "getmeta [key...]", Short: "get metadata for an entity", Long: "Get metadata for an entity. Keys can be exact matches or patterns like 'name:*' to get all keys that start with 'name:'", Args: cobra.ArbitraryArgs, RunE: getMetaRun, PreRunE: preRunSetupRpcClient, } var getMetaRawOutput bool var getMetaClearPrefix bool var getMetaVerbose bool func init() { rootCmd.AddCommand(getMetaCmd) getMetaCmd.Flags().BoolVarP(&getMetaVerbose, "verbose", "v", false, "output full metadata") getMetaCmd.Flags().BoolVar(&getMetaRawOutput, "raw", false, "output singleton string values without quotes") getMetaCmd.Flags().BoolVar(&getMetaClearPrefix, "clear-prefix", false, "output the special clearing key for prefix queries") } func filterMetaKeys(meta map[string]interface{}, keys []string) map[string]interface{} { result := make(map[string]interface{}) // Process each requested key for _, key := range keys { if strings.HasSuffix(key, ":*") { // Handle pattern matching prefix := strings.TrimSuffix(key, "*") baseKey := strings.TrimSuffix(prefix, ":") if getMetaClearPrefix { result[key] = true } // Include the base key without colon if it exists if val, exists := meta[baseKey]; exists { result[baseKey] = val } // Include all keys with the prefix for k, v := range meta { if strings.HasPrefix(k, prefix) { result[k] = v } } } else { // Handle exact key match if val, exists := meta[key]; exists { result[key] = val } else { result[key] = nil } } } return result } func getMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("getmeta", rtnErr == nil) }() fullORef, err := resolveBlockArg() if err != nil { return err } if getMetaVerbose { fmt.Fprintf(os.Stderr, "resolved-id: %s\n", fullORef.String()) } resp, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ORef: *fullORef}, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("getting metadata: %w", err) } var output interface{} if len(args) > 0 { if len(args) == 1 && !strings.HasSuffix(args[0], ":*") { // Single key case - output just the value output = resp[args[0]] } else { // Multiple keys or pattern matching case - output object output = filterMetaKeys(resp, args) } } else { // No args case - output full metadata output = resp } // Handle raw string output if getMetaRawOutput { if str, ok := output.(string); ok { WriteStdout("%s\n", str) return } } outBArr, err := json.MarshalIndent(output, "", " ") if err != nil { return fmt.Errorf("formatting metadata: %w", err) } outStr := string(outBArr) WriteStdout("%s\n", outStr) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-getvar.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var getVarCmd = &cobra.Command{ Use: "getvar [flags] [key]", Short: "get variable(s) from a block", Long: `Get variable(s) from a block. Without --all, requires a key argument. With --all, prints all variables. Use -0 for null-terminated output.`, Example: " wsh getvar FOO\n wsh getvar --all\n wsh getvar --all -0", RunE: getVarRun, PreRunE: preRunSetupRpcClient, } var ( getVarFileName string getVarAllVars bool getVarNullTerminate bool getVarLocal bool getVarFlagNL bool getVarFlagNoNL bool ) func init() { rootCmd.AddCommand(getVarCmd) getVarCmd.Flags().StringVar(&getVarFileName, "varfile", DefaultVarFileName, "var file name") getVarCmd.Flags().BoolVar(&getVarAllVars, "all", false, "get all variables") getVarCmd.Flags().BoolVarP(&getVarNullTerminate, "null", "0", false, "use null terminators in output") getVarCmd.Flags().BoolVarP(&getVarLocal, "local", "l", false, "get variables local to block") getVarCmd.Flags().BoolVarP(&getVarFlagNL, "newline", "n", false, "print newline after output") getVarCmd.Flags().BoolVarP(&getVarFlagNoNL, "no-newline", "N", false, "do not print newline after output") } func shouldPrintNewline() bool { isTty := getIsTty() if getVarFlagNL { return true } if getVarFlagNoNL { return false } return isTty } func getVarRun(cmd *cobra.Command, args []string) error { defer func() { sendActivity("getvar", WshExitCode == 0) }() // Resolve block to get zoneId if blockArg == "" { if getVarLocal { blockArg = "this" } else { blockArg = "client" } } fullORef, err := resolveBlockArg() if err != nil { return err } if getVarAllVars { if len(args) > 0 { return fmt.Errorf("cannot specify key with --all") } return getAllVariables(fullORef.OID) } // Single variable case - existing logic if len(args) != 1 { OutputHelpMessage(cmd) return fmt.Errorf("requires a key argument") } key := args[0] commandData := wshrpc.CommandVarData{ Key: key, ZoneId: fullORef.OID, FileName: getVarFileName, } resp, err := wshclient.GetVarCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("getting variable: %w", err) } if !resp.Exists { WshExitCode = 1 return nil } WriteStdout("%s", resp.Val) if shouldPrintNewline() { WriteStdout("\n") } return nil } func getAllVariables(zoneId string) error { commandData := wshrpc.CommandVarData{ ZoneId: zoneId, FileName: getVarFileName, } vars, err := wshclient.GetAllVarsCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("getting variables: %w", err) } terminator := "\n" if getVarNullTerminate { terminator = "\x00" } for _, v := range vars { WriteStdout("%s=%s%s", v.Key, v.Val, terminator) } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-jobdebug.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/json" "fmt" "io" "os" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var jobDebugCmd = &cobra.Command{ Use: "jobdebug", Short: "debugging commands for the job system", Hidden: true, PersistentPreRunE: preRunSetupRpcClient, } var jobDebugListCmd = &cobra.Command{ Use: "list", Short: "list all jobs with debug information", RunE: jobDebugListRun, } var jobDebugDeleteCmd = &cobra.Command{ Use: "delete", Short: "delete a job entry by jobid", RunE: jobDebugDeleteRun, } var jobDebugDeleteAllCmd = &cobra.Command{ Use: "deleteall", Short: "delete all jobs", RunE: jobDebugDeleteAllRun, } var jobDebugPruneCmd = &cobra.Command{ Use: "prune", Short: "remove jobs where the job manager is no longer running", RunE: jobDebugPruneRun, } var jobDebugTerminateCmd = &cobra.Command{ Use: "terminate", Short: "terminate a job manager", RunE: jobDebugTerminateRun, } var jobDebugDisconnectCmd = &cobra.Command{ Use: "disconnect", Short: "disconnect from a job manager", RunE: jobDebugDisconnectRun, } var jobDebugReconnectCmd = &cobra.Command{ Use: "reconnect", Short: "reconnect to a job manager", RunE: jobDebugReconnectRun, } var jobDebugReconnectConnCmd = &cobra.Command{ Use: "reconnectconn", Short: "reconnect all jobs for a connection", RunE: jobDebugReconnectConnRun, } var jobDebugGetOutputCmd = &cobra.Command{ Use: "getoutput", Short: "get the terminal output for a job", RunE: jobDebugGetOutputRun, } var jobDebugStartCmd = &cobra.Command{ Use: "start", Short: "start a new job", Args: cobra.MinimumNArgs(1), RunE: jobDebugStartRun, } var jobDebugAttachJobCmd = &cobra.Command{ Use: "attachjob", Short: "attach a job to a block", RunE: jobDebugAttachJobRun, } var jobDebugDetachJobCmd = &cobra.Command{ Use: "detachjob", Short: "detach a job from its block", RunE: jobDebugDetachJobRun, } var jobDebugBlockAttachmentCmd = &cobra.Command{ Use: "blockattachment", Short: "show the attached job for a block", RunE: jobDebugBlockAttachmentRun, } var jobIdFlag string var jobDebugJsonFlag bool var jobConnFlag string var terminateJobIdFlag string var disconnectJobIdFlag string var reconnectJobIdFlag string var reconnectConnNameFlag string var attachJobIdFlag string var attachBlockIdFlag string var detachJobIdFlag string func init() { rootCmd.AddCommand(jobDebugCmd) jobDebugCmd.AddCommand(jobDebugListCmd) jobDebugCmd.AddCommand(jobDebugDeleteCmd) jobDebugCmd.AddCommand(jobDebugDeleteAllCmd) jobDebugCmd.AddCommand(jobDebugPruneCmd) jobDebugCmd.AddCommand(jobDebugTerminateCmd) jobDebugCmd.AddCommand(jobDebugDisconnectCmd) jobDebugCmd.AddCommand(jobDebugReconnectCmd) jobDebugCmd.AddCommand(jobDebugReconnectConnCmd) jobDebugCmd.AddCommand(jobDebugGetOutputCmd) jobDebugCmd.AddCommand(jobDebugStartCmd) jobDebugCmd.AddCommand(jobDebugAttachJobCmd) jobDebugCmd.AddCommand(jobDebugDetachJobCmd) jobDebugCmd.AddCommand(jobDebugBlockAttachmentCmd) jobDebugListCmd.Flags().BoolVar(&jobDebugJsonFlag, "json", false, "output as JSON") jobDebugDeleteCmd.Flags().StringVar(&jobIdFlag, "jobid", "", "job id to delete (required)") jobDebugDeleteCmd.MarkFlagRequired("jobid") jobDebugTerminateCmd.Flags().StringVar(&terminateJobIdFlag, "jobid", "", "job id to terminate (required)") jobDebugTerminateCmd.MarkFlagRequired("jobid") jobDebugDisconnectCmd.Flags().StringVar(&disconnectJobIdFlag, "jobid", "", "job id to disconnect (required)") jobDebugDisconnectCmd.MarkFlagRequired("jobid") jobDebugReconnectCmd.Flags().StringVar(&reconnectJobIdFlag, "jobid", "", "job id to reconnect (required)") jobDebugReconnectCmd.MarkFlagRequired("jobid") jobDebugReconnectConnCmd.Flags().StringVar(&reconnectConnNameFlag, "conn", "", "connection name (required)") jobDebugReconnectConnCmd.MarkFlagRequired("conn") jobDebugGetOutputCmd.Flags().StringVar(&jobIdFlag, "jobid", "", "job id to get output for (required)") jobDebugGetOutputCmd.MarkFlagRequired("jobid") jobDebugStartCmd.Flags().StringVar(&jobConnFlag, "conn", "", "connection name (required)") jobDebugStartCmd.MarkFlagRequired("conn") jobDebugAttachJobCmd.Flags().StringVar(&attachJobIdFlag, "jobid", "", "job id to attach (required)") jobDebugAttachJobCmd.MarkFlagRequired("jobid") jobDebugAttachJobCmd.Flags().StringVar(&attachBlockIdFlag, "blockid", "", "block id to attach to (required)") jobDebugAttachJobCmd.MarkFlagRequired("blockid") jobDebugDetachJobCmd.Flags().StringVar(&detachJobIdFlag, "jobid", "", "job id to detach (required)") jobDebugDetachJobCmd.MarkFlagRequired("jobid") } func jobDebugListRun(cmd *cobra.Command, args []string) error { rtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { return fmt.Errorf("getting job debug list: %w", err) } connectedJobIds, err := wshclient.JobControllerConnectedJobsCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { return fmt.Errorf("getting connected job ids: %w", err) } connectedMap := make(map[string]bool) for _, jobId := range connectedJobIds { connectedMap[jobId] = true } if jobDebugJsonFlag { jsonData, err := json.MarshalIndent(rtnData, "", " ") if err != nil { return fmt.Errorf("marshaling json: %w", err) } fmt.Printf("%s\n", string(jsonData)) return nil } fmt.Printf("%-36s %-25s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\n", "OID", "Connection", "Connected", "Manager", "Reason", "Cmd", "ExitCode", "Stream", "Attached") for _, job := range rtnData { connectedStatus := "no" if connectedMap[job.OID] { connectedStatus = "yes" } if job.TerminateOnReconnect { connectedStatus += "*" } streamStatus := "-" if job.StreamDone { if job.StreamError == "" { streamStatus = "EOF" } else { streamStatus = fmt.Sprintf("%q", job.StreamError) } } exitCode := "-" if job.CmdExitTs > 0 { if job.CmdExitCode != nil { exitCode = fmt.Sprintf("%d", *job.CmdExitCode) } else if job.CmdExitSignal != "" { exitCode = job.CmdExitSignal } else { exitCode = "?" } } doneReason := "-" if job.JobManagerDoneReason == "startuperror" { doneReason = "serr" } else if job.JobManagerDoneReason == "gone" { doneReason = "gone" } else if job.JobManagerDoneReason == "terminated" { doneReason = "term" } attachedBlock := "-" if job.AttachedBlockId != "" { if len(job.AttachedBlockId) >= 8 { attachedBlock = job.AttachedBlockId[:8] } else { attachedBlock = job.AttachedBlockId } } fmt.Printf("%-36s %-25s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\n", job.OID, job.Connection, connectedStatus, job.JobManagerStatus, doneReason, job.Cmd, exitCode, streamStatus, attachedBlock) } return nil } func jobDebugDeleteRun(cmd *cobra.Command, args []string) error { err := wshclient.JobControllerDeleteJobCommand(RpcClient, jobIdFlag, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { return fmt.Errorf("deleting job: %w", err) } fmt.Printf("Job %s deleted successfully\n", jobIdFlag) return nil } func jobDebugDeleteAllRun(cmd *cobra.Command, args []string) error { rtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { return fmt.Errorf("getting job debug list: %w", err) } if len(rtnData) == 0 { fmt.Printf("No jobs to delete\n") return nil } deletedCount := 0 for _, job := range rtnData { err := wshclient.JobControllerDeleteJobCommand(RpcClient, job.OID, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { fmt.Printf("Error deleting job %s: %v\n", job.OID, err) } else { deletedCount++ } } fmt.Printf("Deleted %d of %d job(s)\n", deletedCount, len(rtnData)) return nil } func jobDebugPruneRun(cmd *cobra.Command, args []string) error { rtnData, err := wshclient.JobControllerListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { return fmt.Errorf("getting job debug list: %w", err) } if len(rtnData) == 0 { fmt.Printf("No jobs to prune\n") return nil } deletedCount := 0 for _, job := range rtnData { if job.JobManagerStatus != "running" { err := wshclient.JobControllerDeleteJobCommand(RpcClient, job.OID, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { fmt.Printf("Error deleting job %s: %v\n", job.OID, err) } else { deletedCount++ } } } if deletedCount == 0 { fmt.Printf("No jobs with stopped job managers to prune\n") } else { fmt.Printf("Pruned %d job(s) with stopped job managers\n", deletedCount) } return nil } func jobDebugTerminateRun(cmd *cobra.Command, args []string) error { err := wshclient.JobControllerExitJobCommand(RpcClient, terminateJobIdFlag, nil) if err != nil { return fmt.Errorf("terminating job manager: %w", err) } fmt.Printf("Job manager for %s terminated successfully\n", terminateJobIdFlag) return nil } func jobDebugDisconnectRun(cmd *cobra.Command, args []string) error { err := wshclient.JobControllerDisconnectJobCommand(RpcClient, disconnectJobIdFlag, nil) if err != nil { return fmt.Errorf("disconnecting from job manager: %w", err) } fmt.Printf("Disconnected from job manager for %s successfully\n", disconnectJobIdFlag) return nil } func jobDebugReconnectRun(cmd *cobra.Command, args []string) error { err := wshclient.JobControllerReconnectJobCommand(RpcClient, reconnectJobIdFlag, nil) if err != nil { return fmt.Errorf("reconnecting to job manager: %w", err) } fmt.Printf("Reconnected to job manager for %s successfully\n", reconnectJobIdFlag) return nil } func jobDebugReconnectConnRun(cmd *cobra.Command, args []string) error { err := wshclient.JobControllerReconnectJobsForConnCommand(RpcClient, reconnectConnNameFlag, nil) if err != nil { return fmt.Errorf("reconnecting jobs for connection: %w", err) } fmt.Printf("Reconnected all jobs for connection %s successfully\n", reconnectConnNameFlag) return nil } func jobDebugGetOutputRun(cmd *cobra.Command, args []string) error { broker := RpcClient.StreamBroker if broker == nil { return fmt.Errorf("stream broker not available") } readerRouteId, err := wshclient.ControlGetRouteIdCommand(RpcClient, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) if err != nil { return fmt.Errorf("getting route id: %w", err) } if readerRouteId == "" { return fmt.Errorf("no route to receive data") } writerRouteId := "" // main server route reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 64*1024) defer reader.Close() data := wshrpc.CommandWaveFileReadStreamData{ ZoneId: jobIdFlag, Name: "term", StreamMeta: *streamMeta, } _, err = wshclient.WaveFileReadStreamCommand(RpcClient, data, nil) if err != nil { return fmt.Errorf("starting stream read: %w", err) } _, err = io.Copy(os.Stdout, reader) if err != nil { return fmt.Errorf("reading stream: %w", err) } return nil } func jobDebugStartRun(cmd *cobra.Command, args []string) error { cmdToRun := args[0] cmdArgs := args[1:] data := wshrpc.CommandJobControllerStartJobData{ ConnName: jobConnFlag, JobKind: "task", Cmd: cmdToRun, Args: cmdArgs, Env: make(map[string]string), TermSize: nil, } jobId, err := wshclient.JobControllerStartJobCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 10000}) if err != nil { return fmt.Errorf("starting job: %w", err) } fmt.Printf("Job started successfully with ID: %s\n", jobId) return nil } func jobDebugAttachJobRun(cmd *cobra.Command, args []string) error { data := wshrpc.CommandJobControllerAttachJobData{ JobId: attachJobIdFlag, BlockId: attachBlockIdFlag, } err := wshclient.JobControllerAttachJobCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { return fmt.Errorf("attaching job: %w", err) } fmt.Printf("Job %s attached to block %s successfully\n", attachJobIdFlag, attachBlockIdFlag) return nil } func jobDebugDetachJobRun(cmd *cobra.Command, args []string) error { err := wshclient.JobControllerDetachJobCommand(RpcClient, detachJobIdFlag, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { return fmt.Errorf("detaching job: %w", err) } fmt.Printf("Job %s detached successfully\n", detachJobIdFlag) return nil } func jobDebugBlockAttachmentRun(cmd *cobra.Command, args []string) error { blockORef, err := resolveBlockArg() if err != nil { return err } blockId := blockORef.OID jobStatus, err := wshclient.BlockJobStatusCommand(RpcClient, blockId, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { return fmt.Errorf("getting block job status: %w", err) } if jobStatus.JobId == "" { fmt.Printf("Block %s: no attached job\n", blockId) } else { fmt.Printf("Block %s: attached to job %s\n", blockId, jobStatus.JobId) } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-jobmanager.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "bufio" "context" "encoding/base64" "fmt" "os" "strings" "time" "github.com/google/uuid" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/jobmanager" ) var jobManagerCmd = &cobra.Command{ Use: "jobmanager", Hidden: true, Short: "job manager for wave terminal", Args: cobra.NoArgs, RunE: jobManagerRun, } var jobManagerJobId string var jobManagerClientId string func init() { jobManagerCmd.Flags().StringVar(&jobManagerJobId, "jobid", "", "job ID (UUID, required)") jobManagerCmd.Flags().StringVar(&jobManagerClientId, "clientid", "", "client ID (UUID, required)") jobManagerCmd.MarkFlagRequired("jobid") jobManagerCmd.MarkFlagRequired("clientid") rootCmd.AddCommand(jobManagerCmd) } func jobManagerRun(cmd *cobra.Command, args []string) error { _, err := uuid.Parse(jobManagerJobId) if err != nil { return fmt.Errorf("invalid jobid: must be a valid UUID") } _, err = uuid.Parse(jobManagerClientId) if err != nil { return fmt.Errorf("invalid clientid: must be a valid UUID") } publicKeyB64 := os.Getenv("WAVETERM_PUBLICKEY") if publicKeyB64 == "" { return fmt.Errorf("WAVETERM_PUBLICKEY environment variable is not set") } publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64) if err != nil { return fmt.Errorf("failed to decode WAVETERM_PUBLICKEY: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() jobAuthToken, err := readJobAuthToken(ctx) if err != nil { return fmt.Errorf("failed to read job auth token: %v", err) } readyFile := os.NewFile(3, "ready-pipe") _, err = readyFile.Stat() if err != nil { return fmt.Errorf("ready pipe (fd 3) not available: %v", err) } err = jobmanager.SetupJobManager(jobManagerClientId, jobManagerJobId, publicKeyBytes, jobAuthToken, readyFile) if err != nil { return fmt.Errorf("error setting up job manager: %v", err) } select {} } func readJobAuthToken(ctx context.Context) (string, error) { resultCh := make(chan string, 1) errorCh := make(chan error, 1) go func() { reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { errorCh <- fmt.Errorf("error reading from stdin: %v", err) return } line = strings.TrimSpace(line) prefix := jobmanager.JobAccessTokenLabel + ":" if !strings.HasPrefix(line, prefix) { errorCh <- fmt.Errorf("invalid token format: expected '%s'", prefix) return } token := strings.TrimPrefix(line, prefix) token = strings.TrimSpace(token) if token == "" { errorCh <- fmt.Errorf("empty job auth token") return } resultCh <- token }() select { case token := <-resultCh: return token, nil case err := <-errorCh: return "", err case <-ctx.Done(): return "", ctx.Err() } } ================================================ FILE: cmd/wsh/cmd/wshcmd-launch.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var magnifyBlock bool var launchCmd = &cobra.Command{ Use: "launch", Short: "launch a widget by its ID", Args: cobra.ExactArgs(1), RunE: launchRun, PreRunE: preRunSetupRpcClient, } func init() { launchCmd.Flags().BoolVarP(&magnifyBlock, "magnify", "m", false, "start the widget in magnified mode") rootCmd.AddCommand(launchCmd) } func launchRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("launch", rtnErr == nil) }() widgetId := args[0] // Get the full configuration config, err := wshclient.GetFullConfigCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("getting configuration: %w", err) } // Look for widget in both widgets and defaultwidgets widget, ok := config.Widgets[widgetId] if !ok { widget, ok = config.DefaultWidgets[widgetId] if !ok { return fmt.Errorf("widget %q not found in configuration", widgetId) } } tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } // Create block data from widget config createBlockData := wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &widget.BlockDef, Magnified: magnifyBlock || widget.Magnified, Focused: true, } // Create the block oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) if err != nil { return fmt.Errorf("creating widget block: %w", err) } WriteStdout("launched widget %q: %s\n", widgetId, oref) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-notify.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var notifyTitle string var notifySilent bool var setNotifyCmd = &cobra.Command{ Use: "notify [-t ] [-s]", Short: "create a notification", Args: cobra.ExactArgs(1), RunE: notifyRun, PreRunE: preRunSetupRpcClient, } func init() { setNotifyCmd.Flags().StringVarP(¬ifyTitle, "title", "t", "Wsh Notify", "the notification title") setNotifyCmd.Flags().BoolVarP(¬ifySilent, "silent", "s", false, "whether or not the notification sound is silenced") rootCmd.AddCommand(setNotifyCmd) } func notifyRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("notify", rtnErr == nil) }() message := args[0] notificationOptions := &wshrpc.WaveNotificationOptions{ Title: notifyTitle, Body: message, Silent: notifySilent, } err := wshclient.NotifyCommand(RpcClient, *notificationOptions, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute}) if err != nil { return fmt.Errorf("sending notification: %w", err) } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-rcfiles.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshutil" ) func init() { rootCmd.AddCommand(rcfilesCmd) } var rcfilesCmd = &cobra.Command{ Use: "rcfiles", Hidden: true, Short: "Generate the rc files needed for various shells", Run: func(cmd *cobra.Command, args []string) { err := wshutil.InstallRcFiles() if err != nil { WriteStderr("%s\n", err.Error()) return } }, } ================================================ FILE: cmd/wsh/cmd/wshcmd-readfile.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "io" "os" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var readFileCmd = &cobra.Command{ Use: "readfile [filename]", Short: "read a blockfile", Args: cobra.ExactArgs(1), Run: runReadFile, PreRunE: preRunSetupRpcClient, Hidden: true, } func init() { rootCmd.AddCommand(readFileCmd) } func runReadFile(cmd *cobra.Command, args []string) { fullORef, err := resolveBlockArg() if err != nil { WriteStderr("[error] %v\n", err) return } broker := RpcClient.StreamBroker if broker == nil { WriteStderr("[error] stream broker not available\n") return } readerRouteId, err := wshclient.ControlGetRouteIdCommand(RpcClient, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) if err != nil { WriteStderr("[error] getting route id: %v\n", err) return } if readerRouteId == "" { WriteStderr("[error] no route to receive data\n") return } writerRouteId := "" reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, 64*1024) defer reader.Close() data := wshrpc.CommandWaveFileReadStreamData{ ZoneId: fullORef.OID, Name: args[0], StreamMeta: *streamMeta, } _, err = wshclient.WaveFileReadStreamCommand(RpcClient, data, nil) if err != nil { WriteStderr("[error] starting stream read: %v\n", err) return } _, err = io.Copy(os.Stdout, reader) if err != nil { WriteStderr("[error] reading stream: %v\n", err) return } } ================================================ FILE: cmd/wsh/cmd/wshcmd-root.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "io" "os" "runtime/debug" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var ( rootCmd = &cobra.Command{ Use: "wsh", Short: "CLI tool to control Wave Terminal", Long: `wsh is a small utility that lets you do cool things with Wave Terminal, right from the command line`, SilenceUsage: true, } ) var WrappedStdin io.Reader = os.Stdin var WrappedStdout io.Writer = &WrappedWriter{dest: os.Stdout} var WrappedStderr io.Writer = &WrappedWriter{dest: os.Stderr} var RpcClient *wshutil.WshRpc var RpcContext wshrpc.RpcContext var UsingTermWshMode bool var blockArg string var WshExitCode int type WrappedWriter struct { dest io.Writer } func (w *WrappedWriter) Write(p []byte) (n int, err error) { if !UsingTermWshMode { return w.dest.Write(p) } count := 0 for _, b := range p { if b == '\n' { count++ } } if count == 0 { return w.dest.Write(p) } buf := make([]byte, len(p)+count) // Each '\n' adds one extra byte for '\r' writeIdx := 0 for _, b := range p { if b == '\n' { buf[writeIdx] = '\r' buf[writeIdx+1] = '\n' writeIdx += 2 } else { buf[writeIdx] = b writeIdx++ } } return w.dest.Write(buf) } func WriteStderr(fmtStr string, args ...interface{}) { WrappedStderr.Write([]byte(fmt.Sprintf(fmtStr, args...))) } func WriteStdout(fmtStr string, args ...interface{}) { WrappedStdout.Write([]byte(fmt.Sprintf(fmtStr, args...))) } func OutputHelpMessage(cmd *cobra.Command) { cmd.SetOutput(WrappedStderr) cmd.Help() WriteStderr("\n") } func preRunSetupRpcClient(cmd *cobra.Command, args []string) error { jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) if jwtToken == "" { return fmt.Errorf("wsh must be run inside a Wave-managed SSH session (WAVETERM_JWT not found)") } err := setupRpcClient(nil, jwtToken) if err != nil { return err } return nil } func getIsTty() bool { if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { return true } return false } type RunEFnType = func(*cobra.Command, []string) error func activityWrap(activityStr string, origRunE RunEFnType) RunEFnType { return func(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity(activityStr, rtnErr == nil) }() return origRunE(cmd, args) } } func resolveBlockArg() (*waveobj.ORef, error) { oref := blockArg if oref == "" { oref = "this" } fullORef, err := resolveSimpleId(oref) if err != nil { return nil, fmt.Errorf("resolving blockid: %w", err) } return fullORef, nil } func setupRpcClientWithToken(swapTokenStr string) (wshrpc.CommandAuthenticateRtnData, error) { var rtn wshrpc.CommandAuthenticateRtnData token, err := shellutil.UnpackSwapToken(swapTokenStr) if err != nil { return rtn, fmt.Errorf("error unpacking token: %w", err) } if token.RpcContext == nil { return rtn, fmt.Errorf("no rpccontext in token") } if token.RpcContext.SockName == "" { return rtn, fmt.Errorf("no sockname in token") } RpcContext = *token.RpcContext RpcClient, err = wshutil.SetupDomainSocketRpcClient(token.RpcContext.SockName, nil, "wshcmd") if err != nil { return rtn, fmt.Errorf("error setting up domain socket rpc client: %w", err) } return wshclient.AuthenticateTokenCommand(RpcClient, wshrpc.CommandAuthenticateTokenData{Token: token.Token}, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) } // returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output) func setupRpcClient(serverImpl wshutil.ServerImpl, jwtToken string) error { rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken) if err != nil { return fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err) } RpcContext = *rpcCtx sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) if err != nil { return fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err) } RpcClient, err = wshutil.SetupDomainSocketRpcClient(sockName, serverImpl, "wshcmd") if err != nil { return fmt.Errorf("error setting up domain socket rpc client: %v", err) } _, err = wshclient.AuthenticateCommand(RpcClient, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) if err != nil { return fmt.Errorf("error authenticating: %v", err) } blockId := os.Getenv("WAVETERM_BLOCKID") if blockId != "" { peerInfo := fmt.Sprintf("domain:block:%s", blockId) wshclient.SetPeerInfoCommand(RpcClient, peerInfo, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) } // note we don't modify WrappedStdin here (just use os.Stdin) return nil } func isFullORef(orefStr string) bool { _, err := waveobj.ParseORef(orefStr) return err == nil } func resolveSimpleId(id string) (*waveobj.ORef, error) { if isFullORef(id) { orefObj, err := waveobj.ParseORef(id) if err != nil { return nil, fmt.Errorf("error parsing full ORef: %v", err) } return &orefObj, nil } blockId := os.Getenv("WAVETERM_BLOCKID") if blockId == "" { return nil, fmt.Errorf("no WAVETERM_BLOCKID env var set") } rtnData, err := wshclient.ResolveIdsCommand(RpcClient, wshrpc.CommandResolveIdsData{ BlockId: blockId, Ids: []string{id}, }, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return nil, fmt.Errorf("error resolving ids: %v", err) } oref, ok := rtnData.ResolvedIds[id] if !ok { return nil, fmt.Errorf("id not found: %q", id) } return &oref, nil } func getTabIdFromEnv() string { return os.Getenv("WAVETERM_TABID") } // this will send wsh activity to the client running on *your* local machine (it does not contact any wave cloud infrastructure) // if you've turned off telemetry in your local client, this data never gets sent to us // no parameters or timestamps are sent, as you can see below, it just sends the name of the command (and if there was an error) // (e.g. "wsh ai ..." would send "ai") // this helps us understand which commands are actually being used so we know where to concentrate our effort func sendActivity(wshCmdName string, success bool) { if RpcClient == nil || wshCmdName == "" { return } dataMap := make(map[string]int) dataMap[wshCmdName] = 1 if !success { dataMap[wshCmdName+"#"+"error"] = 1 } wshclient.WshActivityCommand(RpcClient, dataMap, nil) } // Execute executes the root command. func Execute() { defer func() { r := recover() if r != nil { WriteStderr("[panic] %v\n", r) debug.PrintStack() wshutil.DoShutdown("", 1, true) } else { wshutil.DoShutdown("", WshExitCode, false) } }() rootCmd.PersistentFlags().StringVarP(&blockArg, "block", "b", "", "for commands which require a block id") err := rootCmd.Execute() if err != nil { wshutil.DoShutdown("", 1, true) return } } ================================================ FILE: cmd/wsh/cmd/wshcmd-run.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "os" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var runCmd = &cobra.Command{ Use: "run [flags] -- command [args...]", Short: "run a command in a new block", RunE: runRun, PreRunE: preRunSetupRpcClient, TraverseChildren: true, } func init() { flags := runCmd.Flags() flags.BoolP("magnified", "m", false, "open view in magnified mode") flags.StringP("command", "c", "", "run command string in shell") flags.BoolP("exit", "x", false, "close block if command exits successfully (will stay open if there was an error)") flags.BoolP("forceexit", "X", false, "close block when command exits, regardless of exit status") flags.IntP("delay", "", 2000, "if -x, delay in milliseconds before closing block") flags.BoolP("paused", "p", false, "create block in paused state") flags.String("cwd", "", "set working directory for command") flags.BoolP("append", "a", false, "append output on restart instead of clearing") rootCmd.AddCommand(runCmd) } func runRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("run", rtnErr == nil) }() flags := cmd.Flags() magnified, _ := flags.GetBool("magnified") commandArg, _ := flags.GetString("command") exit, _ := flags.GetBool("exit") forceExit, _ := flags.GetBool("forceexit") paused, _ := flags.GetBool("paused") cwd, _ := flags.GetString("cwd") delayMs, _ := flags.GetInt("delay") appendOutput, _ := flags.GetBool("append") var cmdArgs []string var useShell bool var shellCmd string for i, arg := range os.Args { if arg == "--" { if i+1 >= len(os.Args) { OutputHelpMessage(cmd) return fmt.Errorf("no command provided after --") } shellCmd = os.Args[i+1] cmdArgs = os.Args[i+2:] break } } if shellCmd != "" && commandArg != "" { OutputHelpMessage(cmd) return fmt.Errorf("cannot specify both -c and command arguments") } if shellCmd == "" && commandArg == "" { OutputHelpMessage(cmd) return fmt.Errorf("command must be specified after -- or with -c") } if commandArg != "" { shellCmd = commandArg useShell = true } // Get current working directory if cwd == "" { var err error cwd, err = os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } } cwd, err := filepath.Abs(cwd) if err != nil { return fmt.Errorf("getting absolute path: %w", err) } // Get current environment and convert to map envMap := make(map[string]string) for _, envStr := range os.Environ() { env := strings.SplitN(envStr, "=", 2) if len(env) == 2 { envMap[env[0]] = env[1] } } // Convert to null-terminated format envContent := envutil.MapToEnv(envMap) createMeta := map[string]any{ waveobj.MetaKey_View: "term", waveobj.MetaKey_CmdCwd: cwd, waveobj.MetaKey_Controller: "cmd", waveobj.MetaKey_CmdClearOnStart: true, } createMeta[waveobj.MetaKey_Cmd] = shellCmd createMeta[waveobj.MetaKey_CmdArgs] = cmdArgs createMeta[waveobj.MetaKey_CmdShell] = useShell if paused { createMeta[waveobj.MetaKey_CmdRunOnStart] = false } else { createMeta[waveobj.MetaKey_CmdRunOnce] = true createMeta[waveobj.MetaKey_CmdRunOnStart] = true } if forceExit { createMeta[waveobj.MetaKey_CmdCloseOnExitForce] = true } else if exit { createMeta[waveobj.MetaKey_CmdCloseOnExit] = true } createMeta[waveobj.MetaKey_CmdCloseOnExitDelay] = float64(delayMs) if appendOutput { createMeta[waveobj.MetaKey_CmdClearOnStart] = false } if RpcContext.Conn != "" { createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn } tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } createBlockData := wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &waveobj.BlockDef{ Meta: createMeta, Files: map[string]*waveobj.FileDef{ wavebase.BlockFile_Env: { Content: envContent, }, }, }, Magnified: magnified, Focused: true, } oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) if err != nil { return fmt.Errorf("creating new run block: %w", err) } WriteStdout("run block created: %s\n", oref) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-secret.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "regexp" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) // secretNameRegex must match the validation in pkg/wconfig/secretstore.go var secretNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) var secretUiMagnified bool var secretCmd = &cobra.Command{ Use: "secret", Short: "manage secrets", Long: "Manage secrets for Wave Terminal", } var secretGetCmd = &cobra.Command{ Use: "get [name]", Short: "get a secret value", Args: cobra.ExactArgs(1), RunE: secretGetRun, PreRunE: preRunSetupRpcClient, } var secretSetCmd = &cobra.Command{ Use: "set [name]=[value]", Short: "set a secret value", Args: cobra.ExactArgs(1), RunE: secretSetRun, PreRunE: preRunSetupRpcClient, } var secretListCmd = &cobra.Command{ Use: "list", Short: "list all secret names", Args: cobra.NoArgs, RunE: secretListRun, PreRunE: preRunSetupRpcClient, } var secretDeleteCmd = &cobra.Command{ Use: "delete [name]", Short: "delete a secret", Args: cobra.ExactArgs(1), RunE: secretDeleteRun, PreRunE: preRunSetupRpcClient, } var secretUiCmd = &cobra.Command{ Use: "ui", Short: "open secrets UI", Args: cobra.NoArgs, RunE: secretUiRun, PreRunE: preRunSetupRpcClient, } func init() { secretUiCmd.Flags().BoolVarP(&secretUiMagnified, "magnified", "m", false, "open secrets UI in magnified mode") rootCmd.AddCommand(secretCmd) secretCmd.AddCommand(secretGetCmd) secretCmd.AddCommand(secretSetCmd) secretCmd.AddCommand(secretListCmd) secretCmd.AddCommand(secretDeleteCmd) secretCmd.AddCommand(secretUiCmd) } func secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("secret", rtnErr == nil) }() name := args[0] if !secretNameRegex.MatchString(name) { return fmt.Errorf("invalid secret name: must start with a letter and contain only letters, numbers, and underscores") } resp, err := wshclient.GetSecretsCommand(RpcClient, []string{name}, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("getting secret: %w", err) } value, ok := resp[name] if !ok { return fmt.Errorf("secret not found: %s", name) } WriteStdout("%s\n", value) return nil } func secretSetRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("secret", rtnErr == nil) }() parts := strings.SplitN(args[0], "=", 2) if len(parts) != 2 { return fmt.Errorf("invalid format: expected [name]=[value]") } name := parts[0] value := parts[1] if name == "" { return fmt.Errorf("secret name cannot be empty") } backend, err := wshclient.GetSecretsLinuxStorageBackendCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("checking secret storage backend: %w", err) } if backend == "basic_text" || backend == "unknown" { return fmt.Errorf("No appropriate secret manager found, cannot set secrets") } secrets := map[string]*string{name: &value} err = wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("setting secret: %w", err) } WriteStdout("secret set: %s\n", name) return nil } func secretListRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("secret", rtnErr == nil) }() names, err := wshclient.GetSecretsNamesCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("listing secrets: %w", err) } for _, name := range names { WriteStdout("%s\n", name) } return nil } func secretDeleteRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("secret", rtnErr == nil) }() name := args[0] if !secretNameRegex.MatchString(name) { return fmt.Errorf("invalid secret name: must start with a letter and contain only letters, numbers, and underscores") } secrets := map[string]*string{name: nil} err := wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("deleting secret: %w", err) } WriteStdout("secret deleted: %s\n", name) return nil } func secretUiRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("secret", rtnErr == nil) }() tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } wshCmd := &wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &waveobj.BlockDef{ Meta: map[string]interface{}{ waveobj.MetaKey_View: "waveconfig", waveobj.MetaKey_File: "secrets", }, }, Magnified: secretUiMagnified, Focused: true, } _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("opening secrets UI: %w", err) } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-setbg.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var setBgCmd = &cobra.Command{ Use: "setbg [--opacity value] [--tile|--center] [--scale value] (image-path|\"#color\"|color-name)", Short: "set background image or color for a tab", Long: `Set a background image or color for a tab. Colors can be specified as: - A quoted hex value like "#ff0000" (quotes required to prevent # being interpreted as a shell comment) - A CSS color name like "blue" or "forestgreen" Or provide a path to a supported image file (jpg, png, gif, webp, or svg). You can also: - Use --clear to remove the background - Use --opacity without other arguments to change just the opacity - Use --center for centered images without scaling (good for logos) - Use --scale with --center to control image size - Use --print to see the metadata without applying it`, RunE: setBgRun, PreRunE: preRunSetupRpcClient, } var ( setBgOpacity float64 setBgTile bool setBgCenter bool setBgSize string setBgClear bool setBgPrint bool ) func init() { rootCmd.AddCommand(setBgCmd) setBgCmd.Flags().Float64Var(&setBgOpacity, "opacity", 0.5, "background opacity (0.0-1.0)") setBgCmd.Flags().BoolVar(&setBgTile, "tile", false, "tile the background image") setBgCmd.Flags().BoolVar(&setBgCenter, "center", false, "center the image without scaling") setBgCmd.Flags().StringVar(&setBgSize, "size", "auto", "size for centered images (px, %, or auto)") setBgCmd.Flags().BoolVar(&setBgClear, "clear", false, "clear the background") setBgCmd.Flags().BoolVar(&setBgPrint, "print", false, "print the metadata without applying it") // Make tile and center mutually exclusive setBgCmd.MarkFlagsMutuallyExclusive("tile", "center") } func validateHexColor(color string) error { if !strings.HasPrefix(color, "#") { return fmt.Errorf("color must start with #") } colorHex := color[1:] if len(colorHex) != 6 && len(colorHex) != 8 { return fmt.Errorf("color must be in #RRGGBB or #RRGGBBAA format") } _, err := hex.DecodeString(colorHex) if err != nil { return fmt.Errorf("invalid hex color: %v", err) } return nil } func setBgRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("setbg", rtnErr == nil) }() // Create base metadata meta := map[string]interface{}{} // Handle opacity-only change or clear if len(args) == 0 { if !cmd.Flags().Changed("opacity") && !setBgClear { OutputHelpMessage(cmd) return fmt.Errorf("setbg requires an image path or color value") } if setBgOpacity < 0 || setBgOpacity > 1 { return fmt.Errorf("opacity must be between 0.0 and 1.0") } if setBgClear { meta["bg:*"] = true } else { meta["bg:opacity"] = setBgOpacity } } else if len(args) > 1 { OutputHelpMessage(cmd) return fmt.Errorf("too many arguments") } else { // Handle background setting meta["bg:*"] = true if setBgOpacity < 0 || setBgOpacity > 1 { return fmt.Errorf("opacity must be between 0.0 and 1.0") } meta["bg:opacity"] = setBgOpacity input := args[0] var bgStyle string // Check for hex color if strings.HasPrefix(input, "#") { if err := validateHexColor(input); err != nil { return err } bgStyle = input } else if CssColorNames[strings.ToLower(input)] { // Handle CSS color name bgStyle = strings.ToLower(input) } else { // Handle image input absPath, err := filepath.Abs(wavebase.ExpandHomeDirSafe(input)) if err != nil { return fmt.Errorf("resolving image path: %v", err) } fileInfo, err := os.Stat(absPath) if err != nil { return fmt.Errorf("cannot access image file: %v", err) } if fileInfo.IsDir() { return fmt.Errorf("path is a directory, not an image file") } mimeType := fileutil.DetectMimeType(absPath, fileInfo, true) switch mimeType { case "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml": // Valid image type default: return fmt.Errorf("file does not appear to be a valid image (detected type: %s)", mimeType) } // Create URL-safe path escapedPath := filepath.ToSlash(absPath) escapedPath = strings.ReplaceAll(escapedPath, "'", "\\'") bgStyle = fmt.Sprintf("url('%s')", escapedPath) switch { case setBgTile: bgStyle += " repeat" case setBgCenter: bgStyle += fmt.Sprintf(" no-repeat center/%s", setBgSize) default: bgStyle += " center/cover no-repeat" } } meta["bg"] = bgStyle } if setBgPrint { jsonBytes, err := json.MarshalIndent(meta, "", " ") if err != nil { return fmt.Errorf("error formatting metadata: %v", err) } WriteStdout("%s\n", string(jsonBytes)) return nil } // Resolve tab reference id := blockArg if id == "" { id = "tab" } oRef, err := resolveSimpleId(id) if err != nil { return err } // Send RPC request setMetaWshCmd := wshrpc.CommandSetMetaData{ ORef: *oRef, Meta: meta, } err = wshclient.SetMetaCommand(RpcClient, setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("setting background: %v", err) } WriteStdout("background set\n") return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-setconfig.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var setConfigCmd = &cobra.Command{ Use: "setconfig", Short: "set config", Args: cobra.MinimumNArgs(1), RunE: setConfigRun, PreRunE: preRunSetupRpcClient, } func init() { rootCmd.AddCommand(setConfigCmd) } func setConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("setconfig", rtnErr == nil) }() metaSetsStrs := args[:] meta, err := parseMetaSets(metaSetsStrs) if err != nil { return err } commandData := wshrpc.MetaSettingsType{MetaMapType: meta} err = wshclient.SetConfigCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("setting config: %w", err) } WriteStdout("config set\n") return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-setmeta.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/json" "fmt" "io" "os" "strconv" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var setMetaCmd = &cobra.Command{ Use: "setmeta [-b {blockid|blocknum|this}] [--json file.json] key=value ...", Short: "set metadata for an entity", Args: cobra.MinimumNArgs(0), RunE: setMetaRun, PreRunE: preRunSetupRpcClient, } var setMetaJsonFilePath string func init() { rootCmd.AddCommand(setMetaCmd) setMetaCmd.Flags().StringVar(&setMetaJsonFilePath, "json", "", "JSON file containing metadata to apply (use '-' for stdin)") } func loadJSONFile(filepath string) (map[string]interface{}, error) { var data []byte var err error if filepath == "-" { data, err = io.ReadAll(os.Stdin) if err != nil { return nil, fmt.Errorf("reading from stdin: %v", err) } } else { data, err = os.ReadFile(filepath) if err != nil { return nil, fmt.Errorf("reading JSON file: %v", err) } } var result map[string]interface{} if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("parsing JSON file: %v", err) } if result == nil { return nil, fmt.Errorf("JSON file must contain an object, not null") } return result, nil } func parseMetaValue(setVal string) (any, error) { if setVal == "" || setVal == "null" { return nil, nil } if setVal == "true" { return true, nil } if setVal == "false" { return false, nil } if setVal[0] == '[' || setVal[0] == '{' || setVal[0] == '"' { var val any err := json.Unmarshal([]byte(setVal), &val) if err != nil { return nil, fmt.Errorf("invalid json value: %v", err) } return val, nil } // Try parsing as integer ival, err := strconv.ParseInt(setVal, 0, 64) if err == nil { return ival, nil } // Try parsing as float fval, err := strconv.ParseFloat(setVal, 64) if err == nil { return fval, nil } // Fallback to string return setVal, nil } func setNestedValue(meta map[string]any, path []string, value any) { // For single key, just set directly if len(path) == 1 { meta[path[0]] = value return } // For nested path, traverse or create maps as needed current := meta for i := 0; i < len(path)-1; i++ { key := path[i] // If next level doesn't exist or isn't a map, create new map next, exists := current[key] if !exists { nextMap := make(map[string]any) current[key] = nextMap current = nextMap } else if nextMap, ok := next.(map[string]any); ok { current = nextMap } else { // If existing value isn't a map, replace with new map nextMap = make(map[string]any) current[key] = nextMap current = nextMap } } // Set the final value current[path[len(path)-1]] = value } func parseMetaSets(metaSets []string) (map[string]any, error) { meta := make(map[string]any) for _, metaSet := range metaSets { fields := strings.SplitN(metaSet, "=", 2) if len(fields) != 2 { return nil, fmt.Errorf("invalid meta set: %q", metaSet) } val, err := parseMetaValue(fields[1]) if err != nil { return nil, err } // Split the key path and set nested value path := strings.Split(fields[0], "/") setNestedValue(meta, path, val) } return meta, nil } func simpleMergeMeta(meta map[string]interface{}, metaUpdate map[string]interface{}) map[string]interface{} { for k, v := range metaUpdate { if v == nil { delete(meta, k) } else { meta[k] = v } } return meta } func setMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("setmeta", rtnErr == nil) }() var jsonMeta map[string]interface{} if setMetaJsonFilePath != "" { var err error jsonMeta, err = loadJSONFile(setMetaJsonFilePath) if err != nil { return err } } cmdMeta, err := parseMetaSets(args) if err != nil { return err } // Merge JSON metadata with command-line metadata, with command-line taking precedence var fullMeta map[string]any if len(jsonMeta) > 0 { fullMeta = simpleMergeMeta(jsonMeta, cmdMeta) } else { fullMeta = cmdMeta } if len(fullMeta) == 0 { return fmt.Errorf("no metadata keys specified") } fullORef, err := resolveBlockArg() if err != nil { return err } setMetaWshCmd := &wshrpc.CommandSetMetaData{ ORef: *fullORef, Meta: fullMeta, } err = wshclient.SetMetaCommand(RpcClient, *setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("setting metadata: %v", err) } WriteStdout("metadata set\n") return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-setvar.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) const DefaultVarFileName = "var" var setVarCmd = &cobra.Command{ Use: "setvar [flags] KEY=VALUE...", Short: "set variable(s) for a block", Long: `Set one or more variables for a block. Use --remove/-r to remove variables instead of setting them. When setting, each argument must be in KEY=VALUE format. When removing, each argument is treated as a key to remove.`, Example: " wsh setvar FOO=bar BAZ=123\n wsh setvar -r FOO BAZ", Args: cobra.MinimumNArgs(1), RunE: setVarRun, PreRunE: preRunSetupRpcClient, } var ( setVarFileName string setVarRemoveVar bool setVarLocal bool ) func init() { rootCmd.AddCommand(setVarCmd) setVarCmd.Flags().StringVar(&setVarFileName, "varfile", DefaultVarFileName, "var file name") setVarCmd.Flags().BoolVarP(&setVarLocal, "local", "l", false, "set variables local to block") setVarCmd.Flags().BoolVarP(&setVarRemoveVar, "remove", "r", false, "remove the variable(s) instead of setting") } func parseKeyValue(arg string) (key, value string, err error) { if setVarRemoveVar { return arg, "", nil } parts := strings.SplitN(arg, "=", 2) if len(parts) != 2 { return "", "", fmt.Errorf("invalid KEY=VALUE format %q (= sign required)", arg) } key = parts[0] if key == "" { return "", "", fmt.Errorf("empty key not allowed") } return key, parts[1], nil } func setVarRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("setvar", rtnErr == nil) }() // Resolve block to get zoneId if blockArg == "" { if getVarLocal { blockArg = "this" } else { blockArg = "client" } } fullORef, err := resolveBlockArg() if err != nil { return err } // Process all variables for _, arg := range args { key, value, err := parseKeyValue(arg) if err != nil { return err } commandData := wshrpc.CommandVarData{ Key: key, ZoneId: fullORef.OID, FileName: setVarFileName, Remove: setVarRemoveVar, } if !setVarRemoveVar { commandData.Val = value } err = wshclient.SetVarCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("setting variable %s: %w", key, err) } } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-shell-unix.go ================================================ //go:build !windows // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "os" "runtime" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/util/shellutil" ) func init() { rootCmd.AddCommand(shellCmd) } var shellCmd = &cobra.Command{ Use: "shell", Hidden: true, Short: "Print the login shell of this user", Run: func(cmd *cobra.Command, args []string) { WriteStdout("%s", shellCmdInner()) }, } func shellCmdInner() string { if runtime.GOOS == "darwin" { return shellutil.GetMacUserShell() + "\n" } shell := os.Getenv("SHELL") if shell == "" { return "/bin/bash\n" } return strings.TrimSpace(shell) + "\n" } ================================================ FILE: cmd/wsh/cmd/wshcmd-shell-win.go ================================================ //go:build windows // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "github.com/spf13/cobra" ) func init() { rootCmd.AddCommand(shellCmd) } var shellCmd = &cobra.Command{ Use: "shell", Hidden: true, Short: "Print the login shell of this user", Run: func(cmd *cobra.Command, args []string) { shellCmdInner() }, } func shellCmdInner() { WriteStderr("not implemented/n") } ================================================ FILE: cmd/wsh/cmd/wshcmd-ssh.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var ( identityFiles []string sshLogin string sshPort string newBlock bool ) var sshCmd = &cobra.Command{ Use: "ssh", Short: "connect this terminal to a remote host", Args: cobra.ExactArgs(1), RunE: sshRun, PreRunE: preRunSetupRpcClient, } func init() { sshCmd.Flags().StringArrayVarP(&identityFiles, "identityfile", "i", []string{}, "add an identity file for publickey authentication") sshCmd.Flags().StringVarP(&sshLogin, "login", "l", "", "set the remote login name") sshCmd.Flags().StringVarP(&sshPort, "port", "p", "", "set the remote port") sshCmd.Flags().BoolVarP(&newBlock, "new", "n", false, "create a new terminal block with this connection") rootCmd.AddCommand(sshCmd) } func sshRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("ssh", rtnErr == nil) }() sshArg := args[0] var err error sshArg, err = applySSHOverrides(sshArg, sshLogin, sshPort) if err != nil { return err } blockId := RpcContext.BlockId if blockId == "" && !newBlock { return fmt.Errorf("cannot determine blockid (not in JWT)") } // Create connection request connOpts := wshrpc.ConnRequest{ Host: sshArg, LogBlockId: blockId, Keywords: wconfig.ConnKeywords{ SshIdentityFile: identityFiles, }, } wshclient.ConnConnectCommand(RpcClient, connOpts, &wshrpc.RpcOpts{Timeout: 60000}) if newBlock { tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } // Create a new block with the SSH connection createMeta := map[string]any{ waveobj.MetaKey_View: "term", waveobj.MetaKey_Controller: "shell", waveobj.MetaKey_Connection: sshArg, } if RpcContext.Conn != "" { createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn } createBlockData := wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &waveobj.BlockDef{ Meta: createMeta, }, Focused: true, } oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) if err != nil { return fmt.Errorf("creating new terminal block: %w", err) } WriteStdout("new terminal block created with connection to %q: %s\n", sshArg, oref) return nil } // Update existing block with the new connection data := wshrpc.CommandSetMetaData{ ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), Meta: map[string]any{ waveobj.MetaKey_Connection: sshArg, waveobj.MetaKey_CmdCwd: nil, }, } err = wshclient.SetMetaCommand(RpcClient, data, nil) if err != nil { return fmt.Errorf("setting connection in block: %w", err) } WriteStderr("switched connection to %q\n", sshArg) return nil } func applySSHOverrides(sshArg string, login string, port string) (string, error) { if login == "" && port == "" { return sshArg, nil } opts, err := remote.ParseOpts(sshArg) if err != nil { return "", err } if login != "" { opts.SSHUser = login } if port != "" { opts.SSHPort = port } return opts.String(), nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-ssh_test.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import "testing" func TestApplySSHOverrides(t *testing.T) { tests := []struct { name string sshArg string login string port string want string wantErr bool }{ { name: "no overrides preserves target", sshArg: "root@bar.com:2022", want: "root@bar.com:2022", }, { name: "login override replaces parsed user", sshArg: "root@bar.com", login: "foo", want: "foo@bar.com", }, { name: "port override replaces parsed port", sshArg: "root@bar.com:2022", port: "2222", want: "root@bar.com:2222", }, { name: "both overrides replace parsed user and port", sshArg: "root@bar.com:2022", login: "foo", port: "2200", want: "foo@bar.com:2200", }, { name: "login override adds user to bare host", sshArg: "bar.com", login: "foo", want: "foo@bar.com", }, { name: "port override adds port to bare host", sshArg: "bar.com", port: "2200", want: "bar.com:2200", }, { name: "invalid target returns parse error when override requested", sshArg: "bad host", login: "foo", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := applySSHOverrides(tt.sshArg, tt.login, tt.port) if (err != nil) != tt.wantErr { t.Fatalf("applySSHOverrides() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { return } if got != tt.want { t.Fatalf("applySSHOverrides() = %q, want %q", got, tt.want) } }) } } ================================================ FILE: cmd/wsh/cmd/wshcmd-tabindicator.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "os" "github.com/google/uuid" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var tabIndicatorCmd = &cobra.Command{ Use: "tabindicator [icon]", Short: "set or clear a tab indicator (deprecated: use 'wsh badge')", Args: cobra.MaximumNArgs(1), RunE: tabIndicatorRun, PreRunE: preRunSetupRpcClient, } var ( tabIndicatorTabId string tabIndicatorColor string tabIndicatorPriority float64 tabIndicatorClear bool tabIndicatorBeep bool ) func init() { rootCmd.AddCommand(tabIndicatorCmd) tabIndicatorCmd.Flags().StringVar(&tabIndicatorTabId, "tabid", "", "tab id (defaults to WAVETERM_TABID)") tabIndicatorCmd.Flags().StringVar(&tabIndicatorColor, "color", "", "indicator color") tabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, "priority", 10, "indicator priority") tabIndicatorCmd.Flags().BoolVar(&tabIndicatorClear, "clear", false, "clear the indicator") tabIndicatorCmd.Flags().BoolVar(&tabIndicatorBeep, "beep", false, "play system bell sound") } func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("tabindicator", rtnErr == nil) }() fmt.Fprintf(os.Stderr, "tabindicator is deprecated, use 'wsh badge' instead\n") tabId := tabIndicatorTabId if tabId == "" { tabId = os.Getenv("WAVETERM_TABID") } if tabId == "" { return fmt.Errorf("no tab id specified (use --tabid or set WAVETERM_TABID)") } oref := waveobj.MakeORef(waveobj.OType_Tab, tabId) var eventData baseds.BadgeEvent eventData.ORef = oref.String() if tabIndicatorClear { eventData.Clear = true } else { icon := "bell" if len(args) > 0 { icon = args[0] } badgeId, err := uuid.NewV7() if err != nil { return fmt.Errorf("generating badge id: %v", err) } eventData.Badge = &baseds.Badge{ BadgeId: badgeId.String(), Icon: icon, Color: tabIndicatorColor, Priority: tabIndicatorPriority, } } event := wps.WaveEvent{ Event: wps.Event_Badge, Scopes: []string{oref.String()}, Data: eventData, } err := wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) if err != nil { return fmt.Errorf("publishing badge event: %v", err) } if tabIndicatorBeep { err = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: "electron"}) if err != nil { return fmt.Errorf("playing system bell: %v", err) } } if tabIndicatorClear { fmt.Printf("tab indicator cleared\n") } else { fmt.Printf("tab indicator set\n") } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-term.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "os" "path/filepath" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var termMagnified bool var termCmd = &cobra.Command{ Use: "term", Short: "open a terminal in directory", Args: cobra.RangeArgs(0, 1), RunE: termRun, PreRunE: preRunSetupRpcClient, } func init() { termCmd.Flags().BoolVarP(&termMagnified, "magnified", "m", false, "open view in magnified mode") rootCmd.AddCommand(termCmd) } func termRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("term", rtnErr == nil) }() var cwd string if len(args) > 0 { cwd = args[0] cwdExpanded, err := wavebase.ExpandHomeDir(cwd) if err != nil { return err } cwd = cwdExpanded } else { var err error cwd, err = os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } } var err error cwd, err = filepath.Abs(cwd) if err != nil { return fmt.Errorf("getting absolute path: %w", err) } tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } createMeta := map[string]any{ waveobj.MetaKey_View: "term", waveobj.MetaKey_CmdCwd: cwd, waveobj.MetaKey_Controller: "shell", } if RpcContext.Conn != "" { createMeta[waveobj.MetaKey_Connection] = RpcContext.Conn } createBlockData := wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &waveobj.BlockDef{ Meta: createMeta, }, Magnified: termMagnified, Focused: true, } oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) if err != nil { return fmt.Errorf("creating new terminal block: %w", err) } WriteStdout("terminal block created: %s\n", oref) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-termscrollback.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "os" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var termScrollbackCmd = &cobra.Command{ Use: "termscrollback", Short: "Get terminal scrollback from a terminal block", Long: `Get the terminal scrollback from a terminal block. By default, retrieves all lines. You can specify line ranges or get the output of the last command using the --lastcommand flag.`, RunE: termScrollbackRun, PreRunE: preRunSetupRpcClient, DisableFlagsInUseLine: true, } var ( termScrollbackLineStart int termScrollbackLineEnd int termScrollbackLastCmd bool termScrollbackOutputFile string ) func init() { rootCmd.AddCommand(termScrollbackCmd) termScrollbackCmd.Flags().IntVar(&termScrollbackLineStart, "start", 0, "starting line number (0 = beginning)") termScrollbackCmd.Flags().IntVar(&termScrollbackLineEnd, "end", 0, "ending line number (0 = all lines)") termScrollbackCmd.Flags().BoolVar(&termScrollbackLastCmd, "lastcommand", false, "get output of last command (requires shell integration)") termScrollbackCmd.Flags().StringVarP(&termScrollbackOutputFile, "output", "o", "", "write output to file instead of stdout") } func termScrollbackRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("termscrollback", rtnErr == nil) }() // Resolve the block argument fullORef, err := resolveBlockArg() if err != nil { return err } // Get block metadata to verify it's a terminal block metaData, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ ORef: *fullORef, }, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("error getting block metadata: %w", err) } // Check if the block is a terminal block viewType, ok := metaData[waveobj.MetaKey_View].(string) if !ok || viewType != "term" { return fmt.Errorf("block %s is not a terminal block (view type: %s)", fullORef.OID, viewType) } // Make the RPC call to get scrollback scrollbackData := wshrpc.CommandTermGetScrollbackLinesData{ LineStart: termScrollbackLineStart, LineEnd: termScrollbackLineEnd, LastCommand: termScrollbackLastCmd, } result, err := wshclient.TermGetScrollbackLinesCommand(RpcClient, scrollbackData, &wshrpc.RpcOpts{ Route: wshutil.MakeFeBlockRouteId(fullORef.OID), Timeout: 5000, }) if err != nil { return fmt.Errorf("error getting terminal scrollback: %w", err) } // Format the output output := strings.Join(result.Lines, "\n") if len(result.Lines) > 0 { output += "\n" // Add final newline } // Write to file or stdout if termScrollbackOutputFile != "" { err = os.WriteFile(termScrollbackOutputFile, []byte(output), 0644) if err != nil { return fmt.Errorf("error writing to file %s: %w", termScrollbackOutputFile, err) } fmt.Printf("terminal scrollback written to %s (%d lines)\n", termScrollbackOutputFile, len(result.Lines)) } else { fmt.Print(output) } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var testCmd = &cobra.Command{ Use: "test", Hidden: true, Short: "test command", PreRunE: preRunSetupRpcClient, RunE: runTestCmd, } func init() { rootCmd.AddCommand(testCmd) } func runTestCmd(cmd *cobra.Command, args []string) error { rtn, err := wshclient.TestMultiArgCommand(RpcClient, "testarg", 42, true, nil) if err != nil { return err } WriteStdout("%s\n", rtn) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-token.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/util/shellutil" ) var tokenCmd = &cobra.Command{ Use: "token [token] [shell-type]", Short: "exchange token for shell initialization script", RunE: tokenCmdRun, Hidden: true, } func init() { rootCmd.AddCommand(tokenCmd) } func tokenCmdRun(cmd *cobra.Command, args []string) (rtnErr error) { if len(args) != 2 { OutputHelpMessage(cmd) return fmt.Errorf("wsh token requires exactly 2 arguments, got %d", len(args)) } tokenStr, shellType := args[0], args[1] if tokenStr == "" || shellType == "" { OutputHelpMessage(cmd) return fmt.Errorf("wsh token requires non-empty arguments") } rtnData, err := setupRpcClientWithToken(tokenStr) if err != nil { return fmt.Errorf("error setting up rpc client: %w", err) } envScriptText, err := shellutil.EncodeEnvVarsForShell(shellType, rtnData.Env) if err != nil { return fmt.Errorf("error encoding env vars: %w", err) } WriteStdout("%s\n", envScriptText) WriteStdout("%s\n", rtnData.InitScriptText) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-version.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/json" "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var versionVerbose bool var versionJSON bool // versionCmd represents the version command var versionCmd = &cobra.Command{ Use: "version [-v] [--json]", Short: "Print the version number of wsh", RunE: runVersionCmd, } func init() { versionCmd.Flags().BoolVarP(&versionVerbose, "verbose", "v", false, "Display full version information") versionCmd.Flags().BoolVar(&versionJSON, "json", false, "Output version information in JSON format") rootCmd.AddCommand(versionCmd) } func runVersionCmd(cmd *cobra.Command, args []string) error { if !versionVerbose && !versionJSON { WriteStdout("wsh v%s\n", wavebase.WaveVersion) return nil } err := preRunSetupRpcClient(cmd, args) if err != nil { return err } resp, err := wshclient.WaveInfoCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return err } updateChannel, err := wshclient.GetUpdateChannelCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute}) if err != nil { return err } if versionJSON { info := map[string]interface{}{ "version": resp.Version, "clientid": resp.ClientId, "buildtime": resp.BuildTime, "configdir": resp.ConfigDir, "datadir": resp.DataDir, "updatechannel": updateChannel, } outBArr, err := json.MarshalIndent(info, "", " ") if err != nil { return fmt.Errorf("formatting version info: %v", err) } WriteStdout("%s\n", string(outBArr)) return nil } // Default verbose text output fmt.Printf("v%s (%s)\n", resp.Version, resp.BuildTime) fmt.Printf("clientid: %s\n", resp.ClientId) fmt.Printf("configdir: %s\n", resp.ConfigDir) fmt.Printf("datadir: %s\n", resp.DataDir) fmt.Printf("update-channel: %s\n", updateChannel) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-view.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "io/fs" "os" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var viewMagnified bool var viewCmd = &cobra.Command{ Use: "view {file|directory|URL}", Aliases: []string{"preview", "open"}, Short: "preview/edit a file or directory", RunE: viewRun, PreRunE: preRunSetupRpcClient, } var editCmd = &cobra.Command{ Use: "edit {file}", Short: "edit a file", RunE: viewRun, PreRunE: preRunSetupRpcClient, } func init() { viewCmd.Flags().BoolVarP(&viewMagnified, "magnified", "m", false, "open view in magnified mode") rootCmd.AddCommand(viewCmd) editCmd.Flags().BoolVarP(&viewMagnified, "magnified", "m", false, "open view in magnified mode") rootCmd.AddCommand(editCmd) } func viewRun(cmd *cobra.Command, args []string) (rtnErr error) { cmdName := cmd.Name() defer func() { sendActivity(cmdName, rtnErr == nil) }() if len(args) == 0 { OutputHelpMessage(cmd) return fmt.Errorf("no arguments. wsh %s requires a file or URL as an argument argument", cmdName) } if len(args) > 1 { OutputHelpMessage(cmd) return fmt.Errorf("too many arguments. wsh %s requires exactly one argument", cmdName) } tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } fileArg := args[0] conn := RpcContext.Conn var wshCmd *wshrpc.CommandCreateBlockData if strings.HasPrefix(fileArg, "http://") || strings.HasPrefix(fileArg, "https://") { wshCmd = &wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &waveobj.BlockDef{ Meta: map[string]any{ waveobj.MetaKey_View: "web", waveobj.MetaKey_Url: fileArg, }, }, Magnified: viewMagnified, Focused: true, } } else { absFile, err := filepath.Abs(fileArg) if err != nil { return fmt.Errorf("getting absolute path: %w", err) } absParent, err := filepath.Abs(filepath.Dir(fileArg)) if err != nil { return fmt.Errorf("getting absolute path of parent dir: %w", err) } _, err = os.Stat(absParent) if err == fs.ErrNotExist { return fmt.Errorf("parent directory does not exist: %q", absParent) } if err != nil { return fmt.Errorf("getting file info: %w", err) } wshCmd = &wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &waveobj.BlockDef{ Meta: map[string]interface{}{ waveobj.MetaKey_View: "preview", waveobj.MetaKey_File: absFile, }, }, Magnified: viewMagnified, Focused: true, } if cmdName == "edit" { wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true } if conn != "" { wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = conn } } _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("running view command: %w", err) } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-wavepath.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "bytes" "fmt" "io" "os" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var wavepathCmd = &cobra.Command{ Use: "wavepath {config|data|log}", Short: "Get paths to various waveterm files and directories", RunE: wavepathRun, PreRunE: preRunSetupRpcClient, } func init() { wavepathCmd.Flags().BoolP("open", "o", false, "Open the path in a new block") wavepathCmd.Flags().BoolP("open-external", "O", false, "Open the path in the default external application") wavepathCmd.Flags().BoolP("tail", "t", false, "Tail the last 100 lines of the log") rootCmd.AddCommand(wavepathCmd) } func wavepathRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("wavepath", rtnErr == nil) }() if len(args) == 0 { OutputHelpMessage(cmd) return fmt.Errorf("no arguments. wsh wavepath requires a type argument (config, data, or log)") } if len(args) > 1 { OutputHelpMessage(cmd) return fmt.Errorf("too many arguments. wsh wavepath requires exactly one argument") } pathType := args[0] if pathType != "config" && pathType != "data" && pathType != "log" { OutputHelpMessage(cmd) return fmt.Errorf("invalid path type %q. must be one of: config, data, log", pathType) } tail, _ := cmd.Flags().GetBool("tail") if tail && pathType != "log" { return fmt.Errorf("--tail can only be used with the log path type") } open, _ := cmd.Flags().GetBool("open") openExternal, _ := cmd.Flags().GetBool("open-external") tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } path, err := wshclient.PathCommand(RpcClient, wshrpc.PathCommandData{ PathType: pathType, Open: open, OpenExternal: openExternal, TabId: tabId, }, nil) if err != nil { return fmt.Errorf("getting path: %w", err) } if tail && pathType == "log" { err = tailLogFile(path) if err != nil { return fmt.Errorf("tailing log file: %w", err) } return nil } WriteStdout("%s\n", path) return nil } func tailLogFile(path string) error { file, err := os.Open(path) if err != nil { return fmt.Errorf("opening log file: %w", err) } defer file.Close() // Get file size stat, err := file.Stat() if err != nil { return fmt.Errorf("getting file stats: %w", err) } // Read last 16KB or whole file if smaller readSize := int64(16 * 1024) var offset int64 if stat.Size() > readSize { offset = stat.Size() - readSize } _, err = file.Seek(offset, 0) if err != nil { return fmt.Errorf("seeking file: %w", err) } buf := make([]byte, readSize) n, err := file.Read(buf) if err != nil && err != io.EOF { return fmt.Errorf("reading file: %w", err) } buf = buf[:n] // Skip partial line at start if we're not at beginning of file if offset > 0 { idx := bytes.IndexByte(buf, '\n') if idx >= 0 { buf = buf[idx+1:] } } // Split into lines lines := bytes.Split(buf, []byte{'\n'}) // Take last 100 lines if we have more startIdx := 0 if len(lines) > 100 { startIdx = len(lines) - 100 } // Print lines for _, line := range lines[startIdx:] { WriteStdout("%s\n", string(line)) } return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-web.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "encoding/json" "fmt" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var webCmd = &cobra.Command{ Use: "web [open|get|set]", Short: "web commands", PersistentPreRunE: preRunSetupRpcClient, } var webOpenCmd = &cobra.Command{ Use: "open url", Short: "open a url a web widget", Args: cobra.ExactArgs(1), RunE: webOpenRun, } var webGetCmd = &cobra.Command{ Use: "get [--inner] [--all] [--json] css-selector", Short: "get the html for a css selector", Args: cobra.ExactArgs(1), Hidden: true, RunE: webGetRun, } var webGetInner bool var webGetAll bool var webGetJson bool var webOpenMagnified bool var webOpenReplaceBlock string func init() { webOpenCmd.Flags().BoolVarP(&webOpenMagnified, "magnified", "m", false, "open view in magnified mode") webOpenCmd.Flags().StringVarP(&webOpenReplaceBlock, "replace", "r", "", "replace block") webCmd.AddCommand(webOpenCmd) webGetCmd.Flags().BoolVarP(&webGetInner, "inner", "", false, "get inner html (instead of outer)") webGetCmd.Flags().BoolVarP(&webGetAll, "all", "", false, "get all matches (querySelectorAll)") webGetCmd.Flags().BoolVarP(&webGetJson, "json", "", false, "output as json") webCmd.AddCommand(webGetCmd) rootCmd.AddCommand(webCmd) } func webGetRun(cmd *cobra.Command, args []string) error { fullORef, err := resolveBlockArg() if err != nil { return fmt.Errorf("resolving blockid: %w", err) } blockInfo, err := wshclient.BlockInfoCommand(RpcClient, fullORef.OID, nil) if err != nil { return fmt.Errorf("getting block info: %w", err) } if blockInfo.Block.Meta.GetString(waveobj.MetaKey_View, "") != "web" { return fmt.Errorf("block %s is not a web block", fullORef.OID) } data := wshrpc.CommandWebSelectorData{ WorkspaceId: blockInfo.WorkspaceId, BlockId: fullORef.OID, TabId: blockInfo.TabId, Selector: args[0], Opts: &wshrpc.WebSelectorOpts{ Inner: webGetInner, All: webGetAll, }, } output, err := wshclient.WebSelectorCommand(RpcClient, data, &wshrpc.RpcOpts{ Route: wshutil.ElectronRoute, Timeout: 5000, }) if err != nil { return err } if webGetJson { barr, err := json.MarshalIndent(output, "", " ") if err != nil { return fmt.Errorf("json encoding: %w", err) } WriteStdout("%s\n", string(barr)) } else { for _, item := range output { WriteStdout("%s\n", item) } } return nil } func webOpenRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("web", rtnErr == nil) }() var replaceBlockORef *waveobj.ORef if webOpenReplaceBlock != "" { var err error replaceBlockORef, err = resolveSimpleId(webOpenReplaceBlock) if err != nil { return fmt.Errorf("resolving -r blockid: %w", err) } } if replaceBlockORef != nil && webOpenMagnified { return fmt.Errorf("cannot use --replace and --magnified together") } tabId := getTabIdFromEnv() if tabId == "" { return fmt.Errorf("no WAVETERM_TABID env var set") } wshCmd := wshrpc.CommandCreateBlockData{ TabId: tabId, BlockDef: &waveobj.BlockDef{ Meta: map[string]any{ waveobj.MetaKey_View: "web", waveobj.MetaKey_Url: args[0], }, }, Magnified: webOpenMagnified, Focused: true, } if replaceBlockORef != nil { wshCmd.TargetBlockId = replaceBlockORef.OID wshCmd.TargetAction = wshrpc.CreateBlockAction_Replace } oref, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, nil) if err != nil { return fmt.Errorf("creating block: %w", err) } WriteStdout("created block %s\n", oref) return nil } ================================================ FILE: cmd/wsh/cmd/wshcmd-workspace.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var workspaceCommand = &cobra.Command{ Use: "workspace", Short: "Manage workspaces", // Args: cobra.MinimumNArgs(1), } func init() { workspaceCommand.AddCommand(workspaceListCommand) rootCmd.AddCommand(workspaceCommand) } var workspaceListCommand = &cobra.Command{ Use: "list", Short: "List workspaces", Run: workspaceListRun, PreRunE: preRunSetupRpcClient, } func workspaceListRun(cmd *cobra.Command, args []string) { workspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { WriteStderr("Unable to list workspaces: %v\n", err) return } WriteStdout("[\n") for i, w := range workspaces { WriteStdout(" {\n \"windowId\": \"%s\",\n", w.WindowId) WriteStderr(" \"workspaceId\": \"%s\",\n", w.WorkspaceData.OID) WriteStdout(" \"name\": \"%s\",\n", w.WorkspaceData.Name) WriteStdout(" \"icon\": \"%s\",\n", w.WorkspaceData.Icon) WriteStdout(" \"color\": \"%s\"\n", w.WorkspaceData.Color) if i < len(workspaces)-1 { WriteStdout(" },\n") } else { WriteStdout(" }\n") } } WriteStdout("]\n") } ================================================ FILE: cmd/wsh/cmd/wshcmd-wsl.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd import ( "fmt" "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var distroName string var wslCmd = &cobra.Command{ Use: "wsl [-d <distribution-name>]", Short: "connect this terminal to a local wsl connection", Args: cobra.NoArgs, RunE: wslRun, PreRunE: preRunSetupRpcClient, } func init() { wslCmd.Flags().StringVarP(&distroName, "distribution", "d", "", "Run the specified distribution") rootCmd.AddCommand(wslCmd) } func wslRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("wsl", rtnErr == nil) }() var err error if distroName == "" { // get default distro from the host distroName, err = wshclient.WslDefaultDistroCommand(RpcClient, nil) if err != nil { return err } } if !strings.HasPrefix(distroName, "wsl://") { distroName = "wsl://" + distroName } blockId := RpcContext.BlockId if blockId == "" { return fmt.Errorf("cannot determine blockid (not in JWT)") } data := wshrpc.CommandSetMetaData{ ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), Meta: map[string]any{ waveobj.MetaKey_Connection: distroName, }, } err = wshclient.SetMetaCommand(RpcClient, data, nil) if err != nil { return fmt.Errorf("setting connection in block: %w", err) } WriteStderr("switched connection to %q\n", distroName) return nil } ================================================ FILE: cmd/wsh/main-wsh.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package main import ( "github.com/wavetermdev/waveterm/cmd/wsh/cmd" "github.com/wavetermdev/waveterm/pkg/wavebase" ) // set by main-server.go var WaveVersion = "0.0.0" var BuildTime = "0" func main() { wavebase.WaveVersion = WaveVersion wavebase.BuildTime = BuildTime cmd.Execute() } ================================================ FILE: db/db.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package db import "embed" //go:embed migrations-filestore/*.sql var FilestoreMigrationFS embed.FS //go:embed migrations-wstore/*.sql var WStoreMigrationFS embed.FS ================================================ FILE: db/migrations-filestore/000001_init.down.sql ================================================ DROP TABLE db_wave_file; DROP TABLE db_file_data; ================================================ FILE: db/migrations-filestore/000001_init.up.sql ================================================ CREATE TABLE db_wave_file ( zoneid varchar(36) NOT NULL, name varchar(200) NOT NULL, size bigint NOT NULL, createdts bigint NOT NULL, modts bigint NOT NULL, opts json NOT NULL, meta json NOT NULL, PRIMARY KEY (zoneid, name) ); CREATE TABLE db_file_data ( zoneid varchar(36) NOT NULL, name varchar(200) NOT NULL, partidx int NOT NULL, data blob NOT NULL, PRIMARY KEY(zoneid, name, partidx) ); ================================================ FILE: db/migrations-wstore/000001_init.down.sql ================================================ DROP TABLE db_client; DROP TABLE db_workspace; DROP TABLE db_tab; DROP TABLE db_block; ================================================ FILE: db/migrations-wstore/000001_init.up.sql ================================================ CREATE TABLE db_client ( oid varchar(36) PRIMARY KEY, version int NOT NULL, data json NOT NULL ); CREATE TABLE db_window ( oid varchar(36) PRIMARY KEY, version int NOT NULL, data json NOT NULL ); CREATE TABLE db_workspace ( oid varchar(36) PRIMARY KEY, version int NOT NULL, data json NOT NULL ); CREATE TABLE db_tab ( oid varchar(36) PRIMARY KEY, version int NOT NULL, data json NOT NULL ); CREATE TABLE db_block ( oid varchar(36) PRIMARY KEY, version int NOT NULL, data json NOT NULL ); ================================================ FILE: db/migrations-wstore/000002_init.down.sql ================================================ DROP TABLE db_layout; ================================================ FILE: db/migrations-wstore/000002_init.up.sql ================================================ CREATE TABLE db_layout ( oid varchar(36) PRIMARY KEY, version int NOT NULL, data json NOT NULL ); ================================================ FILE: db/migrations-wstore/000003_activity.down.sql ================================================ DROP TABLE db_activity; ================================================ FILE: db/migrations-wstore/000003_activity.up.sql ================================================ CREATE TABLE db_activity ( day varchar(20) PRIMARY KEY, uploaded boolean NOT NULL, tdata json NOT NULL, tzname varchar(50) NOT NULL, tzoffset int NOT NULL, clientversion varchar(20) NOT NULL, clientarch varchar(20) NOT NULL, buildtime varchar(20) NOT NULL DEFAULT '-', osrelease varchar(20) NOT NULL DEFAULT '-' ); ================================================ FILE: db/migrations-wstore/000004_history.down.sql ================================================ DROP TABLE history_migrated; ================================================ FILE: db/migrations-wstore/000004_history.up.sql ================================================ CREATE TABLE history_migrated ( historyid varchar(36) PRIMARY KEY, ts bigint NOT NULL, remotename varchar(200) NOT NULL, haderror boolean NOT NULL, cmdstr text NOT NULL, exitcode int NULL DEFAULT NULL, durationms int NULL DEFAULT NULL ); ================================================ FILE: db/migrations-wstore/000005_blockparent.down.sql ================================================ -- we don't need to remove parentoref ================================================ FILE: db/migrations-wstore/000005_blockparent.up.sql ================================================ UPDATE db_block SET data = json_set(db_block.data, '$.parentoref', 'tab:' || db_tab.oid) FROM db_tab WHERE db_block.oid IN (SELECT value FROM json_each(db_tab.data, '$.blockids')); ================================================ FILE: db/migrations-wstore/000006_workspace.down.sql ================================================ -- Step 1: Restore the $.activetabid field to db_window.data UPDATE db_window SET data = json_set( db_window.data, '$.activetabid', (SELECT json_extract(db_workspace.data, '$.activetabid') FROM db_workspace WHERE db_workspace.oid = json_extract(db_window.data, '$.workspaceid')) ) WHERE json_extract(data, '$.workspaceid') IN ( SELECT oid FROM db_workspace ); -- Step 2: Remove the $.activetabid field from db_workspace.data UPDATE db_workspace SET data = json_remove(data, '$.activetabid') WHERE oid IN ( SELECT json_extract(db_window.data, '$.workspaceid') FROM db_window ); ================================================ FILE: db/migrations-wstore/000006_workspace.up.sql ================================================ -- Step 1: Update db_workspace.data to set the $.activetabid field UPDATE db_workspace SET data = json_set( db_workspace.data, '$.activetabid', (SELECT json_extract(db_window.data, '$.activetabid')) ) FROM db_window WHERE db_workspace.oid IN ( SELECT json_extract(db_window.data, '$.workspaceid') ); -- Step 2: Remove the $.activetabid field from db_window.data UPDATE db_window SET data = json_remove(data, '$.activetabid') WHERE json_extract(data, '$.workspaceid') IN ( SELECT oid FROM db_workspace ); ================================================ FILE: db/migrations-wstore/000007_events.down.sql ================================================ DROP TABLE db_tevent; ================================================ FILE: db/migrations-wstore/000007_events.up.sql ================================================ CREATE TABLE db_tevent ( uuid varchar(36) PRIMARY KEY, ts int NOT NULL, tslocal varchar(100) NOT NULL, event varchar(50) NOT NULL, props json NOT NULL, uploaded boolean NOT NULL DEFAULT 0 ); ================================================ FILE: db/migrations-wstore/000008_aimeta.down.sql ================================================ -- presets exist in config files, and should automatically prepopulate the meta in the older code versions ================================================ FILE: db/migrations-wstore/000008_aimeta.up.sql ================================================ --- removes all ai: keys except ai:preset UPDATE db_block SET data = json_remove( db_block.data, '$.meta.ai:*', '$.meta.ai:apitype', '$.meta.ai:baseurl', '$.meta.ai:apitoken', '$.meta.ai:name', '$.meta.ai:model', '$.meta.ai:orgid', '$.meta.ai:apiversion', '$.meta.ai:maxtokens', '$.meta.ai:timeoutms', '$.meta.ai:fontsize', '$.meta.ai:fixedfontsize' ) WHERE json_extract(data, '$.meta.view') = 'waveai'; ================================================ FILE: db/migrations-wstore/000009_mainserver.down.sql ================================================ DROP TABLE IF EXISTS db_mainserver; ================================================ FILE: db/migrations-wstore/000009_mainserver.up.sql ================================================ CREATE TABLE IF NOT EXISTS db_mainserver ( oid varchar(36) PRIMARY KEY, version int NOT NULL, data json NOT NULL ); ================================================ FILE: db/migrations-wstore/000010_merge_pinned_tabs.down.sql ================================================ -- This migration cannot be reversed as pinned tab state is lost -- during the merge operation ================================================ FILE: db/migrations-wstore/000010_merge_pinned_tabs.up.sql ================================================ -- Merge PinnedTabIds into TabIds, preserving tab order UPDATE db_workspace SET data = json_set( data, '$.tabids', ( SELECT json_group_array(value) FROM ( SELECT value, 0 AS src, CAST(key AS INT) AS k FROM json_each(data, '$.pinnedtabids') UNION ALL SELECT value, 1 AS src, CAST(key AS INT) AS k FROM json_each(data, '$.tabids') ORDER BY src, k ) ) ) WHERE json_type(data, '$.pinnedtabids') = 'array' AND json_array_length(data, '$.pinnedtabids') > 0; UPDATE db_workspace SET data = json_remove(data, '$.pinnedtabids') WHERE json_type(data, '$.pinnedtabids') IS NOT NULL; ================================================ FILE: db/migrations-wstore/000011_job.down.sql ================================================ DROP TABLE IF EXISTS db_job; ================================================ FILE: db/migrations-wstore/000011_job.up.sql ================================================ CREATE TABLE IF NOT EXISTS db_job ( oid varchar(36) PRIMARY KEY, version int NOT NULL, data json NOT NULL ); ================================================ FILE: docs/.editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true [*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less}] charset = utf-8 indent_style = space indent_size = 4 [CNAME] insert_final_newline = false ================================================ FILE: docs/.gitignore ================================================ # Dependencies /node_modules /.yarn # Production /build build.zip # Generated files .docusaurus .cache-loader # Misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: docs/.prettierignore ================================================ build .git node_modules *.min.* *.mdx CNAME ================================================ FILE: docs/.remarkrc ================================================ { "plugins": [ "remark-preset-lint-consistent", "remark-preset-lint-recommended", "remark-mdx", "remark-frontmatter" ] } ================================================ FILE: docs/README.md ================================================ # Wave Terminal Documentation This is the home for Wave Terminal's documentation site. This README is specifically about _building_ and contributing to the docs site. If you are looking for the actual hosted docs, go here -- https://docs.waveterm.dev ### Installation Our docs are built using [Docusaurus](https://docusaurus.io/), a modern static website generator. ### Local Development ```sh task docsite ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. ### Build ```sh task docsite:build:public ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. ### Deployment Deployments are handled automatically by the [Docsite CI/CD workflow](../.github/workflows/deploy-docsite.yml) ================================================ FILE: docs/babel.config.js ================================================ module.exports = { presets: [require.resolve("@docusaurus/core/lib/babel/preset")], }; ================================================ FILE: docs/docs/ai-presets.mdx ================================================ --- sidebar_position: 3.6 id: "ai-presets" title: "AI Presets (Deprecated)" --- :::warning Deprecation Notice The AI Widget and its presets are being replaced by [Wave AI](./waveai.mdx). Please refer to the Wave AI documentation for the latest AI features and configuration options. ::: ![AI Presets Menu](./img/ai-presets.png#right) Wave's AI widget can be configured to work with various AI providers and models through presets. Presets allow you to define multiple AI configurations and easily switch between them using the dropdown menu in the AI widget. ## How AI Presets Work AI presets are defined in `~/.config/waveterm/presets/ai.json`. You can easily edit this file using: ```bash wsh editconfig presets/ai.json ``` Each preset defines a complete set of configuration values for the AI widget. When you select a preset from the dropdown menu, those configuration values are applied to the widget. If no preset is selected, the widget uses the default values from `settings.json`. Here's a basic example using Claude: ```json { "ai@claude-sonnet": { "display:name": "Claude 3 Sonnet", "display:order": 1, "ai:*": true, "ai:apitype": "anthropic", "ai:model": "claude-3-5-sonnet-latest", "ai:apitoken": "<your anthropic API key>" } } ``` To make a preset your default, add this single line to your `settings.json`: ```json { "ai:preset": "ai@claude-sonnet" } ``` :::info You can quickly set your default preset using the `setconfig` command: ```bash wsh setconfig ai:preset=ai@claude-sonnet ``` This is easier than editing settings.json directly! ::: ## Provider-Specific Configurations ### Anthropic (Claude) To use Claude models, create a preset like this: ```json { "ai@claude-sonnet": { "display:name": "Claude 3 Sonnet", "display:order": 1, "ai:*": true, "ai:apitype": "anthropic", "ai:model": "claude-3-5-sonnet-latest", "ai:apitoken": "<your anthropic API key>" } } ``` ### OpenAI To use OpenAI's models: ```json { "ai@openai-gpt41": { "display:name": "GPT-4.1", "display:order": 2, "ai:*": true, "ai:model": "gpt-4.1", "ai:apitoken": "<your OpenAI API key>" } } ``` ### Local LLMs (Ollama) To connect to a local Ollama instance: ```json { "ai@ollama-llama": { "display:name": "Ollama - Llama2", "display:order": 3, "ai:*": true, "ai:baseurl": "http://localhost:11434/v1", "ai:name": "llama2", "ai:model": "llama2", "ai:apitoken": "ollama" } } ``` Note: The `ai:apitoken` is required but can be any value as Ollama ignores it. See [Ollama OpenAI compatibility docs](https://github.com/ollama/ollama/blob/main/docs/openai.md) for more details. ### Azure OpenAI To connect to Azure AI services: ```json { "ai@azure-gpt4": { "display:name": "Azure GPT-4", "display:order": 4, "ai:*": true, "ai:apitype": "azure", "ai:baseurl": "<your Azure AI base URL>", "ai:model": "<your model deployment name>", "ai:apitoken": "<your Azure API key>" } } ``` Note: Do not include query parameters or `api-version` in the `ai:baseurl`. The `ai:model` should be your model deployment name in Azure. ### Perplexity To use Perplexity's models: ```json { "ai@perplexity-sonar": { "display:name": "Perplexity Sonar", "display:order": 5, "ai:*": true, "ai:apitype": "perplexity", "ai:model": "llama-3.1-sonar-small-128k-online", "ai:apitoken": "<your perplexity API key>" } } ``` ### Google (Gemini) To use Google's Gemini models from [Google AI Studio](https://aistudio.google.com): ```json { "ai@gemini-2.0": { "display:name": "Gemini 2.0", "display:order": 6, "ai:*": true, "ai:apitype": "google", "ai:model": "gemini-2.0-flash-exp", "ai:apitoken": "<your Google AI API key>" } } ``` ### OpenRouter To use OpenRouter's models: ```json { "ai@openrouter": { "display:name": "OpenRouter (Qwen)", "display:order": 7, "ai:*": true, "ai:model": "qwen/qwen3-next-80b-a3b-thinking", "ai:apitoken": "<openrouter-key>", "ai:baseurl": "https://openrouter.ai/api/v1" } } ``` ## Multiple Presets Example You can define multiple presets in your `ai.json` file: ```json { "ai@claude-sonnet": { "display:name": "Claude 3 Sonnet", "display:order": 1, "ai:*": true, "ai:apitype": "anthropic", "ai:model": "claude-3-5-sonnet-latest", "ai:apitoken": "<your anthropic API key>" }, "ai@openai-gpt41": { "display:name": "GPT-4.1", "display:order": 2, "ai:*": true, "ai:model": "gpt-4.1", "ai:apitoken": "<your OpenAI API key>" }, "ai@ollama-llama": { "display:name": "Ollama - Llama2", "display:order": 3, "ai:*": true, "ai:baseurl": "http://localhost:11434/v1", "ai:name": "llama2", "ai:model": "llama2", "ai:apitoken": "ollama" }, "ai@perplexity-sonar": { "display:name": "Perplexity Sonar", "display:order": 4, "ai:*": true, "ai:apitype": "perplexity", "ai:model": "llama-3.1-sonar-small-128k-online", "ai:apitoken": "<your perplexity API key>" } } ``` The `display:order` value determines the order in which presets appear in the dropdown menu. Remember to set your default preset in `settings.json`: ```json { "ai:preset": "ai@claude-sonnet" } ``` ## Using a Proxy If you need to route AI requests through an HTTP proxy, you can add the `ai:proxyurl` setting to any preset: ```json { "ai@claude-with-proxy": { "display:name": "Claude 3 Sonnet (via Proxy)", "display:order": 1, "ai:*": true, "ai:apitype": "anthropic", "ai:model": "claude-3-5-sonnet-latest", "ai:apitoken": "<your anthropic API key>", "ai:proxyurl": "http://proxy.example.com:8080" } } ``` The proxy URL should be in the format `http://host:port` or `https://host:port`. This setting works with all AI providers except Wave Cloud AI (the default). ================================================ FILE: docs/docs/claude-code.mdx ================================================ --- sidebar_position: 1.9 id: "claude-code" title: "Claude Code Integration" --- import { VersionBadge } from "@site/src/components/versionbadge"; # Claude Code Tab Badges <VersionBadge version="v0.14.2" /> When you run multiple Claude Code sessions in parallel — one per feature, one per repo, a few long-running tasks — it gets hard to know which tabs need your attention without clicking through each one. Wave's badge system solves this: hooks in Claude Code write a small visual indicator to the tab header whenever something important happens, so you can see at a glance which sessions are waiting, done, or in trouble. :::info tl;dr You can copy and paste this page directly into Claude Code and it will help you set everything up! ::: ## How it works Claude Code supports [lifecycle hooks](https://code.claude.com/docs/en/hooks) — shell commands that run automatically at specific points in a session. Wave's `wsh badge` command sets or clears a visual indicator on the current block or tab. By wiring these together, you get ambient awareness across all your sessions without watching any of them. Badges auto-clear when you focus the block, so they're purely a "hey, look over here" signal. Once you click in and read what's happening, the badge disappears on its own. Wave already shows a bell icon when a terminal outputs a BEL character. These hooks complement that with semantic badges — *permission needed*, *done* — that survive across tab switches and work across splits. ### Badge rollup If a tab has multiple terminals (block), Wave shows the highest-priority badge on the tab header. Ties at the same priority go to the earliest badge set, so the most urgent signal from any pane in the tab floats to the top. ## Setup These hooks go in your global Claude Code settings so they apply to every session on your machine, not just one project. Add the following to `~/.claude/settings.json`. If you already have a `hooks` key, merge the entries in: ```json { "hooks": { "Notification": [ { "matcher": "permission_prompt", "hooks": [ { "type": "command", "command": "wsh badge bell-exclamation --color '#e0b956' --priority 20 --beep" } ] }, { "matcher": "elicitation_dialog", "hooks": [ { "type": "command", "command": "wsh badge message-question --color '#e0b956' --priority 20 --beep" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "wsh badge check --color '#58c142' --priority 10" } ] } ], "PreToolUse": [ { "matcher": "AskUserQuestion", "hooks": [ { "type": "command", "command": "wsh badge message-question --color '#e0b956' --priority 20 --beep" } ] } ] } } ``` That's it. Restart any running Claude Code sessions for the hooks to take effect. :::warning Known Issue There is a known issue in Claude Code where `Notification` hooks may be delayed by several seconds before firing. This delay is unrelated to Wave — it occurs in Claude Code itself. See [#5186](https://github.com/anthropics/claude-code/issues/5186) and [#19627](https://github.com/anthropics/claude-code/issues/19627) for details. ::: ## What each hook does ### Permission prompt — `bell-exclamation` gold, priority 20 Claude Code occasionally needs your approval before it can continue — to run a command, write a file outside the project, or use a tool that requires explicit permission. When it hits one of these, it stops and waits. Without a signal, you might not notice for minutes. This hook fires on the `permission_prompt` notification type and sets a high-priority gold badge with an audible beep. Priority 20 means it beats any other badge on that tab, so a waiting session always surfaces above a finished one. When you click into the tab and approve or deny the request, the badge clears automatically. ### Session complete — `check` green, priority 10 When Claude Code finishes responding, this hook sets a green check badge. It's a low-key signal: glance at the tab bar, see which sessions are done, review their output in whatever order you like. ### AskUserQuestion — `message-question` gold, priority 20 When Claude Code uses the `AskUserQuestion` tool, it's paused and waiting for you to respond before it can proceed. This `PreToolUse` hook fires just before that tool call and sets the same high-priority gold badge as the permission prompt. `PreToolUse` hooks can match any tool by name, so you can add badges for other tools as well — for example, to get a signal whenever Claude runs a shell command (`Bash`) or edits a file (`Edit`). Any tool name Claude Code supports can be used as a matcher. ## Choosing your own icons and colors Icon names are [Font Awesome](https://fontawesome.com/icons) icon names without the `fa-` prefix. Colors are any valid CSS color — hex values, named colors, or anything else CSS accepts. Some icon and color ideas: | Situation | Icon | Color | |-----------|------|-------| | Custom high-priority alert | `triangle-exclamation` | `#FF453A` | | Blocked / waiting on input | `hourglass-half` | `#FF9500` | | Neutral / informational | `circle-info` | `#429DFF` | | Background task running | `spinner` | `#00FFDB` | See the [`wsh badge` reference](/wsh-reference#badge) for all available flags. ## Adjusting priorities Priority controls which badge wins when multiple blocks in a tab each have one. Higher numbers take precedence. The defaults above use: - **20** for permission prompts — always surfaces above everything else - **10** for session complete — visible when nothing more urgent is active If you add more hooks, keep permission-blocking signals at the high end (15–25) and informational signals at the low end (5–10). ================================================ FILE: docs/docs/config.mdx ================================================ --- sidebar_position: 3.45 id: "config" title: "Configuration" --- import { Kbd } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; import { VersionBadge } from "@site/src/components/versionbadge"; <PlatformProvider> <PlatformSelectorButton /> <div style={{ marginBottom: 20 }}></div> Wave's configuration files are located at `~/.config/waveterm/`. The main configuration file is `settings.json` (`~/.config/waveterm/settings.json`). The file is structured as a mostly flat JSON file. Instead of using sub-objects we prefer to use ":" as level separators. :::info The easiest way to edit your config files is to use the wsh editconfig command which will open your Wave config file in our built-in preview editor. ``` wsh editconfig ``` ::: ## Configuration Keys | Key Name | Type | Function | | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | app:globalhotkey | string | A systemwide keybinding to open your most recent wave window. This is a set of key names separated by `:`. For more info, see [Customizable Systemwide Global Hotkey](#customizable-systemwide-global-hotkey) | | app:dismissarchitecturewarning | bool | Disable warnings on app start when you are using a non-native architecture for Wave. For more info, see [Why does Wave warn me about ARM64 translation when it launches?](./faq#why-does-wave-warn-me-about-arm64-translation-when-it-launches). | | app:defaultnewblock | string | Sets the default new block (Cmd:n, Cmd:d). "term" for terminal block, "launcher" for launcher block (default = "term") | | app:showoverlayblocknums | bool | Set to false to disable the Ctrl+Shift block number overlay that appears when holding Ctrl+Shift (defaults to true) | | app:ctrlvpaste | bool | On Windows/Linux, when null (default) uses Control+V on Windows only. Set to true to force Control+V on all non-macOS platforms, false to disable the accelerator. macOS always uses Command+V regardless of this setting | | app:confirmquit <VersionBadge version="v0.14" /> | bool | Set to false to disable the quit confirmation dialog when closing Wave Terminal (defaults to true, requires app restart) | | app:hideaibutton <VersionBadge version="v0.14" /> | bool | Set to true to hide the AI button in the tab bar (defaults to false) | | app:disablectrlshiftarrows <VersionBadge version="v0.14" /> | bool | Set to true to disable Ctrl+Shift block-navigation keybindings (`Arrow` and `h/j/k/l`) (defaults to false) | | app:disablectrlshiftdisplay <VersionBadge version="v0.14" /> | bool | Set to true to disable the Ctrl+Shift visual indicator display (defaults to false) | | app:focusfollowscursor <VersionBadge version="v0.14" /> | string | Controls whether block focus follows cursor movement: `"off"` (default), `"on"` (all blocks), or `"term"` (terminal blocks only) | | app:tabbar <VersionBadge version="v0.14.4" /> | string | Controls the position of the tab bar: `"top"` (default) for a horizontal tab bar at the top of the window, or `"left"` for a vertical tab bar on the left side of the window | | ai:preset | string | the default AI preset to use | | ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) | | ai:apitoken | string | your AI api token | | ai:apitype | string | defaults to "open_ai", but can also set to "azure" (forspecial Azure AI handling), "anthropic", or "perplexity" | | ai:name | string | string to display in the Wave AI block header | | ai:model | string | model name to pass to API | | ai:apiversion | string | for Azure AI only (when apitype is "azure", this will default to "2023-05-15") | | ai:orgid | string | | | ai:maxtokens | int | max tokens to pass to API | | ai:timeoutms | int | timeout (in milliseconds) for AI calls | | ai:proxyurl | string | HTTP proxy URL for AI API requests (does not apply to Wave Cloud AI) | | conn:askbeforewshinstall | bool | set to false to disable popup asking if you want to install wsh extensions on new machines | | conn:localhostdisplayname <VersionBadge version="v0.14" /> | string | override the display name for localhost in the UI (e.g., set to "My Laptop" or "Local", or set to empty string to hide the name) | | term:fontsize | float | the fontsize for the terminal block | | term:fontfamily | string | font family to use for terminal block | | term:disablewebgl | bool | set to false to disable WebGL acceleration in terminal | | term:localshellpath | string | set to override the default shell path for local terminals | | term:localshellopts | string[] | set to pass additional parameters to the term:localshellpath (example: `["-NoLogo"]` for PowerShell will remove the copyright notice) | | term:copyonselect | bool | set to false to disable terminal copy-on-select | | term:scrollback | int | size of terminal scrollback buffer, max is 10000 | | term:theme | string | preset name of terminal theme to apply by default (default is "default-dark") | | term:transparency | float64 | set the background transparency of terminal theme (default 0.5, 0 = not transparent, 1.0 = fully transparent) | | term:allowbracketedpaste | bool | allow bracketed paste mode in terminal (default false) | | term:shiftenternewline | bool | when enabled, Shift+Enter sends escape sequence + newline (\u001b\n) instead of carriage return, useful for claude code and similar AI coding tools (default false) | | term:macoptionismeta | bool | on macOS, treat the Option key as Meta key for terminal keybindings (default false) | | term:cursor <VersionBadge version="v0.14" /> | string | terminal cursor style. valid values are `block` (default), `underline`, and `bar` | | term:cursorblink <VersionBadge version="v0.14" /> | bool | when enabled, terminal cursor blinks (default false) | | term:bellsound <VersionBadge version="v0.14" /> | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) | | term:bellindicator <VersionBadge version="v0.14" /> | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default false) | | term:osc52 <VersionBadge version="v0.14" /> | string | controls OSC 52 clipboard behavior: `always` (default, allows OSC 52 at any time) or `focus` (requires focused window and focused block) | | term:durable <VersionBadge version="v0.14" /> | bool | makes remote terminal sessions durable across network disconnects (defaults to false) | | editor:minimapenabled | bool | set to false to disable editor minimap | | editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false | | editor:wordwrap | bool | set to true to enable word wrapping in the editor (defaults to false) | | editor:fontsize | float64 | set the font size for the editor (defaults to 12px) | | editor:inlinediff | bool | set to true to show diffs inline instead of side-by-side, false for side-by-side (defaults to undefined which uses Monaco's responsive behavior) | | preview:showhiddenfiles | bool | set to false to disable showing hidden files in the directory preview (defaults to true) | | preview:defaultsort <VersionBadge version="v0.14.2" /> | string | sets the default sort column for directory preview. `"name"` (default) sorts alphabetically by name ascending; `"modtime"` sorts by last modified time descending (newest first) | | markdown:fontsize | float64 | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px) | | markdown:fixedfontsize | float64 | font size for the code blocks when rendering markdown in preview (default is 12px) | | web:openlinksinternally | bool | set to false to open web links in external browser | | web:defaulturl | string | default web page to open in the web widget when no url is provided (homepage) | | web:defaultsearch | string | search template for web searches. e.g. `https://www.google.com/search?q={query}`. "\{query}" gets replaced by search term | | autoupdate:enabled | bool | enable/disable checking for updates (requires app restart) | | autoupdate:intervalms | float64 | time in milliseconds to wait between update checks (requires app restart) | | autoupdate:installonquit | bool | whether to automatically install updates on quit (requires app restart) | | autoupdate:channel | string | the auto update channel "latest" (stable builds), or "beta" (updated more frequently) (requires app restart) | | tab:preset | string | a "bg@" preset to automatically apply to new tabs. e.g. `bg@green`. should match the preset key | | tab:confirmclose | bool | if set to true, a confirmation dialog will be shown before closing a tab (defaults to false) | | widget:showhelp | bool | whether to show help/tips widgets in right sidebar | | window:transparent | bool | set to true to enable window transparency (cannot be combined with `window:blur`) (macOS and Windows only, requires app restart, see [note on Windows compatibility](https://www.electronjs.org/docs/latest/tutorial/custom-window-styles#limitations)) | | window:blur | bool | set to enable window background blurring (cannot be combined with `window:transparent`) (macOS and Windows only, requires app restart, see [note on Windows compatibility](https://www.electronjs.org/docs/latest/tutorial/custom-window-styles#limitations)) | | window:opacity | float64 | 0-1, window opacity when `window:transparent` or `window:blur` are set | | window:bgcolor | string | set the window background color (should be hex: #xxxxxx) | | window:reducedmotion | bool | set to true to disable most animations | | window:tilegapsize | int | set to change override default gap size (in CSS pixels) between blocks | | window:magnifiedblockopacity | float64 | change the opacity of a magnified block (must be between 0 and 1, defaults to 0.6) | | window:magnifiedblocksize | float64 | change the size of a magnified block as a percentage of the dimensions of its parent layout (must be between 0 and 1, defaults to 0.9) | | window:magnifiedblockblurprimarypx | int | change the blur in CSS pixels that is applied directly behind a magnified block (see [backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter) for more info on how this gets applied) | | window:magnifiedblockblursecondarypx | int | change the blur in CSS pixels that is applied to the visible portions of non-magnified blocks when a block is magnified (see [backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter) for more info on how this gets applied) | | window:maxtabcachesize | int | number of tabs to cache. when tabs are cached, switching between them is very fast. (defaults to 10) | | window:showmenubar | bool | set to use the OS-native menu bar (Windows and Linux only, requires app restart) | | window:nativetitlebar | bool | set to use the OS-native title bar, rather than the overlay (Windows and Linux only, requires app restart) | | window:disablehardwareacceleration | bool | set to disable Chromium hardware acceleration to resolve graphical bugs (requires app restart) | | window:fullscreenonlaunch | bool | set to true to launch the foreground window in fullscreen mode (defaults to false) | | window:savelastwindow | bool | when `true`, the last window that is closed is preserved and is reopened the next time the app is launched (defaults to `true`) | | window:confirmonclose | bool | when `true`, a prompt will ask a user to confirm that they want to close a window if it has an unsaved workspace with more than one tab (defaults to `true`) | | window:dimensions | string | set the default dimensions for new windows using the format "WIDTHxHEIGHT" (e.g. "1920x1080"). when a new window is created, these dimensions will be automatically applied. The width and height values should be specified in pixels. | | telemetry:enabled | bool | set to enable/disable telemetry | For reference, this is the current default configuration (v0.14.0): ```json { "ai:preset": "ai@global", "ai:model": "gpt-5-mini", "ai:maxtokens": 4000, "ai:timeoutms": 60000, "app:defaultnewblock": "term", "app:confirmquit": true, "app:hideaibutton": false, "app:disablectrlshiftarrows": false, "app:disablectrlshiftdisplay": false, "app:focusfollowscursor": "off", "autoupdate:enabled": true, "autoupdate:installonquit": true, "autoupdate:intervalms": 3600000, "conn:askbeforewshinstall": true, "conn:wshenabled": true, "editor:minimapenabled": true, "web:defaulturl": "https://github.com/wavetermdev/waveterm", "web:defaultsearch": "https://www.google.com/search?q={query}", "window:tilegapsize": 3, "window:maxtabcachesize": 10, "window:nativetitlebar": true, "window:magnifiedblockopacity": 0.6, "window:magnifiedblocksize": 0.9, "window:magnifiedblockblurprimarypx": 10, "window:fullscreenonlaunch": false, "window:magnifiedblockblursecondarypx": 2, "window:confirmclose": true, "window:savelastwindow": true, "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": false, "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, "term:durable": false, "waveai:showcloudmodes": true, "waveai:defaultmode": "waveai@balanced", "preview:defaultsort": "name" } ``` :::warning If you installed Wave pre-v0.9.0 your configuration file will be located at `~/.waveterm/config/settings.json`. This includes all of the other configuration files as well: `termthemes.json`, `presets.json`, and `widgets.json`. ::: ## Environment Variable Resolution To avoid putting secrets directly in config files, Wave supports environment variable resolution using `$ENV:VARIABLE_NAME` or `$ENV:VARIABLE_NAME:fallback` syntax. This works for any string value in any config file (settings.json, presets.json, ai.json, etc.). ```json { "ai:apitoken": "$ENV:OPENAI_APIKEY", "ai:baseurl": "$ENV:AI_BASEURL:https://api.openai.com/v1" } ``` ## WebBookmarks Configuration WebBookmarks allows you to store and manage web links with customizable display preferences. The bookmarks are stored in a JSON file (`bookmarks.json`) as a key-value map where the key (`id`) is an arbitrary identifier for the bookmark. By convention, you should start your ids with "bookmark@". In the web widget, you can pull up your bookmarks using <Kbd k="Cmd:o"/> ### Bookmark Structure Each bookmark follows this structure (only `url` is required): ```json { "url": "https://example.com", "title": "Example Site", "iconurl": "https://example.com/custom-icon.png", "display:order": 1 } ``` ### Fields | Field | Type | Description | | ------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | | url | string | **Required.** The URL of the bookmark. | | title | string | **Optional.** A display title for the bookmark. | | icon | string | **Optional, rarely used.** Overrides the default favicon with an icon name. | | iconcolor | string | **Optional, rarely used.** Sets a custom color for the specified icon. | | iconurl | string | **Optional.** Provides a custom icon URL, useful if the favicon is incorrect (e.g., for dark mode compatibility). | | display:order | float64 | **Optional.** Defines the order in which bookmarks appear. | ### Example `bookmarks.json` ```json { "bookmark@google": { "url": "https://www.google.com", "title": "Google" }, "bookmark@claude": { "url": "https://claude.ai", "title": "Claude AI" }, "bookmark@wave": { "url": "https://waveterm.dev", "title": "Wave Terminal", "display:order": -1 }, "bookmark@wave-github": { "url": "https://github.com/wavetermdev/waveterm", "title": "Wave Github", "iconurl": "https://github.githubassets.com/favicons/favicon-dark.png" }, "bookmark@chatgpt": { "url": "https://chatgpt.com", "iconurl": "https://cdn.oaistatic.com/assets/favicon-miwirzcw.ico" }, "bookmark@wave-pulls": { "url": "https://github.com/wavetermdev/waveterm/pulls", "title": "Wave Pull Requests", "iconurl": "https://github.githubassets.com/favicons/favicon-dark.png" } } ``` ### Behavior - If `iconurl` is set, it fetches the icon from the specified URL instead of the site's default favicon. - Bookmarks are sorted based on `display:order` (if provided), otherwise by id. - `icon` and `iconcolor` are rarely needed since the default behavior fetches the site's favicon. - favicons are refreshed every 24-hours ## Terminal Theming User-defined terminal themes are located in `~/.config/waveterm/termthemes.json`. This JSON file is structured as an object, with each sub-key defining a theme. Themes are applied by right-clicking on the terminal's header bar and selecting an entry from the "Themes" sub-menu. Alternatively they can be applied to the block's metadata key `term:theme`. This uses the JSON key value as the identifier. Note, for best consistency all colors should be of the format "#rrggbb" or "#rrggbbaa" (aa = alpha channel for transparency). ``` wsh setmeta this term:theme="default-dark" ``` Here is an example of defining a full terminal theme. All of the built-in themes are defined here: https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/termthemes.json (if you'd like to add a popular terminal theme, please submit a PR!) ```json { "default-dark": { "display:name": "Default Dark", "display:order": 1, "black": "#757575", "red": "#cc685c", "green": "#76c266", "yellow": "#cbca9b", "blue": "#85aacb", "magenta": "#cc72ca", "cyan": "#74a7cb", "white": "#c1c1c1", "brightBlack": "#727272", "brightRed": "#cc9d97", "brightGreen": "#a3dd97", "brightYellow": "#cbcaaa", "brightBlue": "#9ab6cb", "brightMagenta": "#cc8ecb", "brightCyan": "#b7b8cb", "brightWhite": "#f0f0f0", "gray": "#8b918a", "cmdtext": "#f0f0f0", "foreground": "#c1c1c1", "selectionBackground": "", "background": "#00000077", "cursorAccent": "" } } ``` :::info You can easily open the termthemes.json config file by running: ``` wsh editconfig termthemes.json ``` ::: | Key Name | Type | ANSI FG# | ANSI BG# | Function | | ------------------- | --------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | display:name | string | | | the name as it will appear in the UI context menu | | display:order | float | | | entries in the context menu are sorted by display:order | | black | CSS color | 30 | 40 | color for black | | red | CSS color | 31 | 41 | color for red | | green | CSS color | 32 | 42 | color for green | | yellow | CSS color | 33 | 43 | color for yellow | | blue | CSS color | 34 | 44 | color for blue | | magenta | CSS color | 35 | 45 | color for magenta | | cyan | CSS color | 36 | 46 | color for cyan | | white | CSS color | 37 | 47 | color for white | | brightBlack | CSS color | 90 | 100 | color for bright black | | brightRed | CSS color | 91 | 101 | color for bright red | | brightGreen | CSS color | 92 | 102 | color for bright green | | brightYellow | CSS color | 93 | 103 | color for bright yellow | | brightBlue | CSS color | 94 | 104 | color for bright blue | | brightMagenta | CSS color | 95 | 105 | color for bright magenta | | brightCyan | CSS color | 96 | 106 | color for bright cyan | | brightWhite | CSS color | 97 | 107 | color for bright white | | gray | CSS color | | | currently unused | | cmdtext | CSS color | | | currently unused | | foreground | CSS color | | | foreground color (default when no color code is applied) | | background | CSS color | | | background color (default when no color code is applied), must have alpha channel (#rrggbbaa) if you want the terminal to be transparent | | cursorAccent | CSS color | | | color for cursor | | selectionBackground | CSS color | | | background color for selected text | ## Customizable Systemwide Global Hotkey Wave allows settings a custom global hotkey to open your most recent window from anywhere in your computer. This has the name `"app:globalhotkey"` in the `settings.json` file and takes the form of a series of key names separated by the `:` character. ### Examples As a practical example, suppose you want a value of `F5` as your global hotkey. Then you can simply set the value of `"app:globalhotkey"` to `"F5"` and reboot Wave to make that your global hotkey. As a less practical example, suppose you use the combination of the keys `Ctrl`, `Option`, and `e`. Then the value for this keybinding would be `"Ctrl:Option:e"`. ### Allowed Key Names We support the following key names: - `Ctrl` - `Cmd` - `Shift` - `Alt` - `Option` - `Meta` - `Super` - Digits (non-numpad) represented by `c{Digit0}` through `c{Digit9}` - Letters `a` though `z` - F keys `F1` through `F20` - Soft keys `Soft1` through `Soft4`. These are essentially the same as `F21` through `F24`. - Space represented as either `Space` or a literal space  <code> </code> - `Enter` (This is labeled as return on Mac) - `Tab` - `CapsLock` - `NumLock` - `Backspace` (This is labeled as delete on Mac) - `Delete` - `Insert` - The arrow keys `ArrowUp`, `ArrowDown`, `ArrowLeft`, and `ArrowRight` - `Home` - `End` - `PageUp` - `PageDown` - `Esc` - Volume controls `AudioVolumeUp`, `AudioVolumeDown`, `AudioVolumeMute` - Media controls `MediaTrackNext`, `MediaTrackPrevious`, `MediaPlayPause`, and `MediaStop` - `PrintScreen` - Numpad keys represented by `c{Numpad0}` through `c{Numpad9}` - The numpad decimal represented by `Decimal` - The numpad plus/add represented by `Add` - The numpad minus/subtract represented by `Subtract` - The numpad star/multiply represented by `Multiply` - The numpad slash/divide represented by `Divide` </PlatformProvider> ================================================ FILE: docs/docs/connections.mdx ================================================ --- sidebar_position: 3.1 id: "connections" title: "Connections" --- import { VersionBadge } from "@site/src/components/versionbadge"; # Connections Wave allows users to connect to various machines and unify them together in a way that preserves the unique behavior of each. At the moment, this extends to SSH remote connections and local WSL connections. ## Access a Connection in a Block The easiest way to access connections is to click the <i className="fa-sharp fa-laptop"/> icon. From there, you can type one of the following to depending on the connection you want: For SSH Connections: - `[user]@[host]` - `[host]` - `[user]@[host]:[port]` For WSL Connections: - `wsl://<distribution name>` Alternatively, if the connection already exists in the dropdown list, you can either click it or navigate to it with arrow keys and press enter to connect. ![a dropdown showing a list of connections that already exist](./img/connection-dropdown.png) ## Different Types of Connections As there are several different types of connections, not all of the types have access to the same features. SSH and WSL connections can always work in terminal widgets, and if `wsh` shell extensions are installed, they can also work in preview widgets and the sysinfo widget. ## What are wsh Shell Extensions? `wsh` is a small program that helps manage waveterm regardless of which machine you are currently connected to. It is always included on your host machine, but you also have the option to install it when connecting to SSH and WSL Connections. If it is installed on the connection, it is installed at `~/.waveterm/bin/wsh`. Then, when wave connects to your connection (and only when wave connects to your connection), the following happens: - `~/.waveterm/bin` is added to your `PATH` for that individual session. This allows the user to use the `wsh` command without providing the complete path. - Several environment variables are injected into the session to make certain tasks with `wsh` easier. These are [listed below](#additional-environment-variables). - The user-defined environment variables in the `cmd:env` entry of`connections.json` are injected into the session. - The user-defined initialization scripts located in `connections.json` are run. For more information on these scripts, see the section below. If this fails for some reason, Wave will attempt to run without `wsh`. You will see this indicated by a small **<code><i className="fa-link-slash fa-solid fa-sharp"/></code>** icon in the block header. For more info on what `wsh` is capable of, see [wsh command](/wsh). And if you wish to view the source code of `wsh`, you can find it [here](https://github.com/wavetermdev/waveterm/tree/main/cmd/wsh). With `wsh` installed, you have the ability to view certain widgets from the remote machine as if it were your host, for instance the `files` and `sysinfo` widgets. In addition, `wsh` can be used to influence the widgets across various machines. As a simple example, you can close a widget on the host machine by using the `wsh` command in a terminal window on a remote machine. For more information on what you can accomplish with `wsh`, take a look [here](/wsh). ### Additional Environment Variables As mentioned above, `wsh` injects a few environment variables in remote sessions for the user's convenience. These are listed below: | Variable Name | Description | | -------------------- | ----------------------------------------------------------------------------- | | TERM_PROGRAM | Set to `waveterm` in wave. | | WAVETERM | This is set to 1 in wave. | | WAVETERM_BLOCKID | The id of the block containing your current terminal widget. | | WAVETERM_CLIENTID | The id of the RPC Client being used by your current terminal widget. | | WAVETERM_CONN | The name of the remote connection being used by your current terminal widget. | | WAVETERM_TABID | The id of the tab containing your current terminal widget. | | WAVETERM_VERSION | The current semver version of wave. | | WAVETERM_WORKSPACEID | The id of thw workspace containing your current terminal widget. | # Initialization Scripts Wave provides you with options for running initialization scripts on your remote machines when connecting to them. These are defined in `connections.json` and can take either the form of the path of a script or a short script written directly in the file. If multiple scripts are defined, the most specific one relevant to the current shell is applied. The keywords for the scripts are: | Script Keyword | Shells Where Applied | | ------------------- | -------------------- | | cmd:initscript | all shells | | cmd:initscript.sh | bash and zsh | | cmd:initscript.bash | bash | | cmd:initscript.zsh | zsh | | cmd:initscript.pwsh | pwsh | | cmd:initscript.fish | fish | ## Add a New Connection to the Dropdown The SSH values that are loaded into the dropdown by default are obtained by parsing the internal `config/connections.json` file in addition to your `~/.ssh/config` and `/etc/ssh/ssh_config` files. Adding a new connection can be added in a couple ways: - adding a new `Host` to one of your ssh config files, typically the `~/.ssh/config` file - adding a new entry in the internal `config/connections.json` file - manually typing your connection into the connection box (if this successfully connects, the connection will be added to the internal `config/connections.json` file) - use `wsh ssh [user]@[host]` in your terminal (if this successfully connects, the connection will be added to the internal `config/connections.json` file) WSL connections are added by searching the installed WSL distributions as they appear in the Windows Registry. They also exist in the `config/connections.json` file similarly to SSH connections. ## SSH Config Parsing At the moment, we are capable of parsing any SSH config file that does not contain the `Match` keyword. This keyword is incompatible with a library we are using, but we are hoping to fix that soon. While all other valid keywords are parsed, we only support the functionality of a small subset of them at the moment: | Keyword | Description | |---------|-------------| | Host | The pattern to match when attempting to connect via `[user]@[host]`. We list hosts that do not contain any wildcards characters (`*`, `?`, or `!`). Even if a host pattern contains wildcards, it will still be parsed when determining the values associated with the keys as usual.| | User | The user of the SSH remote connection. This will default to the current user on the local machine if not specified.| |HostName| The real host name of the machine to log into. An IP address can be used if desired. This will default to the Host if not specified. | Port | The port to connect to the remote on. `22` is the default if not specified.| | IdentityFile | This can be specified more than once per host. It gives the path to a private identity file (id_rsa, id_ed25519, id_ecdsa, etc.) that is used to authenticate the connection. Each will be tried in order, and they can be encrypted with a passphrase if desired. If no value is set, the default is to try in order: ~/.ssh/id_rsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ecdsa_sk, ~/.ssh/id_ed25519_sk, ~/.ssh/id_dsa.| |BatchMode| If set to true, user interaction via password, challenge/response, and publickey passphrase authentication will be disabled. It is set to false by default.| |PubkeyAuthentication| (partial) This is used to specify if pubkey authentication should be attempted. It is partially implementented as the `unbound` and `host-bound` values simply work the same as the `yes` value. The default is `yes`.| |PasswordAuthentication| This is used to specify if password authentication should be attempted. The default is `yes`.| |KbdInteractiveAuthentication| This is used to specify if keyboard-interactive authentication should be attempted. The default is `yes`.| |PreferredAuthentications| (partial) Specifies the order the client should attempt to authenticate in. It is partially implemented as it does not support `gssapi-with-mic` or `hostbased` authentication. The default is `publickey,keyboard-interactive,password`| |AddKeysToAgent| (partial) This option will automatically add keys and their corresponding passphrase to your running ssh agent if it is enabled. It is partially supported as it can only accept `yes` and `no` as valid inputs. Other inputs such as `confirm` or a time interval will behave the same as `no`. The default value is `no`.| |IdentityAgent| Specifies the Unix Domain Socket used to communicate with the SSH Agent. This is used to overwrite the SSH_AUTH_SOCK identity agent.| |IdentitiesOnly| Specifies that only the specified authentication identity files should be used. This is either the default files or the ones specified with the IdentityFile keyword. It can accept `yes` or `no`. The default value is `no`.| |ProxyJump| Specifies one or more jump proxies in a comma separated list. Each will be visited sequentially using TCP forwarding before connecting to the desired connection (also using TCP forwarding). It can be set to `none` to disable the feature.| |UserKnownHostsFile| Provides the location of one or more user host key database files for recording trusted remote connections. The filenames are entered in the same string and separated by whitespace. The default value is `"~/.ssh/known_hosts ~/.ssh/known_hosts2"`.| |GlobalKnownHostsFile| Provides the location of one or more global host key database files for recording trusted remote connections. The filenames are entered in the same string and separated by whitespace. The default value is `"/etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2"`.| ### Example SSH Config Host For a quick example, a host in your config file may look like: ``` Host myhost User username HostName 203.0.113.254 IdentityFile ~/.ssh/id_rsa AddKeysToAgent yes ``` You would then be able to access this connection with `myhost` or `username@myhost`. And if you wanted to manually specify a port such as port 2222, you could do that by either adding `Port 2222` to the config file or connecting to `username@myhost:2222`. ## Internal SSH Configuration In addition to the regular ssh config file, wave also has its own config file to manage separate variables. These include | Keyword | Description | |---------|-------------| | conn:wshenabled | This boolean allows `wsh` to be used for your connection, if it is set to `false`, `wsh` will never be used for that connection. It defaults to `true`.| | conn:askbeforewshinstall | This boolean is used to prompt the user before installing wsh. If it is set to false, `wsh` will automatically be installed instead without prompting. It defaults to `true`.| | conn:wshpath | A string indicating the path to the `wsh` executable on the connection. It defaults to `"~/.waveterm/bin/wsh"`.| | conn:shellpath | A string indicating the path to the shell executable on the connection. If not set, the output of `$SHELL` on the connection will be used.| | conn:ignoresshconfig | This boolean allows wave to ignore the `~/.ssh/config` file for resolving keywords for this connection. The regular defaults will be used, but all changes to those must be specified in the `connections.json` file instead. This defaults to false.| | display:hidden | This boolean hides the connection from the dropdown list. It defaults to `false` | | display:order | This float determines the order of connections in the connection dropdown. It defaults to `0`.| | term:fontsize | This int can be used to override the terminal font size for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. | | term:fontfamily | This string can be used to specify a terminal font family for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. | | term:theme | This string can be used to specify a terminal theme for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. | | cmd:env | A json object with key value pairs of environment variables and the value they should be set to for this remote. This only works if `wsh` is enabled. | cmd:initscript | A script or a path to a script that runs when initializing this connection with any shell. This only works if `wsh` is enabled. | | cmd:initscript.sh | A script or a path to a script that runs when initializing this connection with POSIX shells like `bash` or `zsh`. This only works if `wsh` is enabled. | cmd:initscript.bash | A script or a path to a script that runs when initializing this connection with the `bash` shell. This only works if `wsh` is enabled. | | cmd:initscript.zsh | A script or a path to a script that runs when initializing this connection with the `zsh` shell. This only works if `wsh` is enabled. | | cmd:initscript.pwsh | A script or a path to a script that runs when initializing this connection with the `pwsh` shell. This only works if `wsh` is enabled. | | cmd:initscript.fish | A script or a path to a script that runs when initializing this connection with the `fish` shell. This only works if `wsh` is enabled. | | ssh:user | A string that indicates the username of the connection. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:hostname | A string representing the internal hostname of the connection. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:port | A string to indicate the numerical port to connect on. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:identityfile | A list of strings containing the paths to identity files that will be used. If a `wsh ssh` command using the `-i` flag is successful, the identity file will automatically be added here. These are used before the `~/.ssh/config` values.| | ssh:identitiesonly | A boolean indicating if only the specified identity files should be used. This means only the files set with the `ssh:identityfile` flag or the defaults. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:batchmode | A boolean indicating if password and passphrase prompts should be skipped. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:pubkeyauthentication | A boolean indicating if public key authentication is enabled. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:passwordauthentication | A boolean indicating if password authentication is enabled. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored. | | ssh:passwordsecretname | A string specifying the name of a secret stored in the [secret store](/secrets) to use as the SSH password. When set, this password will be automatically used for password authentication instead of prompting the user. <VersionBadge version="v0.13" /> | | ssh:kbdinteractiveauthentication | A boolean indicating if keyboard interactive authentication is enabled. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored. | | ssh:preferredauthentications | A list of strings indicating an ordering of different types of authentications. Each authentication type will be tried in order. This supports `"publickey"`, `"keyboard-interactive"`, and `"password"` as valid types. Other types of authentication are not handled and will be skipped. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:addkeystoagent | A boolean indicating if the keys used for a connection should be added to the ssh agent. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:identityagent | A string giving the path to the unix domain socket of the identity agent. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:proxyjump | A list of strings specifying the names of hosts that must be successively visited with tcp forwarding to establish a connection. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:userknownhostsfile | A list containing the paths of any user host key database files used to keep track of authorized connections. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| | ssh:globalknownhostsfile | A list containing the paths of any global host key database files used to keep track of authorized connections. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.| ### SSH Agent Detection Wave resolves the identity agent path in this order: - If `ssh:identityagent` (or `IdentityAgent` in SSH config) is set for the connection, that socket or pipe is used. - If not set on Windows, Wave falls back to the built-in OpenSSH agent pipe `\\.\pipe\openssh-ssh-agent`. Ensure the **OpenSSH Authentication Agent** service is running. - If not set on macOS/Linux, Wave queries your shell environment for `SSH_AUTH_SOCK` to detect the agent path automatically. ### Example Internal Configurations Here are a couple examples of things you can do using the internal configuration file `connections.json`: #### Hiding a Connection Suppose you have a connection named `github.com` in your `~/.ssh/config` file that shows up as `git@github.com` in the connections dropdown. While it does belong in the config file for authentication reasons, it makes no sense to be in the dropdown since it doesn't involve connecting to a remote environment. In that case, you can hide it as in the example below: ```json { <... other connections go here ...>, "git@github.com" : { "display:hidden": true }, <... other connections go here ...> } ``` #### Moving a Connection Suppose you have a connection named `rarelyused` that shows up as `myusername@rarelyused:9999` in the connections dropdown. Since it's so rarely used, you would prefer to move it later in the list. In that case, you can move it as in the example below: ```json { <... other connections go here ...>, "myusername@rarelyused:9999" : { "display:order": 100 }, <... other connections go here ...> } ``` #### Theming a Connection Suppose you have a connection named `myhost` that shows up as `myusername@myhost` in the connections dropdown. You use this connection a lot, but you keep getting it mixed up with your local connections. In this case, you can use the internal configuration file to style it differently. For example: ```json { <... other connections go here ...>, "myusername@myhost" : { "term:theme": "warmyellow", "term:fontsize": 16, "term:fontfamily": "menlo" }, <... other connections go here ...> } ``` This style, font size, and font family will then only apply to the widgets that are using this connection. ### Entirely Defined Internally Suppose you want to set up a connection but have no desire to learn the syntax of `~/.ssh/config`. In this case, you can entirely define the connection in your `connections.json` file. For example: ```json { <... other connections go here ...>, "myusername@myhost" : { "ssh:hostname": "190.0.2.0", "ssh:identityfile": ["~/.ssh/myidentityfile"], "ssh:identitiesonly": true, "ssh:addkeystoagent": true }, <... other connections go here ...> } ``` This will create a connection without that connection needing to be in the `~/.ssh/config` file. A couple additional options are set as well as an example of how that can be done. ### Disabling wsh for a Connection While Wave provides an option disable `wsh` when first connecting to a remote, there are cases where you may wish to disable it afterward. The easiest way to do this is by editing the `connections.json` file. Suppose the connection shows up in the dropdown as `root@wshless`. Then you can disable it manually with the following line: ```json { <... other connections go here ...>, "root@wshless" : { "conn:enablewsh": false, }, <... other connections go here ...> } ``` Note that this same line gets added to your `connections.json` file automatically when you choose to disable `wsh` in gui when initially connecting. ## Managing Connections with the CLI The `wsh` command gives some commands specifically for interacting with the connections. You can view these [here](/wsh-reference#conn). ## Troubleshooting Connections ### Log Files If there are issues with connections, the easiest first step is to enable debugging in a terminal widget that is trying to connect. To do this, click the **<code><i className="fa-gear fa-solid fa-sharp"/></code>** button and hover over the **`Debug Connection`** item. From there you can select two log levels, `Info` and `Verbose`. After this, debug info will print out to the terminal during the connection. If this is not sufficient, it is also possible to view the full log file. To do this, you can run the command `wsh wavepath log` to get the location of a log file. ### Known Limitations In the case that there is an error setting up `wsh`, your connection will still launch without `wsh`. However, depending on the debug info, there are a few things that can cause this. #### Shell Type Wave is capable of injecting `wsh` in the following shells: - bash - zsh - pwsh (powershell) - fish If the shell is different than those, it is possible the `wsh` command will not work by default. The easiest way to fix this at the moment is the switch the shell type. This can be done by setting the `conn:shellpath` value with a path to one of the above shells in the `connections.json` file for the connection you are trying to use. Alternatively, you can use the `chsh` command to change the shell in that connection, but this will also take effect outside of wave. Once this is done, restart wave for the changes to take effect. #### AllowTcpForwarding in sshd Some systems have sshd configured to disable TCP forwarding by default. This can be found on the connection in the `/etc/ssh/sshd_config` file. In that file, search for the line containing `AllowTcpForwarding`. If this is set to `no`, it is likely the reason `wsh` will not work on your connection. In order to get `wsh` working, set the value for `AllowTcpForwarding` to either `yes` or `local` (they both provide different levels of permission but both work in this case). Then, restart the `sshd` service with whichever method your remote machine provides. Once that is done, restart wave, so it can reconnect with this change. ================================================ FILE: docs/docs/customization.mdx ================================================ --- sidebar_position: 3.2 id: "customization" title: "Customization" --- ## Tab Themes ![Tab Context Menu](./img/tab-context-menu.png#right) Right click on any tab to bring up a menu which allows you to rename the tab and select different backgrounds. It is also possible to create your own themes using custom colors, gradients, images and more by editing your presets.json config file. To see how Wave's built in tab themes are defined, you can check out our [default presets file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/presets.json). ## Terminal Customization #### Terminal Theme ![Terminal Context Menu](./img/terminal-context-menu.png#right) Right click in the header area of any terminal block to bring up a menu which allows you to set a terminal theme for that terminal. You can set the default theme for all terminals (which haven't had their theme manually overridden) by editing your settings.json file and adding the key `term:theme` and setting it to the appropriate key. The keys can be found in the [default termthemes.json file](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/termthemes.json). If you add your own termthemes.json file in the config directory, you can also add your own custom terminal themes (just follow the same format). You can set the key `tab:preset` in your [Wave Config File](/config) to apply a theme to all new tabs. #### Font Size From the same context menu you can also change the font-size of the terminal. To change the default font size across all of your (non-overridden) terminals, you can set the config key `term:fontsize` to the size you want. e.g. `{ "term:fontsize": 14}`. #### Font Family There is no UI to edit your default terminal font family. But, it _can_ be overridden. In your settings.json file you can add the key `term:fontfamily` and set it to a font that is _installed_ on your local system. If type a font that is not installed, or use a non-monospace font, your terminal will look terrible (don't do that 🙂), delete the key to return to using the default. ## Widgets Sidebar ![Terminal Context Menu](./img/custom-widgets.png#right) See [Custom Widgets](/customwidgets) for detailed documentation around changing what appears in your right widget sidebar. Using widgets.json, you'll be able to remove any default widgets and add widgets of your own. You can fully customize the icons, colors, text, and defaults (like directories, webpages, AI model, remote connection, commands, etc.) of your custom widgets. You can also suppress the help widgets in the bottom right by setting the config key `widget:showhelp` to `false`. ## Tab Backgrounds Wave supports powerful custom backgrounds for your tabs using images, patterns, gradients, and colors. The quickest way to set an image background is using the `wsh setbg` command: ```bash # Set an image background with 50% opacity (default) wsh setbg ~/pictures/background.jpg # Set a color background (use quotes to prevent # being interpreted as a shell comment) wsh setbg "#ff0000" # hex color wsh setbg forestgreen # CSS color name # Adjust opacity wsh setbg --opacity 0.3 ~/pictures/light-pattern.png wsh setbg --opacity 0.7 # change only opacity of current background # Image positioning options wsh setbg --tile ~/pictures/texture.png # create tiled pattern wsh setbg --center ~/pictures/logo.png # center without scaling wsh setbg --center --size 200px ~/pictures/logo.png # center with specific size (px, %, auto) # Remove background wsh setbg --clear ``` You can use any JPEG, PNG, GIF, WebP, or SVG image as your background. The `--center` option is particularly useful for logos or icons where you want to maintain the original size. To preview the metadata for any background without applying it, use the `--print` flag: ```bash wsh setbg --print "#ff0000" ``` For more advanced customization options including gradients, colors, and saving your own background presets, check out our [Background Configuration](/presets#background-configurations) documentation. ================================================ FILE: docs/docs/customwidgets.mdx ================================================ --- sidebar_position: 6 id: "customwidgets" title: "Custom Widgets" --- # Custom Widgets Wave allows users to create their own widgets to uniquely customize their experience for what works for them. While we plan on greatly expanding on this in the future, it is already possible to make some widgets that you can access at the press of a button. All widgets can be created by modifying the `<WAVETERM_HOME>/config/widgets.json` file. By adding a widget to this file, it is possible to add widgets to the widget bar. By default, the widget bar looks like this: ![The default widget bar](./img/all-widgets-default.webp) By adding additional widgets, it is possible to get a widget bar that looks like this: ![A widget bar with custom widgets added](./img/all-widgets-extra.webp) ## The Structure of a Widget All widgets share a similar structure that roughly looks like the example below: ```json "<widget name>": { "icon": "<font awesome icon name>", "label": "<the text label of the widget>", "color": "<the color of the label>", "blockdef": { "meta": { "view": "term", "controller": "cmd", "cmd": "<the actual cli command>" } } } ``` This consists of a couple different parts. First and foremost, each widget has a unique identifying name. The value associated with this name is the outer `WidgetConfigType`. It is outlined in red below: ![An example of a widget with outer keys labeled as WidgetConfigType and inner keys labeled as MetaTSType. In the example, the outer keys are icon, label, color, and blockdef. The inner keys are view, controller, and cmd.](./img/widget-example.webp) This `WidgetConfigType` is shared between all types of widgets. That is to say, all widgets—regardless of type— will use the same keys for this. The accepted keys are: | Key | Description | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | "display:order" | (optional) Overrides the order of widgets with a number in case you want the widget to be different than the order provided in the `widgets.json` file. Defaults to 0. | | "icon" | (optional) The name of a [font awesome icon](#font-awesome-icons). Defaults to `"browser"`. | | "color" | (optional) A string representing a color as would be used in CSS. Hex codes and custom CSS properties are included. This defaults to `"var(--secondary-text-color)"` which is a color wave uses for text that should be differentiated from other text. Out of the box, it is `"#c3c8c2"`. | | "label" | (optional) A string representing the label that appears underneath the widget. It will also act as a tooltip on hover if the `"description"` key isn't filled out. It is null by default. | | "description" | (optional) A description of what the widget does. If it is specified, this serves as a tooltip on hover. It is null by default. | | "magnified" | (optional) A boolean indicating whether or not the widget should launch magnfied. It is false by default. | | "blockdef" | This is where the the non-visual portion of the widget is defined. Note that all further definition takes place inside a meta object inside this one. | <a name="font-awesome-icons" /> :::info **Font Awesome Icons** [Font Awesome](https://fontawesome.com/search) provides a ton of useful icons that you can use as a widget icon in your app. At its simplest, you can just provide the icon name and it will be used. For example, the string `"house"`, will provide an icon containing a house. We also allow you to apply a few different styles to your icon by modifying the name as follows: | format | description | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | <icon name> | The plain icon with no additional styles applied. | | solid@<icon name> | Adds the `fa-solid` class to the icon to fill in the content with a fill color rather than leaving it a background. | | regular@<icon name> | Adds the `fa-regular` class to the icon to ensure the content will not have a fill color and will use a standard outline instead. | | brands@<icon name> | This is required to add the required `fa-brands` class to an icon associated with a brand. Without this, brand icons will not render properly. This will not work with icons that aren't brand icons. | ::: The other options are part of the inner `MetaTSType` (outlined in blue in the image). This contains all of the details about how the widget actually works. The valid keys vary with each type of widget. They will be individually explored in more detail below. ## Terminal and CLI Widgets A terminal widget, or CLI widget, is a widget that simply opens a terminal and runs a CLI command. They tend to look something like the example below: ```json { <... other widgets go here ...>, "<widget name>": { "icon": "<font awesome icon name>", "label": "<the text label of the widget>", "color": "<the color of the label>", "blockdef": { "meta": { "view": "term", "controller": "cmd", "cmd": "<the actual cli command>" } } }, <... other widgets go here ...> } ``` The `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below: | Key | Description | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | "view" | A string that specifies the general type of widget. In the case of custom terminal widgets, this must be set to `"term"`. | | "controller" | A string that specifies the type of command being used. For more persistent shell sessions, set it to "shell". For one off commands, set it to `"cmd"`. When `"cmd"` is set, the widget has an additional refresh button in its header that allows the command to be re-run. | | "cmd" | (optional) When the `"controller"` is set to `"cmd"`, this option provides the actual command to be run. Note that because it is run as a command, there is no shell session unless you are launching a command that contains a shell session itself. Defaults to an empty string. | | "cmd:args" | (optional, array of strings) arguments to pass to the `cmd` | | "cmd:shell" | (optional) if cmd:shell if false (default), then we use `cmd` + `cmd:args` (suitable to pass to `execve`). if cmd:shell is true, then we just use `cmd`, and cmd can include spaces, and shell syntax (like pipes or redirections, etc.) | | "cmd:interactive" | (optional) When the `"controller"` is set to `"term", this boolean adds the interactive flag to the launched terminal. Defaults to false. | | "cmd:login" | (optional) When the `"controller"` is set to `"term"`, this boolean adds the login flag to the term command. Defaults to false. | | "cmd:runonstart" | (optional) The command will rerun when the block is created or the app is started. Without it, you must manually run the command. Defaults to true. | | "cmd:runonce" | (optional) Runs on start, but then sets "cmd:runonce" and "cmd:runonstart" to false (so future runs require manual restarts) | | "cmd:clearonstart" | (optional) When the cmd runs, the contents of the block are cleared out. Defaults to false. | | "cmd:closeonexit" | (optional) Automatically closes the block if the command successfully exits (exit code = 0) | | "cmd:closeonexitforce" | (optional) Automatically closes the block if when the command exits (success or failure) | | "cmd:closeonexitdelay | (optional) Change the delay between when the command exits and when the block gets closed, in milliseconds, default 2000 | | "cmd:env" | (optional) A key-value object represting environment variables to be run with the command. Defaults to an empty object. | | "cmd:cwd" | (optional) A string representing the current working directory to be run with the command. Currently only works locally. Defaults to the home directory. | | "cmd:nowsh" | (optional) A boolean that will turn off wsh integration for the command. Defaults to false. | | "cmd:jwt" | (optional) A boolean that forces adding JWT token to the environment. Required for running waveapps as widgets (both local and remote). Defaults to false. | | "term:localshellpath" | (optional) Sets the shell used for running your widget command. Only works locally. If left blank, wave will determine your system default instead. | | "term:localshellopts" | (optional) Sets the shell options meant to be used with `"term:localshellpath"`. This is useful if you are using a nonstandard shell and need to provide a specific option that we do not cover. Only works locally. Defaults to an empty string. | | "cmd:initscript" | (optional) for "shell" controller only. an init script to run before starting the shell (can be an inline script or an absolute local file path) | | cmd:initscript.sh" | (optional) same as `cmd:initscript` but applies to bash/zsh shells only | | cmd:initscript.bash" | (optional) same as `cmd:initscript` but applies to bash shells only | | cmd:initscript.zsh" | (optional) same as `cmd:initscript` but applies to zsh shells only | | cmd:initscript.pwsh" | (optional) same as `cmd:initscript` but applies to pwsh/powershell shells only | | cmd:initscript.fish" | (optional) same as `cmd:initscript` but applies to fish shells only | ### Example Local Shell Widgets If you have multiple shells installed on your machine, there may be times when you want to use a non-default shell. For cases like this, it is easy to create a widget for each. Suppose you want a widget to launch a `fish` shell. Once you have `fish` installed on your system, you can define a widget as ```json { <... other widgets go here ...>, "fish" : { "icon": "fish", "color": "#4abc39", "label": "fish", "blockdef": { "meta": { "view": "term", "controller": "shell", "term:localshellpath": "/usr/local/bin/fish", "term:localshellopts": "-i -l" } } }, <... other widgets go here ...> } ``` This adds an icon to the widget bar that you can press to launch a terminal running the `fish` shell. ![The example fish widget](./img/widget-example-fish.webp) :::info It is possible that `fish` is not in your path. If this is true, using `"fish"` as the value of `"term:localshellpath"` will not work. In these cases, you will need to provide a direct path to it. This is often somewhere like `"/usr/local/bin/fish"`, but it may be different on your system. ::: If you want to do the same for something like Powershell Core, or `pwsh`, you can define the widget as ```json { <... other widgets go here ...>, "pwsh" : { "icon": "rectangle-terminal", "color": "#2671be", "label": "pwsh", "blockdef": { "meta": { "view": "term", "controller": "shell", "term:localshellpath": "pwsh" } } }, <... other widgets go here ...> } ``` This adds an icon to the widget bar that you can press to launch a terminal running the `pwsh` shell. ![The example pwsh widget](./img/widget-example-pwsh.webp) :::info It is possible that `pwsh` is not in your path. If this is true, using `"pwsh"` as the value of `"term:localshellpath"` will not work. In these cases, you will need to provide a direct path to it. This could be somewhere like `"/usr/local/bin/pwsh"` on a Unix system or <code>"C:\\Program Files\\PowerShell\\7\\pwsh.exe"</code> on Windows. but it may be different on your system. Also note that both `pwsh.exe` and `pwsh` work on Windows, but only `pwsh` works on Unix systems. ::: ### Example Remote Shell Widgets If you want to open a terminal widget for a particular connection (SSH or WSL), you can use the `connection` meta key. The connection key's value should match connections.json (or what's in your connections dropdown menu). Note that you should only use the canonical name (do not use any custom "display:name" that you've set). For WSL that might look like `wsl://Ubuntu`, and for SSH connections that might look like `user@remotehostname`. ```json { <... other widgets go here ...>, "remote-term": { "icon": "rectangle-terminal", "label": "remote", "blockdef": { "meta": { "view": "term", "controller": "shell", "connection": "<connection>" } } }, <... other widgets go here ...> } ``` ### Example Cmd Widgets Here are a few simple cmd widgets to serve as examples. Suppose I want a widget that will run speedtest-go when opened. Then, I can define a widget as ```json { <... other widgets go here ...>, "speedtest" : { "icon": "gauge-high", "label": "speed", "blockdef": { "meta": { "view": "term", "controller": "cmd", "cmd": "speedtest-go --unix", "cmd:clearonstart": true } } }, <... other widgets go here ...> } ``` This adds an icon to the widget bar that you can press to launch a terminal running the `speedtest-go --unix` command. ![The example speedtest widget](./img/widget-example-speed.webp) Using `"cmd"` for the `"controller"` is the simplest way to accomplish this. `"cmd:clearonstart"` isn't necessary, but it makes it so every time the command is run (which can be done by right clicking the header and selecting `Force Controller Restart`), the previous contents are cleared out. Now suppose I wanted to run a TUI app, for instance, `dua`. Well, it turns out that you can more or less do the same thing: ```json { <... other widgets go here ...>, "dua" : { "icon": "brands@linux", "label": "dua", "blockdef": { "meta": { "view": "term", "controller": "cmd", "cmd": "dua" } } }, <... other widgets go here ...> } ``` This adds an icon to the widget bar that you can press to launch a terminal running the `dua` command. ![The example speedtest widget](./img/widget-example-dua.webp) Because this is a TUI app that does not return anything when closed, the `"cmd:clearonstart"` option doesn't change the behavior, so it has been excluded. ## Web Widgets Sometimes, it is desireable to open a page directly to a website. That can easily be accomplished by creating a custom `"web"` widget. They have the following form in general: ```json { <... other widgets go here ...>, "<widget name>": { "icon": "<font awesome icon name>", "label": "<the text label of the widget>", "color": "<the color of the label>", "blockdef": { "meta": { "view": "web", "url": "<url of the first webpage>" } } }, <... other widgets go here ...> } ``` The `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below: | Key | Description | |-----|-------------| | "view" | A string that specifies the general type of widget. In the case of custom web widgets, this must be set to `"web"`.| | "url" | This string is the url of the current page. As part of a widget, it will serve as the page the widget starts at. If not specified, this will default to the globally configurable `"web:defaulturl"` which is [https://github.com/wavetermdev/waveterm](https://github.com/wavetermdev/waveterm) on a fresh install. | | "pinnedurl" | (optional) This string is the url the homepage button will take you to. If not specified, this will default to the globally configurable `"web:defaulturl"` which is [https://github.com/wavetermdev/waveterm](https://github.com/wavetermdev/waveterm) on a fresh install. | ### Example Web Widgets Say you want a widget that automatically starts at YouTube and will use YouTube as the home page. This can be done using: ```json { <... other widgets go here ...>, "youtube" : { "icon": "brands@youtube", "label": "youtube", "blockdef": { "meta": { "view": "web", "url": "https://youtube.com", "pinnedurl": "https://youtube.com" } } }, <... other widgets go here ...> } ``` This adds an icon to the widget bar that you can press to launch a web widget on the youtube homepage. ![The example speedtest widget](./img/widget-example-youtube.webp) Alternatively, say you want a web widget that opens to github as if it were a bookmark, but will use google as its home page after that. This can easily be done with: ```json { <... other widgets go here ...>, "github" : { "icon": "brands@github", "label": "github", "blockdef": { "meta": { "view": "web", "url": "https://github.com", "pinnedurl": "https://google.com" } } }, <... other widgets go here ...> } ``` This adds an icon to the widget bar that you can press to launch a web widget on the github homepage. ![The example speedtest widget](./img/widget-example-github.webp) ## Sysinfo Widgets The Sysinfo Widget is intentionally kept to a very small subset of possible values that we will expand over time. But it is still possible to configure your own version of it—for instance, if you want to load a different plot by default. The general form of this widget is: ```json { <... other widgets go here ...>, "<widget name>": { "icon": "<font awesome icon name>", "label": "<the text label of the widget>", "color": "<the color of the label>", "blockdef": { "meta": { "view": "sysinfo", "graph:numpoints": <the max number of points in the graph>, "sysinfo:type": <the name of the plot collection>, } } }, <... other widgets go here ...> } ``` The `WidgetConfigType` takes the usual options common to all widgets. The `MetaTSType` can include the keys listed below: | Key | Description | |-----|-------------| | "view" | A string that specifies the general type of widget. In the case of custom sysinfo widgets, this must be set to `"sysinfo"`.| | "graph:numpoints" | The maximum amount of points that can be shown on the graph. Equivalently, the number of seconds the graph window covers. This defaults to 100.| | "sysinfo:type" | A string representing the collection of types to show on the graph. Valid values for this are `"CPU"`, `"Mem"`, `"CPU + Mem"`, and `All CPU`. Note that these are case sensitive. If no value is provided, the plot will default to showing `"CPU"`.| ### Example Sysinfo Widgets Suppose you have a build process that lasts 3 minutes and you'd like to be able to see the entire build on the sysinfo graph. Also, you would really like to view both the cpu and memory since both are impacted by this process. In that case, you can set up a widget as follows: ```json { <... other widgets go here ...>, "3min-info" : { "icon": "circle-3", "label": "3mininfo", "blockdef": { "meta": { "view": "sysinfo", "graph:numpoints": 180, "sysinfo:type": "CPU + Mem" } } }, <... other widgets go here ...> } ``` This adds an icon to the widget bar that you can press to launch the CPU and Memory plots by default with 180 seconds of data. ![The example speedtest widget](./img/widget-example-3mininfo.webp) Now, suppose you are fine with the default 100 points (and 100 seconds) but would like to show all of the CPU data when launched. In that case, you can write: ```json { <... other widgets go here ...>, "all-cpu" : { "icon": "chart-scatter", "label": "all-cpu", "blockdef": { "meta": { "view": "sysinfo", "sysinfo:type": "All CPU" } } }, <... other widgets go here ...> } ``` This adds an icon to the widget bar that you can press to launch All CPU plots by default. ![The example speedtest widget](./img/widget-example-all-cpu.webp) ## Overriding Default Widgets Wave ships with 5 default widgets in the widgets bar (terminal, files, web, ai, and sysinfo). You can modify or remove these by overriding their config in widgets.json. The names of the 5 widgets, in order, are: - `defwidget@terminal` - `defwidget@files` - `defwidget@web` - `defwidget@ai` - `defwidget@sysinfo` To remove any of them, just set that key to `null` in your widgets.json file. To see their definitions, to copy/paste them, or to understand how they work, you can view all of their definitions on [GitHub - default widgets.json](https://github.com/wavetermdev/waveterm/blob/main/pkg/wconfig/defaultconfig/widgets.json) ================================================ FILE: docs/docs/durable-sessions.mdx ================================================ --- sidebar_position: 3.5 id: "durable-sessions" title: "Durable Sessions" --- import { VersionBadge } from "@site/src/components/versionbadge"; # Durable Sessions <VersionBadge version="v0.14" /> Keep your remote SSH shell sessions alive through network changes, computer sleep, and Wave restarts. ## Overview Durable sessions protect your terminal state when working with remote SSH connections, similar to tmux or screen but built directly into Wave. Unlike standard SSH sessions that terminate when the connection drops, durable sessions maintain your: - **Shell state** - Current directory, environment variables, and shell history - **Running programs** - Background jobs and long-running commands continue executing - **Terminal history** - Full scrollback buffer preserved across reconnections Durable sessions automatically reconnect when your connection is restored, picking up right where you left off. :::info Remote Connections Only Durable sessions are designed for **remote SSH connections only**. Local terminals and WSL connections use standard sessions, as they're not affected by network interruptions and remain active as long as Wave is running. ::: ## How It Works When you start a durable session, Wave launches a lightweight job manager on the remote server. Similar to how tmux and screen work, this manager: 1. Keeps your shell process running independently of the Wave connection 2. Buffers terminal output while disconnected 3. Enables Wave to seamlessly reattach when you reconnect 4. Survives Wave restarts and network interruptions The session continues running on the remote server even if you close Wave, put your computer to sleep, or switch networks. ## Session Status Indicator The shield icon in your terminal header shows the current session status: | Icon | Status | Description | |------|--------|-------------| | <i className="fa-sharp fa-regular fa-shield" style={{color: 'rgb(140, 145, 140)'}}></i> | Standard Session | Connection drops will end the session | | <i className="fa-sharp fa-solid fa-shield" style={{color: '#0ea5e9'}}></i> | Durable (Attached) | Session is protected and connected | | <i className="fa-sharp fa-solid fa-shield" style={{color: '#7dd3fc'}}></i> | Durable (Detached) | Session running, currently disconnected | | <i className="fa-sharp fa-solid fa-shield" style={{color: 'rgb(140, 145, 140)'}}></i> | Durable (Awaiting) | Configured but not yet started | Hover over the shield icon to see detailed status information and available actions. ## Configuration Durable sessions can be configured at three levels, with more specific settings overriding general ones: ### Global Settings (Lowest Priority) Set the default for all SSH connections in your `settings.json`: ```json { "term:durable": true } ``` ### Connection Settings (Medium Priority) Configure durability per connection in your `connections.json`: ```json { "connections": { "user@host": { "term:durable": true } } } ``` ### Block Settings (Highest Priority) Override for individual terminal blocks through: - **Context Menu**: Right-click terminal → Advanced → Session Durability - **Flyover Actions**: Click shield icon → "Restart as Durable" or "Restart as Standard" - **Command Line**: Use `wsh setmeta term:durable=true` or `wsh setmeta term:durable=false` Configuration hierarchy (highest to lowest priority): 1. Block-level setting 2. Connection-level setting 3. Global setting ### Default Behavior - **SSH connections**: Durable sessions disabled by default (opt-in via configuration) - **Local terminals**: Always use standard sessions (durability not applicable) - **WSL connections**: Always use standard sessions (durability not applicable) ## Switching Between Modes ### Standard to Durable 1. Hover over the regular shield icon 2. Click **"Restart as Durable"** in the flyover 3. Your session will restart with durability enabled Or use the context menu: - Right-click terminal → Advanced → Session Durability → Restart Session in Durable Mode ### Durable to Standard 1. Access the terminal context menu (right-click) 2. Navigate to Advanced → Session Durability 3. Select **"Restart Session in Standard Mode"** :::warning Switching Modes Restarts the Session Converting between standard and durable modes requires restarting the shell. Any running processes in the current session will be terminated. ::: ## Session States ### Attached Your terminal is connected to the remote session. You can interact with the shell and see real-time output. ### Detached Connection lost, but the session continues running on the remote server. Wave will automatically reconnect when possible. Any commands you ran continue executing. ### Awaiting Start Session configured for durability but not yet started. Click "Start Session" or run a command to begin. ### Starting Job manager is initializing on the remote server. The session will become attached shortly. ### Ended Session has terminated. Common reasons: - **Exited**: Shell was closed normally (e.g., typed `exit`) - **Lost**: Session not found on server (may have been terminated or system rebooted) - **Failed to Start**: Job manager encountered an error during initialization Click "Restart Session" to start a new durable session, or "Restart as Standard" to switch modes. ## Use Cases ### Long-Running Commands Start a build, deployment, or data processing job and close your laptop. The command continues executing, and you can check on it later. ```bash # Start a long build ./build.sh # Close your laptop, get coffee # Later: reconnect and see the completed output ``` ### Unstable Networks Work from a café, train, or cellular connection. Brief disconnections won't terminate your session or lose your work. ### Multiple Locations Start work on your desktop, continue on your laptop. Your session and its state are preserved on the remote server. ### System Maintenance Wave updates, restarts, or crashes won't interrupt your remote work. Reconnect and resume immediately. ## Session Lifecycle Durable sessions are tied to the terminal block in Wave. The session will be terminated when you: - **Close the block**: Closes the terminal and terminates the remote session - **Switch connections**: Changing the connection on a block terminates the old session - **Delete the workspace/tab**: Removes the block and terminates associated sessions ### Cleanup Behavior If you close a block while **disconnected**, the remote session continues running until the next reconnection. When Wave reconnects to that server, it will automatically clean up any orphaned sessions from closed blocks. This ensures that remote sessions don't accumulate on your servers when you close terminals while offline. ## Limitations - **Local terminals**: Not applicable (already persistent with your local machine) - **WSL connections**: Not applicable (WSL sessions managed by Windows) - **Network latency**: Detached sessions buffer output; reconnecting may take a moment to sync - **Server resources**: Each durable session maintains a lightweight Go process on the remote server for session management ## Troubleshooting ### Session Shows as "Lost" The session was terminated on the remote server, possibly due to: - Server reboot - Manual termination of the job manager process - Remote system running out of resources **Solution**: Click "Restart Session" to start a new durable session. ### Session Won't Reconnect Verify that: - Your SSH connection to the server is working (check the connection status) - The job manager process is still running on the remote server **Try**: Right-click terminal → Advanced → Force Restart Controller ### "Failed to Start" Error The job manager couldn't initialize on the remote server. Check the error message for specific details. **Try**: Restart the session. If the issue persists, file a bug report with the error details. :::info Technical Details Durable sessions use Unix domain sockets on the remote server to maintain persistent connections between the shell and Wave's job manager. The job manager process runs independently and survives SSH disconnections. ::: ## Privacy & Security - Durable sessions run entirely on your remote servers - All data is transmitted over SSH between your local Wave instance and the remote machine - No open ports on the remote machine - communication happens through your existing SSH connection - When disconnected, output is buffered locally on the remote machine until you reconnect - Sessions are isolated per user and use your remote user's permissions ================================================ FILE: docs/docs/faq.mdx ================================================ --- sidebar_position: 101 id: "faq" title: "FAQ" --- import { VersionBadge } from "@site/src/components/versionbadge"; # FAQ ### How can I see the block numbers? The block numbers will appear when you hold down Ctrl-Shift (and disappear once you release the key combo). ### How do I make a remote connection? There is a button in the header. Click the <i className="fa-sharp fa-laptop"/> or <i className="fa-sharp fa-arrow-right-arrow-left"/> and type the `[user]@[host]` that you wish to connect to. ### On Windows, how can I use Git Bash as my default shell? Wave automatically detects Git Bash installations and adds them to the connection dropdown. Simply click the <i className="fa-sharp fa-laptop"/> or <i className="fa-sharp fa-arrow-right-arrow-left"/> button in the block header and select "Git Bash" from the list. Alternatively, you can manually set Git Bash as your default shell by setting the configuration variable `term:localshellpath` to the location of the Git Bash "bash.exe" binary. By default it is located at "C:\Program Files\Git\bin\bash.exe". Just remember in JSON, backslashes need to be escaped. So add this to your [settings.json](./config) file: ```json "term:localshellpath": "C:\\Program Files\\Git\\bin\\bash.exe" ``` ### Can I use WSH outside of Wave? `wsh` is an internal CLI for extending control over Wave to the command line, you can learn more about it [here](./wsh). To prevent misuse by other applications, `wsh` requires an access token provided by Wave to work and will not function outside of the app. ## Why does Wave warn me about ARM64 translation when it launches? macOS and Windows both have compatibility layers that allow x64 applications to run on ARM computers. This helps more apps run on these systems while developers work to add native ARM support to their applications. However, it comes with significant performance tradeoffs. To get the best experience using Wave, it is recommended that you uninstall Wave and reinstall the version that is natively compiled for your computer. You can find the right version by consulting our [Installation Instructions](./gettingstarted#installation). You can disable this warning by setting `app:dismissarchitecturewarning=true` in [your configurations](./config). ## How do I join the beta builds of Wave? Wave publishes to two channels, `latest` and `beta`. If you've installed the app for macOS, Windows, or Linux via DEB or RPM, you can set the following configurations in your `settings.json` (see [Configuration](./config) for more info): ```json "autoupdate:enabled": true, "autoupdate:channel": "beta" ``` If you've installed via Snap, you can use the following command: ```sh sudo snap install waveterm --classic --beta ``` ## Can I use Wave AI without enabling telemetry? <VersionBadge version="v0.13.1" noLeftMargin={true}/> Yes! Wave AI is normally disabled when telemetry is not enabled. However, you can enable Wave AI features without telemetry by configuring your own custom AI model (either a local model or using your own API key). To enable Wave AI without telemetry: 1. Configure a custom AI mode (see [Wave AI documentation](./waveai-modes)) 2. Set `waveai:defaultmode` to your custom mode's key in your Wave settings Once you've completed both steps, Wave AI will be enabled and you can use it completely privately without telemetry. This allows you to use local models like Ollama or your own API keys with providers like OpenAI, OpenRouter, or others. ================================================ FILE: docs/docs/gettingstarted.mdx ================================================ --- sidebar_position: 1 id: "gettingstarted" title: "Getting Started" --- import { PlatformProvider, PlatformSelectorButton, PlatformItem } from "@site/src/components/platformcontext"; import { Kbd } from "@site/src/components/kbd"; Wave Terminal is a modern terminal that includes graphical capabilities like web browsing, file previews, and AI assistance alongside traditional terminal features. This guide will help you get started. ## Installation <PlatformProvider> <PlatformSelectorButton /> ### Platform requirements <PlatformItem platforms={["mac"]}> - Supported architectures: Apple Silicon, x64 - Supported OS version: macOS 11 Big Sur or later </PlatformItem> <PlatformItem platforms={["windows"]}> - Supported architectures: x64 - Supported OS version: Windows 10 1809 or later, Windows 11 :::note ARM64 is planned, but is currently blocked by upstream dependencies (see [Windows ARM Support](https://github.com/wavetermdev/waveterm/issues/928)). ::: </PlatformItem> <PlatformItem platforms={["linux"]}> - Supported architectures: x64, ARM64 - Supported OS version: must have glibc-2.28 or later (Debian >=10, RHEL >=8, Ubuntu >=20.04, etc.) </PlatformItem> ### Package managers <PlatformItem platforms={["mac"]}> #### Homebrew ```bash brew install --cask wave ``` </PlatformItem> <PlatformItem platforms={["windows"]}> #### Windows Package Manager ```powershell winget install CommandLine.Wave ``` #### Chocolatey ```powershell choco install wave ``` </PlatformItem> <PlatformItem platforms={["linux"]}> #### Snap ```bash sudo snap install --classic waveterm ``` Other options available: [AUR package](https://aur.archlinux.org/packages/waveterm) (community maintained), [Nix package](https://search.nixos.org/packages?channel=unstable&show=waveterm) (community maintained) </PlatformItem> You can also download installers directly from our [Downloads page](https://www.waveterm.dev/download). ## Core Concepts ### Tabs and Blocks - **Tabs**: Like browser tabs, these help organize your work. Create new tabs with <Kbd k="Cmd:t"/>. - **Blocks**: The building blocks of Wave. Each block can be a terminal, web browser, file preview, or other widget. - **Layout**: Blocks can be dragged, dropped, and resized to create your ideal layout. ### Key Features 1. **Terminal Features** - Works with common shells (bash, zsh, fish) - Supports standard terminal features (readline, control sequences, etc) - Includes the `wsh` command for interacting with Wave's GUI features - GPU accelerated (on most platforms) 2. **Graphical Widgets** - Preview files (images, video, markdown, code with syntax highlighting) - Browse web pages - Ask questions and get AI help directly from the terminal (set up multiple AI models) - Basic system monitoring graphs 3. **Remote Connections** - Easy SSH connections with the connection button <i className="fa-sharp fa-laptop"/> - WSL integration on Windows - Consistent experience across local and remote sessions ## Quick Start Guide 1. **Open Your First New Tab** - New Wave tabs start with a single terminal block - Use it just like your regular terminal - Create additional terminal blocks with <Kbd k="Cmd:n"/> 2. **Try Some Basic Commands** ```bash # View a file or directory wsh view ~/Documents # Open a webpage wsh web open github.com # Get AI assistance wsh ai -m "how do I find large files in my current directory?" -s ``` 3. **Customize Your Layout** - Drag block headers to rearrange them - Hover between blocks to resize them - Right-click tab headers for background options - Right-click block headers for block-specific options 4. **Connect to Remote Machines** - Click the <i className="fa-sharp fa-laptop"/> button - Enter `username@hostname` for SSH connections - Or select a WSL distribution on Windows ## Next Steps - Explore [Key Bindings](./keybindings) to work more efficiently - Learn about [Tab Layouts](./layout) to organize your workspace - Set up [Custom Widgets](./customwidgets) for quick access to your tools - Configure [Wave AI](./waveai) to use your preferred AI models - Check out [Configuration](./config) for detailed customization options ## Getting Help - Join our [Discord community](https://discord.gg/XfvZ334gwU) for help and discussions - Report issues on [GitHub](https://github.com/wavetermdev/waveterm/issues) - Check our [FAQ](./faq) for common questions </PlatformProvider> ================================================ FILE: docs/docs/index.mdx ================================================ --- sidebar_position: -1 id: "index" title: "Home" hide_title: true hide_table_of_contents: true --- import { Card, CardGroup } from "@site/src/components/card"; # Welcome to Wave Terminal Wave is an [open-source](https://github.com/wavetermdev/waveterm) terminal that combines traditional terminal features with graphical capabilities like file previews, web browsing, and AI assistance. It runs on MacOS, Linux, and Windows. Modern development involves constantly switching between terminals and browsers - checking documentation, previewing files, monitoring systems, and using AI tools. Wave brings these graphical tools directly into the terminal, letting you control them from the command line. This means you can stay in your terminal workflow while still having access to the visual interfaces you need. Check out [Getting Started](./gettingstarted) for installation instructions. ![Wave Screenshot](./img/wave-screenshot.webp) <CardGroup> <Card href="./waveai" icon="fa-sparkles" title="Wave AI" description="Context-aware terminal assistant with access to terminal output, widgets, and filesystem." /> <Card href="./customization" icon="fa-paintbrush" title="Customization" description="Set up tabs and terminals to match your workflow needs." /> <Card href="./keybindings" icon="fa-keyboard" title="Key Bindings" description="Boost efficiency with keyboard shortcuts for faster navigation." /> <Card href="./layout" icon="fa-grid-2" title="Layout" description="Organize your workspace using our layout system." /> <Card href="./connections" icon="fa-network-wired" title="Remote Connections" description="Quickly SSH or connect to remote machines in one step." /> <Card href="./widgets" icon="fa-rocket" title="Widgets" description="Explore built-in tools to extend your terminal’s functionality." /> <Card href="./wsh" icon="fa-rectangle-terminal" title="wsh Command" description="Control Wave and launch widgets directly from the command line." /> </CardGroup> <div style={{ marginBottom: 30 }} /> :::info If you have a question, please feel free to ask us in [Discord](https://discord.gg/XfvZ334gwU). If you'd like to file a bug/enchancement, please use [Github Issues](https://github.com/wavetermdev/waveterm/issues). These docs are also open-source and we do accept PRs for docs [here](https://github.com/wavetermdev/waveterm/blob/main/docs). You can click the "Edit this page" link at the bottom of the page to get taken directly to the editor page for that document in GitHub. ::: <div className="reference-links"> Other References: - [Configuration](./config) - [Custom Widgets](./customwidgets) - [Full wsh reference](./wsh-reference) - [Telemetry](./telemetry) - [FAQ](./faq) - [Release Notes](./releasenotes) </div> ## Roadmap Want to provide input to our future releases? Connect with us on [Discord](https://discord.gg/XfvZ334gwU) or open a [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)! ## Links - **Homepage** https://waveterm.dev - **Download** https://waveterm.dev/download - **Discord** https://discord.gg/XfvZ334gwU - **GitHub** https://github.com/wavetermdev/waveterm/ ## Looking for WaveLegacy documentation? WaveLegacy docs can be found at [legacydocs.waveterm.dev](https://legacydocs.waveterm.dev). ================================================ FILE: docs/docs/keybindings.mdx ================================================ --- sidebar_position: 2 id: "keybindings" title: "Key Bindings" --- import { Kbd, KbdChord } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; <PlatformProvider> Here's the set of default keybindings available in Wave. It is split into sections. Some keybindings are always active. Others are only active for certain types of blocks. Note that these are the MacOS keybindings (they use "Cmd"). For Windows and Linux, replace "Cmd" with "Alt" (note that "Ctrl" is "Ctrl" on both Mac, Windows, and Linux). Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd chord key after typing the first key. Hitting Escape after an initial chord key will always be a no-op. ## Global Keybindings <PlatformSelectorButton /> <div style={{ marginBottom: 20 }}></div> | Key | Function | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | <Kbd k="Cmd:t"/> | Open a new tab | | <Kbd k="Cmd:n"/> | Open a new block (defaults to a terminal block with the same connection and working directory). Switch to launcher using `app:defaultnewblock` setting | | <Kbd k="Cmd:Shift:a"/> | Toggle WaveAI panel visibility | | <Kbd k="Cmd:d"/> | Split horizontally, open a new block to the right | | <Kbd k="Cmd:Shift:d"/> | Split vertically, open a new block below | | <KbdChord karr={["Ctrl:Shift:s", "ArrowUp"]}/> | Split vertically, open a new block above | | <KbdChord karr={["Ctrl:Shift:s", "ArrowDown"]}/> | Split vertically, open a new block below | | <KbdChord karr={["Ctrl:Shift:s", "ArrowLeft"]}/> | Split horizontally, open a new block to the left | | <KbdChord karr={["Ctrl:Shift:s", "ArrowRight"]}/> | Split horizontally, open a new block to the right | | <Kbd k="Cmd:Shift:n"/> | Open a new window | | <Kbd k="Cmd:w"/> | Close the current block | | <Kbd k="Cmd:Shift:w"/> | Close the current tab | | <Kbd k="Cmd:m"/> | Magnify / Un-Magnify the current block | | <Kbd k="Cmd:g"/> | Open the "connection" switcher | | <Kbd k="Cmd:i"/> | Refocus the current block (useful if the block has lost input focus) | | <Kbd k="Ctrl:Shift"/> | Show block numbers | | <Kbd k="Ctrl:Shift:0"/> | Focus WaveAI input | | <Kbd k="Ctrl:Shift:1-9"/> | Switch to block number | | <Kbd k="Ctrl:Shift:Arrows"/> / <Kbd k="Ctrl:Shift:h/j/k/l"/> | Move left, right, up, down between blocks | | <Kbd k="Ctrl:Shift:x"/> | Replace the current block with a launcher block | | <Kbd k="Cmd:1-9"/> | Switch to tab number | | <Kbd k="Cmd:["/> / <Kbd k="Shift:Cmd:["/> | Switch tab left | | <Kbd k="Cmd:]"/> / <Kbd k="Shift:Cmd:]"/> | Switch tab right | | <Kbd k="Cmd:Ctrl:1-9"/> | Switch to workspace number | | <Kbd k="Cmd:Shift:r"/> | Refresh the UI | | <Kbd k="Ctrl:Shift:i"/> | Toggle terminal multi-input mode | ## File Preview Keybindings | Key | Function | | ----------------------------------------- | -------------------------------------------------------------------------------------------------- | | <Kbd k="[text]"/> | Any regular character (e.g. "a", "b") will filter the file list | | <Kbd k="Escape"/> | Clears the filter | | <Kbd k="ArrowUp"/> / <Kbd k="ArrowDown"/> | Change file selection up/down | | <Kbd k="Enter"/> | Open the currently selected file/directory | | <Kbd k="Cmd:ArrowUp"/> | Move "up" a directory (parent directory) | | <Kbd k="Cmd:ArrowLeft"/> | Back, move to the previously selected file/directory | | <Kbd k="Cmd:ArrowRight"/> | Forward (opposite of back) | | <Kbd k="Cmd:o"/> | Open a new file (accepts relative paths to the current directory) | | <Kbd k="Cmd:s"/> | When file editor is open, save file | | <Kbd k="Cmd:e"/> | For files that can be previewed or edited (markdown, CSVs), switches between preview and edit mode | | <Kbd k="Cmd:r"/> | When file editor is open, revert changes | ## Web Keybindings | Key | Function | | ------------------------- | ------------------------------------------------------------- | | <Kbd k="Cmd:l"/> | Focus the URL input bar | | <Kbd k="Escape"/> | When the URL input bar is focused, will focus the web content | | <Kbd k="Cmd:r"/> | Reload webpage | | <Kbd k="Cmd:ArrowLeft"/> | Back | | <Kbd k="Cmd:ArrowRight"/> | Forward | | <Kbd k="Cmd:f"/> | Find in webpage | | <Kbd k="Cmd:o"/> | Open a bookmark | ## WaveAI Keybindings | Key | Function | | ----------------------- | ----------------------- | | <Kbd k="Cmd:Shift:a"/> | Toggle WaveAI panel | | <Kbd k="Ctrl:Shift:0" windows="Alt:0"/> | Focus WaveAI input | | <Kbd k="Cmd:k"/> | Clear AI Chat | ## Terminal Keybindings | Key | Function | | ----------------------- | ---------------------------- | | <Kbd k="Ctrl:Shift:c"/> | Copy | | <Kbd k="Ctrl:Shift:v"/> | Paste | | <Kbd k="Ctrl:v" mac="N/A" linux="N/A"/> | Paste (Windows Only) | | <Kbd k="Cmd:k"/> | Clear Terminal | | <Kbd k="Cmd:f"/> | Find in Terminal | | <Kbd k="Shift:Home"/> | Scroll to top | | <Kbd k="Shift:End"/> | Scroll to bottom | | <Kbd k="Cmd:Home" windows="N/A" linux="N/A"/> | Scroll to top (macOS only) | | <Kbd k="Cmd:End" windows="N/A" linux="N/A"/> | Scroll to bottom (macOS only)| | <Kbd k="Cmd:ArrowLeft" windows="N/A" linux="N/A"/> | Move to beginning of line (macOS only) | | <Kbd k="Cmd:ArrowRight" windows="N/A" linux="N/A"/> | Move to end of line (macOS only) | | <Kbd k="Shift:PageUp"/> | Scroll up one page | | <Kbd k="Shift:PageDown"/>| Scroll down one page | ## Customizeable Systemwide Global Hotkey Wave allows setting a custom global hotkey to focus your most recent window from anywhere in your computer. For more information on this, see [the config docs](./config#customizable-systemwide-global-hotkey). </PlatformProvider> ================================================ FILE: docs/docs/layout.mdx ================================================ --- sidebar_class_name: hidden id: "layout" --- import { Redirect } from "@docusaurus/router"; <Redirect to="/tabs#tab-layout-system" /> <!-- The contents of this page has moved to the tabs.mdx file under the "Tab Layout System" section. --> ================================================ FILE: docs/docs/presets.mdx ================================================ --- sidebar_position: 3.5 id: "presets" title: "Presets" --- # Presets Wave's preset system allows you to save and apply multiple configuration settings at once. Presets are used for: - Tab backgrounds: Apply visual styles to your tabs ## Managing Presets You can store presets in two locations: - `~/.config/waveterm/presets.json`: Main presets file - `~/.config/waveterm/presets/`: Directory for organizing presets into separate files All presets are aggregated regardless of which file they're in, so you can use the `presets` directory to organize them (e.g., `presets/bg.json`). :::info You can easily edit your presets using the built-in editor: ```bash wsh editconfig presets.json # Edit main presets file wsh editconfig presets/bg.json # Edit background presets ``` ::: ## File Format Presets follow this format: ```json { "<preset-type>@<preset-key>": { "display:name": "<Preset name>", "display:order": "<number>", // optional "<overridden-config-key-1>": "<overridden-config-value-1>" ... } } ``` The `preset-type` determines where the preset appears in Wave's interface: - `bg`: Appears in the "Backgrounds" submenu when right-clicking a tab ### Common Keys | Key Name | Type | Function | | ------------- | ------ | ----------------------------------------- | | display:name | string | Name shown in the UI menu (required) | | display:order | float | Controls the order in the menu (optional) | :::info When a preset is applied, it overrides the default configuration values for that tab or block. Using `bg:*` will clear any previously overridden values, setting them back to defaults. It's recommended to include this key in your presets to ensure a clean slate. ::: ## Background Presets Wave's background system harnesses the full power of CSS backgrounds, letting you create rich visual effects through the "background" attribute. You can apply solid colors, gradients (both linear and radial), images, and even blend multiple elements together. ### Configuration Keys | Key Name | Type | Function | | -------------------- | ------ | ------------------------------------------------------------------------------------------------------- | | bg:\* | bool | Reset all existing bg keys (recommended to prevent any existing background settings from carrying over) | | bg | string | CSS `background` attribute for the tab (supports colors, gradients images, etc.) | | bg:opacity | float | The opacity of the background (defaults to 0.5) | | bg:blendmode | string | The [blend mode](https://developer.mozilla.org/en-US/docs/Web/CSS/blend-mode) of the background | | bg:bordercolor | string | The color of the border when a block is not active (rarely used) | | bg:activebordercolor | string | The color of the border when a block is active | ### Examples #### Simple solid color: ```json { "bg@blue": { "display:name": "Blue", "bg:*": true, "bg": "blue", "bg:opacity": 0.3, "bg:activebordercolor": "rgba(0, 0, 255, 1.0)" } } ``` #### Complex gradient: ```json { "bg@duskhorizon": { "display:name": "Dusk Horizon", "bg:*": true, "bg": "linear-gradient(0deg, rgba(128,0,0,1) 0%, rgba(204,85,0,0.7) 20%, rgba(255,140,0,0.6) 45%, rgba(160,90,160,0.5) 65%, rgba(60,60,120,1) 100%), radial-gradient(circle at 30% 30%, rgba(255,255,255,0.1), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.05), transparent 70%)", "bg:opacity": 0.9, "bg:blendmode": "overlay" } } ``` #### Background image: ```json { "bg@ocean": { "display:name": "Ocean Scene", "bg:*": true, "bg": "url('/path/to/ocean.jpg') center/cover no-repeat", "bg:opacity": 0.2 } } ``` :::info Background images support both URLs and local file paths. For better reliability, we recommend using local files. Local paths must be absolute or start with `~` (e.g., `~/Downloads/background.png`). We support common web formats: PNG, JPEG/JPG, WebP, GIF, and SVG. ::: :::tip The `setbg` command can help generate background preset JSON: ```bash # Preview a solid color preset wsh setbg --print "#ff0000" { "bg:*": true, "bg": "#ff0000", "bg:opacity": 0.5 } # Preview a centered image preset wsh setbg --print --center --opacity 0.3 ~/logo.png { "bg:*": true, "bg": "url('/absolute/path/to/logo.png') no-repeat center/auto", "bg:opacity": 0.3 } ``` Just add the required `display:name` field to complete your preset! ::: ================================================ FILE: docs/docs/releasenotes.mdx ================================================ --- id: "releasenotes" title: "Release Notes" sidebar_position: 200 --- # Release Notes ### v0.14.2 — Mar 12, 2026 Wave v0.14.2 adds block/tab badges, directory preview improvements, and assorted bug fixes. **Block/Tab Badges:** - **Block Level Badges, Rolled up to Tabs** - Blocks can now display icon badges (with color and priority) that roll up and are visible in the tab bar for at-a-glance status - **Bell Indicator Enabled by Default** - Terminal bell badge is now on by default, lighting up the block and tab when your terminal rings the bell (controlled with `term:bellindicator`) - **`wsh badge`** - New `wsh badge` command to set or clear badges on blocks from the command line. Supports icons, colors, priorities, beep, and PID-linked badges that auto-clear when a process exits. Great for use with Claude Code hooks to surface notifications in the tab bar ([docs](https://docs.waveterm.dev/wsh-reference#badge)) **Other Changes:** - **Directory Preview Improvements** - Improved mod time formatting, zebra-striped rows, better default sort, YAML file support, and context menu improvements - **Search Bar** - Clipboard and focus improvements in the search bar - [bugfix] Fixed "New Window" hanging/not working on GNOME desktops - [bugfix] Fixed "Save Session As..." (focused window tracking bug) - [bugfix] Zoom change notifications were not being properly sent to all tabs (layout inconsistencies) - Added a Release Notes link in the settings menu - Working on anthropic-messages Wave AI backend (for native Claude integration) - Lots of internal work on testing/mock infrastructure to enable quicker async AI edits - Documention updates - Package updates and dependency upgrades ### v0.14.1 — Mar 3, 2026 Wave v0.14.1 fixes several high-impact terminal bugs (Claude Code scrolling, IME input) and adds new config options: focus-follows-cursor, cursor style customization, workspace-scoped widgets, and vim-style block navigation. **Terminal Improvements:** - **Claude Code Scroll Fix** - Fixed a long-standing bug that caused terminal windows to jump to the top unexpectedly, affecting many Claude Code users - **IME Fix** - Fixed Korean/CJK input where characters were lost or stuck in composition and only committed on Space - **Scroll Position Preserved on Resize** - Terminal now stays scrolled to the bottom across resizes when it was already at the bottom - **Better Link Handling** - Terminal URLs now have improved context menus and tooltips for easier navigation - **Terminal Scrollback Save** - New context menu item and `wsh` command to save terminal scrollback to a file **New Features:** - **Focus Follows Cursor** - New `app:focusfollowscursor` setting (off/on/term) for hover-based block focus - **Terminal Cursor Style & Blink** - New settings for cursor style (block/bar/underline) and blink, configurable per-block - **Tab Close Confirmation** - New `tab:confirmclose` setting to prompt before closing a tab - **Workspace-Scoped Widgets** - New optional `workspaces` field in `widgets.json` to show/hide widgets per-workspace - **Vim-Style Block Navigation** - Added Ctrl+Shift+H/J/K/L to navigate between blocks - **New AI Providers** - Added Groq and NanoGPT as built-in AI provider presets **Other Changes:** - Fixed intermittant bugs with connection switching in terminal blocks - Widgets.json schema improvements for better editor validation - Package updates and dependency upgrades - Internal code cleanup and refactoring ### v0.14.0 — Feb 10, 2026 **Durable SSH Sessions and Enhanced Connection Monitoring** Wave v0.14 introduces Durable Sessions for SSH connections, allowing your remote terminal sessions to survive connection interruptions, network changes, and Wave restarts. This release also includes major improvements to connection monitoring, RPC infrastructure with flow control, and expanded terminal capabilities. **Durable Sessions (Remote SSH Only):** - **Survive Interruptions** - SSH terminal sessions persist through network changes, computer sleep, and Wave restarts, automatically reconnecting when the connection is restored - **Session Protection** - Shell state, running programs, and terminal history are maintained even when Wave is closed or disconnected - **Visual Status Indicators** - Shield icons in terminal headers show session status (Standard, Durable Attached, Durable Detached, Durable Awaiting) with detailed flyover information - **Flexible Configuration** - Configure at global, per-connection, or per-block level with easy switching between standard and durable modes - See the new [Durable Sessions documentation](https://docs.waveterm.dev/durable-sessions) for setup and usage **Enhanced Connection Monitoring:** - **Connection Keepalives** - Active monitoring of SSH connections with automatic keepalive probes - **Stalled Connection Detection** - New connection monitor detects and displays "stalled" connection states when network issues occur, providing clear visual feedback - **Better Error Handling** - Improved connection status tracking and user-facing connection state indicators **Terminal Improvements:** - **OSC 52 Clipboard Support** - Terminal applications can now copy directly to your system clipboard using OSC 52 escape sequences - **Enhanced Context Menu** - Right-click terminals for quick access to splits, URL opening, themes, file browser, and more - **Streamlined Header Layout** - Terminal headers now focus on connection info without redundant view type labels **Wave AI Updates:** - **Image/Vision Support** - Added image support for OpenAI chat completions API, enabling vision capabilities with compatible models - **Stop Generation** - New ability to stop AI responses mid-generation across OpenAI and Gemini backends - **AI Panel Scroll Latch** - Improved auto-scrolling behavior in Wave AI panel - **Configurable Verbosity** - Control verbosity levels for OpenAI Responses API - Deprecated old AI-widget proxy endpoint **RPC and Performance:** - **RPC Streaming with Flow Control** - New streaming primitives with built-in flow control for better performance and reliability - **WSH Router Refactor** - Major routing architecture improvements to prevent hangs on connection interruptions - **RPC Client/Server Cleanup** - Improved RPC implementation and error handling **Configuration Updates:** - **Hide AI Button** - New `app:hideaibutton` setting to hide the AI button from the UI - **Disable Ctrl+Shift Arrows** - New `app:disablectrlshiftarrows` setting for keyboard shortcut conflicts - **Disable Ctrl+Shift Display** - New `app:disablectrlshiftdisplay` setting to disable overlay block numbers **Breaking Changes:** - **Removed Pinned Tabs** - Pinned tabs feature has been removed from the UI - **Removed S3 and WaveFile** - S3 filesystem and wavefile implementations removed to prevent large/recursive file transfer issues and simplify codebase **Other Changes:** - **Confirm on Quit** - Added confirmation dialog when closing Wave with active sessions - Monaco Editor upgrade removing `monaco-editor/loader` and `monaco-editor/react` dependencies for better performance and stability - New Tab model with React provider for improved state management - Removed OSC 23198 and OSC 9283 legacy handlers - Updated contribution guidelines - Upgraded Go toolchain to 1.25.6 - Enhanced OpenAI-compatible API provider documentation - [bugfix] Fixed empty data handling in sysinfo view - [bugfix] Fixed `app:ctrlvpaste` setting on Windows (can now be disabled) - [bugfix] Fixed duplicated Wave AI system prompt for some providers - [bugfix] Fixed disconnect hanging issue - disconnects now happen immediately - [bugfix] Fixed tool approval lifecycle to match SSE connection timing - [bugfix] Increased WSL connection timeout to handle slow initial WSL startup - [bugfix] Improved terminal shutdown with SIGHUP for graceful shell exit - Package updates and dependency upgrades ### v0.13.1 — Dec 16, 2025 **Windows Improvements and Wave AI Enhancements** This release focuses on significant Windows platform improvements, Wave AI visual updates, and better flexibility for local AI usage. **Windows Platform Enhancements:** - **Integrated Window Layout** - Removed separate title bar and menu bar on Windows, integrating controls directly into the tab-bar header for a cleaner, more unified interface - **Git Bash Auto-Detection** - Wave now automatically detects Git Bash installations and adds them to the connection dropdown for easy access - **SSH Agent Fallback** - Improved SSH agent support with automatic fallback to `\\.\pipe\openssh-ssh-agent` on Windows - **Updated Focus Keybinding** - Wave AI focus key changed to Alt:0 on Windows for better consistency - **Config Schemas** - Improved configuration validation and schema support - Ctrl-V now works as standard paste in terminal on Windows **Wave AI Updates:** - **Refreshed Visual Design** - Complete UI refresh removing blue accents and adding transparency support for better integration with custom backgrounds - **BYOK Without Telemetry** - Wave AI now works with bring-your-own-key and local models without requiring telemetry to be enabled - [bugfix] Fixed tool type "function" compatibility with providers like Mistral **Terminal Improvements:** - **New Scrolling Keybindings** - Added Shift+Home, Shift+End, Shift+PageUp, and Shift+PageDown for better terminal navigation **Other Changes:** - Package updates and dependency upgrades ### v0.13.0 — Dec 8, 2025 **Wave v0.13 Brings Local AI Support, BYOK, and Unified Configuration** Wave v0.13 is a major release that opens up Wave AI to local models, third-party providers, and bring-your-own-key (BYOK) configurations. This release also includes a completely redesigned configuration system and several terminal improvements. **Local AI & BYOK Support:** - **OpenAI-Compatible API** - Wave now supports any provider or local server using the `/v1/chat/completions` endpoint, enabling use of Ollama, LM Studio, vLLM, OpenRouter, and countless other local and hosted models - **Google Gemini Integration** - Native support for Google's Gemini models with a dedicated API adapter - **Provider Presets** - Simplified configuration with built-in presets for OpenAI, OpenRouter, Google, Azure, and custom endpoints - **Multiple AI Modes** - Easily switch between different models and providers with a unified interface - See the new [Wave AI Modes documentation](https://docs.waveterm.dev/waveai-modes) for configuration examples and setup guides **Unified Configuration Widget:** - **New Config Interface** - Replaced the basic JSON editor with a dedicated configuration widget accessible from the sidebar - **Better Organization** - Browse and edit different configuration types (general settings, AI modes, secrets) with improved validation and error handling - **Integrated Secrets Management** - Access Wave's secret store directly from the config widget for secure credential management **Terminal Improvements:** - **Bracketed Paste Mode** - Now enabled by default to improve multi-line paste behavior and compatibility with tools like Claude Code - **Windows Paste Fix** - Ctrl+V now works as a standard paste accelerator on Windows - **SSH Password Management** - Store SSH connection passwords in Wave's secret store to avoid re-typing credentials **Other Changes:** - Package updates and dependency upgrades - Various bug fixes and stability improvements ### v0.12.5 — Nov 24, 2025 Quick patch release to fix paste behavior on Linux (prevent raw HTML from getting pasted to the terminal). ### v0.12.4 — Nov 21, 2025 Quick patch release with bug fixes and minor improvements. - New `term:macoptionismeta` setting for macOS to treat Option key as Meta key in terminal - Fixed directory tracking for zsh shells - Fixed editor copy operations - Minor Wave AI improvements (image handling, scrolling, focus) - Package updates and dependency upgrades - WIP: WaveApps builder framework (not yet released) ### v0.12.3 — Nov 17, 2025 Patch release with Wave AI model upgrade, new secret management features, and improved terminal input handling. **Wave AI Updates:** - **GPT-5.1 Model** - Upgraded to use OpenAI's GPT-5.1 model for improved responses - **Thinking Mode Toggle** - New dropdown to select between Quick, Balanced, and Deep thinking modes for optimal response quality vs speed - [bugfix] Fixed path mismatch issue when restoring AI write file backups **New Features:** - **Secret Store** - New secret management widget for storing and managing sensitive credentials. Access secrets via CLI with `wsh secret list/get/set` commands **Terminal Improvements:** - **Enhanced Input Handling** - Better support for interactive CLI tools like Claude Code. Shift+Enter now inserts newlines by default for multi-line commands - **Image Paste Support** - Paste images directly into terminal (saved to temp files with path inserted). Works great in Claude Code! - **IME Fix** - Fixed duplicate text issue when switching input methods during Chinese/Japanese/Korean composition **Other Changes:** - Improved backend panic tracking for better debugging - Fixed memory leak around sysinfo events - WIP: New WaveApps builder framework (not yet released) - Package updates and dependency bumps ### v0.12.2 — Nov 4, 2025 Wave v0.12.2 adds file editing ability to Wave AI. Before approving a file edit you can easily see a diff (rendered in the Monaco Editor diff viewer), and after approving an edit you can easily roll back the change using a "Revert File" button. **Wave AI Updates:** - **File Write Tool** - Wave AI can now create and modify files with your approval - **Visual Diff Preview** - See exactly what will change before approving edits, rendered in Monaco Editor - **Easy Rollback** - Revert file changes with a simple "Revert File" button - **Drag & Drop Files** - Drag files from the preview viewer directly to Wave AI - **Directory Listings** - `wsh ai` can now attach directory listings to chats - **Adjustable Settings** - Control thinking level and max output tokens per chat **Bug Fixes & Improvements:** - Fixed a significant memory leak in the RPC system - Schema validation working again for config files - Improved tool descriptions and input validations (run before tool approvals) - Fixed issue with premature tool timeouts - Fixed regression with PowerShell 5.x - Fixed prompt caching issue when attaching files ### v0.12.1 — Oct 20, 2025 Patch release focused on shell integration improvements and Wave AI enhancements. This release fixes syntax highlighting in the code editor and adds significant shell context tracking capabilities. **Shell Integration & Context:** - **OSC 7 Support** - Added OSC 7 (current working directory) support across bash, zsh, fish, and pwsh shells. Wave now automatically tracks and restores your current directory across restarts for both local and remote terminals. - **Shell Context Tracking** - Implemented shell integration for bash, zsh, and fish shells. Wave now tracks when your shell is ready to receive commands, the last command executed, and exit codes. This enhanced context enables better terminal management and lays the groundwork for Wave AI to write and execute commands intelligently. **Wave AI Improvements:** - Display reasoning summaries in the UI while waiting for AI responses - Added enhanced terminal context - Wave AI now has access to shell state including current directory, command history, and exit codes - Added feedback buttons (thumbs up/down) for AI responses to help improve the experience - Added copy button to easily copy AI responses to clipboard **Other Changes:** - Mobile user agent emulation support for web widgets [#2442](https://github.com/wavetermdev/waveterm/issues/2442) - [bugfix] Fixed padding for header buttons in code editor (Tailwind regression) - [bugfix] Restored syntax highlighting in code editor preview blocks [#2427](https://github.com/wavetermdev/waveterm/issues/2427) - Package updates and dependency bumps ### v0.12.0 — Oct 16, 2025 **Wave v0.12 Has Arrived with Wave AI (beta)!** Wave Terminal v0.12.0 introduces a completely redesigned AI experience powered by OpenAI GPT-5. This represents a major upgrade and modernization over Wave's previous AI integration, bringing multi-modal support, advanced tool integration, and an intuitive new interface. The main AI PR alone included 128 commits and added 13,000+ lines of code. **Wave AI Features:** - **New Slide-Out Chat Panel** - Access Wave AI via hotkeys (Cmd-Shift-A or Ctrl-Shift-0) from the left side of your screen - **Multi-Modal Input** - Support for images, PDFs, and text file attachments - **Drag & Drop Files** - Simply drag files into the chat to attach them - **Command-Line Integration** - Send files and command output directly to Wave AI using `wsh ai` - **Smart Context Switching** - Enable Wave AI to see into your widgets and file system - **Built-in Tools:** - Web search capabilities - Local file and directory operations - Widget screenshots - Terminal scrollback access - Web navigation Wave AI is in active beta with included AI credits while we refine the experience. BYOK (Bring Your Own Key) will be available once we've stabilized core features and gathered feedback on what works best. Share your feedback in our [Discord](https://discord.gg/XfvZ334gwU). For more information and upcoming features, visit our [Wave AI documentation](https://docs.waveterm.dev/waveai). **Other Improvements:** - New onboarding flow showcasing block magnification, Wave AI, and wsh view/edit capabilities - New `wsh blocks list` command for listing and filtering blocks by workspace, tab, or view type - Continued migration from SCSS to Tailwind v4 - Package upgrades and dependency updates - Internal code cleanup and refactoring ### v0.11.6 — Sep 22, 2025 Patch release to address an editor bug when you open two files in separate edit widgets. Also adds Mermaid support to markdown blocks. * WIP: Big AI overhaul coming (multi-modal support, premium models, and tool support) * WIP: Integrating new Tsunami widget framework to make writing and running Wave widgets easier * Lots of package updates * Much internal cleanup (preview widget) * More migration to Tailwind v4 CSS * Build updates, switched to npm from yarn ### v0.11.5 — Aug 28, 2025 Another housekeeping release to modernize Wave and bring it more up to date. * Wave AI Cloud Proxy now uses gpt-5-mini (upgraded from gpt-4o-mini) * Fixed JWT issue with running "Wave Apps" from widgets * Added an "$ENV:envvar:fallback" syntax to the config files to allow Wave's config to pick up values from the environment (mostly to allow moving secrets out of the config files) * New setting to disable showing overlay blocknums when holding Ctrl:Shift (`app:showoverlayblocknums`) * New setting to allow Shift-Enter to work with tools like Claude Code (`term:shiftenternewline`) * Upgraded frontend to React 19 * Migrated more of the frontend to Tailwind v4 (work in progress) * Removed Universal MacOS build. 90% of Mac users are now on Apple Silicon, so universal build is less important (has a larger file size, and complicates the build process). * [bugfix] Removed build-ids in RPM build to try to fix conflicts with Slack * Removed some Wave v7 aware upgrades and old code paths * Internal cleanup, TypeScript errors, linting fixes, etc. * Other assorted Go/npm package bumps ### v0.11.4 — Aug 19, 2025 Quick patch release to update packages, fix some security issues (with dependent packages), and some small bug fixes. * Update AI Libraries, GPT-5 now supported in WaveAI * Added `ai:proxyurl` setting to allow proxy access (e.g. SOCKS) for AI access ### v0.11.3 — May 2, 2025 Quick patch release to update packages, fix some security issues (with dependent packages), and some small bug fixes. ### v0.11.2 — March 8, 2025 Quick patch release to fix a backend panic, and revert a change that caused WSL connections to hang. ### v0.11.1 — Feb 28, 2025 Wave Terminal v0.11.1 adds a lot of new functionality over v0.11.0 (it could have almost been a v0.12)! The headline feature is our files/preview widget now supports browsing S3 buckets. We read credential information directly from your ~/.aws/config, and you can now easily select any of your AWS profiles in our connections drop down to start viewing S3 files. We even support editing S3 text files using our built-in editor. Lots of other features and bug fixes as well: - **S3 Bucket** directory viewing and file previews - **Drag and Drop Files and Directories** between Wave directory views. This works across machines and between remote machines and S3 conections. - Added json-schema support for some of our config files. You'll now get auto-complete popups for fields in our settings.json, widgets.json, ai.json, and connections.json file. - New block splitting support -- Use Cmd-D and Cmd-Shift-D to split horizontally and vertically. For more control you can use Ctrl-Shift-S and then Up/Down/Left/Right to split in the given direction. - Delete block (without removing it from the layout). You can use Ctrl-Shift-D to remove a block, while keeping it in the layout. you can then launch a new widget in its place. - `wsh file` now supports copying files between your local machine, remote machines, and to/from S3 - New analytics framework (event based as opposed to counter based). See Telemetry Docs for more information. - Web bookmarks! Edit in your bookmarks.json file, can open them in the web widget using Cmd+O - Edits to your ai.json presets file will now take effect _immediately_ in AI widgets - Much better error handling and messaging when errors occur in the preview or editor widget - `wsh ssh --new` added to open the new ssh connection in a new widget - new `wsh launch` command to open any custom widget defined in widget.json - When using terminal multi-input (Ctrl-Shift-I), pasting text will now be sent to all terminals - [bugfix] Fix some hanging goroutines when commands failed or timed out - [bugfix] Fix some file extension mimetypes to enable the editor for more file types - [bugfix] Hitting "tab" would sometimes scroll a widget off screen making it unusable - [bugfix] XDG variables will no longer leak to terminal widgets - Added tailwind CSS and shadcn support to help build new widgets faster - Better internal widget abstractions ### v0.11.0 — Jan 24, 2025 Wave Terminal v0.11.0 includes a major rewrite of our connections infrastructure, with changes to both our backend and remote file protocol systems, alongside numerous features, bug fixes, and stability improvements. A key addition in this release is the new shell initialization system, which enables customization of your shell environment across local and remote connections. You can now configure environment variables and shell-specific init scripts on both a per-block and per-connection basis. For day-to-day use, we've added search functionality across both terminal and web blocks, along with a terminal multi-input feature for simultaneous input to all terminals within a tab. We've also added support for Google Gemini to Wave AI, expanding our suite of AI integrations. Behind the scenes, we've redesigned our remote file protocol, laying the groundwork for upcoming S3 (and S3-compatible system) support in our preview widget. This architectural change sets the stage for adding more file backends in the future. - **Shell Environment Customization** -- Configure your shell environment using environment variables and init scripts, with support for both local and remote connections - **Connection Backend Improvements** -- Major rewrite with improved shell detection, better error logging, and reduced 2FA prompts when using ForceCommand - **Multi-Shell Support** -- Enhanced support for bash, zsh, pwsh, and fish shells, with shell-specific initialization capabilities - **Terminal Search** -- use Cmd-F to search for text in terminal widgets - **Web Search** -- use Cmd-F to search for text in web views - **Terminal Multi-Input** -- Use Ctrl-Shift-I to allow multi-input to all terminals in the same tab - **Wave AI now supports Google Gemini** - Improved WSL support with wsh-free connection options - Added inline connection debugging information - Fixed file permission handling issues on Windows systems - Connection related popups are now delivered only to the initiating window - Improved timeout handling for SSH connections which require 2FA prompts - Fixed escape key handling in global event handlers (closing modals) - Directory preview now fills the entire block width - Custom widgets can now be launched in magnified mode - Various workspace UX improvements around closing/deleting - file:/// urls now work in web widget - Increased max size of files allowed in `wsh ai` to 50k - Increased maximum allowed term:scrollback to 50k lines - Allow connections to entirely be defined in connections.json without relying on ~/.ssh/config - Added an option to reveal files in external file viewer for local connection - Added a New Window option when right clicking the MacOS dock icon button - [build] Switched to free Ubuntu ARM runners for better ARM64 build support - [build] Windows builds now use zig, simplifying Windows dev setup - [bugfix] Connections dropdown now populated even when ssh config is missing or invalid - [bugfix] Disabled bracketed paste mode by default (configuration option to turn it back on) - [bugfix] Timeout for `wsh ssh` increased to 60s - [bugfix] Fix for sysinfo widget when displaying a huge number of CPU graphs - [bugfix] Fixes XDG variables for Snap installs - [bugfix] Honor SSH IdentitiesOnly flag (useful when many keys are loaded into ssh-agent) - [bugfix] Better shell environment variable setup when running local shells - [bugfix] Fix preview for large text files - [bugfix] Fix URLs in terminal (now clickable again) - [bugfix] Windows URLs now work properly for Wave background images - [bugfix] Connections launch without wsh if the unix domain socket can't be opened - [bugfix] Connection status list lights up correctly with currently connected connections - [bugfix] Use en_US.UTF-8 if the requested LANG is not available in your terminal - Other bug fixes, performance improvements, and dependency updates ### v0.10.4 — Dec 20, 2024 Quick update with bug fixes and new configuration options - Added "window:confirmclose" and "window:savelastwindow" configuration options - [bugfix] Fixed broken scroll bar in the AI widget - [bugfix] Fixed default path for wsh shell detection (used in remote connections) - Dependency updates ### v0.10.3 — Dec 19, 2024 Quick update to v0.10 with new features and bug fixes. - Global hotkey support [docs](https://docs.waveterm.dev/config#customizable-systemwide-global-hotkey) - Added configuration to override the font size for markdown, AI-chat, and preview editor [docs](https://docs.waveterm.dev/config) - Added ability to set independent zoom level for the web view (right click block header) - New `wsh wavepath` command to open the config directory, data directory, and log file - [bugfix] Fixed crash when /etc/sshd_config contained an unsupported Match directive (most common on Fedora) - [bugfix] Workspaces are now more consistent across windows, closes associated window when Workspaces are deleted - [bugfix] Fixed zsh on WSL - [bugfix] Fixed long-standing bug around control sequences sometimes showing up in terminal output when switching tabs - Lots of new examples in the docs for shell overrides, presets, widgets, and connections - Other bug fixes and UI updates (note, v0.10.2 and v0.10.3's release notes have been merged together) ### v0.10.1 — Dec 12, 2024 Quick update to fix the workspace app menu actions. Also fixes workspace switching to always open a new window when invoked from a non-workspace window. This reduces the chance of losing a non-workspace window's tabs accidentally. ### v0.10.0 — Dec 11, 2024 Wave Terminal v0.10.0 introduces workspaces, making it easier to manage multiple work environments. We've added powerful new command execution capabilities with `wsh run`, allowing you to launch and control commands in dedicated blocks. This release also brings significant improvements to SSH with a new connections configuration system for managing your remote environments. - **Workspaces**: Organize your work into separate environments, each with their own tabs, layouts, and settings - **Command Blocks**: New `wsh run` command for launching terminal commands in dedicated blocks, with support for magnification, auto-closing, and execution control ([docs](https://docs.waveterm.dev/wsh-reference#run)) - **Connections**: New configuration system for managing SSH connections, with support for wsh-free operation, per-connection themes, and more ([docs](https://docs.waveterm.dev/connections)) - Improved tab management with better switching behavior and context menus (many bug fixes) - New tab features including pinned tabs and drag-and-drop improvements - Create, rename, and delete files/directories directly in directory preview - Attempt wsh-free connection as a fallback if wsh installation or execution fails - New `-i` flag to add identity files with the `wsh ssh` command - Added Perplexity API integration ([docs](https://docs.waveterm.dev/faq#perplexity)) - `wsh setbg` command for background handling ([docs](https://docs.waveterm.dev/wsh-reference#setbg)) - Switched from Less to SCSS for styling - [bugfix] Fixed tab flickering issues during tab switches - [bugfix] Corrected WaveAI text area resize behavior - [bugfix] Fixed concurrent block controller start issues - [bugfix] Fixed Preview Blocks for uninitialized connections - [bugfix] Fixed unresponsive context menus - [bugfix] Fixed connection errors in Help block - Upgraded Go toolchain to 1.23.4 - Lots of new documentation, including new pages for [Getting Started](https://docs.waveterm.dev/gettingstarted), [AI Presets](https://docs.waveterm.dev/ai-presets), and [wsh overview](https://docs.waveterm.dev/wsh). - Other bug fixes, performance improvements, and dependency updates ### v0.9.3 — Nov 20, 2024 New minor release that introduces Wave's connected computing extensions. We've introduced new `wsh` commands that allow you to store variables and files, and access them across terminal sessions (on both local and remote machines). - `wsh setvar/getvar` to get and set variables -- [Docs](https://docs.waveterm.dev/wsh-reference#getvarsetvar) - `wsh file` operations (cat, write, append, rm, info, cp, and ls) -- [Docs](https://docs.waveterm.dev/wsh-reference#file) - Improved golang panic handling to prevent backend crashes - Improved SSH config logging and fixes a reused connection bug - Updated telemetry to track additional counters - New configuration settings (under "window:magnifiedblock") to control magnified block margins and display - New block/zone aliases (client, global, block, workspace, temp) - `wsh ai` file attachments are now rendered with special handling in the AI block - New ephemeral block type for creating modal widgets which will not disturb the underlying layout - Editing the AI presets file from the Wave AI block now brings up an ephemeral editor - Clicking outside of a magnified bglock will now un-magnify it - New button to clear the AI chat (also bound to Cmd-L) - New button to reset terminal commands in custom cmd widgets - [bugfix] Presets directory was not loading correctly on Windows - [bugfix] Magnified blocks were not showing correct on startup - [bugfix] Window opacity and background color was not getting applied properly in all cases - [bugfix] Fix terminal theming when applying global defaults [#1287](https://github.com/wavetermdev/waveterm/issues/1287) - MacOS 10.15 (Catalina) is no longer supported - Other bug fixes, docs improvements, and dependency bumps ### v0.9.2 — Nov 11, 2024 New minor release with bug fixes and new features! Fixed the bug around making Wave fullscreen (also affecting certain window managers like Hyprland). We've also put a lot of work into the doc site (https://docs.waveterm.dev), including documenting how [Widgets](./widgets) and [Presets](./presets) work! - Updated documentation - Wave AI now supports the Anthropic API! Checkout the [FAQ](./faq) for how to use the Claude models with Wave AI. - Removed defaultwidgets.json and unified it to widgets.json. Makes it more straightforward to override the default widgets. - New resolvers for `-b` param in `wsh`. "tab:N" for accessing the nth tab, "[view]" and "[view]:N" for accessing blocks of a particlar view. - New `wsh ai` command to send AI chats (and files) directly to a new or existing AI block - wsh setmeta/getmeta improvements. Allow setmeta to take a json file (and also read from stdin), also better output formats for getmeta (compatible with setmeta). - [bugfix] Set max completion tokens in the OpenAI API so we can now work with o1 models (also fallback to non-streaming mode) - [bugfix] Fixed content resizing when entering "full screen" mode. This bug also affected certain window managers (like Hyprland) - Lots of other small bug fixes, docs updates, and dependency bumps ### v0.9.1 — Nov 1, 2024 Minor bug fix release to follow-up on the v0.9.0 build. Lots of issues fixed (especially for Windows). - CLI applications that need microphone, camera, or location access will now work on MacOS. You'll see a security popup in Wave to allow/deny [#1086](https://github.com/wavetermdev/waveterm/issues/1086) - Can now use `wsh version -v` to print out the new data/config directories - Restores the old T1, T2, T3, ... tab naming logic - Temporarily revert to using the "Title Bar" on windows to mitgate a bug where the window controls were overlaying on top of our tabs (working on a real fix for the next release) - There is a new setting in the editor to enable/disable word wrapping [#1038](https://github.com/wavetermdev/waveterm/issues/1038) - Ctrl-S will now save files in codeedit [#1081](https://github.com/wavetermdev/waveterm/issues/1081) - [#1020](https://github.com/wavetermdev/waveterm/issues/1020) there is now a preset config option to change the active border color in tab themes - [bugfix] Multiple fixes for [#1167](https://github.com/wavetermdev/waveterm/issues/1167) to try to address tab loss while updating - [bugfix] Windows app crashed on opening View menu because of a bad accelerator key - [bugfix] The auto-updater messages in the tab bar are now more consistent when switching tabs, and we don't show errors when the network is disconnected - [bugfix] Full-screen mode now actually shows tabs in full screen - [bugfix] [#1175](https://github.com/wavetermdev/waveterm/issues/1175) can now edit .awk files - [bugfix] [#1066](https://github.com/wavetermdev/waveterm/issues/1066) applying a default theme now updates the background appropriately without a refresh ### v0.9.0 — Oct 28, 2024 New major Wave Terminal release! Wave tabs are now cached. Tab switching performance is now much faster and webview state, editor state, and scroll positions are now persisted across tab changes. We also have native WSL2 support. You can create native Wave connections to your Windows WSL2 distributions using the connection button. We've also laid the groundwork for some big features that will be released over the next couple of weeks, including Workspaces, AI improvments, and custom widgets. Lots of other smaller changes and bug fixes. See full list of PRs at https://github.com/wavetermdev/waveterm/releases/tag/v0.9.0 ### v0.8.13 — Oct 24, 2024 - Wave is now available as a Snap for Linux users! You can find it [in the Snap Store](https://snapcraft.io/waveterm). - Wave is now available via the Windows Package Manager! You can install it via `winget install CommandLine.Wave` - can now use "term:fontsize" to override an individual terminal block's font size (also in context menu) - we now allow mixed case hostnames for connections to be compatible with ssh config - The Linux app icon is now updated to match the Windows icon - [bugfix] fixed a bug that sometimes caused escape sequences to be printed when switching between tabs - [bugfix] fixed an issue where the preview block was not cleaning up temp files (Windows only) - [bugfix] fixed chrome sandbox permissions errors in linux - [bugfix] fixed shutdown logic on MacOS/Linux which sometimes allowed orphaned processes to survive ### v0.8.12 — Oct 18, 2024 - Added support for multiple AI configurations! You can now run Open AI side-by-side with Ollama models. Can create AI presets in presets.json, and can easily switch between them using a new dropdown in the AI widget - Fix WebSocket reconnection error. this sometimes caused the terminal to hang when waking up from sleep - Added memory graphs, and per-CPU graphs to the sysinfo widget (and renamed it from cpuplot) - Added a new huge red "Config Error" button when there are parse errors in the config JSON file - Preview/CodeEdit widget now shows errors (squiggly lines) when JSON or YAML files fail to parse - New app icon for Windows to better match Fluent UI standards - Added copy-on-select to the terminal (on by default, can disable using "term:copyonselect") - Added a button to mute audio in webviews - Added a right-click "Open Clipboard URL" to easily open a webview from an URL stored in your system clipboard - [bugfix] fixed blank "help" pages when waking from sleep or restarting the app ### v0.8.11 — Oct 10, 2024 Hotfix release to address a couple of bugs introduced in v0.8.10 - Fixes a regression in v0.8.10 which caused new tabs to sometimes come up blank and broken - Layout fixes to the AI widget spacing - Terminal scrollbar is now semi-transparent and overlays last column - Fixes initial window size (on first startup) for both smaller and larger screens - Added a "Don't Ask Again" checkbox for installing `wsh` on remote machines (sets a new config flag) - Prevent the app from downgrading when you install a beta build. Installing a beta-build will now switch you to the beta-update channel. ### v0.8.10 — Oct 9, 2024 Minor big fix release (but there are some new features). - added support for Azure AI [See FAQ](https://docs.waveterm.dev/faq#how-can-i-connect-to-azure-ai) - AI errors now appear in the chat - on MacOS, hitting "Space" in directorypreview will open selected file in Quick Look - [bugfix] fixed transparency settings - [bugfix] fixed issue with non-standard port numbers in connection dropdown - [bugfix] fixed issue with embedded docsite (returned 404 after refresh) ### v0.8.9 — Oct 8, 2024 Lots of bug fixes and new features! - New "help" view -- uses an embedded version of our doc site -- https://docs.waveterm.dev - [breaking] wsh getmeta, wsh setmeta, and wsh deleteblock now take a blockid using a `-b` parameter instead of as a positional parameter - allow metadata to override the block icon, header, and text (frame:title, frame:icon, and frame:text) - home button on web widget to return to the homepage, option to set a homepage default for the whole app or just for the given block - checkpoint the terminal less often to reduce frequency of output bug (still working on a full fix) - new terminal themes -- Warm Yellow, and One Dark Pro - we now support github flavored markdown alerts - `wsh notify` command to send a desktop notification - `wsh createblock` to create any block via the CLI - right click to "Save Image" in webview - `wsh edit` will now allow you to open new files (as long as the parent directly exists) - added 8 new fun tab background presets (right click on any tab and select "Backgrounds" to try them out) - [config] new config key "term:scrollback" to set the number of lines of scrollback for terminals. Use "-1" to set 0, max is 10000. - [config] new config key "term:theme" to set the default terminal theme for all new terminals - [config] new config key "preview:showhiddenfiles" to set the default "show hidden files" setting for preview - [bugfix] fixed an formatting issue with `wsh getmeta` - [bugfix] fix for startup issue on Linux when home directory is an NFS mount - [bugfix] fix cursor color in terminal themes to work - [bugfix] fix some double scrollbars when showing markdown content - [bugfix] improved shutdown sequence to better capture wavesrv logs - [bugfix] fix Alt+G keyboard accelerator for Linux/Windows - other assorted bug fixes, cleanups, and security fixes ### v0.8.8 — Oct 1, 2024 Quick patch release to fix Windows/Linux "Alt" keybindings. Also brings a huge performance improvement to AI streaming speed. ### v0.8.7 — Sep 30, 2024 Quick patch release to fix bugs: - Fixes windows SSH connections (invalid path while trying to install wsh tools) - Fixes an issue resolving `~` in windows paths `~\` now works instead of just `~/` - Tries to fix background color for webpages. Pulls meta tag for color-scheme, and sets a black background if dark detected (fixes issue rendering raw githubusercontent files) - Fixed our useDimensions hook to fire correctly. Fixes some sizing issues including allowing error messages to show consistently when SSH connections fail. - Allow "data:" urls in custom tab backgrounds - All the alias "tab" for the current tab's UUID when using wsh - [BUILD] conditional write generated files only if they are updated ### v0.8.6 — Sep 26, 2024 Another quick hotfix update. Fixes an issue where, if you deleted all of the tabs in a window, the window would be restored on next startup as completely blank. Also, as a bonus, we added fish shell support! ### v0.8.5 — Sep 25, 2024 Hot fix, dowgrade `jotai` library. Upgrading caused a major regression in codeedit which did not allow users to edit files. ### v0.8.4 — Sep 25, 2024 - Added a setting `window:disablehardwareacceleration` to disable native hardware acceleration - New startup model for legacy users given them the option to download the WaveLegacy - Use WAVETERM_HOME for the home directory consistently ### v0.8.3 — Sep 25, 2024 More hotfixes for Linux users. We now link against an older version of glibc and use the zig compiler on linux (the newer version caused us not to run on older distros). Also fixes a permissions issue when installing via .deb. There is also a new config value `window:nativetitlebar` which restores the native titlebar on windows/linux. ### v0.8.2 — Sep 24, 2024 Hot fix, fixes a nasty crash on startup for Linux users (dynamic linking but with netcgo DNS library) ### v0.8.1 — Sep 23, 2024 Minor cleanup release. - fix number parsing for certain config file values - add link to docs site - add new back button for directory view - telemetry fixes ### v0.8.0 — Sep 20, 2024 **Major New Release of Wave Terminal** The new build is a fresh start, and a clean break from the current version. As such, your history, settings, and configuration will not be carried over. If you'd like to continue to run the legacy version, you will need to download it separately. Release Artificats and source code diffs can be found on (Github)[https://github.com/wavetermdev/waveterm]. ================================================ FILE: docs/docs/secrets.mdx ================================================ --- sidebar_position: 3.2 id: "secrets" title: "Secrets" --- import { VersionBadge } from "@site/src/components/versionbadge"; # Secrets <VersionBadge version="v0.13" noLeftMargin={true} /> Wave Terminal provides a secure way to store sensitive information like passwords, API keys, and tokens. Secrets are stored encrypted in your system's native keychain (macOS Keychain, Windows Credential Manager, or Linux Secret Service), ensuring your sensitive data remains protected. ## Why Use Secrets? Secrets in Wave Terminal allow you to: - **Store SSH passwords** - Automatically authenticate to SSH connections without typing passwords - **Manage API keys** - Keep API tokens, keys, and credentials secure - **Share across sessions** - Access your secrets from any terminal block or remote connection - **Avoid plaintext storage** - Never store sensitive data in configuration files or scripts ## Opening the Secrets UI There are several ways to access the secrets management interface: 1. **From the widgets bar** (recommended): - Click the **<i className="fa-gear fa-solid fa-sharp"/>** settings icon on the widgets bar - Select **Secrets** from the menu 2. **From the command line:** ```bash wsh secret ui ``` The secrets UI provides a visual interface to view, add, edit, and delete secrets. ## Managing Secrets via CLI Wave Terminal provides a complete CLI for managing secrets from any terminal block: ```bash # List all secret names (not values) wsh secret list # Get a specific secret value wsh secret get MY_SECRET_NAME # Set a secret (format: name=value, no spaces around =) wsh secret set GITHUB_TOKEN=ghp_xxxxxxxxxx wsh secret set DB_PASSWORD=super_secure_password # Delete a secret wsh secret delete MY_SECRET_NAME ``` ## Secret Naming Rules Secret names must match the pattern: `^[A-Za-z][A-Za-z0-9_]*$` This means: - Must start with a letter (A-Z or a-z) - Can only contain letters, numbers, and underscores - Cannot contain spaces or special characters **Valid names:** `MY_SECRET`, `ApiKey`, `ssh_password_1` **Invalid names:** `123_SECRET`, `my-secret`, `secret name` ## Using Secrets with SSH Connections <VersionBadge version="v0.13" /> Secrets can be used to automatically provide passwords for SSH connections, eliminating the need to type passwords repeatedly. ### Configure in connections.json Add the `ssh:passwordsecretname` field to your connection configuration: ```json { "myserver": { "ssh:hostname": "example.com", "ssh:user": "myuser", "ssh:passwordsecretname": "SERVER_PASSWORD" } } ``` Then store your password as a secret: ```bash wsh secret set SERVER_PASSWORD=my_actual_password ``` Now when Wave connects to `myserver`, it will automatically use the password from your secret store instead of prompting you. ### Benefits - **Security**: Password stored encrypted in your system keychain - **Convenience**: No need to type passwords for each connection - **Flexibility**: Update passwords by changing the secret, not the configuration ## Security Considerations - **Encrypted Storage**: Secrets are stored encrypted in your Wave configuration directory. The encryption key itself is protected by your operating system's secure credential storage (macOS Keychain, Windows Credential Manager, or Linux Secret Service). - **No Plaintext**: Secrets are never stored unencrypted in logs or accessible files. - **Access Control**: Secrets are only accessible to Wave Terminal. ## Storage Backend Wave Terminal automatically detects and uses the appropriate secret storage backend for your operating system: - **macOS**: Uses the macOS Keychain - **Windows**: Uses Windows Credential Manager - **Linux**: Uses the Secret Service API (freedesktop.org specification) :::warning Linux Secret Storage On Linux systems, Wave requires a compatible secret service backend (typically GNOME Keyring or KWallet). These are usually pre-installed with your desktop environment. If no compatible backend is detected, you won't be able to set secrets, and the UI will display a warning. ::: ## Troubleshooting ### "No appropriate secret manager found" This error occurs on Linux when no compatible secret service backend is available. Install GNOME Keyring or KWallet and ensure the secret service is running. ### Secret not found Ensure the secret name is spelled correctly (names are case-sensitive) and that the secret exists: ```bash wsh secret list ``` ### Permission denied on Linux The secret service may require you to unlock your keyring. This typically happens after login. Consult your desktop environment's documentation for keyring management. ## Related Documentation - [Connections](/connections) - Learn about SSH connections and configuration - [wsh Command Reference](/wsh-reference#secret) - Complete CLI command documentation for secrets ================================================ FILE: docs/docs/tabs.mdx ================================================ --- sidebar_position: 3.25 id: "tabs" title: "Tabs" --- import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; import { Kbd } from "@site/src/components/kbd"; <PlatformProvider> Tabs are collections of [Widgets](./widgets) that can be arranged into tiled dashboards. You can create as many tabs as you want within a given workspace to help organize your workflows. ## Tab Bar The tab bar is located at the top of the window and shows all tabs within a given workspace. You can click on a tab to switch to it. When switching tabs, any commands in the previous tab will continue running and any unsaved work will be persisted until you return to it. If you close the window or switch workspaces within the same window, that work will be lost. <PlatformSelectorButton /> ### Creating a new tab You can create a new tab by clicking the <i className="fa-sharp fa-plus" title="plus"/> button to the right of the tabs in the tab bar, or by pressing <Kbd k="Cmd:t"/> on the keyboard. This will also focus you to the new tab. ### Closing a tab You can close a tab by hovering over it and clicking the <i className="fa-sharp fa-xmark-large" title="x"/> button that appears, or by pressing <Kbd k="Cmd:Shift:w"/> on the keyboard. You can also close a tab by [closing all the blocks](#delete-a-block) within it. Closing a block is a destructive action that will stop any running processes and discard any unsaved work. This cannot be undone. ### Rearranging tabs You can rearrange tabs by dragging them around within the tab bar. ### Switching tabs You can switch to an existing tab by clicking on it in the tab bar. You can also use the following shortcuts: | Key | Function | | ------------------ | -------------------- | | <Kbd k="Cmd:1-9"/> | Switch to tab number | | <Kbd k="Cmd:["/> | Switch tab left | | <Kbd k="Cmd:]"/> | Switch tab right | ### Pinning a tab Pinning a tab makes it harder to close accidentally. You can pin a tab by right-clicking on it and selecting "Pin Tab" from the context menu that appears. You can also pin a tab by dragging it to a lesser index than an existing pinned tab. When a tab is pinned, the <i className="fa-sharp fa-xmark-large" title="x"/> button for the tab will be replaced with a <i className="fa-solid fa-sharp fa-thumbtack" title="pin"/> button. Clicking this button will unpin the tab. You can also unpin a tab by dragging it to an index higher than an existing unpinned tab. ## Tab Layout System The tabs are comprised of tiled blocks. The contents of each block is a single widget. You can move blocks around and arrange them into layouts that best-suit your workflow. You can also magnify blocks to focus on a specific widget. ![screenshot showing a block being dragged over another block, with the placeholder depicting a out-of-line before outer drop](./img/drag-edge.png) ### Layout system under the hood :::info **Definitions** - Layout tree: the in-memory representation of a tab layout, comprised of nodes - Node: An entry in the layout tree, either a single block (a leaf) or an ordered list of nodes. Defines a tiling direction (row or column) and a unitless size - Block: The contents of a leaf in the layout tree, defines what contents is displayed at the given layout location ::: Our layout system emulates the [CSS Flexbox](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flexible_box_layout/Basic_concepts_of_flexbox) system, comprising of a tree of columns and rows. Under the hood, the layout is represented as an n-tree, where each node in the tree is either a single block, or a list of nodes. Each level in the tree alternates the direction in which it tiles (level 1 tiles as a row, level 2 as a column, level 3 as a row, etc.). ### Layout actions <PlatformSelectorButton /> #### Add a new block You can add new blocks by selecting a widget from the right sidebar. Starting at the topmost level of the tree, since the first level tiles as a row, new blocks will be added to the right side of existing blocks. Once there are 5 blocks across, new blocks will begin being added below existing blocks, starting from the right side and working to the left. As a new block gets added below an existing one, the node containing the existing block is converted from a single-block node to a list node and the existing block definition is moved one level deeper in the tree as the first element of the node list. New blocks will always be added to the last-available node in the deepest level, where available is defined as having less than five children. We don't set a limit on the number of blocks in a tab, but you may experience degraded performance past around 25 blocks. While we define a 5-child limit for each node in the tree when automatically placing new blocks, there is no actual limit to the number of children a node can hold. After the block is placed, you are free to move it wherever in the layout #### Delete a block You can delete blocks by clicking the <i className="fa-sharp fa-xmark-large" title="x"/> button in the top-right corner of the block, by right-clicking on the block header and selecting "Close Block" from the context menu, or by running the [`wsh deleteblock` command](./wsh-reference#deleteblock). Alternatively, the currently focused block/widget can be closed by pressing <Kbd k="Cmd:w"/> When you delete a block, the layout tree will be automatically adjusted to minimize the tree depth. #### Move a block You can move blocks by clicking on the block header and dragging the block around the tab. You will see placeholders appear to show where the block will land when you drop it. There are 7 different drop targets for any given block. A block is divided into quadrants along its diagonals. If the block is tiling as a row (left-to-right), dropping a block into the left or right quadrant will place the dropped block in the same level as the targeted block. This can be considered dropping the block inline. If you drop the block out of line (in quadrants corresponding to opposite tiling direction), the block will either be placed one level above or one level below the targeted block. Dropping the block towards the outside will place it in the same level as the target block's parent, while dropping it towards the center of the block will create a new level, where both the target block and the dropped block will be moved. The middle fifth of the block is reserved for the swap action. Dropping a block here will cause the target block and the dropped block to swap positions in the layout. <video width="100%" height="100%" playsinline autoplay muted controls> <source src="./img/drag-move-24fps-crf43.mp4" type="video/mp4" /> </video> ##### Possible block movements :::note All block movements except for Swap will cause the rest of the layout to shift to accommodate the block's new displacement. ::: ![screenshot showing a block being dragged over another block, with the placeholder depicting a swap movement](./img/drag-swap.png) ![annotated example showing the drop targets within a block](./img/block-drag-example.jpg) 1. Inline before: Drops the block under the same node as the target block, placing it before the target in the same tiling direction 2. Inline after: Drops the block under the same node as the target block, placing it after the target in the same tiling direction 3. Out-of-line before outer: Drops the block before the target block's parent node in the opposite tiling direction 4. Out-of-line before inner: Segments the target block, creating a new node in the tree. Places the dropped block before the target block in the opposite tiling direction. 5. Out-of-line after inner: Segments the target block, creating a new node in the tree. Places the dropped block after the target block in the opposite tiling direction. 6. Out-of-line after outer: Drops the block after the target block's parent node in the opposite tiling direction 7. Swap: Swaps the position of the dropped block and the targeted block in the layout, preserving the rest of the layout #### Resize a block <video width="100%" height="100%" playsinline autoplay muted controls> <source src="./img/resize-24fps-crf43.mp4" type="video/mp4" /> </video> ![screenshot showing the line that appears when the cursor hovers over the margin of a block, indicating which blocks will be resized by dragging the margin](./img/node-resize.png) You do not directly resize a block. Rather, you resize the nodes containing the blocks. If you hover your mouse over the margin of a block, you will see the cursor change to <i className="fa-sharp fa-arrows-left-right" title="left/right arrows"/> or <i className="fa-sharp fa-arrows-up-down" title="up/down arrows"/> to indicate the direction the node can be resized. You will also see a line appear after 500ms to show you how many blocks will be resized by moving that margin. Clicking and dragging on this margin will cause the block(s) to get resized. Node sizes are unitless values. The ratio of all node sizes at a given tree level determines the displacement of each node. If you move a block and its node is deleted, the other nodes at the given tree level will adjust their sizes to account for the new size ratio. ### Magnify a block You can magnify a block by clicking the <i className="custom-icon-inline custom-icon-magnify-disabled" title="magnify"/> button or by pressing <Kbd k="Cmd:m"/> on the keyboard. You can then un-magnify a block by clicking the <i className="custom-icon-inline custom-icon-magnify-enabled" title="un-magnify"/> button or by pressing <Kbd k="Cmd:m"/> again. ### Change the gap size between blocks The gap between blocks defaults to 3px, but this value can be changed by modifying the `window:tilegapsize` configuration value. See [Configuration](./config) for more information on how to change configuration values. </PlatformProvider> ================================================ FILE: docs/docs/telemetry-old.mdx ================================================ --- id: "telemetry-old" title: "Legacy Telemetry" sidebar_class_name: hidden --- Wave Terminal collects telemetry data to help us track feature use, direct future product efforts, and generate aggregate metrics on Wave's popularity and usage. We do not collect or store any PII (personal identifiable information) and all metric data is only associated with and aggregated using your randomly generated _ClientId_. You may opt out of collection at any time. If you would like to turn telemetry on or off, the first opportunity is a button on the initial welcome page. After this, it can be turned off by adding `"telemetry:enabled": false` to the `config/settings.json` file. It can alternatively be turned on by adding `"telemetry:enabled": true` to the `config/settings.json` file. :::info You can also change your telemetry setting by running the wsh command: ``` wsh setconfig telemetry:enabled=true ``` ::: --- ## Sending Telemetry Provided that telemetry is enabled, it is sent 10 seconds after Waveterm is first booted and then again every 4 hours thereafter. It can also be sent in response to a few special cases listed below. When telemetry is sent, it is grouped into individual days as determined by your time zone. Any data from a previous day is marked as `Uploaded` so it will not need to be sent again. ### Sending Once Telemetry is Enabled As soon as telemetry is enabled, a telemetry update is sent regardless of how long it has been since the last send. This does not reset the usual timer for telemetry sends. ### Notifying that Telemetry is Disabled As soon as telemetry is disabled, Waveterm sends a special update that notifies us of this change. See [When Telemetry is Turned Off](#when-telemetry-is-turned-off) for more info. The timer still runs in the background but no data is sent. ### When Waveterm is Closed Provided that telemetry is enabled, it will be sent when Waveterm is closed. --- ## Telemetry Data When telemetry is active, we collect the following data. It is stored in the `telemetry.TelemetryData` type in the source code. | Name | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ActiveMinutes | The number of minutes that the user has actively used Waveterm on a given day. This requires the terminal window to be in focus while the user is actively interacting with it. | | FgMinutes | The number of minutes that Waveterm has been in the foreground on a given day. This requires the terminal window to be in focus regardless of user interaction. | | OpenMinutes | The number of minutes that Waveterm has been open on a given day. This only requires that the terminal is open, even if the window is out of focus. | | NumBlocks | The number of existing blocks open on a given day | | NumTabs | The number of existing tabs open on a given day. | | NewTab | The number of new tabs created on a given day | | NumWindows | The number of existing windows open on a given day. | | NumWS | The number of existing workspaces on a given day. | | NumWSNamed | The number of named workspaces on a give day. | | NewTab | The number of new tabs opened on a given day. | | NumStartup | The number of times waveterm has been started on a given day. | | NumShutdown | The number of times waveterm has been shut down on a given day. | | SetTabTheme | The number of times the tab theme is changed from the context menu | | NumMagnify | The number of times any block is magnified | | NumPanics | The number of backend (golang) panics caught in the current day | | NumAIReqs | The number of AI requests made in the current day | | NumSSHConn | The number of distinct SSH connections that have been made to distinct hosts | | NumWSLConns | The number of distinct WSL connections that have been made to distinct distros | | Renderers | The number of new block views of each type are open on a given day. | | WshCmds | The number of wsh commands of each type run on a given day | | Blocks | The number of blocks of different view types open on a given day | | Conn | The number of successful remote connections made (and errors) on a given day | ## Associated Data In addition to the telemetry data collected, the following is also reported. It is stored in the `telemetry.ActivityType` type in the source code. | Name | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Day | The date the telemetry is associated with. It does not include the time. | | Uploaded | A boolean that indicates if the telemetry for this day is finalized. It is false during the day the telemetry is associated with, but gets set true at the first telemetry upload after that. Once it is true, the data for that particular day will not be sent up with the telemetry any more. | | TzName | The code for the timezone the user's OS is reporting (e.g. PST, GMT, JST) | | TzOffset | The offset for the timezone the user's OS is reporting (e.g. -08:00, +00:00, +09:00) | | ClientVersion | Which version of Waveterm is installed. | | ClientArch | This includes the user's operating system (e.g. linux or darwin) and architecture (e.g. x86_64 or arm64). It does not include data for any Connections at this time. | | BuildTime | This serves as a more accurate version number that keeps track of when we built the version. It has no bearing on when that version was installed by you. | | OSRelease | This lists the version of the operating system the user has installed. | | Displays | Display resolutions (added in v0.9.3 to help us understand what screen resolutions to optimize for) | ## Telemetry Metadata Lastly, some data is sent along with the telemetry that describes how to classify it. It is stored in the `wcloud.TelemetryInputType` in the source code. | Name | Description | | ----------------- | --------------------------------------------------------------------------------------------------------------------------- | | UserId | Currently Unused. This is an anonymous UUID intended for use in future features. | | ClientId | This is an anonymous UUID created when Waveterm is first launched. It is used for telemetry and sending prompts to Open AI. | | AppType | This is used to differentiate the current version of waveterm from the legacy app. | | AutoUpdateEnabled | Whether or not auto update is turned on. | | AutoUpdateChannel | The type of auto update in use. This specifically refers to whether a latest or beta channel is selected. | | CurDay | The current day (in your time zone) when telemetry is sent. It does not include the time of day. | ## Geo Data We do not store IP addresses in our telemetry table. However, CloudFlare passes us Geo-Location headers. We store these two header values: | Name | Description | | ------------ | ----------------------------------------------------------------- | | CFCountry | 2-letter country code (e.g. "US", "FR", or "JP") | | CFRegionCode | region code (often a provence, region, or state within a country) | --- ## When Telemetry is Turned Off When a user disables telemetry, Waveterm sends a notification that their anonymous _ClientId_ has had its telemetry disabled. This is done with the `wcloud.NoTelemetryInputType` type in the source code. Beyond that, no further information is sent unless telemetry is turned on again. If it is turned on again, the previous 30 days of telemetry will be sent. --- ## A Note on IP Addresses Telemetry is uploaded via https, which means your IP address is known to the telemetry server. We **do not** store your IP address in our telemetry table and **do not** associate it with your _ClientId_. --- ## Previously Collected Telemetry Data While we believe the data we collect with telemetry is fairly minimal, we cannot make that decision for every user. If you ever change your mind about what has been collected previously, you may request that your data be deleted by emailing us at [support@waveterm.dev](mailto:support@waveterm.dev). If you do, we will need your _ClientId_ to remove it. --- ## Privacy Policy For a summary of the above, you can take a look at our [Privacy Policy](https://www.waveterm.dev/privacy). ================================================ FILE: docs/docs/telemetry.mdx ================================================ --- sidebar_position: 100 title: Telemetry id: "telemetry" --- ## tl;dr Wave Terminal collects telemetry data to help us track feature use, direct future product efforts, and generate aggregate metrics on Wave's popularity and usage. We do NOT collect personal information (PII), keystrokes, file contents, AI prompts, IP addresses, hostnames, or commands. We attach all information to an anonymous, randomly generated _ClientId_ (UUID). You may opt out of collection at any time. Here's a quick summary of what is collected: - Basic App/System Info - OS, architecture, app version, update settings - Usage Metrics - App start/shutdown, active minutes, foreground time, tab/block counts/usage - Feature Interactions - When you create tabs, run commands, change settings, etc. - Display Info - Monitor resolution, number of displays - Connection Events - SSH/WSL connection attempts (but NOT hostnames/IPs) - Wave AI Usage - Model/provider selection, token counts, request metrics, latency (but NOT prompts or responses) - Error Reports - Crash/panic events with minimal debugging info, but no stack traces or detailed errors Telemetry can be disabled at any time in settings. If not disabled it is sent on startup, on shutdown, and every 4-hours. ## How to Disable Telemetry Telemetry can be enabled or disabled on the initial welcome screen when Wave first starts. After setup, telemetry can be disabled by setting the `telemetry:enabled` key to `false` in Wave’s general configuration file. It can also be disabled using the CLI command `wsh setconfig telemetry:enabled=false`. :::info This document outlines the current telemetry system as of v0.11.1. As of v0.12.5, Wave Terminal no longer sends legacy telemetry. The previous telemetry documentation can be found in our [Legacy Telemetry Documentation](./telemetry-old.mdx) for historical reference. ::: ## Diagnostics Ping Wave sends a small, anonymous diagnostics ping after the app has been running for a short time and at most once per day thereafter. This is used to estimate active installs and understand which versions are still in use, so we can make informed decisions about ongoing support and deprecations. The ping includes only: your Wave version, OS/CPU arch, local date (yyyy-mm-dd, no timezone or clock time), your randomly generated anonymous client ID, and whether usage telemetry is enabled or disabled. It does not include usage data, commands, files, or any telemetry events. This ping is intentionally separate from telemetry so Wave can count active installs. If you'd like to disable it, set the WAVETERM_NOPING environment variable. ## Sending Telemetry Provided that telemetry is enabled, it is sent shortly after Wave is first launched and then again every 4 hours thereafter. It can also be sent in response to a few special cases listed below. When telemetry is sent, events are marked as sent to prevent duplicate transmissions. ### Sending Once Telemetry is Enabled As soon as telemetry is enabled, a telemetry update is sent regardless of how long it has been since the last send. This does not reset the usual timer for telemetry sends. ### When Wave is Closed Provided that telemetry is enabled, it will be sent when Waveterm is closed. ## Event Types and Properties Wave collects the event types and properties described in the summary above. As we add features, new events and properties may be added to track their usage. For the complete, current list of all telemetry events and properties, see the source code: [telemetrydata.go](https://github.com/wavetermdev/waveterm/blob/main/pkg/telemetry/telemetrydata/telemetrydata.go) ## GDPR Opt-Out Compliance When telemetry is disabled, Wave sends a single minimal opt-out record associated with the anonymous client ID, recording that telemetry was turned off and when it occurred. This record is retained for compliance purposes. After that, no telemetry or usage data is sent. ## Deleting Your Data If you want your previously collected telemetry data deleted, email us at support (at) waveterm.dev with your _ClientId_ and we'll remove it. ## Privacy Policy For a summary of the above, you can take a look at our [Privacy Policy](https://www.waveterm.dev/privacy). ================================================ FILE: docs/docs/waveai-modes.mdx ================================================ --- sidebar_position: 1.6 id: "waveai-modes" title: "Wave AI (Local Models + BYOK)" --- import { VersionBadge } from "@site/src/components/versionbadge"; <VersionBadge version="v0.13" noLeftMargin={true}/> Wave AI supports custom AI modes that allow you to use local models, custom API endpoints, and alternative AI providers. This gives you complete control over which models and providers you use with Wave's AI features. ## Configuration Overview AI modes are configured in `~/.config/waveterm/waveai.json`. **To edit using the UI:** 1. Click the settings (gear) icon in the widget bar 2. Select "Settings" from the menu 3. Choose "Wave AI Modes" from the settings sidebar **Or launch from the command line:** ```bash wsh editconfig waveai.json ``` Each mode defines a complete AI configuration including the model, API endpoint, authentication, and display properties. ## Provider-Based Configuration Wave AI now supports provider-based configuration which automatically applies sensible defaults for common providers. By specifying the `ai:provider` field, you can significantly simplify your configuration as the system will automatically set up endpoints, API types, and secret names. ### Supported Providers - **`openai`** - OpenAI API (automatically configures endpoint and secret name) [[see example](#openai)] - **`openrouter`** - OpenRouter API (automatically configures endpoint and secret name) [[see example](#openrouter)] - **`nanogpt`** - NanoGPT API (automatically configures endpoint and secret name) [[see example](#nanogpt)] - **`groq`** - Groq API (automatically configures endpoint and secret name) [[see example](#groq)] - **`google`** - Google AI (Gemini) [[see example](#google-ai-gemini)] - **`azure`** - Azure OpenAI Service (modern API) [[see example](#azure-openai-modern-api)] - **`azure-legacy`** - Azure OpenAI Service (legacy deployment API) [[see example](#azure-openai-legacy-deployment-api)] - **`custom`** - Custom API endpoint (fully manual configuration) [[see examples](#local-model-examples)] ### Supported API Types Wave AI supports the following API types: - **`openai-chat`**: Uses the `/v1/chat/completions` endpoint (most common) - **`openai-responses`**: Uses the `/v1/responses` endpoint (modern API for GPT-5+ models) - **`google-gemini`**: Google's Gemini API format (automatically set when using `ai:provider: "google"`, not typically used directly) ## Global Wave AI Settings You can configure global Wave AI behavior in your Wave Terminal settings (separate from the mode configurations in `waveai.json`). ### Setting a Default AI Mode After configuring a local model or custom mode, you can make it the default by setting `waveai:defaultmode` in your Wave Terminal settings. :::important Use the **mode key** (the key in your `waveai.json` configuration), not the display name. For example, use `"ollama-llama"` (the key), not `"Ollama - Llama 3.3"` (the display name). ::: **Using the settings command:** ```bash wsh setconfig waveai:defaultmode="ollama-llama" ``` **Or edit settings.json directly:** 1. Click the settings (gear) icon in the widget bar 2. Select "Settings" from the menu 3. Add the `waveai:defaultmode` key to your settings.json: ```json "waveai:defaultmode": "ollama-llama" ``` This will make the specified mode the default selection when opening Wave AI features. :::note Wave AI normally requires telemetry to be enabled. However, if you configure your own custom model (local or BYOK) and set `waveai:defaultmode` to that custom mode's key, you will not receive telemetry requirement messages. This allows you to use Wave AI features completely privately with your own models. <VersionBadge version="v0.13.1"/> ::: ### Hiding Wave Cloud Modes If you prefer to use only your local or custom models and want to hide Wave's cloud AI modes from the mode dropdown, set `waveai:showcloudmodes` to `false`: **Using the settings command:** ```bash wsh setconfig waveai:showcloudmodes=false ``` **Or edit settings.json directly:** 1. Click the settings (gear) icon in the widget bar 2. Select "Settings" from the menu 3. Add the `waveai:showcloudmodes` key to your settings.json: ```json "waveai:showcloudmodes": false ``` This will hide Wave's built-in cloud AI modes, showing only your custom configured modes. ## Local Model Examples ### Ollama [Ollama](https://ollama.ai) provides an OpenAI-compatible API for running models locally: ```json { "ollama-llama": { "display:name": "Ollama - Llama 3.3", "display:order": 1, "display:icon": "microchip", "display:description": "Local Llama 3.3 70B model via Ollama", "ai:apitype": "openai-chat", "ai:model": "llama3.3:70b", "ai:thinkinglevel": "medium", "ai:endpoint": "http://localhost:11434/v1/chat/completions", "ai:apitoken": "ollama" } } ``` :::tip The `ai:apitoken` field is required but Ollama ignores it - you can set it to any value like `"ollama"`. ::: ### LM Studio [LM Studio](https://lmstudio.ai) provides a local server that can run various models: ```json { "lmstudio-qwen": { "display:name": "LM Studio - Qwen", "display:order": 2, "display:icon": "server", "display:description": "Local Qwen model via LM Studio", "ai:apitype": "openai-chat", "ai:model": "qwen/qwen-2.5-coder-32b-instruct", "ai:thinkinglevel": "medium", "ai:endpoint": "http://localhost:1234/v1/chat/completions", "ai:apitoken": "not-needed" } } ``` ### vLLM [vLLM](https://docs.vllm.ai) is a high-performance inference server with OpenAI API compatibility: ```json { "vllm-local": { "display:name": "vLLM", "display:order": 3, "display:icon": "server", "display:description": "Local model via vLLM", "ai:apitype": "openai-chat", "ai:model": "your-model-name", "ai:thinkinglevel": "medium", "ai:endpoint": "http://localhost:8000/v1/chat/completions", "ai:apitoken": "not-needed" } } ``` ## Cloud Provider Examples ### OpenAI Using the `openai` provider automatically configures the endpoint and secret name: ```json { "openai-gpt4o": { "display:name": "GPT-4o", "ai:provider": "openai", "ai:model": "gpt-4o" } } ``` The provider automatically sets: - `ai:endpoint` to `https://api.openai.com/v1/chat/completions` - `ai:apitype` to `openai-chat` (or `openai-responses` for GPT-5+ models) - `ai:apitokensecretname` to `OPENAI_KEY` (store your OpenAI API key with this name) - `ai:capabilities` to `["tools", "images", "pdfs"]` (automatically determined based on model) For newer models like GPT-4.1 or GPT-5, the API type is automatically determined: ```json { "openai-gpt41": { "display:name": "GPT-4.1", "ai:provider": "openai", "ai:model": "gpt-4.1" } } ``` ### OpenAI Compatible To use an OpenAI compatible API provider, you need to provide the ai:endpoint, ai:apitokensecretname, ai:model parameters, and use "openai-chat" as the ai:apitype. :::note The ai:endpoint is *NOT* a baseurl. The endpoint should contain the full endpoint, not just the baseurl. For example: https://api.x.ai/v1/chat/completions If you provide only the baseurl, you are likely to get a 404 message. ::: ```json { "xai-grokfast": { "display:name": "xAI Grok Fast", "display:order": 2, "display:icon": "server", "ai:apitype": "openai-chat", "ai:model": "grok-4-1-fast-reasoning", "ai:endpoint": "https://api.x.ai/v1/chat/completions", "ai:apitokensecretname": "XAI_KEY", "ai:capabilities": ["tools", "images", "pdfs"] } } ``` The `ai:apitokensecretname` should be the name of an environment variable that contains your API key. Set this environment variable before running Wave Terminal. ### OpenRouter [OpenRouter](https://openrouter.ai) provides access to multiple AI models. Using the `openrouter` provider simplifies configuration: ```json { "openrouter-qwen": { "display:name": "OpenRouter - Qwen", "ai:provider": "openrouter", "ai:model": "qwen/qwen-2.5-coder-32b-instruct" } } ``` The provider automatically sets: - `ai:endpoint` to `https://openrouter.ai/api/v1/chat/completions` - `ai:apitype` to `openai-chat` - `ai:apitokensecretname` to `OPENROUTER_KEY` (store your OpenRouter API key with this name) :::note For OpenRouter, you must manually specify `ai:capabilities` based on your model's features. Example: ```json { "openrouter-qwen": { "display:name": "OpenRouter - Qwen", "ai:provider": "openrouter", "ai:model": "qwen/qwen-2.5-coder-32b-instruct", "ai:capabilities": ["tools"] } } ``` ::: ### NanoGPT [NanoGPT](https://nano-gpt.com) provides access to multiple AI models at competitive prices. Using the `nanogpt` provider simplifies configuration: ```json { "nanogpt-glm47": { "display:name": "NanoGPT - GLM 4.7", "ai:provider": "nanogpt", "ai:model": "zai-org/glm-4.7" } } ``` The provider automatically sets: - `ai:endpoint` to `https://nano-gpt.com/api/v1/chat/completions` - `ai:apitype` to `openai-chat` - `ai:apitokensecretname` to `NANOGPT_KEY` (store your NanoGPT API key with this name) :::note NanoGPT is a proxy service that provides access to multiple AI models. You must manually specify `ai:capabilities` based on the model's features. NanoGPT supports OpenAI-compatible tool calling for models that have that capability. Check the model's `capabilities.vision` field from the [NanoGPT models API](https://nano-gpt.com/api/v1/models?detailed=true) to determine image support. Example for a text-only model with tool support: ```json { "nanogpt-glm47": { "display:name": "NanoGPT - GLM 4.7", "ai:provider": "nanogpt", "ai:model": "zai-org/glm-4.7", "ai:capabilities": ["tools"] } } ``` For vision-capable models like `openai/gpt-5`, add `"images"` to capabilities. ::: ### Groq [Groq](https://groq.com) provides fast inference for open models through an OpenAI-compatible API. Using the `groq` provider simplifies configuration: ```json { "groq-kimi-k2": { "display:name": "Groq - Kimi K2", "ai:provider": "groq", "ai:model": "moonshotai/kimi-k2-instruct" } } ``` The provider automatically sets: - `ai:endpoint` to `https://api.groq.com/openai/v1/chat/completions` - `ai:apitype` to `openai-chat` - `ai:apitokensecretname` to `GROQ_KEY` (store your Groq API key with this name) :::note For Groq, you must manually specify `ai:capabilities` based on your model's features. ::: ### Google AI (Gemini) [Google AI](https://ai.google.dev) provides the Gemini family of models. Using the `google` provider simplifies configuration: ```json { "google-gemini": { "display:name": "Gemini 3 Pro", "ai:provider": "google", "ai:model": "gemini-3-pro-preview" } } ``` The provider automatically sets: - `ai:endpoint` to `https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent` - `ai:apitype` to `google-gemini` - `ai:apitokensecretname` to `GOOGLE_AI_KEY` (store your Google AI API key with this name) - `ai:capabilities` to `["tools", "images", "pdfs"]` (automatically configured) ### Azure OpenAI (Modern API) For the modern Azure OpenAI API, use the `azure` provider: ```json { "azure-gpt4": { "display:name": "Azure GPT-4", "ai:provider": "azure", "ai:model": "gpt-4", "ai:azureresourcename": "your-resource-name" } } ``` The provider automatically sets: - `ai:endpoint` to `https://your-resource-name.openai.azure.com/openai/v1/chat/completions` (or `/responses` for newer models) - `ai:apitype` based on the model - `ai:apitokensecretname` to `AZURE_OPENAI_KEY` (store your Azure OpenAI key with this name) :::note For Azure providers, you must manually specify `ai:capabilities` based on your model's features. Example: ```json { "azure-gpt4": { "display:name": "Azure GPT-4", "ai:provider": "azure", "ai:model": "gpt-4", "ai:azureresourcename": "your-resource-name", "ai:capabilities": ["tools", "images"] } } ``` ::: ### Azure OpenAI (Legacy Deployment API) For legacy Azure deployments, use the `azure-legacy` provider: ```json { "azure-legacy-gpt4": { "display:name": "Azure GPT-4 (Legacy)", "ai:provider": "azure-legacy", "ai:azureresourcename": "your-resource-name", "ai:azuredeployment": "your-deployment-name" } } ``` The provider automatically constructs the full endpoint URL and sets the API version (defaults to `2025-04-01-preview`). You can override the API version with `ai:azureapiversion` if needed. :::note For Azure Legacy provider, you must manually specify `ai:capabilities` based on your model's features. ::: ## Using Secrets for API Keys Instead of storing API keys directly in the configuration, you should use Wave's secret store to keep your credentials secure. Secrets are stored encrypted using your system's native keychain. ### Storing an API Key **Using the Secrets UI (recommended):** 1. Click the settings (gear) icon in the widget bar 2. Select "Secrets" from the menu 3. Click "Add New Secret" 4. Enter the secret name (e.g., `OPENAI_API_KEY`) and your API key 5. Click "Save" **Or from the command line:** ```bash wsh secret set OPENAI_KEY=sk-xxxxxxxxxxxxxxxx wsh secret set OPENROUTER_KEY=sk-xxxxxxxxxxxxxxxx ``` ### Referencing the Secret When using providers like `openai` or `openrouter`, the secret name is automatically set. Just ensure the secret exists with the correct name: ```json { "my-openai-mode": { "display:name": "OpenAI GPT-4o", "ai:provider": "openai", "ai:model": "gpt-4o" } } ``` The `openai` provider automatically looks for the `OPENAI_KEY` secret. See the [Secrets documentation](./secrets.mdx) for more information on managing secrets securely in Wave. ## Multiple Modes Example You can define multiple AI modes and switch between them easily: ```json { "ollama-llama": { "display:name": "Ollama - Llama 3.3", "display:order": 1, "ai:model": "llama3.3:70b", "ai:endpoint": "http://localhost:11434/v1/chat/completions", "ai:apitoken": "ollama" }, "ollama-codellama": { "display:name": "Ollama - CodeLlama", "display:order": 2, "ai:model": "codellama:34b", "ai:endpoint": "http://localhost:11434/v1/chat/completions", "ai:apitoken": "ollama" }, "openai-gpt4o": { "display:name": "GPT-4o", "display:order": 10, "ai:provider": "openai", "ai:model": "gpt-4o" } } ``` ## Troubleshooting ### Connection Issues If Wave can't connect to your model server: 1. **For cloud providers with `ai:provider` set**: Ensure you have the correct secret stored (e.g., `OPENAI_KEY`, `OPENROUTER_KEY`) 2. **For local/custom endpoints**: Verify the server is running (`curl http://localhost:11434/v1/models` for Ollama) 3. Check the `ai:endpoint` is the complete endpoint URL including the path (e.g., `http://localhost:11434/v1/chat/completions`) 4. Verify the `ai:apitype` matches your server's API (defaults are usually correct when using providers) 5. Check firewall settings if using a non-localhost address ### Model Not Found If you get "model not found" errors: 1. Verify the model name matches exactly what your server expects 2. For Ollama, use `ollama list` to see available models 3. Some servers require prefixes or specific naming formats ### API Type Selection - The API type defaults to `openai-chat` if not specified, which works for most providers - Use `openai-chat` for Ollama, LM Studio, custom endpoints, and most cloud providers - Use `openai-responses` for newer OpenAI models (GPT-5+) or when your provider specifically requires it - Provider presets automatically set the correct API type when needed ## Configuration Reference ### Minimal Configuration (with Provider) ```json { "mode-key": { "display:name": "Qwen (OpenRouter)", "ai:provider": "openrouter", "ai:model": "qwen/qwen-2.5-coder-32b-instruct" } } ``` ### Full Configuration (all fields) ```json { "mode-key": { "display:name": "Display Name", "display:order": 1, "display:icon": "icon-name", "display:description": "Full description", "ai:provider": "custom", "ai:apitype": "openai-chat", "ai:model": "model-name", "ai:thinkinglevel": "medium", "ai:endpoint": "http://localhost:11434/v1/chat/completions", "ai:azureapiversion": "v1", "ai:apitoken": "your-token", "ai:apitokensecretname": "PROVIDER_KEY", "ai:azureresourcename": "your-resource", "ai:azuredeployment": "your-deployment", "ai:capabilities": ["tools", "images", "pdfs"] } } ``` ### Field Reference | Field | Required | Description | |-------|----------|-------------| | `display:name` | Yes | Name shown in the AI mode selector | | `display:order` | No | Sort order in the selector (lower numbers first) | | `display:icon` | No | Icon identifier for the mode (can use any [FontAwesome icon](https://fontawesome.com/search), use the name without the "fa-" prefix). Default is "sparkles" | | `display:description` | No | Full description of the mode | | `ai:provider` | No | Provider preset: `openai`, `openrouter`, `nanogpt`, `groq`, `google`, `azure`, `azure-legacy`, `custom` | | `ai:apitype` | No | API type: `openai-chat`, `openai-responses`, or `google-gemini` (defaults to `openai-chat` if not specified) | | `ai:model` | No | Model identifier (required for most providers) | | `ai:thinkinglevel` | No | Thinking level: `low`, `medium`, or `high` | | `ai:endpoint` | No | *Full* API endpoint URL (auto-set by provider when available) | | `ai:azureapiversion` | No | Azure API version (for `azure-legacy` provider, defaults to `2025-04-01-preview`) | | `ai:apitoken` | No | API key/token (not recommended - use secrets instead) | | `ai:apitokensecretname` | No | Name of secret containing API token (auto-set by provider) | | `ai:azureresourcename` | No | Azure resource name (for Azure providers) | | `ai:azuredeployment` | No | Azure deployment name (for `azure-legacy` provider) | | `ai:capabilities` | No | Array of supported capabilities: `"tools"`, `"images"`, `"pdfs"` | | `waveai:cloud` | No | Internal - for Wave Cloud AI configuration only | | `waveai:premium` | No | Internal - for Wave Cloud AI configuration only | ### AI Capabilities The `ai:capabilities` field specifies what features the AI mode supports: - **`tools`** - Enables AI tool usage for file reading/writing, shell integration, and widget interaction - **`images`** - Allows image attachments in chat (model can view uploaded images) - **`pdfs`** - Allows PDF file attachments in chat (model can read PDF content) **Provider-specific behavior:** - **OpenAI and Google providers**: Capabilities are automatically configured based on the model. You don't need to specify them. - **OpenRouter, NanoGPT, Groq, Azure, Azure-Legacy, and Custom providers**: You must manually specify capabilities based on your model's features. :::warning If you don't include `"tools"` in the `ai:capabilities` array, the AI model will not be able to interact with your Wave terminal widgets, read/write files, or execute commands. Most AI modes should include `"tools"` for the best Wave experience. ::: Most models support `tools` and can benefit from it. Vision-capable models should include `images`. Not all models support PDFs, so only include `pdfs` if your model can process them. ================================================ FILE: docs/docs/waveai.mdx ================================================ --- sidebar_position: 1.5 id: "waveai" title: "Wave AI" --- import { Kbd } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; <PlatformProvider> <PlatformSelectorButton /> <br/><br/> Context-aware terminal assistant with access to terminal output, widgets, and filesystem. ## Keyboard Shortcuts | Shortcut | Action | |----------|--------| | <Kbd k="Cmd:Shift:a"/> | Toggle AI panel | | <Kbd k="Ctrl:Shift:0" windows="Alt:0"/> | Focus AI input | | <Kbd k="Cmd:k"/> | Clear chat / start new | | <Kbd k="Enter"/> | Send message | | <Kbd k="Shift:Enter"/> | New line | ## Widget Context Toggle Controls AI's access to your workspace: **ON**: AI can read terminal output, capture widget screenshots, access files/directories (with approval), navigate web widgets, and use custom widget tools. Use for debugging, code analysis, and workspace tasks. **OFF**: AI only sees your messages and attached files. Standard chat mode for general questions. ## File Attachments Drag files onto the AI panel to attach (not supported with all models): | Type | Formats | Size Limit | Notes | |------|---------|------------|-------| | Images | JPEG, PNG, GIF, WebP, SVG | 10 MB | Auto-resized to 4096px max, converted to WebP | | PDFs | `.pdf` | 5 MB | Text extraction for analysis | | Text/Code | `.js`, `.ts`, `.py`, `.go`, `.md`, `.json`, `.yaml`, etc. | 200 KB | All common languages and configs | ## CLI Integration Use `wsh ai` to send files and prompts from the command line: ```bash git diff | wsh ai - # Pipe to AI wsh ai main.go -m "find bugs" # Attach files with message wsh ai $(tail -n 500 my.log) -m "review" -s # Auto-submit with output ``` Supports text files, images, PDFs, and directories. Use `-n` for new chat, `-s` to auto-submit. ## AI Tools (Widget Context Enabled) ### Terminal - **Read Terminal Output**: Fetches scrollback from terminal widgets, supports line ranges ### File System - **Read Files**: Reads text files with line range support (requires approval) - **List Directories**: Returns file info, sizes, permissions, timestamps (requires approval) - **Write Text Files**: Create or modify files with diff preview and approval (requires approval) ### Web - **Navigate Web**: Changes URLs in web browser widgets ### All Widgets - **Capture Screenshots**: Takes screenshots of any widget for visual analysis (not supported on all models) :::warning Security File system operations require explicit approval. You control all file access. ::: ## Local Models & BYOK Wave AI supports using your own AI models and API keys: - **Local Models**: Run AI models locally with [Ollama](https://ollama.ai), [LM Studio](https://lmstudio.ai), [vLLM](https://docs.vllm.ai), and other OpenAI-compatible servers - **BYOK (Bring Your Own Key)**: Use your own API keys with OpenAI, OpenRouter, Google AI (Gemini), Azure OpenAI, and other cloud providers - **Multiple Modes**: Configure and switch between multiple AI providers and models - **Privacy**: Keep your data local or use your preferred cloud provider See the [**Local Models & BYOK guide**](./waveai-modes.mdx) for complete configuration instructions, examples, and troubleshooting. ## Privacy **Default Wave AI Service:** - Messages are proxied through the Wave Cloud AI service (powered by OpenAI's APIs). Please refer to OpenAI's privacy policy for details on how they handle your data. - Wave does not store your chats, attachments, or use them for training - Usage counters included in anonymous telemetry - File access requires explicit approval **Local Models & BYOK:** - When using local models, your chat data never leaves your machine - When using BYOK with cloud providers, requests are sent directly to your chosen provider - Refer to your provider's privacy policy for details on how they handle your data :::info Under Active Development Wave AI is in active beta with included AI credits while we refine the experience. Share feedback in our [Discord](https://discord.gg/XfvZ334gwU). **Coming Soon:** - **Remote File Access**: Read files on SSH-connected systems - **Command Execution**: Run terminal commands with approval - **Web Content**: Extract text from web pages (currently screenshots only) ::: </PlatformProvider> ================================================ FILE: docs/docs/widgets.mdx ================================================ --- sidebar_position: 3.3 id: "widgets" title: "Widgets" --- import { Kbd } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; <PlatformProvider> # Widgets Every individual Component is contained in its own widget. These can be added, removed, moved and resized. Each widget has its own header which can be right clicked to reveal more operations you can do with that widget. <PlatformSelectorButton /> ### How to Add a Widget Adding a widget can be done using the widget bar on the right hand side of the window. This will add a widget of the selected type to the current tab. ### How to Close a Widget Widgets can be closed by clicking the **<code><i className="fa-solid fa-sharp fa-xmark"/></code>** button on the right side of the header. Alternatively, the currently focused widget can be closed by pressing <Kbd k="Cmd:w"/> ### How to Navigate Widgets At most, it is possible to have one widget be focused. Depending on the type of widget, this allows you to directly interact with the content in that widget. A focused widget is always outlined with a distinct border. A widget may be focused by clicking on it. Alternatively, you can change the focused widget by pressing <Kbd k="Ctrl:Shift:Arrows"/> (Ctrl + Shift + Arrow Keys) to navigate relative to the currently selected widget. ### How to Magnify Widgets Magnifying a widget will pop the widget out in front of everything else. You can magnify using the header icon, or with <Kbd k="Cmd:m"/>. ### How to Reorganize Widgets By dragging and dropping their headers, widgets can be moved to different locations in the layout. This effectively allows you to reorganize your screen however you see fit. When dragging, you will see a preview of the widget that is being dragged. When the widget is over a valid drop point, the area where it would be moved to will turn green. Releasing the click will place the widget there and reflow the other widgets around it. If you see a green box cover half of two different widgets, the drop will place the widget between the two. If you see the green box cover half of one widget at the edge of the screen, the widget will be placed between that widget and the edge of the screen. If you see the green box cover one widget entirely, the two widgets will swap locations. See [Tab Layout System](./layout#move-a-block) for more information. ### How to Resize Widgets Hovering the mouse between two widgets changes your cursor to <i className="fa-sharp fa-arrows-left-right"/> or <i className="fa-sharp fa-arrows-up-down"/>; and reveals a green line dividing the widgets. By dragging and dropping this green line, you are able to resize the widgets adjacent to it. See [Tab Layout System](./layout#resize-a-block) for more information. ## Types of Widgets ### Term The usual terminal you know and love. We add a few plugins via the `wsh` command that you can read more about further below. ### Preview Preview is the generic type of widget used for viewing files. This can take many different forms based on the type of file being viewed. You can use \`wsh view [path]\` from any Wave terminal window to open a preview widget with the contents of the specified path (e.g. `wsh view .` or `wsh view ~/myimage.jpg`). #### Directory When looking at a directory, preview will show a file viewer much like MacOS' _Finder_ application or Windows' _File Explorer_ application. This variant is slightly more geared toward software development with the focus on seeing what is shown by the `ls -alh` command. ##### View a New File The simplest way to view a new file is to double click its row in the file viewer. Alternatively, while the widget is focused, you can use the <Kbd k="ArrowUp" /> and <Kbd k="ArrowDown" /> arrow keys to select a row and press enter to preview the associated file. ##### Copy a File If you have two directory widgets open, you can copy a file or a directory between them. To do this, simply drag the file or directory from one directory preview widget to another that is opened to where you would like it dropped. This even works for copying files and directories across connections. ##### View the Parent Directory In the directory view, this is as simple as opening the `..` file as if it were a regular file. This can be done with the method above. You can also use the keyboard shortcut <Kbd k="Cmd:ArrowUp"/>. ##### Navigate Back and Forward When looking at a file, you can navigate back by clicking the back button in the widget header or the keyboard shortcut <Kbd k="Cmd:ArrowLeft" />. You can always navigate back and forward using <Kbd k="Cmd:ArrowLeft" /> and <Kbd k="Cmd:ArrowRight" />. ##### Filter the List of Files While the widget is focused, you can filter by filename by typing a substring of the filename you're looking for. To clear the filter, you can click the **<code><i className="fa-solid fa-sharp fa-xmark"/></code>** on the filter dropdown or press <Kbd k="Escape" />. ##### Sort by a File Column To sort a file by a specific column, click on the header for that column. If you click the header again, it will reverse the sort order. ##### Hide and Show Hidden Files At the right of the widget header, there is an **<code><i className="fa fa-sharp fa-solid fa-eye"/></code>** button. Clicking this button hides and shows hidden files. ##### Refresh the Directory At the right of the widget header, there is a refresh button **<code><i className="fa fa-sharp fa-solid fa-arrows-rotate" /></code>**. Clicking this button refreshes the directory contents. ##### Navigate to Common Directories At the left of the widget header, there is a file icon **<code><i className="fa fa-sharp fa-solid fa-folder-open"/></code>**. Clicking and holding on this icon opens a menu where you can select a common folder to navigate to. The available options are _Home_, _Desktop_, _Downloads_, and _Root_. ##### Open a New Terminal in the Current Directory If you right click the header of the widget (alternatively, click the gear icon **<code><i className="fa fa-sharp fa-solid fa-cog"/></code>**), one of the menu items listed is **Open Terminal in New Widget**. This will create a new terminal widget at your current directory. ##### Open a New Terminal in a Child Directory If you want to open a terminal for a child directory instead, you can right click on that file's row to get the **Open Terminal in New Widget** option. Clicking this will open a terminal at that directory. Note that this option is only available for children that are directories. ##### Open a New Preview for a Child To open a new Preview Widget for a Child, you can right click on that file's row and select the **Open Preview in New Widget** option. ##### Quick Look (MacOS only) On a MacOS host, it is possible to use the Quick Look feature from the directory preview. To do this, select the file you wish to view and press <Kbd k="Space" />. This will open a preview of your file in a separate window. This preview can then be closed by pressing <Kbd k="Space" /> again. This currently supports the filetypes that can be accessed by the `qlmanage` command. #### Markdown Opening a markdown file will bring up a view of the rendered markdown. These files cannot be edited in the preview at this time. #### Images/Media Opening a picture will bring up the image of that picture. Opening a video will bring up a player that lets you watch the video. ### Codeedit Opening most text files will open Codeedit to either view or edit the file. It is technically part of the Preview widget, but it is important enough to be singled out. After opening a Codeedit widget, it is often useful to magnify it (<Kbd k="Cmd:m" />) to get a larger view. You can then use the hotkeys below to switch to edit mode, make your edits, save, and then use <Kbd k="Cmd:w" /> to close the widget (all without using the mouse!). #### Switch to Edit Mode To switch to edit mode, click the edit button to the right of the header. This lets you edit the file contents with a regular Monaco editor. You can also switch to edit mode by pressing <Kbd k="Cmd:e" />. #### Save an Edit Once an edit has been made in **edit mode**, click the save button to the right of the header to save the contents. You can also save by pressing <Kbd k="Cmd:s" />. #### Exit Edit Mode Without Saving To exit **edit mode** without saving, click the cancel button to the right of the header. You can also exit without saving by pressing <Kbd k="Cmd:r" />. </PlatformProvider> ================================================ FILE: docs/docs/workspaces.mdx ================================================ --- sidebar_position: 3 id: "workspaces" title: "Workspaces" --- # Workspaces Workspaces are a powerful way to organize your workflows into separate environments, which you can tailor and optimize. ## Workspace Switcher ![Workspace switcher screenshot](./img/workspace-switcher.png#right) The primary mechanism to interact with workspaces is via the Workspace Switcher, located to the left of the tab bar. This is where you can create a new workspace, edit how a workspace entry appears, and delete a workspace. The Workspace Switcher button changes to display the icon and color of the active workspace. If the current workspace is not saved, it will display the <i className="custom-icon-inline custom-icon-workspace"/> icon. Clicking the button will open the Workspace Switcher. The Switcher contains a list of all saved workspaces for your installation, each with a customizable icon, icon color, and name. The active workspace for the current window will have a <i className="fa fa-sharp fa-check"/> next to it. Any workspace that is currently open in another window will have the <i className="fa fa-sharp fa-window"/> icon next to it. Hovering over a workspace in the switcher will display a <i className="fa fa-sharp fa-pencil"/> icon, which will open an editor pane when clicked, in which you can change the workspace name, icon, and icon color. You can also delete a workspace from this pane. ## Creating a new workspace Every new window is initialized with a blank workspace containing a single tab with a single terminal block inside it. There are three ways to create a new workspace: 1. Create a new window, either via `File` app menu or using the [keybinding](./keybindings.mdx#global-keybindings). This will create a new window and a new workspace within that. 2. Create a new workspace via the `Workspace` app menu. This will create a new workspace and switch the current window to that workspace. 3. If you are on a saved workspace, you can click the "Create new workspace" button at the bottom of the Workspace Switcher. This will create a new workspace and switch the current window to that workspace. ## Saving a workspace :::info A new workspace is ephemeral. When a window closes, its workspace, along with all its tabs, is deleted unless the workspace is saved. The exception to this rule is the last window will be preserved when closed and will be reopened next time you open the app, regardless of whether the workspace is saved. ::: To preserve a new workspace, you must save it. This can be acheived by clicking the "Save workspace" button at the bottom of the Workspace Switcher. If you instead see "Create new workspace" at the bottom of the Workspace Switcher, you are already in a saved workspace. You can also confirm this by checking the wording at the top of the Workspace Switcher. For an unsaved workspace, you will see "Open workspace"; for a saved workspace, you will see "Switch workspaces". You can also confirm this because the icon for the Workspace Switcher button will be <i className="custom-icon-inline custom-icon-workspace"/>. Once a workspace is saved, you will see a new entry in the Workspace Switcher list for your saved workspace. It will be named `New Workspace (<random string>)`. To make the most of your workspace, is recommended to change this name, and the icon and icon color, to something more memorable or meaningful. ## Switching workspaces There are two ways to switch workspaces: 1. From an open window, you can open the Workspace Switcher and click on a workspace from the list. 2. From the Workspace app menu, click on a workspace from the list. If the workspace is already open in another window (it has the <i className="fa fa-sharp fa-window"/> next to it if you are in the Workspace Switcher), that window will take focus. If the workspace is not open, your current window will switch to it. If your current workspace is unsaved, you will be prompted whether you want to open the new workspace in a new window or whether you want to open it in the current window. **If you choose the latter option, the current workspace and its contents will be deleted.** The Workspace Switcher button will update with the colored icon for your new active workspace. ## Edit a workspace :::info The tabs, layouts, and terminal and AI histories of a [saved workspace](#saving-a-workspace) are persisted automatically, however if you have unsaved file changes in an editor or a webpage, your progress will be lost when you close the window. ::: To update the name, icon, and icon color of a workspace, hover over the workspace in the Workspace Switcher and click the <i className="fa fa-sharp fa-pencil"/> button that appears. This will open an editor pane, where you can make your changes. They are persisted and updated automatically. ================================================ FILE: docs/docs/wsh-reference.mdx ================================================ --- sidebar_position: 4.1 id: "wsh-reference" title: "wsh reference" --- import { Kbd } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; import { VersionBadge } from "@site/src/components/versionbadge"; <PlatformProvider> # wsh command The `wsh` command is always available from Wave blocks. It is a powerful tool for interacting with Wave blocks and can bridge data between your CLI and the widget GUIs. This is the detailed wsh reference documention. For an overview of `wsh` functionality, please see our [wsh command docs](/wsh). --- ## view You can open a preview block with the contents of any file or directory by running: ```sh wsh view [path] wsh view -m [path] # opens in magnified block ``` You can use this command to easily preview images, markdown files, and directories. For code/text files this will open a codeedit block which you can use to quickly edit the file using Wave's embedded graphical editor. --- ## edit ```sh wsh edit [path] wsh edit -m [path] # opens in magnified block ``` This will open up a codeedit block for the specified file. This is useful for quickly editing files on a local or remote machine in Wave's graphical editor. This command returns immediately after opening the block. For `$EDITOR` integration (e.g. with `git commit`), see [`wsh editor`](#editor) which blocks until the editor is closed. --- ## editor ```sh wsh editor [path] wsh editor -m [path] # opens in magnified block ``` This opens a codeedit block for the specified file and **blocks until the editor is closed**. This is useful for setting your `$EDITOR` environment variable so that CLI tools (e.g. `git commit`, `crontab -e`) open files in Wave's graphical editor: ```sh export EDITOR="wsh editor" ``` The file must already exist. Use `-m` to open the editor in magnified mode. --- ## getmeta You can view the metadata of any block or tab by running: ```sh # get the metadata for the current terminal block wsh getmeta # get the metadata for block num 2 (see block numbers by holidng down Ctrl+Shift) wsh getmeta -b 2 # get the metadata for a blockid (get block ids by right clicking any block header "Copy Block Id") wsh getmeta -b [blockid] # get the metadata for a tab wsh getmeta -b tab # dump a single metadata key wsh getmeta [-b [blockid]] [key] # dump a set of keys with a certain prefix wsh getmeta -b tab "bg:*" # dump a set of keys with prefix (and include the 'clear' key) wsh getmeta -b tab --clear-prefix "bg:*" ``` This is especially useful for preview and web blocks as you can see the file or url that they are pointing to and use that in your CLI scripts. blockid format: - `this` -- the current block (this is also the default) - `tab` -- the id of the current tab - `d6ff4966-231a-4074-b78a-20acc7226b41` -- a full blockid is a UUID - `a67f55a3` -- blockids may be truncated to the first 8 characters - `5` -- if a number less than 100 is given, it is a block number. blocks are numbered sequentially in the current tab from the top-left to bottom-right. holding <Kbd k="Ctrl:Shift"/> will show a block number overlay. --- ## setmeta You can update any metadata key value pair for blocks (and tabs) by using the setmeta command. The setmeta command takes the same `-b` arguments as getmeta. ```sh wsh setmeta -b [blockid] [key]=[value] wsh setmeta -b [blockid] file=~/myfile.txt wsh setmeta -b [blockid] url=https://waveterm.dev/ # set the metadata for the current tab using the given json file wsh setmeta -b tab --json [jsonfile] # set the metadata for the current tab using a json file read from stdin wsh setmeta -b tab --json ``` You can get block and tab ids by right clicking on the appropriate block and selecting "Copy BlockId" (or use the block number via Ctrl:Shift). When you update the metadata for a preview or web block you'll see the changes reflected instantly in the block. Other useful metadata values to override block titles, icons, colors, themes, etc. Here's a complex command that will copy the background (bg:\* keys) from one tab to the current tab: ```sh wsh getmeta -b [other-tab-id] "bg:*" --clear-prefix | wsh setmeta -b tab --json - ``` --- ## ai Append content to the Wave AI sidebar. Files are attached as proper file attachments (supporting images, PDFs, and text), not encoded as text. By default, content is added to the sidebar without auto-submitting, allowing you to review and add more context before sending to the AI. You can attach multiple files at once (up to 15 files). Use `-m` to add a message along with files, `-s` to auto-submit immediately, and `-n` to start a new chat conversation. Use "-" to read from stdin. ```sh # Pipe command output to AI (ask question in UI) git diff | wsh ai - docker logs mycontainer | wsh ai - # Attach files without auto-submit (review in UI first) wsh ai main.go utils.go wsh ai screenshot.png logs.txt # Attach files with message wsh ai app.py -m "find potential bugs" wsh ai *.log -m "analyze these error logs" # Auto-submit immediately wsh ai config.json -s -m "explain this configuration" tail -n 50 app.log | wsh ai -s - -m "what's causing these errors?" # Start new chat and attach files wsh ai -n report.pdf data.csv -m "summarize these reports" # Attach different file types (images, PDFs, code) wsh ai architecture.png api-spec.pdf server.go -m "review the system design" ``` **File Size Limits:** - Text files: 200KB maximum - PDF files: 5MB maximum - Image files: 7MB maximum (accounts for base64 encoding overhead) - Maximum 15 files per command **Flags:** - `-m, --message <text>` - Add message text along with files - `-s, --submit` - Auto-submit immediately (default waits for user) - `-n, --new` - Clear current chat and start fresh conversation --- ## editconfig You can easily open up any of Wave's config files using this command. ```sh wsh editconfig [config-file-name] # opens the default settings.json file wsh editconfig # opens presets.json wsh editconfig presets.json # opens widgets.json wsh editconfig widgets.json # opens ai presets wsh editconfig presets/ai.json ``` --- ## setbg The `setbg` command allows you to set a background image or color for the current tab with various customization options. ```sh wsh setbg [--opacity value] [--tile|--center] [--size value] (image-path|"#color"|color-name) ``` You can set a background using: - An image file (displayed as cover, tiled, or centered) - A hex color (must be quoted like "#ff0000") - A CSS color name (like "blue" or "forestgreen") Flags: - `--opacity value` - set the background opacity (0.0-1.0, default 0.5) - `--tile` - tile the background image instead of using cover mode - `--center` - center the image without scaling (good for logos) - `--size` - size for centered images (px, %, or auto) - `--clear` - remove the background - `--print` - show the metadata without applying it Supported image formats: JPEG, PNG, GIF, WebP, and SVG. Examples: ```sh # Set an image background with default settings wsh setbg ~/pictures/background.jpg # Set a background with custom opacity wsh setbg --opacity 0.3 ~/pictures/light-pattern.png # Set a tiled background wsh setbg --tile --opacity 0.2 ~/pictures/texture.png # Center an image (good for logos) wsh setbg --center ~/pictures/logo.png wsh setbg --center --size 200px ~/pictures/logo.png # Set color backgrounds wsh setbg "#ff0000" # hex color (requires quotes) wsh setbg forestgreen # CSS color name # Change just the opacity of current background wsh setbg --opacity 0.7 # Remove background wsh setbg --clear # Preview the metadata wsh setbg --print "#ff0000" ``` The command validates that: - Color values are valid hex codes or CSS color names - Image paths point to accessible, supported image files - The opacity value is between 0.0 and 1.0 - The center and tile options are not used together :::tip Use `--print` to preview the metadata for any background configuration without applying it. You can then copy this JSON representation to use as a [Background Preset](/presets#background-configurations) ::: --- ## badge <VersionBadge version="v0.14.2" /> The `badge` command sets or clears a visual badge indicator on a block or tab header. ```sh wsh badge [icon] wsh badge --clear ``` Badges are used to draw attention to a block or tab, such as indicating a process has completed or needs attention. If no icon is provided, it defaults to `circle-small`. Icon names are [Font Awesome](https://fontawesome.com/icons) icon names (without the `fa-` prefix). Flags: - `--color string` - set the badge color (CSS color name or hex) - `--priority float` - set the badge priority (default 10; higher priority badges take precedence) - `--clear` - remove the badge from the block or tab - `--beep` - play the system bell sound when setting the badge - `--pid int` - watch a PID and automatically clear the badge when it exits (sets default priority to 5) - `-b, --block` - target a specific block or tab (same format as `getmeta`) Examples: ```sh # Set a default badge on the current block wsh badge # Set a badge with a custom icon and color wsh badge circle-check --color green # Set a high-priority badge on a specific block wsh badge triangle-exclamation --color red --priority 20 -b 2 # Set a badge that clears when a process exits wsh badge --pid 12345 # Play the bell and set a badge when done wsh badge circle-check --beep # Clear the badge on the current block wsh badge --clear # Clear the badge on a specific tab wsh badge --clear -b tab ``` :::note The `--pid` flag is not supported on Windows. ::: --- ## run The `run` command creates a new terminal command block and executes a specified command within it. The command can be provided either as arguments after `--` or using the `-c` flag. Unless the `-x` or `-X` flags are passed, commands can be re-executed by pressing `Enter` once the command has finished running. ```sh # Run a command specified after -- wsh run -- ls -la # Run a command using -c flag wsh run -c "ls -la" # Run with working directory specified wsh run --cwd /path/to/dir -- ./script.sh # Run in magnified mode wsh run -m -- make build # Run and auto-close on successful completion wsh run -x -- npm test # Run and auto-close regardless of exit status wsh run -X -- ./long-running-task.sh ``` The command inherits the current environment variables and working directory by default. Flags: - `-m, --magnified` - open the block in magnified mode - `-c, --command string` - run a command string in _shell_ - `-x, --exit` - close block if command exits successfully (stays open if there was an error) - `-X, --forceexit` - close block when command exits, regardless of exit status - `--delay int` - if using -x/-X, delay in milliseconds before closing block (default 2000) - `-p, --paused` - create block in paused state - `-a, --append` - append output on command restart instead of clearing - `--cwd string` - set working directory for command Examples: ```sh # Run a build command in magnified mode wsh run -m -- npm run build # Execute a script and auto-close after success wsh run -x -- ./backup-script.sh # Run a command in a specific directory wsh run --cwd ./project -- make test # Run a shell command and force close after completion wsh run -X -c "find . -name '*.log' -delete" # Start a command in paused state wsh run -p -- ./server --dev # Run with custom close delay wsh run -x --delay 5000 -- ./deployment.sh ``` When using the `-x` or `-X` flags, the block will automatically close after the command completes. The `-x` flag only closes on successful completion (exit code 0), while `-X` closes regardless of exit status. The `--delay` flag controls how long to wait before closing (default 2000ms). The `-p` flag creates the block in a paused state, allowing you to review the command before execution. :::tip You can use either `--` followed by your command and arguments, or the `-c` flag with a quoted command string. The `--` method is preferred when you want to preserve argument handling, while `-c` is useful for shell commands with pipes or redirections. ::: --- ## deleteblock ```sh wsh deleteblock -b [blockid] ``` This will delete the block with the specified id. --- ## ssh ```sh wsh ssh [user@host] ``` This will use Wave's internal ssh implementation to connect to the specified remote machine. The `-i` flag can be used to specify a path to an identity file. --- ## wsl ```sh wsh wsl [-d <distribution-name>] ``` This will connect to a WSL distribution on the local machine. It will use the default if no distribution is provided. --- ## web The `web` command opens URLs in a web block within Wave Terminal. ```sh wsh web open [url] [-m] [-r blockid] ``` You can open a specific URL or perform a search using the configured search engine. Flags: - `-m, --magnified` - open the web block in magnified mode - `-r, --replace <blockid>` - replace an existing block instead of creating a new one Examples: ```sh # Open a URL wsh web open https://waveterm.dev # Search with the configured search engine wsh web open "wave terminal documentation" # Open in magnified mode wsh web open -m https://github.com # Replace an existing block wsh web open -r 2 https://example.com ``` The command will open a new web block with the desired page, or replace an existing block if the `-r` flag is used. Note that `--replace` and `--magnified` cannot be used together. --- ## notify The `notify` command creates a desktop notification from Wave Terminal. ```sh wsh notify [message] [-t title] [-s] ``` This allows you to trigger desktop notifications from scripts or commands. The notification will appear using your system's native notification system. It works on remote machines as well as your local machine. Flags: - `-t, --title string` - set the notification title (default "Wsh Notify") - `-s, --silent` - disable the notification sound Examples: ```sh # Basic notification wsh notify "Build completed successfully" # Notification with custom title wsh notify -t "Deployment Status" "Production deployment finished" # Silent notification wsh notify -s "Background task completed" ``` This is particularly useful for long-running commands where you want to be notified of completion or status changes. --- ## conn This has several subcommands which all perform various features related to connections. ### status ```sh wsh conn status ``` This command gives the status of all connections made since waveterm started. ### reinstall For ssh connections, ```sh wsh conn reinstall [user@host] ``` For wsl connections, ```sh wsh conn reinstall [wsl://<distribution-name>] ``` This command reinstalls the Wave Shell Extensions on the specified connection. ### disconnect For ssh connections, ```sh wsh conn disconnect [user@host] ``` For wsl connections, ```sh wsh conn disconnect [wsl://<distribution name>] ``` This command completely disconnects the specified connection. This will apply to all blocks where the connection is being used ### connect For ssh connections, ```sh wsh conn connect [user@host] ``` For wsl connections, ```sh wsh conn connect [wsl://<distribution-name>] ``` This command connects to the specified connection but does not create a block for it. ### ensure For ssh connections, ```sh wsh conn ensure [user@host] ``` For wsl connections, ```sh wsh conn ensure [wsl://<distribution-name>] ``` This command connects to the specified connection if it isn't already connected. --- ## setconfig ```sh wsh setconfig [<config-name>=<config-value>] ``` This allows setting various options in the `config/settings.json` file. It will check to be sure a valid config option was provided. --- ## file The `file` command provides a set of subcommands for managing files across different storage systems, such as `wsh` remote servers. :::note Wave Terminal is capable of managing files from remote SSH hosts. Files are addressed via URIs, which vary depending on the storage system. If no scheme is specified, the file will be treated as a local connection. URI format: `[profile]:[uri-scheme]://[connection]/[path]` Supported URI schemes: - `wsh` - Used to access files on remote hosts over SSH via the WSH helper. Allows for file streaming to Wave and other remotes. Profiles are optional for WSH URIs, provided that you have configured the remote host in your "connections.json" or "~/.ssh/config" file. If a profile is provided, it must be defined in "profiles.json" in the Wave configuration directory. Format: `wsh://[remote]/[path]` Shorthands can be used for the current remote and your local computer: `[path]` a relative or absolute path on the current remote `//[remote]/[path]` a path on a remote `/~/[path]` a path relative to the home directory on your local computer ::: ### cat ```sh wsh file cat [file-uri] ``` Display the contents of a file (maximum file size 10MB). For example: ```sh wsh file cat wsh://user@ec2/home/user/config.txt wsh file cat ./local-config.txt ``` ### write ```sh wsh file write [file-uri] ``` Write data from stdin to a file. The maximum file size is 10MB. For example: ```sh echo "hello" | wsh file write ./greeting.txt cat config.json | wsh file write //ec2-user@remote01/~/config.json ``` ### append ```sh wsh file append [file-uri] ``` Append data from stdin to a file. Input is buffered locally (up to 10MB total file size limit) before being written. For example: ```sh cat additional-content.txt | wsh file append ./notes.txt echo "new line" | wsh file append //user@remote/~/notes.txt ``` ### rm ```sh wsh file rm [flag] [file-uri] ``` Remove a file. For example: ```sh wsh file rm wsh://user@ec2/home/user/config.txt wsh file rm ./local-config.txt ``` Flags: - `-r, --recursive` - recursively deletes directory entries ### info ```sh wsh file info [file-uri] ``` Display information about a file including size, creation time, modification time, and metadata. For example: ```sh wsh file info wsh://user@ec2/home/user/config.txt wsh file info ./local-config.txt ``` ### cp ```sh wsh file cp [flags] [source-uri] [destination-uri] ``` Copy files between different storage systems (maximum file size 10MB). For example: ```sh # Copy a remote file to your local filesystem wsh file cp wsh://user@ec2/home/user/config.txt ./local-config.txt # Copy a local file to a remote system wsh file cp ./local-config.txt wsh://user@ec2/home/user/config.txt # Copy between remote systems wsh file cp wsh://user@ec2/home/user/config.txt wsh://user@server2/home/user/backup.txt ``` Flags: - `-f, --force` - overwrites any conflicts when copying - `-m, --merge` - does not clear existing directory entries when copying a directory, instead merging its contents with the destination's ### mv ```sh wsh file mv [flags] [source-uri] [destination-uri] ``` Move files between different storage systems (maximum file size 10MB). The source file will be deleted once the operation completes successfully. For example: ```sh # Move a remote file to your local filesystem wsh file mv wsh://user@ec2/home/user/config.txt ./local-config.txt # Move a local file to a remote system wsh file mv ./local-config.txt wsh://user@ec2/home/user/config.txt # Move between remote systems wsh file mv wsh://user@ec2/home/user/config.txt wsh://user@server2/home/user/backup.txt ``` Flags: - `-f, --force` - overwrites any conflicts when moving ### ls ```sh wsh file ls [flags] [file-uri] ``` List files in a directory. By default, lists files in the current directory for the current terminal session. Examples: ```sh wsh file ls wsh://user@ec2/home/user/ wsh file ls ./local-dir/ ``` Flags: - `-l, --long` - use long listing format showing size, timestamps, and metadata - `-1, --one` - list one file per line - `-f, --files` - list only files (no directories) When output is piped to another command, automatically switches to one-file-per-line format: ```sh # Easy to process with grep, awk, etc. wsh file ls ./ | grep ".json$" ``` --- ## launch The `wsh launch` command allows you to open pre-configured widgets directly from your terminal. ```sh wsh launch [flags] widget-id ``` The command will search for the specified widget ID in both user-defined widgets and default widgets, then create a new block using the widget's configuration. Flags: - `-m, --magnify` - open the widget in magnified mode, overriding the widget's default magnification setting Examples: ```sh # Launch a widget with its default settings wsh launch my-custom-widget # Launch a widget in magnified mode wsh launch -m system-monitor ``` The widget's configuration determines the initial block settings, including the view type, metadata, and default magnification state. The `-m` flag can be used to override the widget's default magnification setting. :::tip Widget configurations can be customized in your `widgets.json` configuration file, which you can edit using `wsh editconfig widgets.json` ::: --- ## getvar/setvar Wave Terminal provides commands for managing persistent variables at different scopes (block, tab, workspace, or client-wide). ### setvar ```sh wsh setvar [flags] KEY=VALUE... ``` Set one or more variables. By default, variables are set at the client (global) level. Use `-l` for block-local variables. Examples: ```sh # Set a single variable wsh setvar API_KEY=abc123 # Set multiple variables at once wsh setvar HOST=localhost PORT=8080 DEBUG=true # Set a block-local variable wsh setvar -l BLOCK_SPECIFIC=value # Remove variables wsh setvar -r API_KEY PORT ``` Flags: - `-l, --local` - set variables local to the current block - `-r, --remove` - remove the specified variables instead of setting them - `--varfile string` - use a different variable file (default "var") - `-b [blockid]` - used to set a specific zone (block, tab, workspace, client, or UUID) ### getvar ```sh wsh getvar [flags] [key] ``` Get the value of a variable. Returns exit code 0 if the variable exists, 1 if it doesn't. This allows for shell scripting like: ```sh # Check if a variable exists if wsh getvar API_KEY >/dev/null; then echo "API key is set" fi # Use a variable in a command curl -H "Authorization: $(wsh getvar API_KEY)" https://api.example.com # Get a block-local variable wsh getvar -l BLOCK_SPECIFIC # List all variables wsh getvar --all # List all variables with null terminators (for scripting) wsh getvar --all -0 ``` Flags: - `-l, --local` - get variables local to the current block - `--all` - list all variables - `-0, --null` - use null terminators in output instead of newlines - `--varfile string` - use a different variable file (default "var") Variables can be accessed at different scopes using the `-b` flag: ```sh # Get/set at block level wsh getvar -b block MYVAR wsh setvar -b block MYVAR=value # Get/set at tab level wsh getvar -b tab MYVAR wsh setvar -b tab MYVAR=value # Get/set at workspace level wsh getvar -b workspace MYVAR wsh setvar -b workspace MYVAR=value # Get/set at client (global) level wsh getvar -b client MYVAR wsh setvar -b client MYVAR=value ``` Variables set with these commands persist across sessions and can be used to store configuration values, secrets, or any other string data that needs to be accessible across blocks or tabs. --- ## termscrollback Get the terminal scrollback from a terminal block. This is useful for capturing terminal output for processing or archiving. ```sh wsh termscrollback [-b blockid] [flags] ``` By default, retrieves all lines from the current terminal block. You can specify line ranges or get only the output of the last command. Flags: - `-b, --block <blockid>` - specify target terminal block (default: current block) - `--start <line>` - starting line number (0 = beginning, default: 0) - `--end <line>` - ending line number (0 = all lines, default: 0) - `--lastcommand` - get output of last command (requires shell integration) - `-o, --output <file>` - write output to file instead of stdout Examples: ```sh # Get all scrollback from current terminal wsh termscrollback # Get scrollback from a specific terminal block wsh termscrollback -b 2 # Get only the last command's output wsh termscrollback --lastcommand # Get a specific line range (lines 100-200) wsh termscrollback --start 100 --end 200 # Save scrollback to a file wsh termscrollback -o terminal-log.txt # Save last command output to a file wsh termscrollback --lastcommand -o last-output.txt # Process last command output with grep wsh termscrollback --lastcommand | grep "ERROR" ``` :::note The `--lastcommand` flag requires shell integration to be enabled. This feature allows you to capture just the output from the most recent command, which is particularly useful for scripting and automation. ::: --- ## wavepath The `wavepath` command lets you get the paths to various Wave Terminal directories and files, including configuration, data storage, and logs. ```sh wsh wavepath {config|data|log} ``` This command returns the full path to the requested Wave Terminal system directory or file. It's useful for accessing Wave's configuration files, data storage, or checking logs. Flags: - `-o, --open` - open the path in a new block - `-O, --open-external` - open the path in the default external application - `-t, --tail` - show the last ~100 lines of the log file (only valid for log path) Examples: ```sh # Get path to config directory wsh wavepath config # Get path to data directory wsh wavepath data # Get path to log file wsh wavepath log # Open log file in a new block wsh wavepath -o log # Open config directory in system file explorer wsh wavepath -O config # View recent log entries wsh wavepath -t log ``` The command will show you the full path to: - `config` - Where Wave Terminal stores its configuration files - `data` - Where Wave Terminal stores its persistent data - `log` - The main Wave Terminal log file :::tip Use the `-t` flag with the log path to quickly view recent log entries without having to open the full file. This is particularly useful for troubleshooting. ::: --- ## blocks The `blocks` command provides operations for listing and querying blocks across workspaces, windows, and tabs. Primarily useful for debugging and scripting. ### list ```sh wsh blocks list [flags] ``` List all blocks with optional filtering by workspace, window, tab, or view type. Output can be formatted as a table (default) or JSON for scripting. Flags: - `--workspace <id>` - restrict to specific workspace id - `--window <id>` - restrict to specific window id - `--tab <id>` - restrict to specific tab id - `--view <type>` - filter by view type (term, web, preview, edit, sysinfo, waveai) - `--json` - output results as JSON - `--timeout <ms>` - RPC timeout in milliseconds (default: 5000) Examples: ```sh # List all blocks wsh blocks list # List only terminal blocks wsh blocks list --view=term # Filter by workspace wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114 # Output as JSON for scripting wsh blocks list --json ``` --- ## secret The `secret` command provides secure storage and management of sensitive information like API keys, passwords, and tokens. Secrets are stored using your system's native secure storage backend (Keychain on macOS, Secret Service on Linux, Credential Manager on Windows). Secret names must start with a letter and contain only letters, numbers, and underscores. ### get ```sh wsh secret get [name] ``` Retrieve and display the value of a stored secret. Examples: ```sh # Get an API key wsh secret get github_token # Use in scripts export API_KEY=$(wsh secret get my_api_key) ``` ### set ```sh wsh secret set [name]=[value] ``` Store a secret value securely. This command requires an appropriate system secret manager to be available and will fail if only basic text storage is available. Examples: ```sh # Set an API token wsh secret set github_token=ghp_abc123xyz # Set a database password wsh secret set db_password=mySecurePassword123 ``` :::warning The `set` command requires a proper system secret manager (Keychain, Secret Service, or Credential Manager). It will not work with basic text storage for security reasons. ::: ### list ```sh wsh secret list ``` Display all stored secret names (values are not shown). Example: ```sh # List all secrets wsh secret list ``` ### delete ```sh wsh secret delete [name] ``` Remove a secret from secure storage. Examples: ```sh # Delete an API key wsh secret delete github_token # Delete multiple secrets wsh secret delete old_api_key wsh secret delete temp_token ``` ### ui ```sh wsh secret ui [-m] ``` Open the secrets management interface in a new block. This provides a graphical interface for viewing and managing all your secrets. Flags: - `-m, --magnified` - open the secrets UI in magnified mode Examples: ```sh # Open the secrets UI wsh secret ui # Open the secrets UI in magnified mode wsh secret ui -m ``` The secrets UI provides a convenient visual way to browse, add, edit, and delete secrets without needing to use the command-line interface. :::tip Use secrets in your scripts to avoid hardcoding sensitive values. Secrets work across remote machines - store an API key locally with `wsh secret set`, then access it from any SSH or WSL connection with `wsh secret get`. The secret is securely retrieved from your local machine without needing to duplicate it on remote systems. ::: </PlatformProvider> ================================================ FILE: docs/docs/wsh.mdx ================================================ --- sidebar_position: 4 id: "wsh" title: "wsh overview" --- The `wsh` command provides Wave Terminal's core command line interface, allowing users to interact with both terminal and graphical elements from the command line. This guide covers the basics of using `wsh` and its key features. See the [wsh reference](/wsh-reference) for a list of all wsh commands and their arguments. ## Overview At its core, `wsh` enables seamless interaction between your terminal commands and Wave's graphical blocks. It allows you to: - Control graphical widgets directly from the command line - Share data between terminal sessions and GUI components - Manage your workspace programmatically - Connect remote and local environments - Send CLI output and files directly to AI conversations - Run terminal commands in separate, isolated blocks ## Key Concepts ### Interacting with Blocks `wsh` provides direct interaction with Wave's graphical blocks through the command line. For example: ```bash # Open a file in the editor wsh edit config.json # Get the current file path from a preview block wsh getmeta -b 2 file # Send output to an AI assistant (the "-" reads from stdin) ls -la | wsh ai - "what are the largest files here?" ``` ### Persistent State `wsh` can maintain state across terminal sessions through its variable system: ```bash # Store a variable that persists across sessions wsh setvar API_KEY=abc123 # Store globally wsh setvar DEPLOY_ENV=prod # Or store in the current workspace wsh setvar -b workspace DEPLOY_ENV=staging # Use stored variables in commands curl -H "Authorization: $(wsh getvar API_KEY)" https://api.example.com ``` ### Accessing Local Files from Remote When working on remote machines, you can access files on your local computer using the `wsh://local/~/` path prefix with `wsh file` commands. The shorthand `/~/` can also be used as an alias for `wsh://local/~/`: ```bash # Read a local file from a remote machine wsh file cat wsh://local/~/config/app.json # Run a local script on the remote machine using shell process substitution bash <(wsh file cat wsh://local/~/scripts/deploy.sh) python <(wsh file cat wsh://local/~/scripts/deploy.py) # Append remote output to a local log file echo "Remote machine log entry" | wsh file append wsh://local/~/app.log # Copy a local file to the remote machine wsh file cp wsh://local/~/data.csv ./remote-data.csv # Copy remote file back to local machine wsh file cp ./results.txt wsh://local/~/results.txt # You can also use the shorthand /~/ instead of wsh://local/~/ wsh file cat /~/config/app.json ``` ### Block Management Every visual element in Wave is a block, and `wsh` gives you complete control over them (hold Ctrl+Shift to see block numbers): ```bash # Create a new block showing a webpage wsh web open github.com # Do a web search in a new block wsh web open "wave terminal" # Run a command in a new block and auto-close when done wsh run -x -- npm test # Get information about the current block wsh getmeta ``` ## Common Workflows Here are some common ways to use `wsh`: ### Development Workflow ```bash # Open directory or markdown files wsh view . wsh view README.md # add a -m to open the block in "magnified" mode wsh view -m README.md # Start development server in a new block (-m will magnify the block on startup) wsh run -m -- npm run dev # Open documentation in a web block wsh web open http://localhost:3000 ``` ### Remote Development ```bash # Connect to remote server with optional key wsh ssh -i ~/.ssh/mykey.pem dev@server # Edit remote files wsh edit /etc/nginx/nginx.conf # Monitor remote logs wsh run -- tail -f /var/log/app.log # Share variables between sessions wsh setvar -b tab SHARED_ENV=staging ``` ### AI-Assisted Development The `wsh ai` command appends content to the Wave AI sidebar. By default, files are attached without auto-submitting, allowing you to review and add more context before sending. ```bash # Pipe output to AI sidebar (ask question in UI) git diff | wsh ai - # Attach files with a message wsh ai main.go utils.go -m "find bugs in these files" # Auto-submit with message wsh ai config.json -s -m "explain this config" # Start new chat with attached files wsh ai -n *.log -m "analyze these logs" # Attach multiple file types (images, PDFs, code) wsh ai screenshot.png report.pdf app.py -m "review these" # Debug with stdin and auto-submit dmesg | wsh ai -s - -m "help me understand these errors" ``` **Flags:** - `-` - Read from stdin instead of a file - `-m, --message` - Add message text along with files - `-s, --submit` - Auto-submit immediately (default is to wait for user) - `-n, --new` - Clear chat and start fresh conversation **File Limits:** - Text files: 200KB max - PDFs: 5MB max - Images: 7MB max - Maximum 15 files per command ## Tips & Features 1. **Working with Blocks** - Use block numbers (1-9) to target specific blocks within a tab (hold Ctrl+Shift to see block numbers) - Can get full block ids by right click a block's header and selecting "Copy Block Id" (useful for scripting) - Use references like "this", "tab", "workspace", or "global" for different scopes 2. **Data Storage** - Use `wsh setvar/getvar` for configuration and secrets - Store file data using `wsh file`, which can be easily referenced in all terminals (local and remote) - Use appropriate storage scopes (block, tab, workspace, global) 3. **Command Execution** - Use `wsh run` to execute commands in new blocks - Send command output and files quickly to AI blocks with `wsh ai` ## Scripting with wsh wsh commands can be combined in scripts to automate common tasks. Here's an example that sets up a development environment and uses `wsh notify` to monitor a long-running build: ```bash #!/bin/bash # Setup development environment wsh run -- docker-compose up -d wsh web open localhost:8080 wsh view ./src wsh run -- npm run test:watch # Get notified when long-running tasks complete using wsh notify npm run build && wsh notify "Build complete" || wsh notify "Build failed" ``` ## Getting Help You can get help on available commands by running `wsh` with no arguments, or get detailed help for a specific command using `wsh [command] -h`. For a complete reference of all `wsh` functionality, see the [WSH Command Reference](./wsh-reference). ================================================ FILE: docs/docusaurus.config.ts ================================================ import type { Config } from "@docusaurus/types"; import rehypeHighlight from "rehype-highlight"; import { docOgRenderer } from "./src/renderer/image-renderers"; const baseUrl = process.env.EMBEDDED ? "/docsite/" : "/"; const config: Config = { title: "Wave Terminal Documentation", tagline: "Level Up Your Terminal With Graphical Widgets", favicon: "img/logo/wave-logo_appicon.svg", // Set the production url of your site here url: "https://docs.waveterm.dev/", // Set the /<baseUrl>/ pathname under which your site is served // For GitHub pages deployment, it is often '/<projectName>/' baseUrl, // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. organizationName: "wavetermdev", // Usually your GitHub org/user name. projectName: "waveterm-docs", // Usually your repo name. deploymentBranch: "main", onBrokenAnchors: "ignore", onBrokenLinks: "throw", onBrokenMarkdownLinks: "warn", trailingSlash: false, // Even if you don't use internationalization, you can use this field to set // useful metadata like html lang. For example, if your site is Chinese, you // may want to replace "en" with "zh-Hans". i18n: { defaultLocale: "en", locales: ["en"], }, plugins: [ [ "content-docs", { path: "docs", routeBasePath: "/", exclude: ["features/**"], editUrl: !process.env.EMBEDDED ? "https://github.com/wavetermdev/waveterm/edit/main/docs/" : undefined, rehypePlugins: [rehypeHighlight], } as import("@docusaurus/plugin-content-docs").Options, ], "ideal-image", [ "@docusaurus/plugin-sitemap", { changefreq: "daily", filename: "sitemap.xml", }, ], !process.env.EMBEDDED && [ "@waveterm/docusaurus-og", { path: "./preview-images", // relative to the build directory imageRenderers: { "docusaurus-plugin-content-docs": docOgRenderer, }, }, ], "docusaurus-plugin-sass", "@docusaurus/plugin-svgr", ].filter((v) => v), themes: [ ["classic", { customCss: "src/css/custom.scss" }], !process.env.EMBEDDED && "@docusaurus/theme-search-algolia", ].filter((v) => v), themeConfig: { docs: { sidebar: { hideable: false, autoCollapseCategories: false, }, }, colorMode: { defaultMode: "light", disableSwitch: false, respectPrefersColorScheme: true, }, navbar: { logo: { src: "img/logo/wave-light.png", srcDark: "img/logo/wave-dark.png", href: "https://www.waveterm.dev/", }, hideOnScroll: true, items: [ { type: "doc", position: "left", docId: "index", label: "Docs", }, !process.env.EMBEDDED ? [ { position: "left", href: "https://docs.waveterm.dev/storybook", label: "Storybook", }, { href: "https://discord.gg/zUeP2aAjaP", position: "right", className: "header-link-custom custom-icon-discord", "aria-label": "Discord invite", }, { href: "https://github.com/wavetermdev/waveterm", position: "right", className: "header-link-custom custom-icon-github", "aria-label": "GitHub repository", }, ] : [], ].flat(), }, metadata: [ { name: "keywords", content: "terminal, developer, development, command, line, wave, linux, macos, windows, connection, ssh, cli, waveterm, documentation, docs, ai, graphical, widgets, remote, open, source, open-source, go, golang, react, typescript, javascript", }, { name: "og:type", content: "website", }, { name: "og:site_name", content: "Wave Terminal Documentation", }, { name: "application-name", content: "Wave Terminal Documentation", }, { name: "apple-mobile-web-app-title", content: "Wave Terminal Documentation", }, ], footer: { copyright: `Copyright © ${new Date().getFullYear()} Command Line Inc. Built with Docusaurus.`, }, algolia: { appId: "B6A8512SN4", apiKey: "e879cd8663f109b2822cd004d9cd468c", indexName: "waveterm", }, }, headTags: [ { tagName: "link", attributes: { rel: "preload", as: "font", type: "font/woff2", "data-next-font": "size-adjust", href: `${baseUrl}fontawesome/webfonts/fa-sharp-regular-400.woff2`, }, }, { tagName: "link", attributes: { rel: "preload", as: "font", type: "font/woff2", "data-next-font": "size-adjust", href: `${baseUrl}fontawesome/webfonts/fa-sharp-solid-900.woff2`, }, }, { tagName: "link", attributes: { rel: "sitemap", type: "application/xml", title: "Sitemap", href: `${baseUrl}sitemap.xml`, }, }, !process.env.EMBEDDED && { tagName: "script", attributes: { defer: "true", "data-domain": "docs.waveterm.dev", src: "https://plausible.io/js/script.file-downloads.outbound-links.tagged-events.js", }, }, ].filter((v) => v), stylesheets: [ `${baseUrl}fontawesome/css/fontawesome.min.css`, `${baseUrl}fontawesome/css/sharp-regular.min.css`, `${baseUrl}fontawesome/css/sharp-solid.min.css`, ], staticDirectories: ["static", "storybook"], }; export default config; ================================================ FILE: docs/eslint.config.js ================================================ // @ts-check import eslint from "@eslint/js"; import eslintConfigPrettier from "eslint-config-prettier"; import * as mdx from "eslint-plugin-mdx"; import tseslint from "typescript-eslint"; const baseConfig = tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, mdx.flat, mdx.flatCodeBlocks ); const customConfig = { ...baseConfig, overrides: [ { files: ["emain/emain.ts", "electron.vite.config.ts"], env: { node: true, }, }, ], }; export default [customConfig, eslintConfigPrettier]; ================================================ FILE: docs/package.json ================================================ { "name": "waveterm-docs", "version": "0.0.0", "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc" }, "dependencies": { "@docusaurus/core": "^3.9.2", "@docusaurus/plugin-content-docs": "^3.9.2", "@docusaurus/plugin-debug": "^3.9.2", "@docusaurus/plugin-ideal-image": "^3.9.2", "@docusaurus/plugin-sitemap": "^3.9.2", "@docusaurus/plugin-svgr": "^3.9.2", "@docusaurus/theme-classic": "^3.9.2", "@docusaurus/theme-search-algolia": "^3.9.2", "@mdx-js/react": "^3.0.0", "@waveterm/docusaurus-og": "https://codeload.github.com/wavetermdev/docusaurus-og/tar.gz/2156619012b8970d922c1ef47789d2f14e47e283", "clsx": "^2.1.1", "docusaurus-plugin-sass": "^0.2.6", "prism-react-renderer": "^2.4.1", "react": "^18.0.0", "react-dom": "^18.0.0", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "remark-typescript-code-import": "^1.0.1", "sass": "^1.93.2" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.9.2", "@docusaurus/tsconfig": "3.9.2", "@docusaurus/types": "3.9.2", "@eslint/js": "^9.39", "@mdx-js/typescript-plugin": "^0.1.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "eslint": "^9.39", "eslint-config-prettier": "^10.1.8", "eslint-plugin-mdx": "^3.7.0", "prettier": "^3.8.1", "prettier-plugin-jsdoc": "^1.8.0", "prettier-plugin-organize-imports": "^4.3.0", "remark-cli": "^12.0.1", "remark-frontmatter": "^5.0.0", "remark-mdx": "^3.1.0", "remark-preset-lint-consistent": "^6.0.1", "remark-preset-lint-recommended": "^7.0.1", "typescript": "^5.9.3", "typescript-eslint": "^8.56" }, "resolutions": { "path-to-regexp@npm:2.2.1": "^3", "cookie@0.6.0": "^0.7.0" }, "browserslist": { "production": [ ">0.5%", "not dead", "not op_mini all" ], "development": [ "last 3 chrome version", "last 3 firefox version", "last 5 safari version" ] }, "engines": { "node": ">=18.0" } } ================================================ FILE: docs/prettier.config.cjs ================================================ /** @type {import("prettier").Config} */ module.exports = { plugins: ["prettier-plugin-jsdoc", "prettier-plugin-organize-imports"], printWidth: 120, trailingComma: "es5", useTabs: false, singleQuote: false, jsdocVerticalAlignment: true, jsdocSeparateReturnsFromParam: true, jsdocSeparateTagGroups: true, jsdocPreferCodeFences: true, }; ================================================ FILE: docs/src/components/card.css ================================================ .card-group { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: auto; gap: 1rem; } @media (max-width: 450px) { .card-group { grid-template-columns: 1fr; } } @media (min-width: 451px) and (max-width: 995px) { .card-group { grid-template-columns: repeat(2, 1fr); } } @media (min-width: 996px) { .card-group { grid-template-columns: repeat(3, 1fr); } } .card { display: grid; grid-template-columns: 1.5rem 1rem 1fr; grid-template-rows: subgrid; grid-column: span 1; grid-row: span 2; padding: 1rem; .icon { grid-column: 1; grid-row: 1; font-size: 1.5rem; line-height: 1.5rem; } .title { grid-column: 3; grid-row: 1; font-weight: bold; font-size: 1.2rem; line-height: 1.5rem; } .description { color: var(--ifm-font-color-base); grid-column: span 3; grid-row: 2; } border: 2px solid var(--ifm-color-primary-lightest); transition: transform 0.1s ease; transform-origin: 50% 50%; } .card:hover { text-decoration: none; transform: translateZ(0) scale(1.024); -webkit-transform: translateZ(0) scale(1.024); } ================================================ FILE: docs/src/components/card.tsx ================================================ import clsx from "clsx"; import "./card.css"; interface CardProps { icon: string; title: string; description: string; href: string; } export function Card({ icon, title, description, href }: CardProps) { return ( <a className="card" href={href}> <div className={clsx("icon", "fa-sharp fa-regular", icon)} /> <div className="title">{title}</div> <div className="description">{description}</div> </a> ); } export function CardGroup({ children }) { return <div className="card-group">{children}</div>; } ================================================ FILE: docs/src/components/kbd.css ================================================ @font-face { font-family: "JetBrains Mono"; src: url("/static/fonts/JetBrainsMono-Regular.woff2") format("woff2"); font-weight: normal; font-style: normal; } @font-face { font-family: "JetBrains Mono"; src: url("/static/fonts/JetBrainsMono-Bold.woff2") format("woff2"); font-weight: bold; font-style: normal; } .kbd-group { display: inline-flex; gap: 4px; align-items: center; } kbd { background-color: var(--ifm-color-primary-contrast-background); border-radius: 4px; border: 1px solid var(--ifm-color-secondary-darker); color: var(--ifm-color-primary-contrast-foreground); padding: 2px 6px; font-size: 0.8em; font-family: "JetBrains Mono", monospace; display: inline-flex; justify-content: center; align-items: center; height: 24px; line-height: 24px; .spaced { letter-spacing: 0.2em; } } .kbd-group kbd.symbol { font-size: 0.8em; line-height: 24px; } ================================================ FILE: docs/src/components/kbd.tsx ================================================ import BrowserOnly from "@docusaurus/BrowserOnly"; import { useContext } from "react"; import "./kbd.css"; import type { Platform } from "./platformcontext"; import { PlatformContext } from "./platformcontext"; function convertKey(platform: Platform, key: string): [any, string, boolean] { if (key == "Arrows") { return [<span className="spaced">↑→↓←</span>, "Arrow Keys", true]; } if (key == "ArrowUp") { return ["↑", "Arrow Up", true]; } if (key == "ArrowRight") { return ["→", "Arrow Right", true]; } if (key == "ArrowDown") { return ["↓", "Arrow Down", true]; } if (key == "ArrowLeft") { return ["←", "Arrow Left", true]; } if (key == "Cmd") { if (platform === "mac") { return ["⌘", "Command", true]; } else { return ["Alt", "Alt", false]; } } if (key == "Ctrl") { if (platform === "mac") { return ["⌃", "Control", true]; } else { return ["Ctrl", "Control", false]; } } if (key == "Shift") { return ["⇧", "Shift", true]; } if (key == "Escape") { return ["Esc", "Escape", false]; } return [key.length > 1 ? key : key.toUpperCase(), key, false]; } // Custom KBD component const KbdInternal = ({ k, windows, mac, linux }: { k: string; windows?: string; mac?: string; linux?: string }) => { const { platform } = useContext(PlatformContext); // Determine which key binding to use based on platform overrides let keyBinding = k; if (platform === "windows" && windows) { keyBinding = windows; } else if (platform === "mac" && mac) { keyBinding = mac; } else if (platform === "linux" && linux) { keyBinding = linux; } if (keyBinding == "N/A") { return "N/A"; } const keys = keyBinding.split(":"); const keyElems = keys.map((key, i) => { const [displayKey, title, symbol] = convertKey(platform, key); return ( <kbd key={i} title={title} aria-label={title} className={symbol ? "symbol" : null}> {displayKey} </kbd> ); }); return <div className="kbd-group">{keyElems}</div>; }; export const Kbd = ({ k, windows, mac, linux }: { k: string; windows?: string; mac?: string; linux?: string }) => { return ( <BrowserOnly fallback={<kbd>{k}</kbd>}> {() => <KbdInternal k={k} windows={windows} mac={mac} linux={linux} />} </BrowserOnly> ); }; export const KbdChord = ({ karr }: { karr: string[] }) => { const elems: React.ReactNode[] = []; for (let i = 0; i < karr.length; i++) { if (i > 0) { elems.push(<span style={{ padding: "0 2px" }}>+</span>); } elems.push(<Kbd key={i} k={karr[i]} />); } const fullElem = <span style={{ whiteSpace: "nowrap" }}>{elems}</span>; return <BrowserOnly fallback={null}>{() => fullElem}</BrowserOnly>; }; ================================================ FILE: docs/src/components/platformcontext.css ================================================ .pill-toggle { display: inline-flex; border: 1px solid var(--ifm-scrollbar-thumb-background-color); border-radius: 20px; overflow: hidden; background-color: var(--ifm-scrollbar-track-background-color); } .pill-option { padding: 8px 16px; font-size: 0.9em; font-weight: 500; color: var(--ifm-color-secondary-contrast-foreground); background-color: transparent; border: none; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; outline: none; font-weight: bold; &:not(:first-of-type) { border-left: 1px solid var(--ifm-scrollbar-thumb-background-color); } } .pill-option.active { background-color: var(--ifm-color-primary); color: var(--ifm-color-secondary-contrast-background); } .pill-option:not(.active):hover { background-color: var(--ifm-scrollbar-thumb-background-color); } ================================================ FILE: docs/src/components/platformcontext.tsx ================================================ import BrowserOnly from "@docusaurus/BrowserOnly"; import { createContext, ReactNode, useCallback, useContext, useState } from "react"; import clsx from "clsx"; import "./platformcontext.css"; export type Platform = "mac" | "linux" | "windows"; interface PlatformContextProps { platform: Platform; setPlatform: (platform: Platform) => void; } export const PlatformContext = createContext<PlatformContextProps | undefined>(undefined); function getOS(): Platform { const platform = window.navigator.platform; const macosPlatforms = ["Macintosh", "MacIntel", "MacPPC", "Mac68K"]; const windowsPlatforms = ["Win32", "Win64", "Windows", "WinCE"]; const iosPlatforms = ["iPhone", "iPad", "iPod"]; if (macosPlatforms.includes(platform) || iosPlatforms.includes(platform)) { return "mac"; } else if (windowsPlatforms.includes(platform)) { return "windows"; } else { return "linux"; } } const PlatformProviderInternal = ({ children }: { children: ReactNode }) => { const [platform, setPlatform] = useState<Platform>(getOS()); const setPlatformCallback = useCallback((newPlatform: Platform) => { setPlatform(newPlatform); localStorage.setItem("platform", newPlatform); // Store in localStorage }, []); return ( <PlatformContext.Provider value={{ platform, setPlatform: setPlatformCallback }}> {children} </PlatformContext.Provider> ); }; export function PlatformProvider({ children }: { children: ReactNode }) { return ( <BrowserOnly fallback={<div />}> {() => <PlatformProviderInternal>{children}</PlatformProviderInternal>} </BrowserOnly> ); } export const usePlatform = (): PlatformContextProps => { const context = useContext(PlatformContext); if (!context) { throw new Error("usePlatform must be used within a PlatformProvider"); } return context; }; function PlatformSelectorButtonInternal() { const { platform, setPlatform } = usePlatform(); return ( <div className="pill-toggle"> <button className={clsx("pill-option", { active: platform === "mac" })} onClick={() => setPlatform("mac")}> macOS </button> <button className={clsx("pill-option", { active: platform === "linux" })} onClick={() => setPlatform("linux")} > Linux </button> <button className={clsx("pill-option", { active: platform === "windows" })} onClick={() => setPlatform("windows")} > Windows </button> </div> ); } export function PlatformSelectorButton() { return <BrowserOnly fallback={<div />}>{() => <PlatformSelectorButtonInternal />}</BrowserOnly>; } interface PlatformItemProps { children: ReactNode; platforms: Platform[]; } const PlatformItemInternal = ({ children, platforms }: PlatformItemProps) => { const platform = usePlatform(); return platforms.includes(platform.platform) && children; }; export const PlatformItem = (props: PlatformItemProps) => { return <BrowserOnly fallback={<div />}>{() => <PlatformItemInternal {...props} />}</BrowserOnly>; }; ================================================ FILE: docs/src/components/versionbadge.css ================================================ .version-badge { display: inline-block; padding: 0.125rem 0.5rem; margin-left: 0.25rem; font-size: 0.75rem; font-weight: 600; line-height: 1.5; border-radius: 0.25rem; background-color: var(--ifm-color-primary-lightest); color: var(--ifm-background-color); vertical-align: middle; white-space: nowrap; } .version-badge.no-left-margin { margin-left: 0; } [data-theme="dark"] .version-badge { background-color: var(--ifm-color-primary-dark); color: var(--ifm-background-color); } ================================================ FILE: docs/src/components/versionbadge.tsx ================================================ import "./versionbadge.css"; interface VersionBadgeProps { version: string; noLeftMargin?: boolean; } export function VersionBadge({ version, noLeftMargin }: VersionBadgeProps) { return <span className={`version-badge${noLeftMargin ? " no-left-margin" : ""}`}>{version}</span>; } ================================================ FILE: docs/src/css/custom.scss ================================================ @import url("../../../node_modules/highlight.js/scss/github-dark-dimmed.scss"); :root { --ifm-background-color: #ffffff; --ifm-color-primary: #1a660b; --ifm-color-primary-dark: #175c0a; --ifm-color-primary-darker: #165709; --ifm-color-primary-darkest: #124708; --ifm-color-primary-light: #1d700c; --ifm-color-primary-lighter: #1e750d; --ifm-color-primary-lightest: #22850e; } [data-theme="dark"] { --ifm-background-color: #1b1b1d; --ifm-color-primary: #58c142; --ifm-color-primary-dark: #4eb03a; --ifm-color-primary-darker: #4aa636; --ifm-color-primary-darkest: #429431; --ifm-color-primary-light: #69c756; --ifm-color-primary-lighter: #72cb5f; --ifm-color-primary-lightest: #8cd47d; } .docs-doc-id-index article nav { display: none; } body .markdown h1:first-child { --ifm-h1-font-size: 2rem; } body .markdown h2 { --ifm-h2-font-size: 1.75rem; } @media (min-width: 996px) { .reference-links { display: none; } } /* Adds extra margin between last navbar item and the dark mode toggle. */ .navbar__items--right .navbar__item:last-of-type { margin-right: 4px; } .header-link-custom:before { display: block; height: 24px; width: 24px; background-color: var(--ifm-navbar-link-color); transition: background-color 0.15s linear; } .header-link-custom:hover:before { background-color: var(--ifm-navbar-link-hover-color); } .custom-icon-inline:before { display: inline-block; height: 16px; width: 16px; background-color: var(--ifm-color-primary-contrast-foreground); transition: background-color 0.15s linear; } .custom-icon-github:before { content: ""; mask: url(/img/github.svg) no-repeat center / contain; -webkit-mask: url(/img/github.svg) no-repeat center / contain; } .custom-icon-discord:before { content: ""; mask: url(/img/discord.svg) no-repeat center / contain; -webkit-mask: url(/img/discord.svg) no-repeat center / contain; } .custom-icon-workspace:before { content: ""; mask: url(/img/workspace.svg) no-repeat center / contain; -webkit-mask: url(/img/workspace.svg) no-repeat center / contain; } .custom-icon-magnify-enabled:before { content: ""; mask: url(/img/magnify-enabled.svg) no-repeat center / contain; -webkit-mask: url(/img/magnify-enabled.svg) no-repeat center / contain; margin-bottom: -2px; } .custom-icon-magnify-disabled:before { content: ""; mask: url(/img/magnify-disabled.svg) no-repeat center / contain; -webkit-mask: url(/img/magnify-disabled.svg) no-repeat center / contain; margin-bottom: -2px; } img[src*="#left"] { float: left; margin: 0 10px 10px 0; max-width: 300px; } img[src*="#right"] { float: right; margin: 0 0 10px 10px; max-width: 300px; } img[src*="#center"] { display: block; margin: auto; } .hidden { display: none; } ================================================ FILE: docs/src/renderer/image-renderers.ts ================================================ import type { DocsPageData, ImageGeneratorOptions, ImageRenderer } from "@waveterm/docusaurus-og"; import { readFileSync } from "fs"; import { join } from "path"; import React, { ReactNode } from "react"; const waveLogo = join(__dirname, "../../static/img/logo/wave-dark.png"); const waveLogoBase64 = `data:image/png;base64,${readFileSync(waveLogo).toString("base64")}`; const titleElement = ({ children }) => React.createElement( "label", { style: { fontSize: 72, fontWeight: 800, letterSpacing: 1, margin: "25px 225px 10px 0px", color: "#e3e3e3", wordBreak: "break-word", }, }, children ); const waveLogoElement = React.createElement("img", { src: waveLogoBase64, style: { width: 300, }, }); const headerElement = (header: string, svg: ReactNode) => React.createElement( "div", { style: { display: "flex", alignItems: "center", marginTop: "50px", }, }, svg, React.createElement( "label", { style: { fontSize: 30, fontWeight: 600, letterSpacing: 1, color: "#58c142", }, }, header ) ); const rootDivStyle: React.CSSProperties = { display: "flex", flexDirection: "column", height: "100%", width: "100%", padding: "50px 50px", justifyContent: "center", fontFamily: "Roboto", fontSize: 32, fontWeight: 400, backgroundColor: "#1b1b1d", color: "#e3e3e3", borderBottom: "2rem solid #58c142", zIndex: "2 !important", }; export const docOgRenderer: ImageRenderer<DocsPageData> = async (data, context) => { const element = React.createElement( "div", { style: rootDivStyle }, waveLogoElement, headerElement("Documentation", null), React.createElement(titleElement, null, data.metadata.title), React.createElement("div", null, data.metadata.description.replace("—", "-")) ); return [element, await imageGeneratorOptions()]; }; const imageGeneratorOptions = async (): Promise<ImageGeneratorOptions> => { return { width: 1200, height: 600, fonts: [ { name: "Roboto", data: await getTtfFont("Roboto", ["ital", "wght"], [0, 400]), weight: 400, style: "normal", }, ], }; }; function docSectionPath(slug: string, title: string) { let section = slug.split("/")[1].toString(); // Override some sections by slug switch (section) { case "api": section = "REST APIs"; break; } section = section.charAt(0).toUpperCase() + section.slice(1); return `${title} / ${section}`; } async function getTtfFont(family: string, axes: string[], value: number[]): Promise<ArrayBuffer> { const familyParam = axes.join(",") + "@" + value.join(","); // Get css style sheet with user agent Mozilla/5.0 Firefox/1.0 to ensure TTF is returned const cssCall = await fetch(`https://fonts.googleapis.com/css2?family=${family}:${familyParam}&display=swap`, { headers: { "User-Agent": "Mozilla/5.0 Firefox/1.0", }, }); const css = await cssCall.text(); const ttfUrl = css.match(/url\(([^)]+)\)/)?.[1]; return await fetch(ttfUrl).then((res) => res.arrayBuffer()); } ================================================ FILE: docs/src/theme/MDXComponents/Heading.tsx ================================================ import type { WrapperProps } from "@docusaurus/types"; import Heading from "@theme-original/MDXComponents/Heading"; import type HeadingType from "@theme/MDXComponents/Heading"; type Props = WrapperProps<typeof HeadingType>; export default function HeadingWrapper(props: Props): JSX.Element { return ( <> <div style={{ clear: "both" }} /> <Heading {...props} /> </> ); } ================================================ FILE: docs/static/.nojekyll ================================================ ================================================ FILE: docs/tsconfig.json ================================================ { // This file is not used in compilation. It is here just for a nice editor experience. "extends": "@docusaurus/tsconfig", "compilerOptions": { "baseUrl": ".", "plugins": [ { "name": "@mdx-js/typescript-plugin" } ] }, "mdx": { // Enable strict type checking in MDX files. "checkMdx": true } } ================================================ FILE: electron-builder.config.cjs ================================================ const { Arch } = require("electron-builder"); const pkg = require("./package.json"); const fs = require("fs"); const path = require("path"); const windowsShouldSign = !!process.env.SM_CODE_SIGNING_CERT_SHA1_HASH; /** * @type {import('electron-builder').Configuration} * @see https://www.electron.build/configuration/configuration */ const config = { appId: pkg.build.appId, productName: pkg.productName, executableName: pkg.productName, artifactName: "${productName}-${platform}-${arch}-${version}.${ext}", generateUpdatesFilesForAllChannels: true, npmRebuild: false, nodeGypRebuild: false, electronCompile: false, files: [ { from: "./dist", to: "./dist", filter: ["**/*", "!bin/*", "bin/wavesrv.${arch}*", "bin/wsh*", "!tsunamiscaffold/**/*"], }, { from: ".", to: ".", filter: ["package.json"], }, "!node_modules", // We don't need electron-builder to package in Node modules as Vite has already bundled any code that our program is using. ], extraResources: [ { from: "dist/tsunamiscaffold", to: "tsunamiscaffold", }, ], directories: { output: "make", }, asarUnpack: [ "dist/bin/**/*", // wavesrv and wsh binaries "dist/schema/**/*", // schema files for Monaco editor ], mac: { target: [ { target: "zip", arch: ["arm64", "x64"], }, { target: "dmg", arch: ["arm64", "x64"], }, ], category: "public.app-category.developer-tools", minimumSystemVersion: "10.15.0", mergeASARs: true, singleArchFiles: "**/dist/bin/wavesrv.*", entitlements: "build/entitlements.mac.plist", entitlementsInherit: "build/entitlements.mac.plist", extendInfo: { NSContactsUsageDescription: "A CLI application running in Wave wants to use your contacts.", NSRemindersUsageDescription: "A CLI application running in Wave wants to use your reminders.", NSLocationWhenInUseUsageDescription: "A CLI application running in Wave wants to use your location information while active.", NSLocationAlwaysUsageDescription: "A CLI application running in Wave wants to use your location information, even in the background.", NSCameraUsageDescription: "A CLI application running in Wave wants to use the camera.", NSMicrophoneUsageDescription: "A CLI application running in Wave wants to use your microphone.", NSCalendarsUsageDescription: "A CLI application running in Wave wants to use Calendar data.", NSLocationUsageDescription: "A CLI application running in Wave wants to use your location information.", NSAppleEventsUsageDescription: "A CLI application running in Wave wants to use AppleScript.", }, }, linux: { artifactName: "${name}-${platform}-${arch}-${version}.${ext}", category: "TerminalEmulator", executableName: pkg.name, target: ["zip", "deb", "rpm", "snap", "AppImage", "pacman"], synopsis: pkg.description, description: null, desktop: { entry: { Name: pkg.productName, Comment: pkg.description, Keywords: "developer;terminal;emulator;", Categories: "Development;Utility;", }, }, executableArgs: ["--enable-features", "UseOzonePlatform", "--ozone-platform-hint", "auto"], // Hint Electron to use Ozone abstraction layer for native Wayland support }, deb: { afterInstall: "build/deb-postinstall.tpl", }, win: { target: ["nsis", "msi", "zip"], signtoolOptions: windowsShouldSign && { signingHashAlgorithms: ["sha256"], publisherName: "Command Line Inc", certificateSubjectName: "Command Line Inc", certificateSha1: process.env.SM_CODE_SIGNING_CERT_SHA1_HASH, }, }, appImage: { license: "LICENSE", }, snap: { base: "core22", confinement: "classic", allowNativeWayland: true, artifactName: "${name}_${version}_${arch}.${ext}", }, rpm: { // this should remove /usr/lib/.build-id/ links which can conflict with other electron apps like slack fpm: ["--rpm-rpmbuild-define", "_build_id_links none"], }, publish: { provider: "generic", url: "https://dl.waveterm.dev/releases-w2", }, afterPack: (context) => { // This is a workaround to restore file permissions to the wavesrv binaries on macOS after packaging the universal binary. if (context.electronPlatformName === "darwin" && context.arch === Arch.universal) { const packageBinDir = path.resolve( context.appOutDir, `${pkg.productName}.app/Contents/Resources/app.asar.unpacked/dist/bin` ); // Reapply file permissions to the wavesrv binaries in the final app package fs.readdirSync(packageBinDir, { recursive: true, withFileTypes: true, }) .filter((f) => f.isFile() && f.name.startsWith("wavesrv")) .forEach((f) => fs.chmodSync(path.resolve(f.parentPath ?? f.path, f.name), 0o755)); // 0o755 corresponds to -rwxr-xr-x } }, }; module.exports = config; ================================================ FILE: electron.vite.config.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react-swc"; import { defineConfig } from "electron-vite"; import { ViteImageOptimizer } from "vite-plugin-image-optimizer"; import svgr from "vite-plugin-svgr"; import tsconfigPaths from "vite-tsconfig-paths"; // from our electron build const CHROME = "chrome140"; const NODE = "node22"; // for debugging // target is like -- path.resolve(__dirname, "frontend/app/workspace/workspace-layout-model.ts"); function whoImportsTarget(target: string) { return { name: "who-imports-target", buildEnd() { // Build reverse graph: child -> [importers...] const parents = new Map<string, string[]>(); for (const id of (this as any).getModuleIds()) { const info = (this as any).getModuleInfo(id); if (!info) continue; for (const child of [...info.importedIds, ...info.dynamicallyImportedIds]) { const arr = parents.get(child) ?? []; arr.push(id); parents.set(child, arr); } } // Walk upward from TARGET and print paths to entries const entries = [...parents.keys()].filter((id) => { const m = (this as any).getModuleInfo(id); return m?.isEntry; }); const seen = new Set<string>(); const stack: string[] = []; const dfs = (node: string) => { if (seen.has(node)) return; seen.add(node); stack.push(node); const ps = parents.get(node) || []; if (ps.length === 0) { // hit a root (likely main entry or plugin virtual) console.log("\nImporter chain:"); stack .slice() .reverse() .forEach((s) => console.log(" ↳", s)); } else { for (const p of ps) dfs(p); } stack.pop(); }; if (!parents.has(target)) { console.log(`[who-imports] TARGET not in MAIN graph: ${target}`); } else { dfs(target); } }, async resolveId(id: any, importer: any) { const r = await (this as any).resolve(id, importer, { skipSelf: true }); if (r?.id === target) { console.log(`[resolve] ${importer} -> ${id} -> ${r.id}`); } return null; }, }; } export default defineConfig({ main: { root: ".", build: { target: NODE, rollupOptions: { input: { index: "emain/emain.ts", }, }, outDir: "dist/main", externalizeDeps: false, }, plugins: [tsconfigPaths()], resolve: { alias: { "@": "frontend", }, }, server: { open: false, }, define: { "process.env.WS_NO_BUFFER_UTIL": "true", "process.env.WS_NO_UTF_8_VALIDATE": "true", }, }, preload: { root: ".", build: { target: NODE, sourcemap: true, rollupOptions: { input: { index: "emain/preload.ts", "preload-webview": "emain/preload-webview.ts", }, output: { format: "cjs", }, }, outDir: "dist/preload", externalizeDeps: false, }, server: { open: false, }, plugins: [tsconfigPaths()], }, renderer: { root: ".", build: { target: CHROME, sourcemap: true, outDir: "dist/frontend", rollupOptions: { input: { index: "index.html", }, output: { manualChunks(id) { const p = id.replace(/\\/g, "/"); if (p.includes("node_modules/monaco") || p.includes("node_modules/@monaco")) return "monaco"; if (p.includes("node_modules/mermaid") || p.includes("node_modules/@mermaid")) return "mermaid"; if (p.includes("node_modules/katex") || p.includes("node_modules/@katex")) return "katex"; if (p.includes("node_modules/shiki") || p.includes("node_modules/@shiki")) { return "shiki"; } if (p.includes("node_modules/cytoscape") || p.includes("node_modules/@cytoscape")) return "cytoscape"; return undefined; }, }, }, }, optimizeDeps: { include: ["monaco-yaml/yaml.worker.js"], }, server: { open: false, watch: { ignored: [ "dist/**", "**/*.go", "**/go.mod", "**/go.sum", "**/*.md", "**/*.mdx", "**/*.json", "emain/**", "**/*.txt", "**/*.log", ], }, }, css: { preprocessorOptions: { scss: { silenceDeprecations: ["mixed-decls"], }, }, }, plugins: [ tsconfigPaths(), { ...ViteImageOptimizer(), apply: "build" }, svgr({ svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true }, include: "**/*.svg", }), react({}), tailwindcss(), ], }, }); ================================================ FILE: emain/authkey.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ipcMain } from "electron"; import { getWebServerEndpoint, getWSServerEndpoint } from "../frontend/util/endpoints"; const AuthKeyHeader = "X-AuthKey"; export const WaveAuthKeyEnv = "WAVETERM_AUTH_KEY"; export const AuthKey = crypto.randomUUID(); ipcMain.on("get-auth-key", (event) => { event.returnValue = AuthKey; }); export function configureAuthKeyRequestInjection(session: Electron.Session) { const filter: Electron.WebRequestFilter = { urls: [`${getWebServerEndpoint()}/*`, `${getWSServerEndpoint()}/*`], }; session.webRequest.onBeforeSendHeaders(filter, (details, callback) => { details.requestHeaders[AuthKeyHeader] = AuthKey; callback({ requestHeaders: details.requestHeaders }); }); } ================================================ FILE: emain/emain-activity.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // for activity updates let wasActive = true; let wasInFg = true; let globalIsQuitting = false; let globalIsStarting = true; let globalIsRelaunching = false; let forceQuit = false; let userConfirmedQuit = false; let termCommandsRun = 0; let termCommandsRemote = 0; let termCommandsWsl = 0; let termCommandsDurable = 0; export function setWasActive(val: boolean) { wasActive = val; } export function setWasInFg(val: boolean) { wasInFg = val; } export function getActivityState(): { wasActive: boolean; wasInFg: boolean } { return { wasActive, wasInFg }; } export function setGlobalIsQuitting(val: boolean) { globalIsQuitting = val; } export function getGlobalIsQuitting(): boolean { return globalIsQuitting; } export function setGlobalIsStarting(val: boolean) { globalIsStarting = val; } export function getGlobalIsStarting(): boolean { return globalIsStarting; } export function setGlobalIsRelaunching(val: boolean) { globalIsRelaunching = val; } export function getGlobalIsRelaunching(): boolean { return globalIsRelaunching; } export function setForceQuit(val: boolean) { forceQuit = val; } export function getForceQuit(): boolean { return forceQuit; } export function setUserConfirmedQuit(val: boolean) { userConfirmedQuit = val; } export function getUserConfirmedQuit(): boolean { return userConfirmedQuit; } export function incrementTermCommandsRun() { termCommandsRun++; } export function getAndClearTermCommandsRun(): number { const count = termCommandsRun; termCommandsRun = 0; return count; } export function incrementTermCommandsRemote() { termCommandsRemote++; } export function getAndClearTermCommandsRemote(): number { const count = termCommandsRemote; termCommandsRemote = 0; return count; } export function incrementTermCommandsWsl() { termCommandsWsl++; } export function getAndClearTermCommandsWsl(): number { const count = termCommandsWsl; termCommandsWsl = 0; return count; } export function incrementTermCommandsDurable() { termCommandsDurable++; } export function getAndClearTermCommandsDurable(): number { const count = termCommandsDurable; termCommandsDurable = 0; return count; } ================================================ FILE: emain/emain-builder.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ClientService } from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; import { randomUUID } from "crypto"; import { BrowserWindow } from "electron"; import { globalEvents } from "emain/emain-events"; import path from "path"; import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform"; import { calculateWindowBounds, MinWindowHeight, MinWindowWidth } from "./emain-window"; import { ElectronWshClient } from "./emain-wsh"; export type BuilderWindowType = BrowserWindow & { builderId: string; builderAppId?: string; savedInitOpts: BuilderInitOpts; }; const builderWindows: BuilderWindowType[] = []; export let focusedBuilderWindow: BuilderWindowType = null; export function getBuilderWindowById(builderId: string): BuilderWindowType { return builderWindows.find((win) => win.builderId === builderId); } export function getBuilderWindowByWebContentsId(webContentsId: number): BuilderWindowType { return builderWindows.find((win) => win.webContents.id === webContentsId); } export function getAllBuilderWindows(): BuilderWindowType[] { return builderWindows; } export async function createBuilderWindow(appId: string): Promise<BuilderWindowType> { const builderId = randomUUID(); const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); const clientData = await ClientService.GetClientData(); const clientId = clientData?.oid; const windowId = randomUUID(); if (appId) { const oref = `builder:${builderId}`; await RpcApi.SetRTInfoCommand(ElectronWshClient, { oref, data: { "builder:appid": appId }, }); } const winBounds = calculateWindowBounds(undefined, undefined, fullConfig.settings); const builderWindow = new BrowserWindow({ x: winBounds.x, y: winBounds.y, width: winBounds.width, height: winBounds.height, minWidth: MinWindowWidth, minHeight: MinWindowHeight, titleBarStyle: unamePlatform === "darwin" ? "hiddenInset" : "default", icon: unamePlatform === "linux" ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") : undefined, show: false, backgroundColor: "#222222", webPreferences: { preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), webviewTag: true, }, }); if (isDevVite) { await builderWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`); } else { await builderWindow.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); } const initOpts: BuilderInitOpts = { builderId, clientId, windowId, }; const typedBuilderWindow = builderWindow as BuilderWindowType; typedBuilderWindow.builderId = builderId; typedBuilderWindow.builderAppId = appId; typedBuilderWindow.savedInitOpts = initOpts; typedBuilderWindow.on("focus", () => { focusedBuilderWindow = typedBuilderWindow; console.log("builder window focused", builderId); setTimeout(() => globalEvents.emit("windows-updated"), 50); }); typedBuilderWindow.on("blur", () => { if (focusedBuilderWindow === typedBuilderWindow) { focusedBuilderWindow = null; } setTimeout(() => globalEvents.emit("windows-updated"), 50); }); typedBuilderWindow.on("closed", () => { console.log("builder window closed", builderId); const index = builderWindows.indexOf(typedBuilderWindow); if (index !== -1) { builderWindows.splice(index, 1); } if (focusedBuilderWindow === typedBuilderWindow) { focusedBuilderWindow = null; } RpcApi.DeleteBuilderCommand(ElectronWshClient, builderId, { noresponse: true }); setTimeout(() => globalEvents.emit("windows-updated"), 50); }); builderWindows.push(typedBuilderWindow); typedBuilderWindow.show(); console.log("created builder window", builderId, appId); return typedBuilderWindow; } ================================================ FILE: emain/emain-events.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { EventEmitter } from "events"; interface GlobalEvents { "windows-updated": () => void; // emitted whenever a window is opened/closed } class GlobalEventEmitter extends EventEmitter { emit<K extends keyof GlobalEvents>(event: K, ...args: Parameters<GlobalEvents[K]>): boolean { return super.emit(event, ...args); } on<K extends keyof GlobalEvents>(event: K, listener: GlobalEvents[K]): this { return super.on(event, listener); } once<K extends keyof GlobalEvents>(event: K, listener: GlobalEvents[K]): this { return super.once(event, listener); } off<K extends keyof GlobalEvents>(event: K, listener: GlobalEvents[K]): this { return super.off(event, listener); } } const globalEvents = new GlobalEventEmitter(); export { globalEvents }; ================================================ FILE: emain/emain-ipc.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as electron from "electron"; import { FastAverageColor } from "fast-average-color"; import fs from "fs"; import * as child_process from "node:child_process"; import * as path from "path"; import { PNG } from "pngjs"; import { Readable } from "stream"; import { RpcApi } from "../frontend/app/store/wshclientapi"; import { getWebServerEndpoint } from "../frontend/util/endpoints"; import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget, parseDataUrl } from "../frontend/util/util"; import { incrementTermCommandsDurable, incrementTermCommandsRemote, incrementTermCommandsRun, incrementTermCommandsWsl, setWasActive, } from "./emain-activity"; import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; import { getWaveTabViewByWebContentsId } from "./emain-tabview"; import { handleCtrlShiftState } from "./emain-util"; import { getWaveVersion } from "./emain-wavesrv"; import { createNewWaveWindow, getWaveWindowByWebContentsId } from "./emain-window"; import { ElectronWshClient } from "./emain-wsh"; const electronApp = electron.app; let webviewFocusId: number = null; let webviewKeys: string[] = []; export function openBuilderWindow(appId?: string) { const normalizedAppId = appId || ""; const existingBuilderWindows = getAllBuilderWindows(); const existingWindow = existingBuilderWindows.find((win) => win.builderAppId === normalizedAppId); if (existingWindow) { existingWindow.focus(); return; } fireAndForget(() => createBuilderWindow(normalizedAppId)); } type UrlInSessionResult = { stream: Readable; mimeType: string; fileName: string; }; function getSingleHeaderVal(headers: Record<string, string | string[]>, key: string): string { const val = headers[key]; if (val == null) { return null; } if (Array.isArray(val)) { return val[0]; } return val; } function cleanMimeType(mimeType: string): string { if (mimeType == null) { return null; } const parts = mimeType.split(";"); return parts[0].trim(); } function getFileNameFromUrl(url: string): string { try { const pathname = new URL(url).pathname; const filename = pathname.substring(pathname.lastIndexOf("/") + 1); return filename; } catch (e) { return null; } } function getUrlInSession(session: Electron.Session, url: string): Promise<UrlInSessionResult> { return new Promise((resolve, reject) => { if (url.startsWith("data:")) { try { const parsed = parseDataUrl(url); const buffer = Buffer.from(parsed.buffer); const readable = Readable.from(buffer); resolve({ stream: readable, mimeType: parsed.mimeType, fileName: "image" }); } catch (err) { return reject(err); } return; } const request = electron.net.request({ url, method: "GET", session, }); const readable = new Readable({ read() {}, }); request.on("response", (response) => { const statusCode = response.statusCode; if (statusCode < 200 || statusCode >= 300) { readable.destroy(); request.abort(); reject(new Error(`HTTP request failed with status ${statusCode}: ${response.statusMessage || ""}`)); return; } const mimeType = cleanMimeType(getSingleHeaderVal(response.headers, "content-type")); const fileName = getFileNameFromUrl(url) || "image"; response.on("data", (chunk) => { readable.push(chunk); }); response.on("end", () => { readable.push(null); resolve({ stream: readable, mimeType, fileName }); }); response.on("error", (err) => { readable.destroy(err); reject(err); }); }); request.on("error", (err) => { readable.destroy(err); reject(err); }); request.end(); }); } function saveImageFileWithNativeDialog( sender: electron.WebContents, defaultFileName: string, mimeType: string, readStream: Readable ) { if (defaultFileName == null || defaultFileName == "") { defaultFileName = "image"; } const ww = electron.BrowserWindow.fromWebContents(sender); if (ww == null) { readStream.destroy(); return; } const mimeToExtension: { [key: string]: string } = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp", "image/tiff": "tiff", "image/heic": "heic", "image/svg+xml": "svg", }; function addExtensionIfNeeded(fileName: string, mimeType: string): string { const extension = mimeToExtension[mimeType]; if (!path.extname(fileName) && extension) { return `${fileName}.${extension}`; } return fileName; } defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType); electron.dialog .showSaveDialog(ww, { title: "Save Image", defaultPath: defaultFileName, filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }], }) .then((file) => { if (file.canceled) { readStream.destroy(); return; } const writeStream = fs.createWriteStream(file.filePath); readStream.pipe(writeStream); writeStream.on("finish", () => { console.log("saved file", file.filePath); }); writeStream.on("error", (err) => { console.log("error saving file (writeStream)", err); readStream.destroy(); }); readStream.on("error", (err) => { console.error("error saving file (readStream)", err); writeStream.destroy(); }); }) .catch((err) => { console.log("error trying to save file", err); }); } export function initIpcHandlers() { electron.ipcMain.on("open-external", (event, url) => { if (url && typeof url === "string") { fireAndForget(() => callWithOriginalXdgCurrentDesktopAsync(() => electron.shell.openExternal(url).catch((err) => { console.error(`Failed to open URL ${url}:`, err); }) ) ); } else { console.error("Invalid URL received in open-external event:", url); } }); electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { const menu = new electron.Menu(); const win = getWaveWindowByWebContentsId(event.sender.hostWebContents?.id); if (win == null) { return; } menu.append( new electron.MenuItem({ label: "Save Image", click: () => { const resultP = getUrlInSession(event.sender.session, payload.src); resultP .then((result) => { saveImageFileWithNativeDialog( event.sender.hostWebContents, result.fileName, result.mimeType, result.stream ); }) .catch((e) => { console.log("error getting image", e); }); }, }) ); menu.popup(); }); electron.ipcMain.on("download", (event, payload) => { const baseName = encodeURIComponent(path.basename(payload.filePath)); const streamingUrl = getWebServerEndpoint() + "/wave/stream-file/" + baseName + "?path=" + encodeURIComponent(payload.filePath); event.sender.downloadURL(streamingUrl); }); electron.ipcMain.on("get-cursor-point", (event) => { const tabView = getWaveTabViewByWebContentsId(event.sender.id); if (tabView == null) { event.returnValue = null; return; } const screenPoint = electron.screen.getCursorScreenPoint(); const windowRect = tabView.getBounds(); const retVal: Electron.Point = { x: screenPoint.x - windowRect.x, y: screenPoint.y - windowRect.y, }; event.returnValue = retVal; }); electron.ipcMain.handle("capture-screenshot", async (event, rect) => { const tabView = getWaveTabViewByWebContentsId(event.sender.id); if (!tabView) { throw new Error("No tab view found for the given webContents id"); } const image = await tabView.webContents.capturePage(rect); const base64String = image.toPNG().toString("base64"); return `data:image/png;base64,${base64String}`; }); electron.ipcMain.on("get-env", (event, varName) => { event.returnValue = process.env[varName] ?? null; }); electron.ipcMain.on("get-about-modal-details", (event) => { event.returnValue = getWaveVersion() as AboutModalDetails; }); electron.ipcMain.on("get-zoom-factor", (event) => { event.returnValue = event.sender.getZoomFactor(); }); const hasBeforeInputRegisteredMap = new Map<number, boolean>(); electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => { webviewFocusId = focusedId; console.log("webview-focus", focusedId); if (focusedId == null) { return; } const parentWc = event.sender; const webviewWc = electron.webContents.fromId(focusedId); if (webviewWc == null) { webviewFocusId = null; return; } if (!hasBeforeInputRegisteredMap.get(focusedId)) { hasBeforeInputRegisteredMap.set(focusedId, true); webviewWc.on("before-input-event", (e, input) => { let waveEvent = keyutil.adaptFromElectronKeyEvent(input); handleCtrlShiftState(parentWc, waveEvent); if (webviewFocusId != focusedId) { return; } if (input.type != "keyDown") { return; } for (let keyDesc of webviewKeys) { if (keyutil.checkKeyPressed(waveEvent, keyDesc)) { e.preventDefault(); parentWc.send("reinject-key", waveEvent); console.log("webview reinject-key", keyDesc); return; } } }); webviewWc.on("destroyed", () => { hasBeforeInputRegisteredMap.delete(focusedId); }); } }); electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => { webviewKeys = keys ?? []; }); electron.ipcMain.on("set-keyboard-chord-mode", (event) => { event.returnValue = null; const tabView = getWaveTabViewByWebContentsId(event.sender.id); tabView?.setKeyboardChordMode(true); }); electron.ipcMain.handle("set-is-active", () => { setWasActive(true); }); const fac = new FastAverageColor(); electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { if (unamePlatform === "darwin") return; try { const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); if (fullConfig?.settings?.["window:nativetitlebar"] && unamePlatform !== "win32") return; const zoomFactor = event.sender.getZoomFactor(); const electronRect: Electron.Rectangle = { x: rect.left * zoomFactor, y: rect.top * zoomFactor, height: rect.height * zoomFactor, width: rect.width * zoomFactor, }; const overlay = await event.sender.capturePage(electronRect); const overlayBuffer = overlay.toPNG(); const png = PNG.sync.read(overlayBuffer); const color = fac.prepareResult(fac.getColorFromArray4(png.data)); const ww = getWaveWindowByWebContentsId(event.sender.id); if (ww == null) return; ww.setTitleBarOverlay({ color: unamePlatform === "linux" ? color.rgba : "#00000000", symbolColor: color.isDark ? "white" : "black", }); } catch (e) { console.error("Error updating window controls overlay:", e); } }); electron.ipcMain.on("quicklook", (event, filePath: string) => { if (unamePlatform !== "darwin") return; child_process.execFile("/usr/bin/qlmanage", ["-p", filePath], (error, stdout, stderr) => { if (error) { console.error(`Error opening Quick Look: ${error}`); } }); }); electron.ipcMain.handle("clear-webview-storage", async (event, webContentsId: number) => { try { const wc = electron.webContents.fromId(webContentsId); if (wc && wc.session) { await wc.session.clearStorageData(); console.log("Cleared cookies and storage for webContentsId:", webContentsId); } } catch (e) { console.error("Failed to clear cookies and storage:", e); throw e; } }); electron.ipcMain.on("open-native-path", (event, filePath: string) => { console.log("open-native-path", filePath); filePath = filePath.replace("~", electronApp.getPath("home")); fireAndForget(() => callWithOriginalXdgCurrentDesktopAsync(() => electron.shell.openPath(filePath).then((excuse) => { if (excuse) console.error(`Failed to open ${filePath} in native application: ${excuse}`); }) ) ); }); electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => { const tabView = getWaveTabViewByWebContentsId(event.sender.id); if (tabView != null && tabView.initResolve != null) { if (status === "ready") { tabView.initResolve(); if (tabView.savedInitOpts) { console.log("savedInitOpts calling wave-init", tabView.waveTabId); tabView.webContents.send("wave-init", tabView.savedInitOpts); } } else if (status === "wave-ready") { tabView.waveReadyResolve(); } return; } const builderWindow = getBuilderWindowByWebContentsId(event.sender.id); if (builderWindow != null) { if (status === "ready") { if (builderWindow.savedInitOpts) { console.log("savedInitOpts calling builder-init", builderWindow.savedInitOpts.builderId); builderWindow.webContents.send("builder-init", builderWindow.savedInitOpts); } } return; } console.log("set-window-init-status: no window found for webContentsId", event.sender.id); }); electron.ipcMain.on("fe-log", (event, logStr: string) => { console.log("fe-log", logStr); }); electron.ipcMain.on( "increment-term-commands", (event, opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => { incrementTermCommandsRun(); if (opts?.isRemote) { incrementTermCommandsRemote(); } if (opts?.isWsl) { incrementTermCommandsWsl(); } if (opts?.isDurable) { incrementTermCommandsDurable(); } } ); electron.ipcMain.on("native-paste", (event) => { event.sender.paste(); }); electron.ipcMain.on("open-builder", (event, appId?: string) => { openBuilderWindow(appId); }); electron.ipcMain.on("set-builder-window-appid", (event, appId: string) => { const bw = getBuilderWindowByWebContentsId(event.sender.id); if (bw == null) { return; } bw.builderAppId = appId; console.log("set-builder-window-appid", bw.builderId, appId); }); electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); electron.ipcMain.on("close-builder-window", async (event) => { const bw = getBuilderWindowByWebContentsId(event.sender.id); if (bw == null) { return; } const builderId = bw.builderId; if (builderId) { try { await RpcApi.SetRTInfoCommand(ElectronWshClient, { oref: `builder:${builderId}`, data: {} as ObjRTInfo, delete: true, }); } catch (e) { console.error("Error deleting builder rtinfo:", e); } } bw.destroy(); }); electron.ipcMain.on("do-refresh", (event) => { event.sender.reloadIgnoringCache(); }); electron.ipcMain.handle("save-text-file", async (event, fileName: string, content: string) => { const ww = electron.BrowserWindow.fromWebContents(event.sender); if (ww == null) { return false; } const result = await electron.dialog.showSaveDialog(ww, { title: "Save Scrollback", defaultPath: fileName || "session.log", filters: [{ name: "Text Files", extensions: ["txt", "log"] }], }); if (result.canceled || !result.filePath) { return false; } try { await fs.promises.writeFile(result.filePath, content, "utf-8"); console.log("saved scrollback to", result.filePath); return true; } catch (err) { console.error("error saving scrollback file", err); return false; } }); } ================================================ FILE: emain/emain-log.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import fs from "fs"; import path from "path"; import { format } from "util"; import winston from "winston"; import { getWaveDataDir, isDev } from "./emain-platform"; const oldConsoleLog = console.log; function findHighestLogNumber(logsDir: string): number { if (!fs.existsSync(logsDir)) { return 0; } const files = fs.readdirSync(logsDir); let maxNum = 0; for (const file of files) { const match = file.match(/^waveapp\.(\d+)\.log$/); if (match) { const num = parseInt(match[1], 10); if (num > maxNum) { maxNum = num; } } } return maxNum; } function pruneOldLogs(logsDir: string): { pruned: string[]; error: any } { if (!fs.existsSync(logsDir)) { return { pruned: [], error: null }; } const files = fs.readdirSync(logsDir); const logFiles: { name: string; num: number }[] = []; for (const file of files) { const match = file.match(/^waveapp\.(\d+)\.log$/); if (match) { logFiles.push({ name: file, num: parseInt(match[1], 10) }); } } if (logFiles.length <= 5) { return { pruned: [], error: null }; } logFiles.sort((a, b) => b.num - a.num); const toDelete = logFiles.slice(5); const pruned: string[] = []; let firstError: any = null; for (const logFile of toDelete) { try { fs.unlinkSync(path.join(logsDir, logFile.name)); pruned.push(logFile.name); } catch (e) { if (firstError == null) { firstError = e; } } } return { pruned, error: firstError }; } function rotateLogIfNeeded(): string | null { const waveDataDir = getWaveDataDir(); const logFile = path.join(waveDataDir, "waveapp.log"); const logsDir = path.join(waveDataDir, "logs"); if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); } if (!fs.existsSync(logFile)) { return null; } const stats = fs.statSync(logFile); if (stats.size > 10 * 1024 * 1024) { const nextNum = findHighestLogNumber(logsDir) + 1; const rotatedPath = path.join(logsDir, `waveapp.${nextNum}.log`); fs.renameSync(logFile, rotatedPath); return rotatedPath; } return null; } let logRotateError: any = null; let rotatedPath: string | null = null; let prunedFiles: string[] = []; let pruneError: any = null; try { rotatedPath = rotateLogIfNeeded(); const logsDir = path.join(getWaveDataDir(), "logs"); const pruneResult = pruneOldLogs(logsDir); prunedFiles = pruneResult.pruned; pruneError = pruneResult.error; } catch (e) { logRotateError = e; } const loggerTransports: winston.transport[] = [ new winston.transports.File({ filename: path.join(getWaveDataDir(), "waveapp.log"), level: "info" }), ]; if (isDev) { loggerTransports.push(new winston.transports.Console()); } const loggerConfig = { level: "info", format: winston.format.combine( winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }), winston.format.printf((info) => `${info.timestamp} ${info.message}`) ), transports: loggerTransports, }; const logger = winston.createLogger(loggerConfig); function log(...msg: any[]) { try { logger.info(format(...msg)); } catch (e) { oldConsoleLog(...msg); } } if (logRotateError != null) { log("error rotating/pruning logs (non-fatal):", logRotateError); } if (rotatedPath != null) { log("rotated old log file to:", rotatedPath); } if (prunedFiles.length > 0) { log("pruned old log files:", prunedFiles.join(", ")); } if (pruneError != null) { log("error pruning some log files (non-fatal):", pruneError); } export { log }; ================================================ FILE: emain/emain-menu.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import * as electron from "electron"; import { fireAndForget } from "../frontend/util/util"; import { focusedBuilderWindow, getBuilderWindowById } from "./emain-builder"; import { openBuilderWindow } from "./emain-ipc"; import { isDev, unamePlatform } from "./emain-platform"; import { clearTabCache } from "./emain-tabview"; import { decreaseZoomLevel, increaseZoomLevel, resetZoomLevel } from "./emain-util"; import { createNewWaveWindow, createWorkspace, focusedWaveWindow, getAllWaveWindows, getWaveWindowByWorkspaceId, relaunchBrowserWindows, WaveBrowserWindow, } from "./emain-window"; import { ElectronWshClient } from "./emain-wsh"; import { updater } from "./updater"; type AppMenuCallbacks = { createNewWaveWindow: () => Promise<void>; relaunchBrowserWindows: () => Promise<void>; }; function getWindowWebContents(window: electron.BaseWindow): electron.WebContents { if (window == null) { return null; } // Check BrowserWindow first (for Tsunami Builder windows) if (window instanceof electron.BrowserWindow) { return window.webContents; } // Check WaveBrowserWindow (for main Wave windows with tab views) if (window instanceof WaveBrowserWindow) { if (window.activeTabView) { return window.activeTabView.webContents; } return null; } return null; } async function getWorkspaceMenu(ww?: WaveBrowserWindow): Promise<Electron.MenuItemConstructorOptions[]> { const workspaceList = await RpcApi.WorkspaceListCommand(ElectronWshClient); const workspaceMenu: Electron.MenuItemConstructorOptions[] = [ { label: "Create Workspace", click: (_, window) => fireAndForget(() => createWorkspace((window as WaveBrowserWindow) ?? ww)), }, ]; function getWorkspaceSwitchAccelerator(i: number): string { if (i < 9) { return unamePlatform == "darwin" ? `Command+Control+${i + 1}` : `Alt+Control+${i + 1}`; } } if (workspaceList?.length) { workspaceMenu.push( { type: "separator" }, ...workspaceList.map<Electron.MenuItemConstructorOptions>((workspace, i) => { return { label: `${workspace.workspacedata.name}`, click: (_, window) => { ((window as WaveBrowserWindow) ?? ww)?.switchWorkspace(workspace.workspacedata.oid); }, accelerator: getWorkspaceSwitchAccelerator(i), }; }) ); } return workspaceMenu; } function makeEditMenu(fullConfig?: FullConfigType): Electron.MenuItemConstructorOptions[] { let pasteAccelerator: string; if (unamePlatform === "darwin") { pasteAccelerator = "Command+V"; } else { const ctrlVPaste = fullConfig?.settings?.["app:ctrlvpaste"]; if (ctrlVPaste == null) { pasteAccelerator = unamePlatform === "win32" ? "Control+V" : ""; } else if (ctrlVPaste) { pasteAccelerator = "Control+V"; } else { pasteAccelerator = ""; } } return [ { role: "undo", accelerator: unamePlatform === "darwin" ? "Command+Z" : "", }, { role: "redo", accelerator: unamePlatform === "darwin" ? "Command+Shift+Z" : "", }, { type: "separator" }, { role: "cut", accelerator: unamePlatform === "darwin" ? "Command+X" : "", }, { role: "copy", accelerator: unamePlatform === "darwin" ? "Command+C" : "", }, { role: "paste", accelerator: pasteAccelerator, }, { role: "pasteAndMatchStyle", accelerator: unamePlatform === "darwin" ? "Command+Shift+V" : "", }, { role: "delete", }, { role: "selectAll", accelerator: unamePlatform === "darwin" ? "Command+A" : "", }, ]; } function makeFileMenu( numWaveWindows: number, callbacks: AppMenuCallbacks, fullConfig: FullConfigType ): Electron.MenuItemConstructorOptions[] { const fileMenu: Electron.MenuItemConstructorOptions[] = [ { label: "New Window", accelerator: "CommandOrControl+Shift+N", click: () => fireAndForget(callbacks.createNewWaveWindow), }, { role: "close", accelerator: "", click: () => { focusedWaveWindow?.close(); }, }, ]; const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"]; if (isDev || featureWaveAppBuilder) { fileMenu.splice(1, 0, { label: "New WaveApp Builder Window", accelerator: unamePlatform === "darwin" ? "Command+Shift+B" : "Alt+Shift+B", click: () => openBuilderWindow(""), }); } if (numWaveWindows == 0) { fileMenu.push({ label: "New Window (hidden-1)", accelerator: unamePlatform === "darwin" ? "Command+N" : "Alt+N", acceleratorWorksWhenHidden: true, visible: false, click: () => fireAndForget(callbacks.createNewWaveWindow), }); fileMenu.push({ label: "New Window (hidden-2)", accelerator: unamePlatform === "darwin" ? "Command+T" : "Alt+T", acceleratorWorksWhenHidden: true, visible: false, click: () => fireAndForget(callbacks.createNewWaveWindow), }); } return fileMenu; } function makeAppMenuItems(webContents: electron.WebContents): Electron.MenuItemConstructorOptions[] { const appMenuItems: Electron.MenuItemConstructorOptions[] = [ { label: "About Wave Terminal", click: (_, window) => { (getWindowWebContents(window) ?? webContents)?.send("menu-item-about"); }, }, { label: "Check for Updates", click: () => { fireAndForget(() => updater?.checkForUpdates(true)); }, }, { type: "separator" }, ]; if (unamePlatform === "darwin") { appMenuItems.push( { role: "services" }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, { type: "separator" } ); } appMenuItems.push({ role: "quit" }); return appMenuItems; } function makeViewMenu( webContents: electron.WebContents, callbacks: AppMenuCallbacks, isBuilderWindowFocused: boolean, fullscreenOnLaunch: boolean ): Electron.MenuItemConstructorOptions[] { const devToolsAccel = unamePlatform === "darwin" ? "Option+Command+I" : "Alt+Shift+I"; return [ { label: isBuilderWindowFocused ? "Reload Window" : "Reload Tab", accelerator: "Shift+CommandOrControl+R", click: (_, window) => { (getWindowWebContents(window) ?? webContents)?.reloadIgnoringCache(); }, }, { label: "Relaunch All Windows", click: () => callbacks.relaunchBrowserWindows(), }, { label: "Clear Tab Cache", click: () => clearTabCache(), }, { label: "Toggle DevTools", accelerator: devToolsAccel, click: (_, window) => { const wc = getWindowWebContents(window) ?? webContents; wc?.toggleDevTools(); }, }, { type: "separator" }, { label: "Reset Zoom", accelerator: "CommandOrControl+0", click: (_, window) => { const wc = getWindowWebContents(window) ?? webContents; if (wc) { resetZoomLevel(wc); } }, }, { label: "Zoom In", accelerator: "CommandOrControl+=", click: (_, window) => { const wc = getWindowWebContents(window) ?? webContents; if (wc) { increaseZoomLevel(wc); } }, }, { label: "Zoom In (hidden)", accelerator: "CommandOrControl+Shift+=", click: (_, window) => { const wc = getWindowWebContents(window) ?? webContents; if (wc) { increaseZoomLevel(wc); } }, visible: false, acceleratorWorksWhenHidden: true, }, { label: "Zoom Out", accelerator: "CommandOrControl+-", click: (_, window) => { const wc = getWindowWebContents(window) ?? webContents; if (wc) { decreaseZoomLevel(wc); } }, }, { label: "Zoom Out (hidden)", accelerator: "CommandOrControl+Shift+-", click: (_, window) => { const wc = getWindowWebContents(window) ?? webContents; if (wc) { decreaseZoomLevel(wc); } }, visible: false, acceleratorWorksWhenHidden: true, }, { label: "Launch On Full Screen", submenu: [ { label: "On", type: "radio", checked: fullscreenOnLaunch, click: () => { RpcApi.SetConfigCommand(ElectronWshClient, { "window:fullscreenonlaunch": true }); }, }, { label: "Off", type: "radio", checked: !fullscreenOnLaunch, click: () => { RpcApi.SetConfigCommand(ElectronWshClient, { "window:fullscreenonlaunch": false }); }, }, ], }, { type: "separator" }, { role: "togglefullscreen", }, ]; } async function makeFullAppMenu(callbacks: AppMenuCallbacks, workspaceOrBuilderId?: string): Promise<Electron.Menu> { const numWaveWindows = getAllWaveWindows().length; const webContents = workspaceOrBuilderId && getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId); const appMenuItems = makeAppMenuItems(webContents); const isBuilderWindowFocused = focusedBuilderWindow != null; let fullscreenOnLaunch = false; let fullConfig: FullConfigType = null; try { fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); fullscreenOnLaunch = fullConfig?.settings["window:fullscreenonlaunch"]; } catch (e) { console.error("Error fetching config:", e); } const editMenu = makeEditMenu(fullConfig); const fileMenu = makeFileMenu(numWaveWindows, callbacks, fullConfig); const viewMenu = makeViewMenu(webContents, callbacks, isBuilderWindowFocused, fullscreenOnLaunch); let workspaceMenu: Electron.MenuItemConstructorOptions[] = null; try { workspaceMenu = await getWorkspaceMenu(); } catch (e) { console.error("getWorkspaceMenu error:", e); } const windowMenu: Electron.MenuItemConstructorOptions[] = [ { role: "minimize", accelerator: "" }, { role: "zoom" }, { type: "separator" }, { role: "front" }, ]; const menuTemplate: Electron.MenuItemConstructorOptions[] = [ { role: "appMenu", submenu: appMenuItems }, { role: "fileMenu", submenu: fileMenu }, { role: "editMenu", submenu: editMenu }, { role: "viewMenu", submenu: viewMenu }, ]; if (workspaceMenu != null && !isBuilderWindowFocused) { menuTemplate.push({ label: "Workspace", id: "workspace-menu", submenu: workspaceMenu, }); } menuTemplate.push({ role: "windowMenu", submenu: windowMenu, }); return electron.Menu.buildFromTemplate(menuTemplate); } export function instantiateAppMenu(workspaceOrBuilderId?: string): Promise<electron.Menu> { return makeFullAppMenu( { createNewWaveWindow, relaunchBrowserWindows, }, workspaceOrBuilderId ); } // does not a set a menu on windows export function makeAndSetAppMenu() { if (unamePlatform === "win32") { return; } fireAndForget(async () => { const menu = await instantiateAppMenu(); electron.Menu.setApplicationMenu(menu); }); } function initMenuEventSubscriptions() { waveEventSubscribeSingle({ eventType: "workspace:update", handler: makeAndSetAppMenu, }); } function getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId: string): electron.WebContents { const ww = getWaveWindowByWorkspaceId(workspaceOrBuilderId); if (ww) { return ww.activeTabView?.webContents; } const bw = getBuilderWindowById(workspaceOrBuilderId); if (bw) { return bw.webContents; } return null; } function convertMenuDefArrToMenu( webContents: electron.WebContents, menuDefArr: ElectronContextMenuItem[], menuState: { hasClick: boolean } ): electron.Menu { const menuItems: electron.MenuItem[] = []; for (const menuDef of menuDefArr) { const menuItemTemplate: electron.MenuItemConstructorOptions = { role: menuDef.role as any, label: menuDef.label, type: menuDef.type, click: () => { menuState.hasClick = true; webContents.send("contextmenu-click", menuDef.id); }, checked: menuDef.checked, enabled: menuDef.enabled, }; if (menuDef.submenu != null) { menuItemTemplate.submenu = convertMenuDefArrToMenu(webContents, menuDef.submenu, menuState); } const menuItem = new electron.MenuItem(menuItemTemplate); menuItems.push(menuItem); } return electron.Menu.buildFromTemplate(menuItems); } electron.ipcMain.on( "contextmenu-show", (event, workspaceOrBuilderId: string, menuDefArr: ElectronContextMenuItem[]) => { const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId); if (!webContents) { console.error("invalid window for context menu:", workspaceOrBuilderId); event.returnValue = true; return; } if (menuDefArr.length === 0) { webContents.send("contextmenu-click", null); event.returnValue = true; return; } fireAndForget(async () => { const menuState = { hasClick: false }; const menu = convertMenuDefArrToMenu(webContents, menuDefArr, menuState); menu.popup({ callback: () => { if (!menuState.hasClick) { webContents.send("contextmenu-click", null); } }, }); }); event.returnValue = true; } ); electron.ipcMain.on("workspace-appmenu-show", (event, workspaceId: string) => { fireAndForget(async () => { const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceId); if (!webContents) { console.error("invalid window for workspace app menu:", workspaceId); return; } const menu = await instantiateAppMenu(workspaceId); menu.popup(); }); event.returnValue = true; }); electron.ipcMain.on("builder-appmenu-show", (event, builderId: string) => { fireAndForget(async () => { const webContents = getWebContentsByWorkspaceOrBuilderId(builderId); if (!webContents) { console.error("invalid window for builder app menu:", builderId); return; } const menu = await instantiateAppMenu(builderId); menu.popup(); }); event.returnValue = true; }); const dockMenu = electron.Menu.buildFromTemplate([ { label: "New Window", click() { fireAndForget(createNewWaveWindow); }, }, ]); function makeDockTaskbar() { if (unamePlatform == "darwin") { electron.app.dock.setMenu(dockMenu); } } export { initMenuEventSubscriptions, makeDockTaskbar }; ================================================ FILE: emain/emain-platform.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { fireAndForget } from "@/util/util"; import { app, dialog, ipcMain, shell } from "electron"; import envPaths from "env-paths"; import { existsSync, mkdirSync } from "fs"; import os from "os"; import path from "path"; import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev"; import * as keyutil from "../frontend/util/keyutil"; // This is a little trick to ensure that Electron puts all its runtime data into a subdirectory to avoid conflicts with our own data. // On macOS, it will store to ~/Library/Application \Support/waveterm/electron // On Linux, it will store to ~/.config/waveterm/electron // On Windows, it will store to %LOCALAPPDATA%/waveterm/electron app.setName("waveterm/electron"); const isDev = !app.isPackaged; const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; console.log(`Running in ${isDev ? "development" : "production"} mode`); if (isDev) { process.env[WaveDevVarName] = "1"; } if (isDevVite) { process.env[WaveDevViteVarName] = "1"; } const waveDirNamePrefix = "waveterm"; const waveDirNameSuffix = isDev ? "dev" : ""; const waveDirName = `${waveDirNamePrefix}${waveDirNameSuffix ? `-${waveDirNameSuffix}` : ""}`; const paths = envPaths("waveterm", { suffix: waveDirNameSuffix }); app.setName(isDev ? "Wave (Dev)" : "Wave"); const unamePlatform = process.platform; const unameArch: string = process.arch; keyutil.setKeyUtilPlatform(unamePlatform); const WaveConfigHomeVarName = "WAVETERM_CONFIG_HOME"; const WaveDataHomeVarName = "WAVETERM_DATA_HOME"; const WaveHomeVarName = "WAVETERM_HOME"; export function checkIfRunningUnderARM64Translation(fullConfig: FullConfigType) { if (!fullConfig.settings["app:dismissarchitecturewarning"] && app.runningUnderARM64Translation) { console.log("Running under ARM64 translation, alerting user"); const dialogOpts: Electron.MessageBoxOptions = { type: "warning", buttons: ["Dismiss", "Learn More"], title: "Wave has detected a performance issue", message: `Wave is running in ARM64 translation mode which may impact performance.\n\nRecommendation: Download the native ARM64 version from our website for optimal performance.`, }; const choice = dialog.showMessageBoxSync(null, dialogOpts); if (choice === 1) { // Open the documentation URL console.log("User chose to learn more"); fireAndForget(() => shell.openExternal( "https://docs.waveterm.dev/faq#why-does-wave-warn-me-about-arm64-translation-when-it-launches" ) ); throw new Error("User redirected to docsite to learn more about ARM64 translation, exiting"); } else { console.log("User dismissed the dialog"); } } } /** * Gets the path to the old Wave home directory (defaults to `~/.waveterm`). * @returns The path to the directory if it exists and contains valid data for the current app, otherwise null. */ function getWaveHomeDir(): string { let home = process.env[WaveHomeVarName]; if (!home) { const homeDir = app.getPath("home"); if (homeDir) { home = path.join(homeDir, `.${waveDirName}`); } } // If home exists and it has `wave.lock` in it, we know it has valid data from Wave >=v0.8. Otherwise, it could be for WaveLegacy (<v0.8) if (home && existsSync(home) && existsSync(path.join(home, "wave.lock"))) { return home; } return null; } /** * Ensure the given path exists, creating it recursively if it doesn't. * @param path The path to ensure. * @returns The same path, for chaining. */ function ensurePathExists(path: string): string { if (!existsSync(path)) { mkdirSync(path, { recursive: true }); } return path; } /** * Gets the path to the directory where Wave configurations are stored. Creates the directory if it does not exist. * Handles backwards compatibility with the old Wave Home directory model, where configurations and data were stored together. * @returns The path where configurations should be stored. */ function getWaveConfigDir(): string { // If wave home dir exists, use it for backwards compatibility const waveHomeDir = getWaveHomeDir(); if (waveHomeDir) { return path.join(waveHomeDir, "config"); } const override = process.env[WaveConfigHomeVarName]; const xdgConfigHome = process.env.XDG_CONFIG_HOME; let retVal: string; if (override) { retVal = override; } else if (xdgConfigHome) { retVal = path.join(xdgConfigHome, waveDirName); } else { retVal = path.join(app.getPath("home"), ".config", waveDirName); } return ensurePathExists(retVal); } /** * Gets the path to the directory where Wave data is stored. Creates the directory if it does not exist. * Handles backwards compatibility with the old Wave Home directory model, where configurations and data were stored together. * @returns The path where data should be stored. */ function getWaveDataDir(): string { // If wave home dir exists, use it for backwards compatibility const waveHomeDir = getWaveHomeDir(); if (waveHomeDir) { return waveHomeDir; } const override = process.env[WaveDataHomeVarName]; const xdgDataHome = process.env.XDG_DATA_HOME; let retVal: string; if (override) { retVal = override; } else if (xdgDataHome) { retVal = path.join(xdgDataHome, waveDirName); } else { retVal = paths.data; } return ensurePathExists(retVal); } function getElectronAppBasePath(): string { // import.meta.dirname in dev points to waveterm/dist/main return path.dirname(import.meta.dirname); } function getElectronAppUnpackedBasePath(): string { return getElectronAppBasePath().replace("app.asar", "app.asar.unpacked"); } function getElectronAppResourcesPath(): string { if (isDev) { // import.meta.dirname in dev points to waveterm/dist/main return path.dirname(import.meta.dirname); } return process.resourcesPath; } const wavesrvBinName = `wavesrv.${unameArch}`; function getWaveSrvPath(): string { if (process.platform === "win32") { const winBinName = `${wavesrvBinName}.exe`; const appPath = path.join(getElectronAppUnpackedBasePath(), "bin", winBinName); return `${appPath}`; } return path.join(getElectronAppUnpackedBasePath(), "bin", wavesrvBinName); } function getWaveSrvCwd(): string { return getWaveDataDir(); } ipcMain.on("get-is-dev", (event) => { event.returnValue = isDev; }); ipcMain.on("get-platform", (event, url) => { event.returnValue = unamePlatform; }); ipcMain.on("get-user-name", (event) => { const userInfo = os.userInfo(); event.returnValue = userInfo.username; }); ipcMain.on("get-host-name", (event) => { event.returnValue = os.hostname(); }); ipcMain.on("get-webview-preload", (event) => { event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); }); ipcMain.on("get-data-dir", (event) => { event.returnValue = getWaveDataDir(); }); ipcMain.on("get-config-dir", (event) => { event.returnValue = getWaveConfigDir(); }); ipcMain.on("get-home-dir", (event) => { event.returnValue = app.getPath("home"); }); /** * Gets the value of the XDG_CURRENT_DESKTOP environment variable. If ORIGINAL_XDG_CURRENT_DESKTOP is set, it will be returned instead. * This corrects for a strange behavior in Electron, where it sets its own value for XDG_CURRENT_DESKTOP to improve Chromium compatibility. * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop * @returns The value of the XDG_CURRENT_DESKTOP environment variable, or ORIGINAL_XDG_CURRENT_DESKTOP if set, or undefined if neither are set. */ function getXdgCurrentDesktop(): string { if (process.env.ORIGINAL_XDG_CURRENT_DESKTOP) { return process.env.ORIGINAL_XDG_CURRENT_DESKTOP; } else if (process.env.XDG_CURRENT_DESKTOP) { return process.env.XDG_CURRENT_DESKTOP; } else { return undefined; } } /** * Calls the given callback with the value of the XDG_CURRENT_DESKTOP environment variable set to ORIGINAL_XDG_CURRENT_DESKTOP if it is set. * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop * @param callback The callback to call. */ function callWithOriginalXdgCurrentDesktop(callback: () => void) { const currXdgCurrentDesktopDefined = "XDG_CURRENT_DESKTOP" in process.env; const currXdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP; const originalXdgCurrentDesktop = getXdgCurrentDesktop(); if (originalXdgCurrentDesktop) { process.env.XDG_CURRENT_DESKTOP = originalXdgCurrentDesktop; } callback(); if (originalXdgCurrentDesktop) { if (currXdgCurrentDesktopDefined) { process.env.XDG_CURRENT_DESKTOP = currXdgCurrentDesktop; } else { delete process.env.XDG_CURRENT_DESKTOP; } } } /** * Calls the given async callback with the value of the XDG_CURRENT_DESKTOP environment variable set to ORIGINAL_XDG_CURRENT_DESKTOP if it is set. * @see https://www.electronjs.org/docs/latest/api/environment-variables#original_xdg_current_desktop * @param callback The async callback to call. */ async function callWithOriginalXdgCurrentDesktopAsync(callback: () => Promise<void>) { const currXdgCurrentDesktopDefined = "XDG_CURRENT_DESKTOP" in process.env; const currXdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP; const originalXdgCurrentDesktop = getXdgCurrentDesktop(); if (originalXdgCurrentDesktop) { process.env.XDG_CURRENT_DESKTOP = originalXdgCurrentDesktop; } await callback(); if (originalXdgCurrentDesktop) { if (currXdgCurrentDesktopDefined) { process.env.XDG_CURRENT_DESKTOP = currXdgCurrentDesktop; } else { delete process.env.XDG_CURRENT_DESKTOP; } } } export { callWithOriginalXdgCurrentDesktop, callWithOriginalXdgCurrentDesktopAsync, getElectronAppBasePath, getElectronAppResourcesPath, getElectronAppUnpackedBasePath, getWaveConfigDir, getWaveDataDir, getWaveSrvCwd, getWaveSrvPath, getXdgCurrentDesktop, isDev, isDevVite, unameArch, unamePlatform, WaveConfigHomeVarName, WaveDataHomeVarName, }; ================================================ FILE: emain/emain-tabview.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; import { adaptFromElectronKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { CHORD_TIMEOUT } from "@/util/sharedconst"; import { Rectangle, shell, WebContentsView } from "electron"; import { createNewWaveWindow, getWaveWindowById } from "emain/emain-window"; import path from "path"; import { configureAuthKeyRequestInjection } from "./authkey"; import { setWasActive } from "./emain-activity"; import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform"; import { decreaseZoomLevel, handleCtrlShiftFocus, handleCtrlShiftState, increaseZoomLevel, resetZoomLevel, shFrameNavHandler, shNavHandler, } from "./emain-util"; import { ElectronWshClient } from "./emain-wsh"; function handleWindowsMenuAccelerators( waveEvent: WaveKeyboardEvent, tabView: WaveTabView, fullConfig: FullConfigType ): boolean { const waveWindow = getWaveWindowById(tabView.waveWindowId); if (checkKeyPressed(waveEvent, "Ctrl:Shift:n")) { createNewWaveWindow(); return true; } if (checkKeyPressed(waveEvent, "Ctrl:Shift:r")) { tabView.webContents.reloadIgnoringCache(); return true; } if (checkKeyPressed(waveEvent, "Ctrl:v")) { const ctrlVPaste = fullConfig?.settings?.["app:ctrlvpaste"]; const shouldPaste = ctrlVPaste ?? true; if (!shouldPaste) { return false; } tabView.webContents.paste(); return true; } if (checkKeyPressed(waveEvent, "Ctrl:0")) { resetZoomLevel(tabView.webContents); return true; } if (checkKeyPressed(waveEvent, "Ctrl:=") || checkKeyPressed(waveEvent, "Ctrl:Shift:=")) { increaseZoomLevel(tabView.webContents); return true; } if (checkKeyPressed(waveEvent, "Ctrl:-") || checkKeyPressed(waveEvent, "Ctrl:Shift:-")) { decreaseZoomLevel(tabView.webContents); return true; } if (checkKeyPressed(waveEvent, "F11")) { if (waveWindow) { waveWindow.setFullScreen(!waveWindow.isFullScreen()); } return true; } for (let i = 1; i <= 9; i++) { if (checkKeyPressed(waveEvent, `Alt:Ctrl:${i}`)) { const workspaceNum = i - 1; RpcApi.WorkspaceListCommand(ElectronWshClient).then((workspaceList) => { if (workspaceList && workspaceNum < workspaceList.length) { const workspace = workspaceList[workspaceNum]; if (waveWindow) { waveWindow.switchWorkspace(workspace.workspacedata.oid); } } }); return true; } } if (checkKeyPressed(waveEvent, "Alt:Shift:i")) { tabView.webContents.toggleDevTools(); return true; } return false; } function computeBgColor(fullConfig: FullConfigType): string { const settings = fullConfig?.settings; const isTransparent = settings?.["window:transparent"] ?? false; const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); if (isTransparent) { return "#00000000"; } else if (isBlur) { return "#00000000"; } else { return "#222222"; } } const wcIdToWaveTabMap = new Map<number, WaveTabView>(); export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView { if (webContentsId == null) { return null; } return wcIdToWaveTabMap.get(webContentsId); } export class WaveTabView extends WebContentsView { waveWindowId: string; // this will be set for any tabviews that are initialized. (unset for the hot spare) isActiveTab: boolean; isWaveAIOpen: boolean; private _waveTabId: string; // always set, WaveTabViews are unique per tab lastUsedTs: number; // ts milliseconds createdTs: number; // ts milliseconds initPromise: Promise<void>; initResolve: () => void; savedInitOpts: WaveInitOpts; waveReadyPromise: Promise<void>; waveReadyResolve: () => void; isInitialized: boolean = false; isWaveReady: boolean = false; isDestroyed: boolean = false; keyboardChordMode: boolean = false; resetChordModeTimeout: NodeJS.Timeout = null; constructor(fullConfig: FullConfigType) { console.log("createBareTabView"); super({ webPreferences: { preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), webviewTag: true, }, }); this.createdTs = Date.now(); this.isWaveAIOpen = false; this.savedInitOpts = null; this.initPromise = new Promise((resolve, _) => { this.initResolve = resolve; }); this.initPromise.then(() => { this.isInitialized = true; console.log("tabview init", Date.now() - this.createdTs + "ms"); }); this.waveReadyPromise = new Promise((resolve, _) => { this.waveReadyResolve = resolve; }); this.waveReadyPromise.then(() => { this.isWaveReady = true; }); const wcId = this.webContents.id; wcIdToWaveTabMap.set(wcId, this); if (isDevVite) { this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`); } else { this.webContents.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); } this.webContents.on("destroyed", () => { wcIdToWaveTabMap.delete(wcId); removeWaveTabView(this.waveTabId); this.isDestroyed = true; }); this.setBackgroundColor(computeBgColor(fullConfig)); } get waveTabId(): string { return this._waveTabId; } set waveTabId(waveTabId: string) { this._waveTabId = waveTabId; } setKeyboardChordMode(mode: boolean) { this.keyboardChordMode = mode; if (mode) { if (this.resetChordModeTimeout) { clearTimeout(this.resetChordModeTimeout); } this.resetChordModeTimeout = setTimeout(() => { this.keyboardChordMode = false; }, CHORD_TIMEOUT); } else { if (this.resetChordModeTimeout) { clearTimeout(this.resetChordModeTimeout); this.resetChordModeTimeout = null; } } } positionTabOnScreen(winBounds: Rectangle) { const curBounds = this.getBounds(); if ( curBounds.width == winBounds.width && curBounds.height == winBounds.height && curBounds.x == 0 && curBounds.y == 0 ) { return; } this.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); } positionTabOffScreen(winBounds: Rectangle) { this.setBounds({ x: -15000, y: -15000, width: winBounds.width, height: winBounds.height, }); } isOnScreen() { const bounds = this.getBounds(); return bounds.x == 0 && bounds.y == 0; } destroy() { console.log("destroy tab", this.waveTabId); removeWaveTabView(this.waveTabId); if (!this.isDestroyed) { this.webContents?.close(); } this.isDestroyed = true; } } let MaxCacheSize = 10; const wcvCache = new Map<string, WaveTabView>(); export function setMaxTabCacheSize(size: number) { console.log("setMaxTabCacheSize", size); MaxCacheSize = size; } export function getWaveTabView(waveTabId: string): WaveTabView | undefined { const rtn = wcvCache.get(waveTabId); if (rtn) { rtn.lastUsedTs = Date.now(); } return rtn; } function tryEvictEntry(waveTabId: string): boolean { const tabView = wcvCache.get(waveTabId); if (!tabView) { return false; } if (tabView.isActiveTab) { return false; } const lastUsedDiff = Date.now() - tabView.lastUsedTs; if (lastUsedDiff < 1000) { return false; } const ww = getWaveWindowById(tabView.waveWindowId); if (!ww) { // this shouldn't happen, but if it does, just destroy the tabview console.log("[error] WaveWindow not found for WaveTabView", tabView.waveTabId); tabView.destroy(); return true; } else { // will trigger a destroy on the tabview ww.removeTabView(tabView.waveTabId, false); return true; } } function checkAndEvictCache(): void { if (wcvCache.size <= MaxCacheSize) { return; } const sorted = Array.from(wcvCache.values()).sort((a, b) => { // Prioritize entries which are active if (a.isActiveTab && !b.isActiveTab) { return -1; } // Otherwise, sort by lastUsedTs return a.lastUsedTs - b.lastUsedTs; }); for (let i = 0; i < sorted.length - MaxCacheSize; i++) { tryEvictEntry(sorted[i].waveTabId); } } export function clearTabCache() { const wcVals = Array.from(wcvCache.values()); for (let i = 0; i < wcVals.length; i++) { const tabView = wcVals[i]; tryEvictEntry(tabView.waveTabId); } } // returns [tabview, initialized] export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: string): Promise<[WaveTabView, boolean]> { let tabView = getWaveTabView(tabId); if (tabView) { return [tabView, true]; } const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); tabView = getSpareTab(fullConfig); tabView.waveWindowId = waveWindowId; tabView.lastUsedTs = Date.now(); setWaveTabView(tabId, tabView); tabView.waveTabId = tabId; tabView.webContents.on("will-navigate", shNavHandler); tabView.webContents.on("will-frame-navigate", shFrameNavHandler); tabView.webContents.on("did-attach-webview", (event, wc) => { wc.setWindowOpenHandler((details) => { if (wc == null || wc.isDestroyed() || tabView.webContents == null || tabView.webContents.isDestroyed()) { return { action: "deny" }; } tabView.webContents.send("webview-new-window", wc.id, details); return { action: "deny" }; }); }); tabView.webContents.on("before-input-event", (e, input) => { const waveEvent = adaptFromElectronKeyEvent(input); // console.log("WIN bie", tabView.waveTabId.substring(0, 8), waveEvent.type, waveEvent.code); handleCtrlShiftState(tabView.webContents, waveEvent); setWasActive(true); if (input.type == "keyDown" && tabView.keyboardChordMode) { e.preventDefault(); tabView.setKeyboardChordMode(false); tabView.webContents.send("reinject-key", waveEvent); return; } if (unamePlatform === "win32" && input.type == "keyDown") { if (handleWindowsMenuAccelerators(waveEvent, tabView, fullConfig)) { e.preventDefault(); return; } } }); tabView.webContents.setWindowOpenHandler(({ url, frameName }) => { if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { console.log("openExternal fallback", url); shell.openExternal(url); } console.log("window-open denied", url); return { action: "deny" }; }); tabView.webContents.on("blur", () => { handleCtrlShiftFocus(tabView.webContents, false); }); configureAuthKeyRequestInjection(tabView.webContents.session); return [tabView, false]; } export function setWaveTabView(waveTabId: string, wcv: WaveTabView): void { if (waveTabId == null) { return; } wcvCache.set(waveTabId, wcv); checkAndEvictCache(); } function removeWaveTabView(waveTabId: string): void { if (waveTabId == null) { return; } wcvCache.delete(waveTabId); } let HotSpareTab: WaveTabView = null; export function ensureHotSpareTab(fullConfig: FullConfigType) { console.log("ensureHotSpareTab"); if (HotSpareTab == null) { HotSpareTab = new WaveTabView(fullConfig); } } export function getSpareTab(fullConfig: FullConfigType): WaveTabView { setTimeout(() => ensureHotSpareTab(fullConfig), 500); if (HotSpareTab != null) { const rtn = HotSpareTab; HotSpareTab = null; console.log("getSpareTab: returning hotspare"); return rtn; } else { console.log("getSpareTab: creating new tab"); return new WaveTabView(fullConfig); } } ================================================ FILE: emain/emain-util.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as electron from "electron"; import { getWebServerEndpoint } from "../frontend/util/endpoints"; export const WaveAppPathVarName = "WAVETERM_APP_PATH"; export const WaveAppResourcesPathVarName = "WAVETERM_RESOURCES_PATH"; export const WaveAppElectronExecPath = "WAVETERM_ELECTRONEXECPATH"; const MinZoomLevel = 0.4; const MaxZoomLevel = 2.6; const ZoomDelta = 0.2; // Note: Chromium automatically syncs zoom factor across all WebContents // sharing the same origin/session, so we only need to notify renderers // to update their CSS/state — not call setZoomFactor on each one. // We broadcast to all WebContents (including devtools, webviews, etc.) but // that is safe because "zoom-factor-change" is a custom app-defined event // that only our renderers listen to; unrecognized IPC messages are ignored. function broadcastZoomFactorChanged(newZoomFactor: number): void { for (const wc of electron.webContents.getAllWebContents()) { if (wc.isDestroyed()) { continue; } wc.send("zoom-factor-change", newZoomFactor); } } export function increaseZoomLevel(webContents: electron.WebContents): void { const newZoom = Math.min(MaxZoomLevel, webContents.getZoomFactor() + ZoomDelta); webContents.setZoomFactor(newZoom); broadcastZoomFactorChanged(newZoom); } export function decreaseZoomLevel(webContents: electron.WebContents): void { const newZoom = Math.max(MinZoomLevel, webContents.getZoomFactor() - ZoomDelta); webContents.setZoomFactor(newZoom); broadcastZoomFactorChanged(newZoom); } export function resetZoomLevel(webContents: electron.WebContents): void { webContents.setZoomFactor(1); broadcastZoomFactorChanged(1); } export function getElectronExecPath(): string { return process.execPath; } // not necessarily exact, but we use this to help get us unstuck in certain cases let lastCtrlShiftSate: boolean = false; export function delay(ms): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } function setCtrlShift(wc: Electron.WebContents, state: boolean) { lastCtrlShiftSate = state; wc.send("control-shift-state-update", state); } export function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) { if (!focused) { setCtrlShift(sender, false); } } export function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) { if (waveEvent.type == "keyup") { if (waveEvent.key === "Control" || waveEvent.key === "Shift") { setCtrlShift(sender, false); } if (waveEvent.key == "Meta") { if (waveEvent.control && waveEvent.shift) { setCtrlShift(sender, true); } } if (lastCtrlShiftSate) { if (!waveEvent.control || !waveEvent.shift) { setCtrlShift(sender, false); } } return; } if (waveEvent.type == "keydown") { if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { // Set the control and shift without the Meta key setCtrlShift(sender, true); } else { // Unset if Meta is pressed setCtrlShift(sender, false); } } return; } } export function shNavHandler(event: Electron.Event<Electron.WebContentsWillNavigateEventParams>, url: string) { const isDev = !electron.app.isPackaged; if ( isDev && (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html") || url.startsWith("http://127.0.0.1:5174/index.html") || url.startsWith("http://localhost:5174/index.html")) ) { // this is a dev-mode hot-reload, ignore it console.log("allowing hot-reload of index.html"); return; } event.preventDefault(); if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { console.log("open external, shNav", url); electron.shell.openExternal(url); } else { console.log("navigation canceled", url); } } function frameOrAncestorHasName(frame: Electron.WebFrameMain, name: string): boolean { let cur: Electron.WebFrameMain = frame; while (cur != null) { if (cur.name === name) { return true; } cur = cur.parent; } return false; } export function shFrameNavHandler(event: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>) { if (!event.frame?.parent) { // only use this handler to process iframe events (non-iframe events go to shNavHandler) return; } const url = event.url; console.log(`frame-navigation url=${url} frame=${event.frame.name}`); if (event.frame.name == "webview") { // "webview" links always open in new window // this will *not* effect the initial load because srcdoc does not count as an electron navigation console.log("open external, frameNav", url); event.preventDefault(); electron.shell.openExternal(url); return; } if ( frameOrAncestorHasName(event.frame, "pdfview") && (url.startsWith("blob:file:///") || url.startsWith("chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file/") || url.startsWith(getWebServerEndpoint() + "/wave/stream-local-file?")) ) { // allowed return; } if (event.frame.name != null && event.frame.name.startsWith("tsunami:")) { // Parse port from frame name: tsunami:[port]:[blockid] const nameParts = event.frame.name.split(":"); const expectedPort = nameParts.length >= 2 ? nameParts[1] : null; try { const tsunamiUrl = new URL(url); if ( tsunamiUrl.protocol === "http:" && tsunamiUrl.hostname === "localhost" && expectedPort && tsunamiUrl.port === expectedPort ) { // allowed return; } // If navigation is not to expected port, open externally event.preventDefault(); electron.shell.openExternal(url); return; } catch (e) { // Invalid URL, fall through to prevent navigation } } event.preventDefault(); console.log("frame navigation canceled", event.frame.name, url); } function isWindowFullyVisible(bounds: electron.Rectangle): boolean { const displays = electron.screen.getAllDisplays(); // Helper function to check if a point is inside any display function isPointInDisplay(x: number, y: number) { for (const display of displays) { const { x: dx, y: dy, width, height } = display.bounds; if (x >= dx && x < dx + width && y >= dy && y < dy + height) { return true; } } return false; } // Check all corners of the window const topLeft = isPointInDisplay(bounds.x, bounds.y); const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y); const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height); const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height); return topLeft && topRight && bottomLeft && bottomRight; } function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display { const displays = electron.screen.getAllDisplays(); let maxArea = 0; let bestDisplay = null; for (let display of displays) { const { x, y, width, height } = display.bounds; const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); const overlapArea = overlapX * overlapY; if (overlapArea > maxArea) { maxArea = overlapArea; bestDisplay = display; } } return bestDisplay; } function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle { const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea; let { x, y, width, height } = bounds; // Adjust width and height to fit within the display's work area width = Math.min(width, dWidth); height = Math.min(height, dHeight); // Adjust x to ensure the window fits within the display if (x < dx) { x = dx; } else if (x + width > dx + dWidth) { x = dx + dWidth - width; } // Adjust y to ensure the window fits within the display if (y < dy) { y = dy; } else if (y + height > dy + dHeight) { y = dy + dHeight - height; } return { x, y, width, height }; } export function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle { if (!isWindowFullyVisible(bounds)) { let targetDisplay = findDisplayWithMostArea(bounds); if (!targetDisplay) { targetDisplay = electron.screen.getPrimaryDisplay(); } return adjustBoundsToFitDisplay(bounds, targetDisplay); } return bounds; } export function waveKeyToElectronKey(waveKey: string): string { const waveParts = waveKey.split(":"); const electronParts: Array<string> = waveParts.map((part: string) => { const digitRegexpMatch = new RegExp("^c{Digit([0-9])}$").exec(part); const numpadRegexpMatch = new RegExp("^c{Numpad([0-9])}$").exec(part); const lowercaseCharMatch = new RegExp("^([a-z])$").exec(part); if (part == "ArrowUp") { return "Up"; } if (part == "ArrowDown") { return "Down"; } if (part == "ArrowLeft") { return "Left"; } if (part == "ArrowRight") { return "Right"; } if (part == "Soft1") { return "F21"; } if (part == "Soft2") { return "F22"; } if (part == "Soft3") { return "F23"; } if (part == "Soft4") { return "F24"; } if (part == " ") { return "Space"; } if (part == "CapsLock") { return "Capslock"; } if (part == "NumLock") { return "Numlock"; } if (part == "ScrollLock") { return "Scrolllock"; } if (part == "AudioVolumeUp") { return "VolumeUp"; } if (part == "AudioVolumeDown") { return "VolumeDown"; } if (part == "AudioVolumeMute") { return "VolumeMute"; } if (part == "MediaTrackNext") { return "MediaNextTrack"; } if (part == "MediaTrackPrevious") { return "MediaPreviousTrack"; } if (part == "Decimal") { return "numdec"; } if (part == "Add") { return "numadd"; } if (part == "Subtract") { return "numsub"; } if (part == "Multiply") { return "nummult"; } if (part == "Divide") { return "numdiv"; } if (digitRegexpMatch && digitRegexpMatch.length > 1) { return digitRegexpMatch[1]; } if (numpadRegexpMatch && numpadRegexpMatch.length > 1) { return `num${numpadRegexpMatch[1]}`; } if (lowercaseCharMatch && lowercaseCharMatch.length > 1) { return lowercaseCharMatch[1].toUpperCase(); } return part; }); return electronParts.join("+"); } ================================================ FILE: emain/emain-wavesrv.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as electron from "electron"; import * as child_process from "node:child_process"; import * as readline from "readline"; import { WebServerEndpointVarName, WSServerEndpointVarName } from "../frontend/util/endpoints"; import { AuthKey, WaveAuthKeyEnv } from "./authkey"; import { setForceQuit, setUserConfirmedQuit } from "./emain-activity"; import { getElectronAppResourcesPath, getElectronAppUnpackedBasePath, getWaveConfigDir, getWaveDataDir, getWaveSrvCwd, getWaveSrvPath, getXdgCurrentDesktop, WaveConfigHomeVarName, WaveDataHomeVarName, } from "./emain-platform"; import { getElectronExecPath, WaveAppElectronExecPath, WaveAppPathVarName, WaveAppResourcesPathVarName, } from "./emain-util"; import { updater } from "./updater"; let isWaveSrvDead = false; let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; let WaveVersion = "unknown"; // set by WAVESRV-ESTART let WaveBuildTime = 0; // set by WAVESRV-ESTART export function getWaveVersion(): { version: string; buildTime: number } { return { version: WaveVersion, buildTime: WaveBuildTime }; } let waveSrvReadyResolve = (value: boolean) => {}; const waveSrvReady: Promise<boolean> = new Promise((resolve, _) => { waveSrvReadyResolve = resolve; }); export function getWaveSrvReady(): Promise<boolean> { return waveSrvReady; } export function getWaveSrvProc(): child_process.ChildProcessWithoutNullStreams | null { return waveSrvProc; } export function getIsWaveSrvDead(): boolean { return isWaveSrvDead; } export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promise<boolean> { let pResolve: (value: boolean) => void; let pReject: (reason?: any) => void; const rtnPromise = new Promise<boolean>((argResolve, argReject) => { pResolve = argResolve; pReject = argReject; }); const envCopy = { ...process.env }; const xdgCurrentDesktop = getXdgCurrentDesktop(); if (xdgCurrentDesktop != null) { envCopy["XDG_CURRENT_DESKTOP"] = xdgCurrentDesktop; } envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath(); envCopy[WaveAppResourcesPathVarName] = getElectronAppResourcesPath(); envCopy[WaveAppElectronExecPath] = getElectronExecPath(); envCopy[WaveAuthKeyEnv] = AuthKey; envCopy[WaveDataHomeVarName] = getWaveDataDir(); envCopy[WaveConfigHomeVarName] = getWaveConfigDir(); const waveSrvCmd = getWaveSrvPath(); console.log("trying to run local server", waveSrvCmd); const proc = child_process.spawn(getWaveSrvPath(), { cwd: getWaveSrvCwd(), env: envCopy, }); proc.on("exit", (e) => { if (updater?.status == "installing") { return; } console.log("wavesrv exited, shutting down"); setForceQuit(true); isWaveSrvDead = true; electron.app.quit(); }); proc.on("spawn", (e) => { console.log("spawned wavesrv"); waveSrvProc = proc; pResolve(true); }); proc.on("error", (e) => { console.log("error running wavesrv", e); pReject(e); }); const rlStdout = readline.createInterface({ input: proc.stdout, terminal: false, }); rlStdout.on("line", (line) => { console.log(line); }); const rlStderr = readline.createInterface({ input: proc.stderr, terminal: false, }); rlStderr.on("line", (line) => { if (line.includes("WAVESRV-ESTART")) { const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.-]+) buildtime:(\d+)/gm.exec( line ); if (startParams == null) { console.log("error parsing WAVESRV-ESTART line", line); setUserConfirmedQuit(true); electron.app.quit(); return; } process.env[WSServerEndpointVarName] = startParams[1]; process.env[WebServerEndpointVarName] = startParams[2]; WaveVersion = startParams[3]; WaveBuildTime = parseInt(startParams[4]); waveSrvReadyResolve(true); return; } if (line.startsWith("WAVESRV-EVENT:")) { const evtJson = line.slice("WAVESRV-EVENT:".length); try { const evtMsg: WSEventType = JSON.parse(evtJson); handleWSEvent(evtMsg); } catch (e) { console.log("error handling WAVESRV-EVENT", e); } return; } console.log(line); }); return rtnPromise; } ================================================ FILE: emain/emain-web.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ipcMain, webContents, WebContents } from "electron"; import { WaveBrowserWindow } from "./emain-window"; export function getWebContentsByBlockId(ww: WaveBrowserWindow, tabId: string, blockId: string): Promise<WebContents> { const prtn = new Promise<WebContents>((resolve, reject) => { const randId = Math.floor(Math.random() * 1000000000).toString(); const respCh = `getWebContentsByBlockId-${randId}`; ww?.activeTabView?.webContents.send("webcontentsid-from-blockid", blockId, respCh); ipcMain.once(respCh, (event, webContentsId) => { if (webContentsId == null) { resolve(null); return; } const wc = webContents.fromId(parseInt(webContentsId)); resolve(wc); }); setTimeout(() => { reject(new Error("timeout waiting for response")); }, 2000); }); return prtn; } function escapeSelector(selector: string): string { return selector .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/'/g, "\\'") .replace(/\n/g, "\\n") .replace(/\r/g, "\\r") .replace(/\t/g, "\\t"); } export type WebGetOpts = { all?: boolean; inner?: boolean; }; export async function webGetSelector(wc: WebContents, selector: string, opts?: WebGetOpts): Promise<string[]> { if (!wc || !selector) { return null; } const escapedSelector = escapeSelector(selector); const queryMethod = opts?.all ? "querySelectorAll" : "querySelector"; const prop = opts?.inner ? "innerHTML" : "outerHTML"; const execExpr = ` (() => { const toArr = x => (x instanceof NodeList) ? Array.from(x) : (x ? [x] : []); try { const result = document.${queryMethod}("${escapedSelector}"); const value = toArr(result).map(el => el.${prop}); return { value }; } catch (error) { return { error: error.message }; } })()`; const results = await wc.executeJavaScript(execExpr); if (results.error) { throw new Error(results.error); } return results.value; } ================================================ FILE: emain/emain-window.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ClientService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; import { fireAndForget } from "@/util/util"; import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen } from "electron"; import { globalEvents } from "emain/emain-events"; import path from "path"; import { debounce } from "throttle-debounce"; import { getGlobalIsQuitting, getGlobalIsRelaunching, setGlobalIsRelaunching, setWasActive, setWasInFg, } from "./emain-activity"; import { log } from "./emain-log"; import { getElectronAppBasePath, isDev, unamePlatform } from "./emain-platform"; import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview"; import { delay, ensureBoundsAreVisible, waveKeyToElectronKey } from "./emain-util"; import { ElectronWshClient } from "./emain-wsh"; import { updater } from "./updater"; const DevInitTimeoutMs = 5000; export type WindowOpts = { unamePlatform: NodeJS.Platform; isPrimaryStartupWindow?: boolean; foregroundWindow?: boolean; }; export const MinWindowWidth = 800; export const MinWindowHeight = 500; export function calculateWindowBounds( winSize?: { width?: number; height?: number }, pos?: { x?: number; y?: number }, settings?: any ): { x: number; y: number; width: number; height: number } { let winWidth = winSize?.width; let winHeight = winSize?.height; let winPosX = pos?.x ?? 100; let winPosY = pos?.y ?? 100; if ( (winWidth == null || winWidth === 0 || winHeight == null || winHeight === 0) && settings?.["window:dimensions"] ) { const dimensions = settings["window:dimensions"]; const match = dimensions.match(/^(\d+)[xX](\d+)$/); if (match) { const [, dimensionWidth, dimensionHeight] = match; const parsedWidth = parseInt(dimensionWidth, 10); const parsedHeight = parseInt(dimensionHeight, 10); if ((!winWidth || winWidth === 0) && Number.isFinite(parsedWidth) && parsedWidth > 0) { winWidth = parsedWidth; } if ((!winHeight || winHeight === 0) && Number.isFinite(parsedHeight) && parsedHeight > 0) { winHeight = parsedHeight; } } else { console.warn('Invalid window:dimensions format. Expected "widthxheight".'); } } if (winWidth == null || winWidth == 0) { const primaryDisplay = screen.getPrimaryDisplay(); const { width } = primaryDisplay.workAreaSize; winWidth = width - winPosX - 100; if (winWidth > 2000) { winWidth = 2000; } } if (winHeight == null || winHeight == 0) { const primaryDisplay = screen.getPrimaryDisplay(); const { height } = primaryDisplay.workAreaSize; winHeight = height - winPosY - 100; if (winHeight > 1200) { winHeight = 1200; } } winWidth = Math.max(winWidth, MinWindowWidth); winHeight = Math.max(winHeight, MinWindowHeight); let winBounds = { x: winPosX, y: winPosY, width: winWidth, height: winHeight, }; return ensureBoundsAreVisible(winBounds); } export const waveWindowMap = new Map<string, WaveBrowserWindow>(); // waveWindowId -> WaveBrowserWindow // on blur we do not set this to null (but on destroy we do), so this tracks the *last* focused window // e.g. it persists when the app itself is not focused export let focusedWaveWindow: WaveBrowserWindow = null; let cachedClientId: string = null; let hasCompletedFirstRelaunch = false; async function getClientId() { if (cachedClientId != null) { return cachedClientId; } const clientData = await ClientService.GetClientData(); cachedClientId = clientData?.oid; return cachedClientId; } type WindowActionQueueEntry = | { op: "switchtab"; tabId: string; setInBackend: boolean; primaryStartupTab?: boolean; } | { op: "createtab"; } | { op: "closetab"; tabId: string; } | { op: "switchworkspace"; workspaceId: string; }; function isNonEmptyUnsavedWorkspace(workspace: Workspace): boolean { return !workspace.name && !workspace.icon && workspace.tabids?.length > 1; } export class WaveBrowserWindow extends BaseWindow { waveWindowId: string; workspaceId: string; allLoadedTabViews: Map<string, WaveTabView>; activeTabView: WaveTabView; private canClose: boolean; private deleteAllowed: boolean; private actionQueue: WindowActionQueueEntry[]; constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) { const settings = fullConfig?.settings; console.log("create win", waveWindow.oid); const winBounds = calculateWindowBounds(waveWindow.winsize, waveWindow.pos, settings); const winOpts: BaseWindowConstructorOptions = { x: winBounds.x, y: winBounds.y, width: winBounds.width, height: winBounds.height, minWidth: MinWindowWidth, minHeight: MinWindowHeight, show: false, }; const isTransparent = settings?.["window:transparent"] ?? false; const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); if (opts.unamePlatform === "darwin") { winOpts.titleBarStyle = "hiddenInset"; winOpts.titleBarOverlay = false; winOpts.autoHideMenuBar = !settings?.["window:showmenubar"]; if (isTransparent) { winOpts.transparent = true; } else if (isBlur) { winOpts.vibrancy = "fullscreen-ui"; } else { winOpts.backgroundColor = "#222222"; } } else if (opts.unamePlatform === "linux") { winOpts.titleBarStyle = settings["window:nativetitlebar"] ? "default" : "hidden"; winOpts.titleBarOverlay = { symbolColor: "white", color: "#00000000", }; winOpts.icon = path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png"); winOpts.autoHideMenuBar = !settings?.["window:showmenubar"]; if (isTransparent) { winOpts.transparent = true; } else { winOpts.backgroundColor = "#222222"; } } else if (opts.unamePlatform === "win32") { winOpts.titleBarStyle = "hidden"; winOpts.titleBarOverlay = { color: "#222222", symbolColor: "#c3c8c2", height: 32, }; if (isTransparent) { winOpts.transparent = true; } else if (isBlur) { winOpts.backgroundMaterial = "acrylic"; } else { winOpts.backgroundColor = "#222222"; } } super(winOpts); if (opts.unamePlatform === "win32") { this.setMenu(null); } const fullscreenOnLaunch = fullConfig?.settings["window:fullscreenonlaunch"]; if (fullscreenOnLaunch && opts.foregroundWindow) { this.once("show", () => { this.setFullScreen(true); }); } this.actionQueue = []; this.waveWindowId = waveWindow.oid; this.workspaceId = waveWindow.workspaceid; this.allLoadedTabViews = new Map<string, WaveTabView>(); const winBoundsPoller = setInterval(() => { if (this.isDestroyed()) { clearInterval(winBoundsPoller); return; } if (this.actionQueue.length > 0) { return; } this.finalizePositioning(); }, 1000); this.on( // @ts-expect-error -- "resize" event with debounce handler not in Electron type definitions "resize", debounce(400, (e) => this.mainResizeHandler(e)) ); this.on("resize", () => { if (this.isDestroyed()) { return; } this.activeTabView?.positionTabOnScreen(this.getContentBounds()); }); this.on( // @ts-expect-error -- "move" event with debounce handler not in Electron type definitions "move", debounce(400, (e) => this.mainResizeHandler(e)) ); this.on("enter-full-screen", async () => { if (this.isDestroyed()) { return; } console.log("enter-full-screen event", this.getContentBounds()); const tabView = this.activeTabView; if (tabView) { tabView.webContents.send("fullscreen-change", true); } this.activeTabView?.positionTabOnScreen(this.getContentBounds()); }); this.on("leave-full-screen", async () => { if (this.isDestroyed()) { return; } const tabView = this.activeTabView; if (tabView) { tabView.webContents.send("fullscreen-change", false); } this.activeTabView?.positionTabOnScreen(this.getContentBounds()); }); this.on("focus", () => { if (this.isDestroyed()) { return; } if (getGlobalIsRelaunching()) { return; } focusedWaveWindow = this; // eslint-disable-line @typescript-eslint/no-this-alias console.log("focus win", this.waveWindowId); fireAndForget(() => ClientService.FocusWindow(this.waveWindowId)); setWasInFg(true); setWasActive(true); setTimeout(() => globalEvents.emit("windows-updated"), 50); }); this.on("blur", () => { setTimeout(() => globalEvents.emit("windows-updated"), 50); }); this.on("close", (e) => { if (this.canClose) { return; } if (this.isDestroyed()) { return; } console.log("win 'close' handler fired", this.waveWindowId); if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) { return; } e.preventDefault(); fireAndForget(async () => { const numWindows = waveWindowMap.size; const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); if (numWindows > 1 || !fullConfig.settings["window:savelastwindow"]) { if (fullConfig.settings["window:confirmclose"]) { const workspace = await WorkspaceService.GetWorkspace(this.workspaceId); if (isNonEmptyUnsavedWorkspace(workspace)) { const choice = dialog.showMessageBoxSync(this, { type: "question", buttons: ["Cancel", "Close Window"], title: "Confirm", message: "Window has unsaved tabs, closing window will delete existing tabs.\n\nContinue?", }); if (choice === 0) { return; } } } this.deleteAllowed = true; } this.canClose = true; this.close(); }); }); this.on("closed", () => { console.log("win 'closed' handler fired", this.waveWindowId); if (getGlobalIsQuitting() || updater?.status == "installing") { console.log("win quitting or updating", this.waveWindowId); return; } setTimeout(() => globalEvents.emit("windows-updated"), 50); waveWindowMap.delete(this.waveWindowId); if (focusedWaveWindow == this) { focusedWaveWindow = null; } this.removeAllChildViews(); if (getGlobalIsRelaunching()) { console.log("win relaunching", this.waveWindowId); this.destroy(); return; } if (this.deleteAllowed) { console.log("win removing window from backend DB", this.waveWindowId); fireAndForget(() => WindowService.CloseWindow(this.waveWindowId, true)); } }); waveWindowMap.set(waveWindow.oid, this); setTimeout(() => globalEvents.emit("windows-updated"), 50); } private removeAllChildViews() { for (const tabView of this.allLoadedTabViews.values()) { if (!this.isDestroyed()) { this.contentView.removeChildView(tabView); } tabView?.destroy(); } } async switchWorkspace(workspaceId: string) { console.log("switchWorkspace", workspaceId, this.waveWindowId); if (workspaceId == this.workspaceId) { console.log("switchWorkspace already on this workspace", this.waveWindowId); return; } // If the workspace is already owned by a window, then we can just call SwitchWorkspace without first prompting the user, since it'll just focus to the other window. const workspaceList = await WorkspaceService.ListWorkspaces(); if (!workspaceList?.find((wse) => wse.workspaceid === workspaceId)?.windowid) { const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId); if (curWorkspace && isNonEmptyUnsavedWorkspace(curWorkspace)) { console.log( `existing unsaved workspace ${this.workspaceId} has content, opening workspace ${workspaceId} in new window` ); await createWindowForWorkspace(workspaceId); return; } } await this._queueActionInternal({ op: "switchworkspace", workspaceId }); } async setActiveTab(tabId: string, setInBackend: boolean, primaryStartupTab = false) { console.log( "setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend, primaryStartupTab ? "(primary startup)" : "" ); await this._queueActionInternal({ op: "switchtab", tabId, setInBackend, primaryStartupTab }); } private async initializeTab(tabView: WaveTabView, primaryStartupTab: boolean) { const clientId = await getClientId(); await this.awaitWithDevTimeout(tabView.initPromise, "initPromise", tabView.waveTabId); this.contentView.addChildView(tabView); const initOpts: WaveInitOpts = { tabId: tabView.waveTabId, clientId: clientId, windowId: this.waveWindowId, activate: true, }; if (primaryStartupTab) { initOpts.primaryTabStartup = true; } tabView.savedInitOpts = { ...initOpts }; tabView.savedInitOpts.activate = false; delete tabView.savedInitOpts.primaryTabStartup; let startTime = Date.now(); console.log( "before wave ready, init tab, sending wave-init", tabView.waveTabId, primaryStartupTab ? "(primary startup)" : "" ); tabView.webContents.send("wave-init", initOpts); await this.awaitWithDevTimeout(tabView.waveReadyPromise, "waveReadyPromise", tabView.waveTabId); console.log("wave-ready init time", Date.now() - startTime + "ms"); } private async awaitWithDevTimeout<T>(promise: Promise<T>, name: string, tabId: string): Promise<T> { if (!isDev) { return promise; } let timeoutHandle: ReturnType<typeof setTimeout> = null; const timeoutPromise = new Promise<never>((_, reject) => { timeoutHandle = setTimeout(() => { console.log( `[dev] ${name} timed out after ${DevInitTimeoutMs}ms for tab ${tabId}, showing window for devtools` ); if (!this.isDestroyed() && !this.isVisible()) { this.show(); } if (this.activeTabView?.webContents && !this.activeTabView.webContents.isDevToolsOpened()) { this.activeTabView.webContents.openDevTools(); } reject(new Error(`[dev] ${name} timed out after ${DevInitTimeoutMs}ms`)); }, DevInitTimeoutMs); }); try { return await Promise.race([promise, timeoutPromise]); } finally { clearTimeout(timeoutHandle); } } private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean, primaryStartupTab = false) { if (this.activeTabView == tabView) { return; } const oldActiveView = this.activeTabView; tabView.isActiveTab = true; if (oldActiveView != null) { oldActiveView.isActiveTab = false; } this.activeTabView = tabView; this.allLoadedTabViews.set(tabView.waveTabId, tabView); if (!tabInitialized) { console.log("initializing a new tab", primaryStartupTab ? "(primary startup)" : ""); const p1 = this.initializeTab(tabView, primaryStartupTab); const p2 = this.repositionTabsSlowly(100); await Promise.all([p1, p2]); } else { console.log("reusing an existing tab, calling wave-init", tabView.waveTabId); const p1 = this.repositionTabsSlowly(35); const p2 = tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit await Promise.all([p1, p2]); } // something is causing the new tab to lose focus so it requires manual refocusing tabView.webContents.focus(); setTimeout(() => { if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) { tabView.webContents.focus(); } }, 10); setTimeout(() => { if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) { tabView.webContents.focus(); } }, 30); } private async repositionTabsSlowly(delayMs: number) { const activeTabView = this.activeTabView; const winBounds = this.getContentBounds(); if (activeTabView == null) { return; } if (activeTabView.isOnScreen()) { activeTabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height, }); } else { activeTabView.setBounds({ x: winBounds.width - 10, y: winBounds.height - 10, width: winBounds.width, height: winBounds.height, }); } await delay(delayMs); if (this.activeTabView != activeTabView) { // another tab view has been set, do not finalize this layout return; } this.finalizePositioning(); } private finalizePositioning() { if (this.isDestroyed()) { return; } const curBounds = this.getContentBounds(); this.activeTabView?.positionTabOnScreen(curBounds); for (const tabView of this.allLoadedTabViews.values()) { if (tabView == this.activeTabView) { continue; } tabView?.positionTabOffScreen(curBounds); } } async queueCreateTab() { await this._queueActionInternal({ op: "createtab" }); } async queueCloseTab(tabId: string) { await this._queueActionInternal({ op: "closetab", tabId }); } private async _queueActionInternal(entry: WindowActionQueueEntry) { if (this.actionQueue.length >= 2) { this.actionQueue[1] = entry; return; } const wasEmpty = this.actionQueue.length === 0; this.actionQueue.push(entry); if (wasEmpty) { await this.processActionQueue(); } } private removeTabViewLater(tabId: string, delayMs: number) { setTimeout(() => { this.removeTabView(tabId, false); }, 1000); } // the queue and this function are used to serialize operations that update the window contents view // processActionQueue will replace [1] if it is already set // we don't mess with [0] because it is "in process" // we replace [1] because there is no point to run an action that is going to be overwritten private async processActionQueue() { while (this.actionQueue.length > 0) { try { if (this.isDestroyed()) { break; } const entry = this.actionQueue[0]; let tabId: string = null; // have to use "===" here to get the typechecker to work :/ switch (entry.op) { case "createtab": tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true); break; case "switchtab": tabId = entry.tabId; if (this.activeTabView?.waveTabId == tabId) { continue; } if (entry.setInBackend) { await WorkspaceService.SetActiveTab(this.workspaceId, tabId); } break; case "closetab": { tabId = entry.tabId; const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); if (rtn == null) { console.log( "[error] closeTab: no return value", tabId, this.workspaceId, this.waveWindowId ); return; } this.removeTabViewLater(tabId, 1000); if (rtn.closewindow) { this.close(); return; } if (!rtn.newactivetabid) { return; } tabId = rtn.newactivetabid; break; } case "switchworkspace": { const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, entry.workspaceId); if (!newWs) { return; } console.log("processActionQueue switchworkspace newWs", newWs); this.removeAllChildViews(); console.log("destroyed all tabs", this.waveWindowId); this.workspaceId = entry.workspaceId; this.allLoadedTabViews = new Map(); tabId = newWs.activetabid; break; } } if (tabId == null) { return; } const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); const primaryStartupTabFlag = entry.op === "switchtab" ? (entry.primaryStartupTab ?? false) : false; await this.setTabViewIntoWindow(tabView, tabInitialized, primaryStartupTabFlag); } catch (e) { console.log("error caught in processActionQueue", e); } finally { this.actionQueue.shift(); } } } private async mainResizeHandler(_: any) { if (this == null || this.isDestroyed() || this.fullScreen) { return; } const bounds = this.getBounds(); try { await WindowService.SetWindowPosAndSize( this.waveWindowId, { x: bounds.x, y: bounds.y }, { width: bounds.width, height: bounds.height } ); } catch (e) { console.log("error sending new window bounds to backend", e); } } removeTabView(tabId: string, force: boolean) { if (!force && this.activeTabView?.waveTabId == tabId) { console.log("cannot remove active tab", tabId, this.waveWindowId); return; } const tabView = this.allLoadedTabViews.get(tabId); if (tabView == null) { console.log("removeTabView -- tabView not found", tabId, this.waveWindowId); // the tab was never loaded, so just return return; } this.contentView.removeChildView(tabView); this.allLoadedTabViews.delete(tabId); tabView.destroy(); } destroy() { console.log("destroy win", this.waveWindowId); this.deleteAllowed = true; super.destroy(); } } export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow { for (const ww of waveWindowMap.values()) { if (ww.allLoadedTabViews.has(tabId)) { return ww; } } } export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow { if (webContentsId == null) { return null; } const tabView = getWaveTabViewByWebContentsId(webContentsId); if (tabView == null) { return null; } return getWaveWindowByTabId(tabView.waveTabId); } export function getWaveWindowById(windowId: string): WaveBrowserWindow { return waveWindowMap.get(windowId); } export function getWaveWindowByWorkspaceId(workspaceId: string): WaveBrowserWindow { for (const waveWindow of waveWindowMap.values()) { if (waveWindow.workspaceId === workspaceId) { return waveWindow; } } } export function getAllWaveWindows(): WaveBrowserWindow[] { return Array.from(waveWindowMap.values()); } export async function createWindowForWorkspace(workspaceId: string) { const newWin = await WindowService.CreateWindow(null, workspaceId); if (!newWin) { console.log("error creating new window", this.waveWindowId); } const newBwin = await createBrowserWindow(newWin, await RpcApi.GetFullConfigCommand(ElectronWshClient), { unamePlatform, isPrimaryStartupWindow: false, }); newBwin.show(); } // note, this does not *show* the window. // to show, await win.readyPromise and then win.show() export async function createBrowserWindow( waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts ): Promise<WaveBrowserWindow> { if (!waveWindow) { console.log("createBrowserWindow: no waveWindow"); waveWindow = await WindowService.CreateWindow(null, ""); } let workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid); if (!workspace) { console.log("createBrowserWindow: no workspace, creating new window"); await WindowService.CloseWindow(waveWindow.oid, true); waveWindow = await WindowService.CreateWindow(null, ""); workspace = await WorkspaceService.GetWorkspace(waveWindow.workspaceid); } console.log("createBrowserWindow", waveWindow.oid, workspace.oid, workspace); const bwin = new WaveBrowserWindow(waveWindow, fullConfig, opts); if (workspace.activetabid) { await bwin.setActiveTab(workspace.activetabid, false, opts.isPrimaryStartupWindow ?? false); } return bwin; } ipcMain.on("set-active-tab", async (event, tabId) => { const ww = getWaveWindowByWebContentsId(event.sender.id); console.log("set-active-tab", tabId, ww?.waveWindowId); await ww?.setActiveTab(tabId, true); }); ipcMain.on("create-tab", async (event, opts) => { const senderWc = event.sender; const ww = getWaveWindowByWebContentsId(senderWc.id); if (ww != null) { await ww.queueCreateTab(); } event.returnValue = true; return null; }); ipcMain.on("set-waveai-open", (event, isOpen: boolean) => { const tabView = getWaveTabViewByWebContentsId(event.sender.id); if (tabView) { tabView.isWaveAIOpen = isOpen; } }); ipcMain.handle("close-tab", async (event, workspaceId: string, tabId: string, confirmClose: boolean) => { const ww = getWaveWindowByWorkspaceId(workspaceId); if (ww == null) { console.log(`close-tab: no window found for workspace ws=${workspaceId} tab=${tabId}`); return false; } if (confirmClose) { const choice = dialog.showMessageBoxSync(ww, { type: "question", defaultId: 1, // Enter activates "Close Tab" cancelId: 0, // Esc activates "Cancel" buttons: ["Cancel", "Close Tab"], title: "Confirm", message: "Are you sure you want to close this tab?", }); if (choice === 0) { return false; } } await ww.queueCloseTab(tabId); return true; }); ipcMain.on("switch-workspace", (event, workspaceId) => { fireAndForget(async () => { const ww = getWaveWindowByWebContentsId(event.sender.id); console.log("switch-workspace", workspaceId, ww?.waveWindowId); await ww?.switchWorkspace(workspaceId); }); }); export async function createWorkspace(window: WaveBrowserWindow) { const newWsId = await WorkspaceService.CreateWorkspace("", "", "", true); if (newWsId) { if (window) { await window.switchWorkspace(newWsId); } else { await createWindowForWorkspace(newWsId); } } } ipcMain.on("create-workspace", (event) => { fireAndForget(async () => { const ww = getWaveWindowByWebContentsId(event.sender.id); console.log("create-workspace", ww?.waveWindowId); await createWorkspace(ww); }); }); ipcMain.on("delete-workspace", (event, workspaceId) => { fireAndForget(async () => { const ww = getWaveWindowByWebContentsId(event.sender.id); console.log("delete-workspace", workspaceId, ww?.waveWindowId); const workspaceList = await WorkspaceService.ListWorkspaces(); const workspaceHasWindow = !!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid; const choice = dialog.showMessageBoxSync(this, { type: "question", buttons: ["Cancel", "Delete Workspace"], title: "Confirm", message: `Deleting workspace will also delete its contents.\n\nContinue?`, }); if (choice === 0) { console.log("user cancelled workspace delete", workspaceId, ww?.waveWindowId); return; } const newWorkspaceId = await WorkspaceService.DeleteWorkspace(workspaceId); console.log("delete-workspace done", workspaceId, ww?.waveWindowId); if (ww?.workspaceId == workspaceId) { if (newWorkspaceId) { await ww.switchWorkspace(newWorkspaceId); } else { console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId); ww.destroy(); } } }); }); export async function createNewWaveWindow() { log("createNewWaveWindow"); const clientData = await ClientService.GetClientData(); const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); let recreatedWindow = false; const allWindows = getAllWaveWindows(); if (allWindows.length === 0 && clientData?.windowids?.length >= 1) { console.log("no windows, but clientData has windowids, recreating first window"); // reopen the first window const existingWindowId = clientData.windowids[0]; const existingWindowData = (await ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow; if (existingWindowData != null) { const win = await createBrowserWindow(existingWindowData, fullConfig, { unamePlatform, isPrimaryStartupWindow: false, }); win.show(); recreatedWindow = true; } } if (recreatedWindow) { console.log("recreated window, returning"); return; } console.log("creating new window"); const newBrowserWindow = await createBrowserWindow(null, fullConfig, { unamePlatform, isPrimaryStartupWindow: false, }); newBrowserWindow.show(); } export async function relaunchBrowserWindows() { console.log("relaunchBrowserWindows"); setGlobalIsRelaunching(true); const windows = getAllWaveWindows(); if (windows.length > 0) { for (const window of windows) { console.log("relaunch -- closing window", window.waveWindowId); window.close(); } await delay(1200); } setGlobalIsRelaunching(false); const clientData = await ClientService.GetClientData(); const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); const windowIds = clientData.windowids ?? []; const wins: WaveBrowserWindow[] = []; const isFirstRelaunch = !hasCompletedFirstRelaunch; const primaryWindowId = windowIds.length > 0 ? windowIds[0] : null; for (const windowId of windowIds.slice().reverse()) { const windowData: WaveWindow = await WindowService.GetWindow(windowId); if (windowData == null) { console.log("relaunch -- window data not found, closing window", windowId); await WindowService.CloseWindow(windowId, true); continue; } const isPrimaryStartupWindow = isFirstRelaunch && windowId === primaryWindowId; console.log( "relaunch -- creating window", windowId, windowData, isPrimaryStartupWindow ? "(primary startup)" : "" ); const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform, isPrimaryStartupWindow, foregroundWindow: windowId === primaryWindowId, }); wins.push(win); } hasCompletedFirstRelaunch = true; for (const win of wins) { console.log("show window", win.waveWindowId); win.show(); } } export function registerGlobalHotkey(rawGlobalHotKey: string) { try { const electronHotKey = waveKeyToElectronKey(rawGlobalHotKey); console.log("registering globalhotkey of ", electronHotKey); globalShortcut.register(electronHotKey, () => { const selectedWindow = focusedWaveWindow; const firstWaveWindow = getAllWaveWindows()[0]; if (focusedWaveWindow) { selectedWindow.focus(); } else if (firstWaveWindow) { firstWaveWindow.focus(); } else { fireAndForget(createNewWaveWindow); } }); } catch (e) { console.log("error registering global hotkey: ", e); } } ================================================ FILE: emain/emain-wsh.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WindowService } from "@/app/store/services"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { Notification, net, safeStorage, shell } from "electron"; import { getResolvedUpdateChannel } from "emain/updater"; import { unamePlatform } from "./emain-platform"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; import { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from "./emain-window"; export class ElectronWshClientType extends WshClient { constructor() { super("electron"); } async handle_webselector(rh: RpcResponseHelper, data: CommandWebSelectorData): Promise<string[]> { if (!data.tabid || !data.blockid || !data.workspaceid) { throw new Error("tabid and blockid are required"); } const ww = getWaveWindowByWorkspaceId(data.workspaceid); if (ww == null) { throw new Error(`no window found with workspace ${data.workspaceid}`); } const wc = await getWebContentsByBlockId(ww, data.tabid, data.blockid); if (wc == null) { throw new Error(`no webcontents found with blockid ${data.blockid}`); } const rtn = await webGetSelector(wc, data.selector, data.opts); return rtn; } async handle_notify(rh: RpcResponseHelper, notificationOptions: WaveNotificationOptions) { new Notification({ title: notificationOptions.title, body: notificationOptions.body, silent: notificationOptions.silent, }).show(); } async handle_getupdatechannel(rh: RpcResponseHelper): Promise<string> { return getResolvedUpdateChannel(); } async handle_focuswindow(rh: RpcResponseHelper, windowId: string) { console.log(`focuswindow ${windowId}`); const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); let ww = getWaveWindowById(windowId); if (ww == null) { const window = await WindowService.GetWindow(windowId); if (window == null) { throw new Error(`window ${windowId} not found`); } ww = await createBrowserWindow(window, fullConfig, { unamePlatform, isPrimaryStartupWindow: false, }); } ww.focus(); } async handle_electronencrypt( rh: RpcResponseHelper, data: CommandElectronEncryptData ): Promise<CommandElectronEncryptRtnData> { if (!safeStorage.isEncryptionAvailable()) { throw new Error("encryption is not available"); } const encrypted = safeStorage.encryptString(data.plaintext); const ciphertext = encrypted.toString("base64"); let storagebackend = ""; if (process.platform === "linux") { storagebackend = safeStorage.getSelectedStorageBackend(); } return { ciphertext, storagebackend, }; } async handle_electrondecrypt( rh: RpcResponseHelper, data: CommandElectronDecryptData ): Promise<CommandElectronDecryptRtnData> { if (!safeStorage.isEncryptionAvailable()) { throw new Error("encryption is not available"); } const encrypted = Buffer.from(data.ciphertext, "base64"); const plaintext = safeStorage.decryptString(encrypted); let storagebackend = ""; if (process.platform === "linux") { storagebackend = safeStorage.getSelectedStorageBackend(); } return { plaintext, storagebackend, }; } async handle_networkonline(rh: RpcResponseHelper): Promise<boolean> { return net.isOnline(); } async handle_electronsystembell(rh: RpcResponseHelper): Promise<void> { shell.beep(); } // async handle_workspaceupdate(rh: RpcResponseHelper) { // console.log("workspaceupdate"); // fireAndForget(async () => { // console.log("workspace menu clicked"); // const updatedWorkspaceMenu = await getWorkspaceMenu(); // const workspaceMenu = Menu.getApplicationMenu().getMenuItemById("workspace-menu"); // workspaceMenu.submenu = Menu.buildFromTemplate(updatedWorkspaceMenu); // }); // } } export let ElectronWshClient: ElectronWshClientType; export function initElectronWshClient() { ElectronWshClient = new ElectronWshClientType(); } ================================================ FILE: emain/emain.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; import * as electron from "electron"; import { focusedBuilderWindow, getAllBuilderWindows } from "emain/emain-builder"; import { globalEvents } from "emain/emain-events"; import { sprintf } from "sprintf-js"; import * as services from "../frontend/app/store/services"; import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil-base"; import { fireAndForget, sleep } from "../frontend/util/util"; import { AuthKey, configureAuthKeyRequestInjection } from "./authkey"; import { getActivityState, getAndClearTermCommandsDurable, getAndClearTermCommandsRemote, getAndClearTermCommandsRun, getAndClearTermCommandsWsl, getForceQuit, getGlobalIsRelaunching, getUserConfirmedQuit, setForceQuit, setGlobalIsQuitting, setGlobalIsStarting, setUserConfirmedQuit, setWasActive, setWasInFg, } from "./emain-activity"; import { initIpcHandlers } from "./emain-ipc"; import { log } from "./emain-log"; import { initMenuEventSubscriptions, makeAndSetAppMenu, makeDockTaskbar } from "./emain-menu"; import { checkIfRunningUnderARM64Translation, getElectronAppBasePath, getElectronAppUnpackedBasePath, getWaveConfigDir, getWaveDataDir, isDev, unameArch, unamePlatform, } from "./emain-platform"; import { ensureHotSpareTab, setMaxTabCacheSize } from "./emain-tabview"; import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, runWaveSrv } from "./emain-wavesrv"; import { createBrowserWindow, createNewWaveWindow, focusedWaveWindow, getAllWaveWindows, getWaveWindowById, getWaveWindowByWorkspaceId, registerGlobalHotkey, relaunchBrowserWindows, WaveBrowserWindow, } from "./emain-window"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { getLaunchSettings } from "./launchsettings"; import { configureAutoUpdater, updater } from "./updater"; const electronApp = electron.app; let confirmQuit = true; const waveDataDir = getWaveDataDir(); const waveConfigDir = getWaveConfigDir(); electron.nativeTheme.themeSource = "dark"; console.log = log; console.log( sprintf( "waveterm-app starting, data_dir=%s, config_dir=%s electronpath=%s gopath=%s arch=%s/%s electron=%s", waveDataDir, waveConfigDir, getElectronAppBasePath(), getElectronAppUnpackedBasePath(), unamePlatform, unameArch, process.versions.electron ) ); if (isDev) { console.log("waveterm-app WAVETERM_DEV set"); } function handleWSEvent(evtMsg: WSEventType) { fireAndForget(async () => { console.log("handleWSEvent", evtMsg?.eventtype); if (evtMsg.eventtype == "electron:newwindow") { console.log("electron:newwindow", evtMsg.data); const windowId: string = evtMsg.data; const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow; if (windowData == null) { return; } const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); const newWin = await createBrowserWindow(windowData, fullConfig, { unamePlatform, isPrimaryStartupWindow: false, }); newWin.show(); } else if (evtMsg.eventtype == "electron:closewindow") { console.log("electron:closewindow", evtMsg.data); if (evtMsg.data === undefined) return; const ww = getWaveWindowById(evtMsg.data); if (ww != null) { ww.destroy(); // bypass the "are you sure?" dialog } } else if (evtMsg.eventtype == "electron:updateactivetab") { const activeTabUpdate: { workspaceid: string; newactivetabid: string } = evtMsg.data; console.log("electron:updateactivetab", activeTabUpdate); const ww = getWaveWindowByWorkspaceId(activeTabUpdate.workspaceid); if (ww == null) { return; } await ww.setActiveTab(activeTabUpdate.newactivetabid, false); } else { console.log("unhandled electron ws eventtype", evtMsg.eventtype); } }); } // we try to set the primary display as index [0] function getActivityDisplays(): ActivityDisplayType[] { const displays = electron.screen.getAllDisplays(); const primaryDisplay = electron.screen.getPrimaryDisplay(); const rtn: ActivityDisplayType[] = []; for (const display of displays) { const adt = { width: display.size.width, height: display.size.height, dpr: display.scaleFactor, internal: display.internal, }; if (display.id === primaryDisplay?.id) { rtn.unshift(adt); } else { rtn.push(adt); } } return rtn; } async function sendDisplaysTDataEvent() { const displays = getActivityDisplays(); if (displays.length === 0) { return; } const props: TEventProps = {}; props["display:count"] = displays.length; props["display:height"] = displays[0].height; props["display:width"] = displays[0].width; props["display:dpr"] = displays[0].dpr; props["display:all"] = displays; try { await RpcApi.RecordTEventCommand( ElectronWshClient, { event: "app:display", props, }, { noresponse: true } ); } catch (e) { console.log("error sending display tdata event", e); } } function logActiveState() { fireAndForget(async () => { const astate = getActivityState(); const activity: ActivityUpdate = { openminutes: 1 }; const ww = focusedWaveWindow; const activeTabView = ww?.activeTabView; const isWaveAIOpen = activeTabView?.isWaveAIOpen ?? false; if (astate.wasInFg) { activity.fgminutes = 1; } if (astate.wasActive) { activity.activeminutes = 1; } activity.displays = getActivityDisplays(); const termCmdCount = getAndClearTermCommandsRun(); if (termCmdCount > 0) { activity.termcommandsrun = termCmdCount; } const termCmdRemoteCount = getAndClearTermCommandsRemote(); const termCmdWslCount = getAndClearTermCommandsWsl(); const termCmdDurableCount = getAndClearTermCommandsDurable(); const props: TEventProps = { "activity:activeminutes": activity.activeminutes, "activity:fgminutes": activity.fgminutes, "activity:openminutes": activity.openminutes, }; if (termCmdCount > 0) { props["activity:termcommandsrun"] = termCmdCount; } if (termCmdRemoteCount > 0) { props["activity:termcommands:remote"] = termCmdRemoteCount; } if (termCmdWslCount > 0) { props["activity:termcommands:wsl"] = termCmdWslCount; } if (termCmdDurableCount > 0) { props["activity:termcommands:durable"] = termCmdDurableCount; } if (astate.wasActive && isWaveAIOpen) { props["activity:waveaiactiveminutes"] = 1; } if (astate.wasInFg && isWaveAIOpen) { props["activity:waveaifgminutes"] = 1; } try { await RpcApi.ActivityCommand(ElectronWshClient, activity, { noresponse: true }); await RpcApi.RecordTEventCommand( ElectronWshClient, { event: "app:activity", props, }, { noresponse: true } ); } catch (e) { console.log("error logging active state", e); } finally { setWasInFg(ww?.isFocused() ?? false); setWasActive(false); } }); } // this isn't perfect, but gets the job done without being complicated function runActiveTimer() { logActiveState(); setTimeout(runActiveTimer, 60000); } function hideWindowWithCatch(window: WaveBrowserWindow) { if (window == null) { return; } try { if (window.isDestroyed()) { return; } window.hide(); } catch (e) { console.log("error hiding window", e); } } electronApp.on("window-all-closed", () => { if (getGlobalIsRelaunching()) { return; } if (unamePlatform !== "darwin") { setUserConfirmedQuit(true); electronApp.quit(); } }); electronApp.on("before-quit", (e) => { const allWindows = getAllWaveWindows(); const allBuilders = getAllBuilderWindows(); if ( confirmQuit && !getForceQuit() && !getUserConfirmedQuit() && (allWindows.length > 0 || allBuilders.length > 0) && !getIsWaveSrvDead() && !process.env.WAVETERM_NOCONFIRMQUIT ) { e.preventDefault(); const choice = electron.dialog.showMessageBoxSync(null, { type: "question", buttons: ["Cancel", "Quit"], title: "Confirm Quit", message: "Are you sure you want to quit Wave Terminal?", defaultId: 0, cancelId: 0, }); if (choice === 0) { return; } setUserConfirmedQuit(true); electronApp.quit(); return; } setGlobalIsQuitting(true); updater?.stop(); if (unamePlatform == "win32") { // win32 doesn't have a SIGINT, so we just let electron die, which // ends up killing wavesrv via closing it's stdin. return; } getWaveSrvProc()?.kill("SIGINT"); shutdownWshrpc(); if (getForceQuit()) { return; } e.preventDefault(); for (const window of allWindows) { hideWindowWithCatch(window); } for (const builder of allBuilders) { builder.hide(); } if (getIsWaveSrvDead()) { console.log("wavesrv is dead, quitting immediately"); setForceQuit(true); electronApp.quit(); return; } setTimeout(() => { console.log("waiting for wavesrv to exit..."); setForceQuit(true); electronApp.quit(); }, 3000); }); process.on("SIGINT", () => { console.log("Caught SIGINT, shutting down"); setUserConfirmedQuit(true); electronApp.quit(); }); process.on("SIGHUP", () => { console.log("Caught SIGHUP, shutting down"); setUserConfirmedQuit(true); electronApp.quit(); }); process.on("SIGTERM", () => { console.log("Caught SIGTERM, shutting down"); setUserConfirmedQuit(true); electronApp.quit(); }); let caughtException = false; process.on("uncaughtException", (error) => { if (caughtException) { return; } // Check if the error is related to QUIC protocol, if so, ignore (can happen with the updater) if (error?.message?.includes("net::ERR_QUIC_PROTOCOL_ERROR")) { console.log("Ignoring QUIC protocol error:", error.message); console.log("Stack Trace:", error.stack); return; } caughtException = true; console.log("Uncaught Exception, shutting down: ", error); console.log("Stack Trace:", error.stack); // Optionally, handle cleanup or exit the app setUserConfirmedQuit(true); electronApp.quit(); }); let lastWaveWindowCount = 0; let lastIsBuilderWindowActive = false; globalEvents.on("windows-updated", () => { const wwCount = getAllWaveWindows().length; const isBuilderActive = focusedBuilderWindow != null; if (wwCount == lastWaveWindowCount && isBuilderActive == lastIsBuilderWindowActive) { return; } lastWaveWindowCount = wwCount; lastIsBuilderWindowActive = isBuilderActive; console.log("windows-updated", wwCount, "builder-active:", isBuilderActive); makeAndSetAppMenu(); }); async function appMain() { // Set disableHardwareAcceleration as early as possible, if required. const launchSettings = getLaunchSettings(); if (launchSettings?.["window:disablehardwareacceleration"]) { console.log("disabling hardware acceleration, per launch settings"); electronApp.disableHardwareAcceleration(); } const startTs = Date.now(); const instanceLock = electronApp.requestSingleInstanceLock(); if (!instanceLock) { console.log("waveterm-app could not get single-instance-lock, shutting down"); setUserConfirmedQuit(true); electronApp.quit(); return; } electronApp.on("second-instance", (_event, argv, workingDirectory) => { console.log("second-instance event, argv:", argv, "workingDirectory:", workingDirectory); fireAndForget(createNewWaveWindow); }); try { await runWaveSrv(handleWSEvent); } catch (e) { console.log(e.toString()); } const ready = await getWaveSrvReady(); console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms"); await electronApp.whenReady(); configureAuthKeyRequestInjection(electron.session.defaultSession); initIpcHandlers(); await sleep(10); // wait a bit for wavesrv to be ready try { initElectronWshClient(); initElectronWshrpc(ElectronWshClient, { authKey: AuthKey }); initMenuEventSubscriptions(); } catch (e) { console.log("error initializing wshrpc", e); } const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); checkIfRunningUnderARM64Translation(fullConfig); if (fullConfig?.settings?.["app:confirmquit"] != null) { confirmQuit = fullConfig.settings["app:confirmquit"]; } ensureHotSpareTab(fullConfig); await relaunchBrowserWindows(); setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe setTimeout(sendDisplaysTDataEvent, 5000); makeAndSetAppMenu(); makeDockTaskbar(); await configureAutoUpdater(); setGlobalIsStarting(false); if (fullConfig?.settings?.["window:maxtabcachesize"] != null) { setMaxTabCacheSize(fullConfig.settings["window:maxtabcachesize"]); } electronApp.on("activate", () => { const allWindows = getAllWaveWindows(); if (allWindows.length === 0) { fireAndForget(createNewWaveWindow); } }); electron.powerMonitor.on("resume", () => { console.log("system resumed from sleep, notifying server"); fireAndForget(async () => { try { await RpcApi.NotifySystemResumeCommand(ElectronWshClient, { noresponse: true }); } catch (e) { console.log("error calling NotifySystemResumeCommand", e); } }); }); const rawGlobalHotKey = launchSettings?.["app:globalhotkey"]; if (rawGlobalHotKey) { registerGlobalHotkey(rawGlobalHotKey); } } appMain().catch((e) => { console.log("appMain error", e); setUserConfirmedQuit(true); electronApp.quit(); }); ================================================ FILE: emain/launchsettings.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import fs from "fs"; import path from "path"; import { getWaveConfigDir } from "./emain-platform"; /** * Get settings directly from the Wave Home directory on launch. * Only use this when the app is first starting up. Otherwise, prefer the settings.GetFullConfig function. * @returns The initial launch settings for the application. */ export function getLaunchSettings(): SettingsType { const settingsPath = path.join(getWaveConfigDir(), "settings.json"); try { const settingsContents = fs.readFileSync(settingsPath, "utf8"); return JSON.parse(settingsContents); } catch (_) { // fail silently } } ================================================ FILE: emain/preload-webview.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ipcRenderer } from "electron"; document.addEventListener("contextmenu", (event) => { console.log("contextmenu event", event); if (event.target == null) { return; } const targetElement = event.target as HTMLElement; // Check if the right-click is on an image if (targetElement.tagName === "IMG") { setTimeout(() => { if (event.defaultPrevented) { return; } event.preventDefault(); const imgElem = targetElement as HTMLImageElement; const imageUrl = imgElem.src; ipcRenderer.send("webview-image-contextmenu", { src: imageUrl }); }, 50); return; } // do nothing }); console.log("loaded wave preload-webview.ts"); ================================================ FILE: emain/preload.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { contextBridge, ipcRenderer, Rectangle, WebviewTag } from "electron"; // update type in custom.d.ts (ElectronApi type) contextBridge.exposeInMainWorld("api", { getAuthKey: () => ipcRenderer.sendSync("get-auth-key"), getIsDev: () => ipcRenderer.sendSync("get-is-dev"), getPlatform: () => ipcRenderer.sendSync("get-platform"), getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), getUserName: () => ipcRenderer.sendSync("get-user-name"), getHostName: () => ipcRenderer.sendSync("get-host-name"), getDataDir: () => ipcRenderer.sendSync("get-data-dir"), getConfigDir: () => ipcRenderer.sendSync("get-config-dir"), getHomeDir: () => ipcRenderer.sendSync("get-home-dir"), getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), getZoomFactor: () => ipcRenderer.sendSync("get-zoom-factor"), openNewWindow: () => ipcRenderer.send("open-new-window"), showWorkspaceAppMenu: (workspaceId) => ipcRenderer.send("workspace-appmenu-show", workspaceId), showBuilderAppMenu: (builderId) => ipcRenderer.send("builder-appmenu-show", builderId), showContextMenu: (workspaceId, menu) => ipcRenderer.send("contextmenu-show", workspaceId, menu), onContextMenuClick: (callback: (id: string | null) => void) => ipcRenderer.on("contextmenu-click", (_event, id: string | null) => callback(id)), downloadFile: (filePath) => ipcRenderer.send("download", { filePath }), openExternal: (url) => { if (url && typeof url === "string") { ipcRenderer.send("open-external", url); } else { console.error("Invalid URL passed to openExternal:", url); } }, getEnv: (varName) => ipcRenderer.sendSync("get-env", varName), onFullScreenChange: (callback) => ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)), onZoomFactorChange: (callback) => ipcRenderer.on("zoom-factor-change", (_event, zoomFactor) => callback(zoomFactor)), onUpdaterStatusChange: (callback) => ipcRenderer.on("app-update-status", (_event, status) => callback(status)), getUpdaterStatus: () => ipcRenderer.sendSync("get-app-update-status"), getUpdaterChannel: () => ipcRenderer.sendSync("get-updater-channel"), installAppUpdate: () => ipcRenderer.send("install-app-update"), onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect), onReinjectKey: (callback) => ipcRenderer.on("reinject-key", (_event, waveEvent) => callback(waveEvent)), setWebviewFocus: (focused: number) => ipcRenderer.send("webview-focus", focused), registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), onControlShiftStateUpdate: (callback) => ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), createWorkspace: () => ipcRenderer.send("create-workspace"), switchWorkspace: (workspaceId) => ipcRenderer.send("switch-workspace", workspaceId), deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId), setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId), createTab: () => ipcRenderer.send("create-tab"), closeTab: (workspaceId, tabId, confirmClose) => ipcRenderer.invoke("close-tab", workspaceId, tabId, confirmClose), setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status), onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)), onBuilderInit: (callback) => ipcRenderer.on("builder-init", (_event, initOpts) => callback(initOpts)), sendLog: (log) => ipcRenderer.send("fe-log", log), onQuicklook: (filePath: string) => ipcRenderer.send("quicklook", filePath), openNativePath: (filePath: string) => ipcRenderer.send("open-native-path", filePath), captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke("capture-screenshot", rect), setKeyboardChordMode: () => ipcRenderer.send("set-keyboard-chord-mode"), clearWebviewStorage: (webContentsId: number) => ipcRenderer.invoke("clear-webview-storage", webContentsId), setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen), closeBuilderWindow: () => ipcRenderer.send("close-builder-window"), incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => ipcRenderer.send("increment-term-commands", opts), nativePaste: () => ipcRenderer.send("native-paste"), openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), doRefresh: () => ipcRenderer.send("do-refresh"), saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content), setIsActive: () => ipcRenderer.invoke("set-is-active"), }); // Custom event for "new-window" ipcRenderer.on("webview-new-window", (e, webContentsId, details) => { const event = new CustomEvent("new-window", { detail: details }); document.getElementById("webview").dispatchEvent(event); }); ipcRenderer.on("webcontentsid-from-blockid", (e, blockId, responseCh) => { const webviewElem: WebviewTag = document.querySelector("div[data-blockid='" + blockId + "'] webview"); const wcId = webviewElem?.dataset?.webcontentsid; ipcRenderer.send(responseCh, wcId); }); ================================================ FILE: emain/updater.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { dialog, ipcMain, Notification } from "electron"; import { autoUpdater } from "electron-updater"; import { readFileSync } from "fs"; import path from "path"; import YAML from "yaml"; import { RpcApi } from "../frontend/app/store/wshclientapi"; import { isDev } from "../frontend/util/isdev"; import { fireAndForget } from "../frontend/util/util"; import { setUserConfirmedQuit } from "./emain-activity"; import { delay } from "./emain-util"; import { focusedWaveWindow, getAllWaveWindows } from "./emain-window"; import { ElectronWshClient } from "./emain-wsh"; export let updater: Updater; function getUpdateChannel(settings: SettingsType): string { const updaterConfigPath = path.join(process.resourcesPath!, "app-update.yml"); const updaterConfig = YAML.parse(readFileSync(updaterConfigPath, { encoding: "utf8" }).toString()); console.log("Updater config from binary:", updaterConfig); const updaterChannel: string = updaterConfig.channel ?? "latest"; const settingsChannel = settings["autoupdate:channel"]; let retVal = settingsChannel; // If the user setting doesn't exist yet, set it to the value of the updater config. // If the user was previously on the `latest` channel and has downloaded a `beta` version, update their configured channel to `beta` to prevent downgrading. if (!settingsChannel || (settingsChannel == "latest" && updaterChannel == "beta")) { console.log("Update channel setting does not exist, setting to value from updater config."); RpcApi.SetConfigCommand(ElectronWshClient, { "autoupdate:channel": updaterChannel }); retVal = updaterChannel; } console.log("Update channel:", retVal); return retVal; } export class Updater { autoCheckInterval: NodeJS.Timeout | null; intervalms: number; autoCheckEnabled: boolean; availableUpdateReleaseName: string | null; availableUpdateReleaseNotes: string | null; private _status: UpdaterStatus; lastUpdateCheck: Date; constructor(settings: SettingsType) { this.intervalms = settings["autoupdate:intervalms"]; console.log("Update check interval in milliseconds:", this.intervalms); this.autoCheckEnabled = settings["autoupdate:enabled"]; console.log("Update check enabled:", this.autoCheckEnabled); this._status = "up-to-date"; this.lastUpdateCheck = new Date(0); this.autoCheckInterval = null; this.availableUpdateReleaseName = null; autoUpdater.autoInstallOnAppQuit = settings["autoupdate:installonquit"]; console.log("Install update on quit:", settings["autoupdate:installonquit"]); // Only update the release channel if it's specified, otherwise use the one configured in the updater. autoUpdater.channel = getUpdateChannel(settings); autoUpdater.allowDowngrade = false; autoUpdater.removeAllListeners(); autoUpdater.on("error", (err) => { console.log("updater error"); console.log(err); if (!err.toString()?.includes("net::ERR_INTERNET_DISCONNECTED")) this.status = "error"; }); autoUpdater.on("checking-for-update", () => { console.log("checking-for-update"); this.status = "checking"; }); autoUpdater.on("update-available", () => { console.log("update-available; downloading..."); this.status = "downloading"; }); autoUpdater.on("update-not-available", () => { console.log("update-not-available"); this.status = "up-to-date"; }); autoUpdater.on("update-downloaded", (event) => { console.log("update-downloaded", [event]); this.availableUpdateReleaseName = event.releaseName; this.availableUpdateReleaseNotes = event.releaseNotes as string | null; // Display the update banner and create a system notification this.status = "ready"; const updateNotification = new Notification({ title: "Wave Terminal", body: "A new version of Wave Terminal is ready to install.", }); updateNotification.on("click", () => { fireAndForget(this.promptToInstallUpdate.bind(this)); }); updateNotification.show(); }); } /** * The status of the Updater. */ get status(): UpdaterStatus { return this._status; } private set status(value: UpdaterStatus) { this._status = value; getAllWaveWindows().forEach((window) => { const allTabs = Array.from(window.allLoadedTabViews.values()); allTabs.forEach((tab) => { tab.webContents.send("app-update-status", value); }); }); } /** * Check for updates and start the background update check, if configured. */ async start() { if (this.autoCheckEnabled) { console.log("starting updater"); this.autoCheckInterval = setInterval(() => { fireAndForget(() => this.checkForUpdates(false)); }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if the interval has passed. await this.checkForUpdates(false); } } /** * Stop the background update check, if configured. */ stop() { console.log("stopping updater"); if (this.autoCheckInterval) { clearInterval(this.autoCheckInterval); this.autoCheckInterval = null; } } /** * Checks if the configured interval time has passed since the last update check, and if so, checks for updates using the `autoUpdater` object * @param userInput Whether the user is requesting this. If so, an alert will report the result of the check. */ async checkForUpdates(userInput: boolean) { const now = new Date(); // Run an update check always if the user requests it, otherwise only if there's an active update check interval and enough time has elapsed. if ( userInput || (this.autoCheckInterval && (!this.lastUpdateCheck || Math.abs(now.getTime() - this.lastUpdateCheck.getTime()) > this.intervalms)) ) { const result = await autoUpdater.checkForUpdates(); // If the user requested this check and we do not have an available update, let them know with a popup dialog. No need to tell them if there is an update, because we show a banner once the update is ready to install. if (userInput && !result.downloadPromise) { const dialogOpts: Electron.MessageBoxOptions = { type: "info", message: "There are currently no updates available.", }; if (focusedWaveWindow) { dialog.showMessageBox(focusedWaveWindow, dialogOpts); } } // Only update the last check time if this is an automatic check. This ensures the interval remains consistent. if (!userInput) this.lastUpdateCheck = now; } } /** * Prompts the user to install the downloaded application update and restarts the application */ async promptToInstallUpdate() { const dialogOpts: Electron.MessageBoxOptions = { type: "info", buttons: ["Restart", "Later"], title: "Application Update", message: process.platform === "win32" ? this.availableUpdateReleaseNotes : this.availableUpdateReleaseName, detail: "A new version has been downloaded. Restart the application to apply the updates.", }; const allWindows = getAllWaveWindows(); if (allWindows.length > 0) { await dialog.showMessageBox(focusedWaveWindow ?? allWindows[0], dialogOpts).then(({ response }) => { if (response === 0) { fireAndForget(this.installUpdate.bind(this)); } }); } } /** * Restarts the app and installs an update if it is available. */ async installUpdate() { if (this.status == "ready") { this.status = "installing"; await delay(1000); setUserConfirmedQuit(true); autoUpdater.quitAndInstall(); } } } export function getResolvedUpdateChannel(): string { return isDev() ? "dev" : (autoUpdater.channel ?? "latest"); } ipcMain.on("install-app-update", () => fireAndForget(updater?.promptToInstallUpdate.bind(updater))); ipcMain.on("get-app-update-status", (event) => { event.returnValue = updater?.status; }); ipcMain.on("get-updater-channel", (event) => { event.returnValue = getResolvedUpdateChannel(); }); let autoUpdateLock = false; /** * Configures the auto-updater based on the user's preference */ export async function configureAutoUpdater() { if (isDev()) { console.log("skipping auto-updater in dev mode"); return; } // simple lock to prevent multiple auto-update configuration attempts, this should be very rare if (autoUpdateLock) { console.log("auto-update configuration already in progress, skipping"); return; } autoUpdateLock = true; try { console.log("Configuring updater"); const settings = (await RpcApi.GetFullConfigCommand(ElectronWshClient)).settings; updater = new Updater(settings); await updater.start(); } catch (e) { console.warn("error configuring updater", e.toString()); } autoUpdateLock = false; } ================================================ FILE: eslint.config.js ================================================ // @ts-check import eslint from "@eslint/js"; import eslintConfigPrettier from "eslint-config-prettier"; import globals from "globals"; import path from "node:path"; import { fileURLToPath } from "node:url"; import tseslint from "typescript-eslint"; const tsconfigRootDir = path.dirname(fileURLToPath(new URL(import.meta.url))); export default [ { languageOptions: { parserOptions: { tsconfigRootDir, }, }, }, { ignores: [ "**/node_modules/**", "**/dist/**", "**/build/**", "**/make/**", "tsunami/frontend/scaffold/**", "docs/.docusaurus/**", ], }, { files: ["frontend/**/*.{ts,tsx}", "emain/**/*.{ts,tsx}"], languageOptions: { parserOptions: { tsconfigRootDir, project: "./tsconfig.json", }, }, }, { files: ["docs/**/*.{ts,tsx}"], languageOptions: { parserOptions: { tsconfigRootDir, project: "./docs/tsconfig.json" }, }, }, eslint.configs.recommended, ...tseslint.configs.recommended, { rules: { "@typescript-eslint/no-explicit-any": "off", }, }, { files: ["emain/**/*.ts", "electron.vite.config.ts", "**/*.cjs", "eslint.config.js", "docs/babel.config.js"], languageOptions: { globals: { ...globals.node, }, }, }, { files: ["**/*.js", "**/*.cjs"], rules: { "@typescript-eslint/no-require-imports": "off", }, }, { rules: { "@typescript-eslint/no-unused-vars": [ "warn", { argsIgnorePattern: "^(_[a-zA-Z0-9_]*|e|get)$", varsIgnorePattern: "^(_[a-zA-Z0-9_]*|dlog|e)$", caughtErrorsIgnorePattern: "^(_[a-zA-Z0-9_]*|e)$", }, ], "prefer-const": "warn", "no-empty": "warn", }, }, { files: ["frontend/app/store/services.ts"], rules: { "@typescript-eslint/no-unused-vars": "off", "prefer-rest-params": "off", }, }, eslintConfigPrettier, ]; ================================================ FILE: frontend/app/aipanel/ai-utils.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { sortByDisplayOrder } from "@/util/util"; const TextFileLimit = 200 * 1024; // 200KB const PdfLimit = 5 * 1024 * 1024; // 5MB const ImageLimit = 10 * 1024 * 1024; // 10MB const ImagePreviewSize = 128; const ImagePreviewWebPQuality = 0.8; const ImageMaxEdge = 4096; export const isAcceptableFile = (file: File): boolean => { const acceptableTypes = [ // Images "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/svg+xml", // PDFs "application/pdf", // Text files "text/plain", "text/markdown", "text/html", "text/css", "text/javascript", "text/typescript", // Application types for code files "application/javascript", "application/typescript", "application/json", "application/xml", ]; if (acceptableTypes.includes(file.type)) { return true; } // Check file extensions for files without proper MIME types const extension = file.name.split(".").pop()?.toLowerCase(); const acceptableExtensions = [ "txt", "log", "md", "js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx", "go", "py", "java", "c", "cpp", "h", "hpp", "html", "htm", "css", "scss", "sass", "json", "jsonc", "json5", "jsonl", "ndjson", "xml", "yaml", "yml", "sh", "bat", "sql", "php", "rb", "rs", "swift", "kt", "cs", "vb", "r", "scala", "clj", "ex", "exs", "ini", "toml", "conf", "cfg", "env", "zsh", "fish", "ps1", "psm1", "bazel", "bzl", "csv", "tsv", "properties", "ipynb", "rmd", "gradle", "groovy", "cmake", ]; if (extension && acceptableExtensions.includes(extension)) { return true; } // Check for specific filenames (case-insensitive) const fileName = file.name.toLowerCase(); const acceptableFilenames = [ "makefile", "dockerfile", "containerfile", "go.mod", "go.sum", "go.work", "go.work.sum", "package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "composer.json", "composer.lock", "gemfile", "gemfile.lock", "podfile", "podfile.lock", "cargo.toml", "cargo.lock", "pipfile", "pipfile.lock", "requirements.txt", "setup.py", "pyproject.toml", "poetry.lock", "build.gradle", "settings.gradle", "pom.xml", "build.xml", "readme", "readme.md", "license", "license.md", "changelog", "changelog.md", "contributing", "contributing.md", "authors", "codeowners", "procfile", "jenkinsfile", "vagrantfile", "rakefile", "gruntfile.js", "gulpfile.js", "webpack.config.js", "rollup.config.js", "vite.config.js", "jest.config.js", "vitest.config.js", ".dockerignore", ".gitignore", ".gitattributes", ".gitmodules", ".editorconfig", ".eslintrc", ".prettierrc", ".pylintrc", ".bashrc", ".bash_profile", ".bash_login", ".bash_logout", ".profile", ".zshrc", ".zprofile", ".zshenv", ".zlogin", ".zlogout", ".kshrc", ".cshrc", ".tcshrc", ".xonshrc", ".shrc", ".aliases", ".functions", ".exports", ".direnvrc", ".vimrc", ".gvimrc", ]; return acceptableFilenames.includes(fileName); }; export const getFileIcon = (fileName: string, fileType: string): string => { if (fileType === "directory") { return "fa-folder"; } if (fileType.startsWith("image/")) { return "fa-image"; } if (fileType === "application/pdf") { return "fa-file-pdf"; } // Check file extensions for code files const ext = fileName.split(".").pop()?.toLowerCase(); switch (ext) { case "js": case "jsx": case "ts": case "tsx": return "fa-file-code"; case "go": return "fa-file-code"; case "py": return "fa-file-code"; case "java": case "c": case "cpp": case "h": case "hpp": return "fa-file-code"; case "html": case "css": case "scss": case "sass": return "fa-file-code"; case "json": case "xml": case "yaml": case "yml": return "fa-file-code"; case "md": case "txt": return "fa-file-text"; default: return "fa-file"; } }; export const formatFileSize = (bytes: number): string => { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; }; // Normalize MIME type for AI processing export const normalizeMimeType = (file: File): string => { const fileType = file.type; // Images keep their real mimetype if (fileType.startsWith("image/")) { return fileType; } // PDFs keep their mimetype if (fileType === "application/pdf") { return fileType; } // Everything else (code files, markdown, text, etc.) becomes text/plain return "text/plain"; }; // Helper function to read file as base64 for AIMessage export const readFileAsBase64 = (file: File): Promise<string> => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; // Remove data URL prefix to get just base64 const base64 = result.split(",")[1]; resolve(base64); }; reader.onerror = reject; reader.readAsDataURL(file); }); }; // Helper function to create data URL for UIMessage export const createDataUrl = (file: File): Promise<string> => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); }; export interface FileSizeError { fileName: string; fileSize: number; maxSize: number; fileType: "text" | "pdf" | "image"; } export const validateFileSize = (file: File): FileSizeError | null => { if (file.type.startsWith("image/")) { if (file.size > ImageLimit) { return { fileName: file.name, fileSize: file.size, maxSize: ImageLimit, fileType: "image", }; } } else if (file.type === "application/pdf") { if (file.size > PdfLimit) { return { fileName: file.name, fileSize: file.size, maxSize: PdfLimit, fileType: "pdf", }; } } else { if (file.size > TextFileLimit) { return { fileName: file.name, fileSize: file.size, maxSize: TextFileLimit, fileType: "text", }; } } return null; }; export const validateFileSizeFromInfo = ( fileName: string, fileSize: number, mimeType: string ): FileSizeError | null => { let maxSize: number; let fileType: "text" | "pdf" | "image"; if (mimeType.startsWith("image/")) { maxSize = ImageLimit; fileType = "image"; } else if (mimeType === "application/pdf") { maxSize = PdfLimit; fileType = "pdf"; } else { maxSize = TextFileLimit; fileType = "text"; } if (fileSize > maxSize) { return { fileName, fileSize, maxSize, fileType, }; } return null; }; export const formatFileSizeError = (error: FileSizeError): string => { const typeLabel = error.fileType === "image" ? "Image" : error.fileType === "pdf" ? "PDF" : "Text file"; return `${typeLabel} "${error.fileName}" is too large (${formatFileSize(error.fileSize)}). Maximum size is ${formatFileSize(error.maxSize)}.`; }; /** * Resize an image to have a maximum edge of 4096px and convert to WebP format * Returns the optimized image if it's smaller than the original, otherwise returns the original */ export const resizeImage = async (file: File): Promise<File> => { // Only process actual image files (not SVG) if (!file.type.startsWith("image/") || file.type === "image/svg+xml") { return file; } return new Promise((resolve) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = async () => { URL.revokeObjectURL(url); let { width, height } = img; // Check if resizing is needed if (width <= ImageMaxEdge && height <= ImageMaxEdge) { // Image is already small enough, just try WebP conversion const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); ctx?.drawImage(img, 0, 0); canvas.toBlob( (blob) => { if (blob && blob.size < file.size) { const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, ".webp"), { type: "image/webp", }); console.log( `Image resized (no dimension change): ${file.name} - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}` ); resolve(webpFile); } else { console.log( `Image kept original (WebP not smaller): ${file.name} - ${formatFileSize(file.size)}` ); resolve(file); } }, "image/webp", ImagePreviewWebPQuality ); return; } // Calculate new dimensions while maintaining aspect ratio if (width > height) { height = Math.round((height * ImageMaxEdge) / width); width = ImageMaxEdge; } else { width = Math.round((width * ImageMaxEdge) / height); height = ImageMaxEdge; } // Create canvas and resize const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); ctx?.drawImage(img, 0, 0, width, height); // Convert to WebP canvas.toBlob( (blob) => { if (blob && blob.size < file.size) { const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, ".webp"), { type: "image/webp", }); console.log( `Image resized: ${file.name} (${img.width}x${img.height} → ${width}x${height}) - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}` ); resolve(webpFile); } else { console.log( `Image kept original (WebP not smaller): ${file.name} (${img.width}x${img.height} → ${width}x${height}) - ${formatFileSize(file.size)}` ); resolve(file); } }, "image/webp", ImagePreviewWebPQuality ); }; img.onerror = () => { URL.revokeObjectURL(url); resolve(file); }; img.src = url; }); }; /** * Create a 128x128 preview data URL for an image file */ export const createImagePreview = async (file: File): Promise<string | null> => { if (!file.type.startsWith("image/") || file.type === "image/svg+xml") { return null; } return new Promise((resolve) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); let { width, height } = img; if (width > height) { height = Math.round((height * ImagePreviewSize) / width); width = ImagePreviewSize; } else { width = Math.round((width * ImagePreviewSize) / height); height = ImagePreviewSize; } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); ctx?.drawImage(img, 0, 0, width, height); canvas.toBlob( (blob) => { if (blob) { const reader = new FileReader(); reader.onloadend = () => { resolve(reader.result as string); }; reader.readAsDataURL(blob); } else { resolve(null); } }, "image/webp", ImagePreviewWebPQuality ); }; img.onerror = () => { URL.revokeObjectURL(url); resolve(null); }; img.src = url; }); }; /** * Filter and organize AI mode configs into Wave and custom provider groups * Returns organized configs that should be displayed based on settings and premium status */ export interface FilteredAIModeConfigs { waveProviderConfigs: Array<{ mode: string } & AIModeConfigType>; otherProviderConfigs: Array<{ mode: string } & AIModeConfigType>; shouldShowCloudModes: boolean; } export const getFilteredAIModeConfigs = ( aiModeConfigs: Record<string, AIModeConfigType>, showCloudModes: boolean, inBuilder: boolean, hasPremium: boolean, currentMode?: string ): FilteredAIModeConfigs => { const hideQuick = inBuilder && hasPremium; const allConfigs = Object.entries(aiModeConfigs) .map(([mode, config]) => ({ mode, ...config })) .filter((config) => !(hideQuick && config.mode === "waveai@quick")); const otherProviderConfigs = allConfigs .filter((config) => config["ai:provider"] !== "wave") .sort(sortByDisplayOrder); const hasCustomModels = otherProviderConfigs.length > 0; const isCurrentModeCloud = currentMode?.startsWith("waveai@") ?? false; const shouldShowCloudModes = showCloudModes || !hasCustomModels || isCurrentModeCloud; const waveProviderConfigs = shouldShowCloudModes ? allConfigs.filter((config) => config["ai:provider"] === "wave").sort(sortByDisplayOrder) : []; return { waveProviderConfigs, otherProviderConfigs, shouldShowCloudModes, }; }; /** * Get the display name for an AI mode configuration. * If display:name is set, use that. Otherwise, construct from model/provider. * For azure-legacy, show "azureresourcename (azure)". * For other providers, show "model (provider)". */ export function getModeDisplayName(config: AIModeConfigType): string { if (config["display:name"]) { return config["display:name"]; } const provider = config["ai:provider"]; const model = config["ai:model"]; const azureResourceName = config["ai:azureresourcename"]; if (provider === "azure-legacy") { return `${azureResourceName || "unknown"} (azure)`; } return `${model || "unknown"} (${provider || "custom"})`; } ================================================ FILE: frontend/app/aipanel/aidroppedfiles.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo } from "react"; import { formatFileSize, getFileIcon } from "./ai-utils"; import type { WaveAIModel } from "./waveai-model"; interface AIDroppedFilesProps { model: WaveAIModel; } export const AIDroppedFiles = memo(({ model }: AIDroppedFilesProps) => { const droppedFiles = useAtomValue(model.droppedFiles); if (droppedFiles.length === 0) { return null; } return ( <div className="p-2 border-b border-gray-600"> <div className="flex gap-2 overflow-x-auto pb-1"> {droppedFiles.map((file) => ( <div key={file.id} className="relative bg-zinc-700 rounded-lg p-2 min-w-20 flex-shrink-0 group"> <button onClick={() => model.removeFile(file.id)} className="absolute top-1 right-1 w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer" > <i className="fa fa-times text-xs"></i> </button> <div className="flex flex-col items-center text-center"> {file.previewUrl ? ( <div className="w-12 h-12 mb-1"> <img src={file.previewUrl} alt={file.name} className="w-full h-full object-cover rounded" /> </div> ) : ( <div className="w-12 h-12 mb-1 flex items-center justify-center bg-zinc-600 rounded"> <i className={cn("fa text-lg text-gray-300", getFileIcon(file.name, file.type))} ></i> </div> )} <div className="text-[10px] text-gray-200 truncate w-full max-w-16" title={file.name}> {file.name} </div> <div className="text-[9px] text-gray-400">{formatFileSize(file.size)}</div> </div> </div> ))} </div> </div> ); }); AIDroppedFiles.displayName = "AIDroppedFiles"; ================================================ FILE: frontend/app/aipanel/aifeedbackbuttons.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { cn, makeIconClass } from "@/util/util"; import { memo, useState } from "react"; import { WaveAIModel } from "./waveai-model"; interface AIFeedbackButtonsProps { messageText: string; } export const AIFeedbackButtons = memo(({ messageText }: AIFeedbackButtonsProps) => { const [thumbsUpClicked, setThumbsUpClicked] = useState(false); const [thumbsDownClicked, setThumbsDownClicked] = useState(false); const [copied, setCopied] = useState(false); const handleThumbsUp = () => { setThumbsUpClicked(!thumbsUpClicked); if (thumbsDownClicked) { setThumbsDownClicked(false); } if (!thumbsUpClicked) { WaveAIModel.getInstance().handleAIFeedback("good"); } }; const handleThumbsDown = () => { setThumbsDownClicked(!thumbsDownClicked); if (thumbsUpClicked) { setThumbsUpClicked(false); } if (!thumbsDownClicked) { WaveAIModel.getInstance().handleAIFeedback("bad"); } }; const handleCopy = () => { navigator.clipboard.writeText(messageText); setCopied(true); setTimeout(() => setCopied(false), 2000); }; return ( <div className="flex items-center gap-0.5 mt-2"> <button onClick={handleThumbsUp} className={cn( "p-1.5 rounded cursor-pointer transition-colors", thumbsUpClicked ? "text-accent" : "text-secondary hover:bg-zinc-700 hover:text-primary" )} title="Good Response" > <i className={makeIconClass(thumbsUpClicked ? "solid@thumbs-up" : "regular@thumbs-up", false)} /> </button> <button onClick={handleThumbsDown} className={cn( "p-1.5 rounded cursor-pointer transition-colors", thumbsDownClicked ? "text-accent" : "text-secondary hover:bg-zinc-700 hover:text-primary" )} title="Bad Response" > <i className={makeIconClass(thumbsDownClicked ? "solid@thumbs-down" : "regular@thumbs-down", false)} /> </button> {messageText?.trim() && ( <button onClick={handleCopy} className={cn( "p-1.5 rounded cursor-pointer transition-colors", copied ? "text-success" : "text-secondary hover:bg-zinc-700 hover:text-primary" )} title="Copy Message" > <i className={makeIconClass(copied ? "solid@check" : "regular@copy", false)} /> </button> )} </div> ); }); AIFeedbackButtons.displayName = "AIFeedbackButtons"; ================================================ FILE: frontend/app/aipanel/aimessage.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveStreamdown } from "@/app/element/streamdown"; import { cn } from "@/util/util"; import { memo, useEffect, useRef } from "react"; import { getFileIcon } from "./ai-utils"; import { AIFeedbackButtons } from "./aifeedbackbuttons"; import { AIToolUseGroup } from "./aitooluse"; import { WaveUIMessage, WaveUIMessagePart } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; const AIThinking = memo( ({ message = "AI is thinking...", reasoningText, isWaitingApproval = false, }: { message?: string; reasoningText?: string; isWaitingApproval?: boolean; }) => { const scrollRef = useRef<HTMLDivElement>(null); useEffect(() => { if (scrollRef.current && reasoningText) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [reasoningText]); const displayText = reasoningText ? (() => { const lastDoubleNewline = reasoningText.lastIndexOf("\n\n"); return lastDoubleNewline !== -1 ? reasoningText.substring(lastDoubleNewline + 2) : reasoningText; })() : ""; return ( <div className="flex flex-col gap-1"> <div className="flex items-center gap-2"> {isWaitingApproval ? ( <i className="fa fa-clock text-base text-yellow-500"></i> ) : ( <div className="animate-pulse flex items-center"> <i className="fa fa-circle text-[10px]"></i> <i className="fa fa-circle text-[10px] mx-1"></i> <i className="fa fa-circle text-[10px]"></i> </div> )} {message && <span className="text-sm text-gray-400">{message}</span>} </div> <div ref={scrollRef} className="text-sm text-gray-500 overflow-y-auto h-[3lh] max-w-[600px] pl-9"> {displayText} </div> </div> ); } ); AIThinking.displayName = "AIThinking"; interface UserMessageFilesProps { fileParts: Array<WaveUIMessagePart & { type: "data-userfile" }>; } const UserMessageFiles = memo(({ fileParts }: UserMessageFilesProps) => { if (fileParts.length === 0) return null; return ( <div className="mt-2 pt-2 border-t border-gray-600"> <div className="flex gap-2 overflow-x-auto pb-1"> {fileParts.map((file, index) => ( <div key={index} className="relative bg-zinc-700 rounded-lg p-2 min-w-20 flex-shrink-0"> <div className="flex flex-col items-center text-center"> <div className="w-12 h-12 mb-1 flex items-center justify-center bg-zinc-600 rounded overflow-hidden"> {file.data?.previewurl ? ( <img src={file.data.previewurl} alt={file.data?.filename || "File"} className="w-full h-full object-cover" /> ) : ( <i className={cn( "fa text-lg text-gray-300", getFileIcon(file.data?.filename || "", file.data?.mimetype || "") )} ></i> )} </div> <div className="text-[10px] text-gray-200 truncate w-full max-w-16" title={file.data?.filename || "File"} > {file.data?.filename || "File"} </div> </div> </div> ))} </div> </div> ); }); UserMessageFiles.displayName = "UserMessageFiles"; interface AIMessagePartProps { part: WaveUIMessagePart; role: string; isStreaming: boolean; } const AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) => { const model = WaveAIModel.getInstance(); if (part.type === "text") { const content = part.text ?? ""; if (role === "user") { return <div className="whitespace-pre-wrap break-words">{content}</div>; } else { return ( <WaveStreamdown text={content} parseIncompleteMarkdown={isStreaming} className="text-gray-100" codeBlockMaxWidthAtom={model.codeBlockMaxWidth} /> ); } } return null; }); AIMessagePart.displayName = "AIMessagePart"; interface AIMessageProps { message: WaveUIMessage; isStreaming: boolean; } const isDisplayPart = (part: WaveUIMessagePart): boolean => { return ( part.type === "text" || part.type === "data-tooluse" || part.type === "data-toolprogress" || (part.type.startsWith("tool-") && "state" in part && part.state === "input-available") ); }; type MessagePart = | { type: "single"; part: WaveUIMessagePart } | { type: "toolgroup"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }> }; const groupMessageParts = (parts: WaveUIMessagePart[]): MessagePart[] => { const grouped: MessagePart[] = []; let currentToolGroup: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }> = []; for (const part of parts) { if (part.type === "data-tooluse" || part.type === "data-toolprogress") { currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }); } else { if (currentToolGroup.length > 0) { grouped.push({ type: "toolgroup", parts: currentToolGroup }); currentToolGroup = []; } grouped.push({ type: "single", part }); } } if (currentToolGroup.length > 0) { grouped.push({ type: "toolgroup", parts: currentToolGroup }); } return grouped; }; const getThinkingMessage = ( parts: WaveUIMessagePart[], isStreaming: boolean, role: string ): { message: string; reasoningText?: string; isWaitingApproval?: boolean } | null => { if (!isStreaming || role !== "assistant") { return null; } const hasPendingApprovals = parts.some( (part) => part.type === "data-tooluse" && part.data?.approval === "needs-approval" ); if (hasPendingApprovals) { return { message: "Waiting for Tool Approvals...", isWaitingApproval: true }; } const lastPart = parts[parts.length - 1]; if (lastPart?.type === "reasoning") { const reasoningContent = lastPart.text || ""; return { message: "AI is thinking...", reasoningText: reasoningContent }; } if (lastPart?.type === "text" && lastPart.text) { return null; } return { message: "" }; }; export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { const parts = message.parts || []; const displayParts = parts.filter(isDisplayPart); const fileParts = parts.filter( (part): part is WaveUIMessagePart & { type: "data-userfile" } => part.type === "data-userfile" ); const thinkingData = getThinkingMessage(parts, isStreaming, message.role); const groupedParts = groupMessageParts(displayParts); return ( <div className={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}> <div className={cn( "px-2 rounded-lg [&>*:first-child]:!mt-0", message.role === "user" ? "py-2 bg-zinc-700/60 text-white max-w-[calc(100%-50px)]" : "min-w-[min(100%,500px)]" )} > {displayParts.length === 0 && !isStreaming && !thinkingData ? ( <div className="whitespace-pre-wrap break-words">(no text content)</div> ) : ( <> {groupedParts.map((group, index: number) => group.type === "toolgroup" ? ( <AIToolUseGroup key={index} parts={group.parts} isStreaming={isStreaming} /> ) : ( <div key={index} className="mt-2"> <AIMessagePart part={group.part} role={message.role} isStreaming={isStreaming} /> </div> ) )} {thinkingData != null && ( <div className="mt-2"> <AIThinking message={thinkingData.message} reasoningText={thinkingData.reasoningText} isWaitingApproval={thinkingData.isWaitingApproval} /> </div> )} </> )} {message.role === "user" && <UserMessageFiles fileParts={fileParts} />} {message.role === "assistant" && !isStreaming && displayParts.length > 0 && ( <AIFeedbackButtons messageText={parts .filter((p) => p.type === "text") .map((p) => p.text || "") .join("\n\n")} /> )} </div> </div> ); }); AIMessage.displayName = "AIMessage"; ================================================ FILE: frontend/app/aipanel/aimode.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; import { atoms, getSettingsKeyAtom } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { cn, fireAndForget, makeIconClass } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo, useRef, useState } from "react"; import { getFilteredAIModeConfigs, getModeDisplayName } from "./ai-utils"; import { WaveAIModel } from "./waveai-model"; interface AIModeMenuItemProps { config: AIModeConfigWithMode; isSelected: boolean; isDisabled: boolean; isPremiumDisabled: boolean; onClick: () => void; isFirst?: boolean; isLast?: boolean; } const AIModeMenuItem = memo(({ config, isSelected, isDisabled, isPremiumDisabled, onClick, isFirst, isLast }: AIModeMenuItemProps) => { return ( <button key={config.mode} onClick={onClick} disabled={isDisabled} className={cn( "w-full flex flex-col gap-0.5 px-3 transition-colors text-left", isFirst ? "pt-1 pb-0.5" : isLast ? "pt-0.5 pb-1" : "pt-0.5 pb-0.5", isDisabled ? "text-zinc-500" : "text-zinc-300 hover:bg-zinc-700 cursor-pointer" )} > <div className="flex items-center gap-2 w-full"> <i className={makeIconClass(config["display:icon"] || "sparkles", false)}></i> <span className={cn("text-sm", isSelected && "font-bold")}> {getModeDisplayName(config)} {isPremiumDisabled && " (premium)"} </span> {isSelected && <i className="fa fa-check ml-auto"></i>} </div> {config["display:description"] && ( <div className={cn("text-xs pl-5", isDisabled ? "text-gray-500" : "text-muted")} style={{ whiteSpace: "pre-line" }} > {config["display:description"]} </div> )} </button> ); }); AIModeMenuItem.displayName = "AIModeMenuItem"; interface ConfigSection { sectionName: string; configs: AIModeConfigWithMode[]; isIncompatible?: boolean; noTelemetry?: boolean; } function computeCompatibleSections( currentMode: string, aiModeConfigs: Record<string, AIModeConfigType>, waveProviderConfigs: AIModeConfigWithMode[], otherProviderConfigs: AIModeConfigWithMode[] ): ConfigSection[] { const currentConfig = aiModeConfigs[currentMode]; const allConfigs = [...waveProviderConfigs, ...otherProviderConfigs]; if (!currentConfig) { return [{ sectionName: "Incompatible Modes", configs: allConfigs, isIncompatible: true }]; } const currentSwitchCompat = currentConfig["ai:switchcompat"] || []; const compatibleConfigs: AIModeConfigWithMode[] = [{ ...currentConfig, mode: currentMode }]; const incompatibleConfigs: AIModeConfigWithMode[] = []; if (currentSwitchCompat.length === 0) { allConfigs.forEach((config) => { if (config.mode !== currentMode) { incompatibleConfigs.push(config); } }); } else { allConfigs.forEach((config) => { if (config.mode === currentMode) return; const configSwitchCompat = config["ai:switchcompat"] || []; const hasMatch = currentSwitchCompat.some((currentTag: string) => configSwitchCompat.includes(currentTag)); if (hasMatch) { compatibleConfigs.push(config); } else { incompatibleConfigs.push(config); } }); } const sections: ConfigSection[] = []; const compatibleSectionName = compatibleConfigs.length === 1 ? "Current" : "Compatible Modes"; sections.push({ sectionName: compatibleSectionName, configs: compatibleConfigs }); if (incompatibleConfigs.length > 0) { sections.push({ sectionName: "Incompatible Modes", configs: incompatibleConfigs, isIncompatible: true }); } return sections; } function computeWaveCloudSections( waveProviderConfigs: AIModeConfigWithMode[], otherProviderConfigs: AIModeConfigWithMode[], telemetryEnabled: boolean ): ConfigSection[] { const sections: ConfigSection[] = []; if (waveProviderConfigs.length > 0) { sections.push({ sectionName: "Wave AI Cloud", configs: waveProviderConfigs, noTelemetry: !telemetryEnabled, }); } if (otherProviderConfigs.length > 0) { sections.push({ sectionName: "Custom", configs: otherProviderConfigs }); } return sections; } interface AIModeDropdownProps { compatibilityMode?: boolean; } export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdownProps) => { const model = WaveAIModel.getInstance(); const currentMode = useAtomValue(model.currentAIMode); const aiModeConfigs = useAtomValue(model.aiModeConfigs); const waveaiModeConfigs = useAtomValue(atoms.waveaiModeConfigAtom); const widgetContextEnabled = useAtomValue(model.widgetAccessAtom); const hasPremium = useAtomValue(model.hasPremiumAtom); const showCloudModes = useAtomValue(getSettingsKeyAtom("waveai:showcloudmodes")); const telemetryEnabled = useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef<HTMLDivElement>(null); const { waveProviderConfigs, otherProviderConfigs } = getFilteredAIModeConfigs( aiModeConfigs, showCloudModes, model.inBuilder, hasPremium, currentMode ); const sections: ConfigSection[] = compatibilityMode ? computeCompatibleSections(currentMode, aiModeConfigs, waveProviderConfigs, otherProviderConfigs) : computeWaveCloudSections(waveProviderConfigs, otherProviderConfigs, telemetryEnabled); const showSectionHeaders = compatibilityMode || sections.length > 1; const handleSelect = (mode: string) => { const config = aiModeConfigs[mode]; if (!config) return; if (!hasPremium && config["waveai:premium"]) { return; } model.setAIMode(mode); setIsOpen(false); }; const displayConfig = aiModeConfigs[currentMode]; const displayName = displayConfig ? getModeDisplayName(displayConfig) : `Invalid (${currentMode})`; const displayIcon = displayConfig ? displayConfig["display:icon"] || "sparkles" : "question"; const resolvedConfig = waveaiModeConfigs[currentMode]; const hasToolsSupport = resolvedConfig && resolvedConfig["ai:capabilities"]?.includes("tools"); const showNoToolsWarning = widgetContextEnabled && resolvedConfig && !hasToolsSupport; const handleNewChatClick = () => { model.clearChat(); setIsOpen(false); }; const handleConfigureClick = () => { fireAndForget(async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "action:other", props: { "action:type": "waveai:configuremodes:contextmenu", }, }, { noresponse: true } ); await model.openWaveAIConfig(); setIsOpen(false); }); }; const handleEnableTelemetry = () => { fireAndForget(async () => { await RpcApi.WaveAIEnableTelemetryCommand(TabRpcClient); setTimeout(() => { model.focusInput(); }, 100); }); }; return ( <div className="relative" ref={dropdownRef}> <button onClick={() => setIsOpen(!isOpen)} className={cn( "group flex items-center gap-1.5 px-2 py-1 text-xs text-gray-300 hover:text-white rounded transition-colors cursor-pointer border border-gray-600/50", isOpen ? "bg-zinc-700" : "bg-zinc-800/50 hover:bg-zinc-700" )} title={`AI Mode: ${displayName}`} > <i className={cn(makeIconClass(displayIcon, false), "text-[10px]")}></i> <span className={`text-[11px]`}>{displayName}</span> <i className="fa fa-chevron-down text-[8px]"></i> </button> {showNoToolsWarning && ( <Tooltip content={ <div className="max-w-xs"> Warning: This custom mode was configured without the "tools" capability in the "ai:capabilities" array. Without tool support, Wave AI will not be able to interact with widgets or files. </div> } placement="bottom" > <div className="flex items-center gap-1 text-[10px] text-yellow-600 mt-1 ml-1 cursor-default"> <i className="fa fa-triangle-exclamation"></i> <span>No Tools Support</span> </div> </Tooltip> )} {isOpen && ( <> <div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} /> <div className="absolute top-full left-0 mt-1 bg-zinc-800 border border-zinc-600 rounded shadow-lg z-50 min-w-[280px]"> {sections.map((section, sectionIndex) => { const isFirstSection = sectionIndex === 0; const isLastSection = sectionIndex === sections.length - 1; return ( <div key={section.sectionName}> {!isFirstSection && <div className="border-t border-gray-600 my-2" />} {showSectionHeaders && ( <> <div className={cn( "pb-1 text-center text-[10px] text-gray-400 uppercase tracking-wide", isFirstSection ? "pt-2" : "pt-0" )} > {section.sectionName} </div> {section.isIncompatible && ( <div className="text-center text-[11px] text-red-300 pb-1"> (Start a New Chat to Switch) </div> )} {section.noTelemetry && ( <button onClick={handleEnableTelemetry} className="text-center text-[11px] text-green-300 hover:text-green-200 pb-1 cursor-pointer transition-colors w-full" > (enable telemetry to unlock Wave AI Cloud) </button> )} </> )} {section.configs.map((config, index) => { const isFirst = index === 0 && isFirstSection && !showSectionHeaders; const isLast = index === section.configs.length - 1 && isLastSection; const isPremiumDisabled = !hasPremium && config["waveai:premium"]; const isIncompatibleDisabled = section.isIncompatible || false; const isTelemetryDisabled = section.noTelemetry || false; const isDisabled = isPremiumDisabled || isIncompatibleDisabled || isTelemetryDisabled; const isSelected = currentMode === config.mode; return ( <AIModeMenuItem key={config.mode} config={config} isSelected={isSelected} isDisabled={isDisabled} isPremiumDisabled={isPremiumDisabled} onClick={() => handleSelect(config.mode)} isFirst={isFirst} isLast={isLast} /> ); })} </div> ); })} <div className="border-t border-gray-600 my-1" /> <button onClick={handleNewChatClick} className="w-full flex items-center gap-2 px-3 pt-1 pb-1 text-gray-300 hover:bg-zinc-700 cursor-pointer transition-colors text-left" > <i className={makeIconClass("plus", false)}></i> <span className="text-sm">New Chat</span> </button> <button onClick={handleConfigureClick} className="w-full flex items-center gap-2 px-3 pt-1 pb-2 text-gray-300 hover:bg-zinc-700 cursor-pointer transition-colors text-left" > <i className={makeIconClass("gear", false)}></i> <span className="text-sm">Configure Modes</span> </button> </div> </> )} </div> ); }); AIModeDropdown.displayName = "AIModeDropdown"; ================================================ FILE: frontend/app/aipanel/aipanel-contextmenu.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { isDev } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WaveAIModel } from "./waveai-model"; export async function handleWaveAIContextMenu(e: React.MouseEvent, showCopy: boolean): Promise<void> { e.preventDefault(); e.stopPropagation(); const model = WaveAIModel.getInstance(); const menu: ContextMenuItem[] = []; if (showCopy) { const hasSelection = waveAIHasSelection(); if (hasSelection) { menu.push({ role: "copy", }); menu.push({ type: "separator" }); } } menu.push({ label: "New Chat", click: () => { model.clearChat(); }, }); menu.push({ type: "separator" }); const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: model.orefContext, }); const defaultTokens = model.inBuilder ? 24576 : 4096; const currentMaxTokens = rtInfo?.["waveai:maxoutputtokens"] ?? defaultTokens; const maxTokensSubmenu: ContextMenuItem[] = []; if (model.inBuilder) { maxTokensSubmenu.push( { label: "24k", type: "checkbox", checked: currentMaxTokens === 24576, click: () => { RpcApi.SetRTInfoCommand(TabRpcClient, { oref: model.orefContext, data: { "waveai:maxoutputtokens": 24576 }, }); }, }, { label: "64k (Pro)", type: "checkbox", checked: currentMaxTokens === 65536, click: () => { RpcApi.SetRTInfoCommand(TabRpcClient, { oref: model.orefContext, data: { "waveai:maxoutputtokens": 65536 }, }); }, } ); } else { if (isDev()) { maxTokensSubmenu.push({ label: "1k (Dev Testing)", type: "checkbox", checked: currentMaxTokens === 1024, click: () => { RpcApi.SetRTInfoCommand(TabRpcClient, { oref: model.orefContext, data: { "waveai:maxoutputtokens": 1024 }, }); }, }); } maxTokensSubmenu.push( { label: "4k", type: "checkbox", checked: currentMaxTokens === 4096, click: () => { RpcApi.SetRTInfoCommand(TabRpcClient, { oref: model.orefContext, data: { "waveai:maxoutputtokens": 4096 }, }); }, }, { label: "16k (Pro)", type: "checkbox", checked: currentMaxTokens === 16384, click: () => { RpcApi.SetRTInfoCommand(TabRpcClient, { oref: model.orefContext, data: { "waveai:maxoutputtokens": 16384 }, }); }, }, { label: "64k (Pro)", type: "checkbox", checked: currentMaxTokens === 65536, click: () => { RpcApi.SetRTInfoCommand(TabRpcClient, { oref: model.orefContext, data: { "waveai:maxoutputtokens": 65536 }, }); }, } ); } menu.push({ label: "Max Output Tokens", submenu: maxTokensSubmenu, }); menu.push({ type: "separator" }); menu.push({ label: "Configure Modes", click: () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "action:other", props: { "action:type": "waveai:configuremodes:contextmenu", }, }, { noresponse: true } ); model.openWaveAIConfig(); }, }); if (model.canCloseWaveAIPanel()) { menu.push({ type: "separator" }); menu.push({ label: "Hide Wave AI", click: () => { model.closeWaveAIPanel(); }, }); } ContextMenuModel.getInstance().showContextMenu(menu, e); } ================================================ FILE: frontend/app/aipanel/aipanel.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { handleWaveAIContextMenu } from "@/app/aipanel/aipanel-contextmenu"; import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; import { ErrorBoundary } from "@/app/element/errorboundary"; import { atoms, getSettingsKeyAtom } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { useTabModelMaybe } from "@/app/store/tab-model"; import { isBuilderWindow } from "@/app/store/windowtype"; import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { isMacOS, isWindows } from "@/util/platformutil"; import { cn } from "@/util/util"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import * as jotai from "jotai"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { useDrop } from "react-dnd"; import { formatFileSizeError, isAcceptableFile, validateFileSize } from "./ai-utils"; import { AIDroppedFiles } from "./aidroppedfiles"; import { AIModeDropdown } from "./aimode"; import { AIPanelHeader } from "./aipanelheader"; import { AIPanelInput } from "./aipanelinput"; import { AIPanelMessages } from "./aipanelmessages"; import { AIRateLimitStrip } from "./airatelimitstrip"; import { WaveUIMessage } from "./aitypes"; import { BYOKAnnouncement } from "./byokannouncement"; import { TelemetryRequiredMessage } from "./telemetryrequired"; import { WaveAIModel } from "./waveai-model"; const AIBlockMask = memo(() => { return ( <div key="block-mask" className="absolute top-0 left-0 right-0 bottom-0 border-1 border-transparent pointer-events-auto select-none p-0.5" style={{ borderRadius: "var(--block-border-radius)", zIndex: "var(--zindex-block-mask-inner)", }} > <div className="w-full mt-[44px] h-[calc(100%-44px)] flex items-center justify-center" style={{ backgroundColor: "rgb(from var(--block-bg-color) r g b / 50%)", }} > <div className="font-bold opacity-70 mt-[-25%] text-[60px]">0</div> </div> </div> ); }); AIBlockMask.displayName = "AIBlockMask"; const AIDragOverlay = memo(() => { return ( <div key="drag-overlay" className="absolute inset-0 bg-accent/20 border-2 border-dashed border-accent rounded-lg flex items-center justify-center z-10 p-4" > <div className="text-accent text-center"> <i className="fa fa-upload text-3xl mb-2"></i> <div className="text-lg font-semibold">Drop files here</div> <div className="text-sm">Images, PDFs, and text/code files supported</div> </div> </div> ); }); AIDragOverlay.displayName = "AIDragOverlay"; const KeyCap = memo(({ children, className }: { children: React.ReactNode; className?: string }) => { return ( <kbd className={cn( "px-1.5 py-0.5 text-xs bg-zinc-700 border border-zinc-600 rounded-sm shadow-sm font-mono", className )} > {children} </kbd> ); }); KeyCap.displayName = "KeyCap"; const AIWelcomeMessage = memo(() => { const modKey = isMacOS() ? "⌘" : "Alt"; const aiModeConfigs = jotai.useAtomValue(atoms.waveaiModeConfigAtom); const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith("waveai@")); return ( <div className="text-secondary py-8"> <div className="text-center"> <i className="fa fa-sparkles text-4xl text-accent mb-2 block"></i> <p className="text-lg font-bold text-primary">Welcome to Wave AI</p> </div> <div className="mt-4 text-left max-w-md mx-auto"> <p className="text-sm mb-6"> Wave AI is your terminal assistant with context. I can read your terminal output, analyze widgets, access files, and help you solve problems faster. </p> <div className="bg-accent/10 border border-accent/30 rounded-lg p-4"> <div className="text-sm font-semibold mb-3 text-accent">Getting Started:</div> <div className="space-y-3 text-sm"> <div className="flex items-start gap-3"> <div className="w-4 text-center flex-shrink-0"> <i className="fa-solid fa-plug text-accent"></i> </div> <div> <span className="font-bold">Widget Context</span> <div className="">When ON, I can read your terminal and analyze widgets.</div> <div className="">When OFF, I'm sandboxed with no system access.</div> </div> </div> <div className="flex items-start gap-3"> <div className="w-4 text-center flex-shrink-0"> <i className="fa-solid fa-file-import text-accent"></i> </div> <div>Drag & drop files or images for analysis</div> </div> <div className="flex items-start gap-3"> <div className="w-4 text-center flex-shrink-0"> <i className="fa-solid fa-keyboard text-accent"></i> </div> <div className="space-y-1"> <div> <KeyCap>{modKey}</KeyCap> <KeyCap className="ml-1">K</KeyCap> <span className="ml-1.5">to start a new chat</span> </div> <div> <KeyCap>{modKey}</KeyCap> <KeyCap className="ml-1">Shift</KeyCap> <KeyCap className="ml-1">A</KeyCap> <span className="ml-1.5">to toggle panel</span> </div> <div> {isWindows() ? ( <> <KeyCap>Alt</KeyCap> <KeyCap className="ml-1">0</KeyCap> <span className="ml-1.5">to focus</span> </> ) : ( <> <KeyCap>Ctrl</KeyCap> <KeyCap className="ml-1">Shift</KeyCap> <KeyCap className="ml-1">0</KeyCap> <span className="ml-1.5">to focus</span> </> )} </div> </div> </div> <div className="flex items-start gap-3"> <div className="w-4 text-center flex-shrink-0"> <i className="fa-brands fa-discord text-accent"></i> </div> <div> Questions or feedback?{" "} <a target="_blank" href="https://discord.gg/XfvZ334gwU" rel="noopener" className="text-accent hover:underline cursor-pointer" > Join our Discord </a> </div> </div> </div> </div> {!hasCustomModes && <BYOKAnnouncement />} <div className="mt-4 text-center text-[12px] text-muted"> BETA: Free to use. Daily limits keep our costs in check. </div> </div> </div> ); }); AIWelcomeMessage.displayName = "AIWelcomeMessage"; const AIBuilderWelcomeMessage = memo(() => { return ( <div className="text-secondary py-8"> <div className="text-center"> <i className="fa fa-sparkles text-4xl text-accent mb-4 block"></i> <p className="text-lg font-bold text-primary">WaveApp Builder</p> </div> <div className="mt-4 text-left max-w-md mx-auto"> <p className="text-sm mb-6"> The WaveApp builder helps create wave widgets that integrate seamlessly into Wave Terminal. </p> </div> </div> ); }); AIBuilderWelcomeMessage.displayName = "AIBuilderWelcomeMessage"; const AIErrorMessage = memo(() => { const model = WaveAIModel.getInstance(); const errorMessage = jotai.useAtomValue(model.errorMessage); if (!errorMessage) { return null; } return ( <div className="px-4 py-2 text-red-400 bg-red-900/20 border-l-4 border-red-500 mx-2 mb-2 relative"> <button onClick={() => model.clearError()} className="absolute top-2 right-2 text-red-400 hover:text-red-300 cursor-pointer z-10" aria-label="Close error" > <i className="fa fa-times text-sm"></i> </button> <div className="text-sm pr-6 max-h-[100px] overflow-y-auto"> {errorMessage} <button onClick={() => model.clearChat()} className="ml-2 text-xs text-red-300 hover:text-red-200 cursor-pointer underline" > New Chat </button> </div> </div> ); }); AIErrorMessage.displayName = "AIErrorMessage"; const ConfigChangeModeFixer = memo(() => { const model = WaveAIModel.getInstance(); const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); useEffect(() => { model.fixModeAfterConfigChange(); }, [telemetryEnabled, aiModeConfigs, model]); return null; }); ConfigChangeModeFixer.displayName = "ConfigChangeModeFixer"; type AIPanelComponentInnerProps = { roundTopLeft: boolean; }; const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps) => { const [isDragOver, setIsDragOver] = useState(false); const [isReactDndDragOver, setIsReactDndDragOver] = useState(false); const [initialLoadDone, setInitialLoadDone] = useState(false); const model = WaveAIModel.getInstance(); const containerRef = useRef<HTMLDivElement>(null); const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; const isFocused = jotai.useAtomValue(model.isWaveAIFocusedAtom); const focusFollowsCursorMode = jotai.useAtomValue(getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom()); const tabModel = useTabModelMaybe(); const defaultMode = jotai.useAtomValue(getSettingsKeyAtom("waveai:defaultmode")) ?? "waveai@balanced"; const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith("waveai@")); const isUsingCustomMode = !defaultMode.startsWith("waveai@"); const allowAccess = telemetryEnabled || (hasCustomModes && isUsingCustomMode); const { messages, sendMessage, status, setMessages, error, stop } = useChat<WaveUIMessage>({ transport: new DefaultChatTransport({ api: model.getUseChatEndpointUrl(), prepareSendMessagesRequest: (_opts) => { const msg = model.getAndClearMessage(); const body: any = { msg, chatid: globalStore.get(model.chatId), widgetaccess: globalStore.get(model.widgetAccessAtom), aimode: globalStore.get(model.currentAIMode), }; if (isBuilderWindow()) { body.builderid = globalStore.get(atoms.builderId); body.builderappid = globalStore.get(atoms.builderAppId); } else { body.tabid = tabModel.tabId; } return { body }; }, }), onError: (error) => { console.error("AI Chat error:", error); model.setError(error.message || "An error occurred"); }, }); model.registerUseChatData(sendMessage, setMessages, status, stop); // console.log("AICHAT messages", messages); (window as any).aichatmessages = messages; (window as any).aichatstatus = status; const handleKeyDown = (waveEvent: WaveKeyboardEvent): boolean => { if (checkKeyPressed(waveEvent, "Cmd:k")) { model.clearChat(); return true; } return false; }; useEffect(() => { globalStore.set(model.isAIStreaming, status === "streaming" || status === "submitted"); }, [status]); useEffect(() => { const keyHandler = keydownWrapper(handleKeyDown); document.addEventListener("keydown", keyHandler); return () => { document.removeEventListener("keydown", keyHandler); }; }, []); useEffect(() => { const loadChat = async () => { await model.uiLoadInitialChat(); setInitialLoadDone(true); }; loadChat(); }, [model]); useEffect(() => { const updateWidth = () => { if (containerRef.current) { globalStore.set(model.containerWidth, containerRef.current.offsetWidth); } }; updateWidth(); const resizeObserver = new ResizeObserver(updateWidth); if (containerRef.current) { resizeObserver.observe(containerRef.current); } return () => { resizeObserver.disconnect(); }; }, [model]); useEffect(() => { model.ensureRateLimitSet(); }, [model]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await model.handleSubmit(); setTimeout(() => { model.focusInput(); }, 100); }; const hasFilesDragged = (dataTransfer: DataTransfer): boolean => { // Check if the drag operation contains files by looking at the types return dataTransfer.types.includes("Files"); }; const handleDragOver = (e: React.DragEvent) => { if (!allowAccess) { return; } const hasFiles = hasFilesDragged(e.dataTransfer); // Only handle native file drags here, let react-dnd handle FILE_ITEM drags if (!hasFiles) { return; } e.preventDefault(); e.stopPropagation(); if (!isDragOver) { setIsDragOver(true); } }; const handleDragEnter = (e: React.DragEvent) => { if (!allowAccess) { return; } const hasFiles = hasFilesDragged(e.dataTransfer); // Only handle native file drags here, let react-dnd handle FILE_ITEM drags if (!hasFiles) { return; } e.preventDefault(); e.stopPropagation(); setIsDragOver(true); }; const handleDragLeave = (e: React.DragEvent) => { if (!allowAccess) { return; } const hasFiles = hasFilesDragged(e.dataTransfer); // Only handle native file drags here, let react-dnd handle FILE_ITEM drags if (!hasFiles) { return; } e.preventDefault(); e.stopPropagation(); // Only set drag over to false if we're actually leaving the drop zone const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const x = e.clientX; const y = e.clientY; if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { setIsDragOver(false); } }; const handleDrop = async (e: React.DragEvent) => { if (!allowAccess) { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); return; } // Check if this is a FILE_ITEM drag from react-dnd // If so, let react-dnd handle it instead if (!e.dataTransfer.files.length) { return; // Let react-dnd handle FILE_ITEM drags } e.preventDefault(); e.stopPropagation(); setIsDragOver(false); const files = Array.from(e.dataTransfer.files); const acceptableFiles = files.filter(isAcceptableFile); for (const file of acceptableFiles) { const sizeError = validateFileSize(file); if (sizeError) { model.setError(formatFileSizeError(sizeError)); return; } await model.addFile(file); } if (acceptableFiles.length < files.length) { const rejectedCount = files.length - acceptableFiles.length; const rejectedFiles = files.filter((f) => !isAcceptableFile(f)); const fileNames = rejectedFiles.map((f) => f.name).join(", "); model.setError( `${rejectedCount} file${rejectedCount > 1 ? "s" : ""} rejected (unsupported type): ${fileNames}. Supported: images, PDFs, and text/code files.` ); } }; const handleFileItemDrop = useCallback( (draggedFile: DraggedFile) => { if (!allowAccess) { return; } model.addFileFromRemoteUri(draggedFile); }, [model, allowAccess] ); const [{ isOver, canDrop }, drop] = useDrop( () => ({ accept: "FILE_ITEM", drop: handleFileItemDrop, collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), }), [handleFileItemDrop] ); // Update drag over state for FILE_ITEM drags useEffect(() => { if (isOver && canDrop) { setIsReactDndDragOver(true); } else { setIsReactDndDragOver(false); } }, [isOver, canDrop]); // Attach the drop ref to the container useEffect(() => { if (containerRef.current) { drop(containerRef.current); } }, [drop]); const handleFocusCapture = useCallback( (_event: React.FocusEvent) => { // console.log("Wave AI focus capture", getElemAsStr(event.target)); model.requestWaveAIFocus(); }, [model] ); const handlePointerEnter = useCallback( (event: React.PointerEvent<HTMLDivElement>) => { if (focusFollowsCursorMode !== "on") return; if (event.pointerType === "touch" || event.buttons > 0) return; if (isFocused) return; model.focusInput(); }, [focusFollowsCursorMode, isFocused, model] ); const handleClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); if (isInteractive) { return; } const hasSelection = waveAIHasSelection(); if (hasSelection) { model.requestWaveAIFocus(); return; } setTimeout(() => { if (!waveAIHasSelection()) { model.focusInput(); } }, 0); }; const showBlockMask = isLayoutMode && showOverlayBlockNums; return ( <div ref={containerRef} data-waveai-panel="true" className={cn( "@container bg-zinc-900/70 flex flex-col relative", model.inBuilder ? "mt-0 h-full" : "mt-1 h-[calc(100%-4px)]", (isDragOver || isReactDndDragOver) && "bg-zinc-800 border-accent", isFocused ? "border-2 border-accent" : "border-2 border-transparent" )} style={{ borderTopLeftRadius: roundTopLeft ? 10 : 0, borderTopRightRadius: model.inBuilder ? 0 : 10, borderBottomRightRadius: model.inBuilder ? 0 : 10, borderBottomLeftRadius: 10, }} onFocusCapture={handleFocusCapture} onPointerEnter={handlePointerEnter} onDragOver={handleDragOver} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={handleClick} inert={!isPanelVisible ? true : undefined} > <ConfigChangeModeFixer /> {(isDragOver || isReactDndDragOver) && allowAccess && <AIDragOverlay />} {showBlockMask && <AIBlockMask />} <AIPanelHeader /> <AIRateLimitStrip /> <div key="main-content" className="flex-1 flex flex-col min-h-0"> {!allowAccess ? ( <TelemetryRequiredMessage /> ) : ( <> {messages.length === 0 && initialLoadDone ? ( <div className="flex-1 overflow-y-auto p-2 relative" onContextMenu={(e) => handleWaveAIContextMenu(e, true)} > <div className="absolute top-2 left-2 z-10"> <AIModeDropdown /> </div> {model.inBuilder ? <AIBuilderWelcomeMessage /> : <AIWelcomeMessage />} </div> ) : ( <AIPanelMessages messages={messages} status={status} onContextMenu={(e) => handleWaveAIContextMenu(e, true)} /> )} <AIErrorMessage /> <AIDroppedFiles model={model} /> <AIPanelInput onSubmit={handleSubmit} status={status} model={model} /> </> )} </div> </div> ); }); AIPanelComponentInner.displayName = "AIPanelInner"; type AIPanelComponentProps = { roundTopLeft: boolean; }; const AIPanelComponent = ({ roundTopLeft }: AIPanelComponentProps) => { return ( <ErrorBoundary> <AIPanelComponentInner roundTopLeft={roundTopLeft} /> </ErrorBoundary> ); }; AIPanelComponent.displayName = "AIPanel"; export { AIPanelComponent as AIPanel }; ================================================ FILE: frontend/app/aipanel/aipanelheader.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { handleWaveAIContextMenu } from "@/app/aipanel/aipanel-contextmenu"; import { useAtomValue } from "jotai"; import { memo } from "react"; import { WaveAIModel } from "./waveai-model"; export const AIPanelHeader = memo(() => { const model = WaveAIModel.getInstance(); const widgetAccess = useAtomValue(model.widgetAccessAtom); const inBuilder = model.inBuilder; const handleKebabClick = (e: React.MouseEvent) => { handleWaveAIContextMenu(e, false); }; const handleContextMenu = (e: React.MouseEvent) => { handleWaveAIContextMenu(e, false); }; return ( <div className="py-2 pl-3 pr-1 @xs:p-2 @xs:pl-4 border-b border-gray-600 flex items-center justify-between min-w-0" onContextMenu={handleContextMenu} > <h2 className="text-white text-sm @xs:text-lg font-semibold flex items-center gap-2 flex-shrink-0 whitespace-nowrap"> <i className="fa fa-sparkles text-accent"></i> Wave AI </h2> <div className="flex items-center flex-shrink-0 whitespace-nowrap"> {!inBuilder && ( <div className="flex items-center text-sm whitespace-nowrap"> <span className="text-gray-300 @xs:hidden mr-1 text-[12px]">Context</span> <span className="text-gray-300 hidden @xs:inline mr-2 text-[12px]">Widget Context</span> <button onClick={() => { model.setWidgetAccess(!widgetAccess); setTimeout(() => { model.focusInput(); }, 0); }} className={`relative inline-flex h-6 w-14 items-center rounded-full transition-colors cursor-pointer ${ widgetAccess ? "bg-accent-600" : "bg-zinc-600" }`} title={`Widget Access ${widgetAccess ? "ON" : "OFF"}`} > <span className={`absolute inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ widgetAccess ? "translate-x-8" : "translate-x-1" }`} /> <span className={`relative z-10 text-xs text-white transition-all ${ widgetAccess ? "ml-2.5 mr-6 text-left" : "ml-6 mr-1 text-right" }`} > {widgetAccess ? "ON" : "OFF"} </span> </button> </div> )} <button onClick={handleKebabClick} className="text-gray-400 hover:text-white cursor-pointer transition-colors p-1 rounded flex-shrink-0 ml-2 focus:outline-none" title="More options" > <i className="fa fa-ellipsis-vertical"></i> </button> </div> </div> ); }); AIPanelHeader.displayName = "AIPanelHeader"; ================================================ FILE: frontend/app/aipanel/aipanelinput.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { formatFileSizeError, isAcceptableFile, validateFileSize } from "@/app/aipanel/ai-utils"; import { waveAIHasFocusWithin } from "@/app/aipanel/waveai-focus-utils"; import { type WaveAIModel } from "@/app/aipanel/waveai-model"; import { Tooltip } from "@/element/tooltip"; import { cn } from "@/util/util"; import { useAtom, useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useRef } from "react"; interface AIPanelInputProps { onSubmit: (e: React.FormEvent) => void; status: string; model: WaveAIModel; } export interface AIPanelInputRef { focus: () => void; resize: () => void; scrollToBottom: () => void; } export const AIPanelInput = memo(({ onSubmit, status, model }: AIPanelInputProps) => { const [input, setInput] = useAtom(model.inputAtom); const isFocused = useAtomValue(model.isWaveAIFocusedAtom); const isChatEmpty = useAtomValue(model.isChatEmptyAtom); const textareaRef = useRef<HTMLTextAreaElement>(null); const fileInputRef = useRef<HTMLInputElement>(null); const isPanelOpen = useAtomValue(model.getPanelVisibleAtom()); let placeholder: string; if (!isChatEmpty) { placeholder = "Continue..."; } else if (model.inBuilder) { placeholder = "What would you like to build..."; } else { placeholder = "Ask Wave AI anything..."; } const resizeTextarea = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.style.height = "auto"; const scrollHeight = textarea.scrollHeight; const maxHeight = 7 * 24; textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; }, []); useEffect(() => { const inputRefObject: React.RefObject<AIPanelInputRef> = { current: { focus: () => { textareaRef.current?.focus(); }, resize: resizeTextarea, scrollToBottom: () => { const textarea = textareaRef.current; if (textarea) { textarea.scrollTop = textarea.scrollHeight; } }, }, }; model.registerInputRef(inputRefObject); }, [model, resizeTextarea]); const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const isComposing = e.nativeEvent?.isComposing || e.keyCode == 229; if (e.key === "Enter" && !e.shiftKey && !isComposing) { e.preventDefault(); onSubmit(e as any); } }; const handleFocus = useCallback(() => { model.requestWaveAIFocus(); }, [model]); const handleBlur = useCallback( (e: React.FocusEvent) => { if (e.relatedTarget === null) { return; } if (waveAIHasFocusWithin(e.relatedTarget)) { return; } model.requestNodeFocus(); }, [model] ); useEffect(() => { resizeTextarea(); }, [input, resizeTextarea]); useEffect(() => { if (isPanelOpen) { resizeTextarea(); } }, [isPanelOpen, resizeTextarea]); const handleUploadClick = () => { fileInputRef.current?.click(); }; const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const files = Array.from(e.target.files || []); const acceptableFiles = files.filter(isAcceptableFile); for (const file of acceptableFiles) { const sizeError = validateFileSize(file); if (sizeError) { model.setError(formatFileSizeError(sizeError)); if (e.target) { e.target.value = ""; } return; } await model.addFile(file); } if (acceptableFiles.length < files.length) { console.warn(`${files.length - acceptableFiles.length} files were rejected due to unsupported file types`); } if (e.target) { e.target.value = ""; } }; return ( <div className={cn("border-t", isFocused ? "border-accent/50" : "border-gray-600")}> <input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.txt,.md,.js,.jsx,.ts,.tsx,.go,.py,.java,.c,.cpp,.h,.hpp,.html,.css,.scss,.sass,.json,.xml,.yaml,.yml,.sh,.bat,.sql" onChange={handleFileChange} className="hidden" /> <form onSubmit={onSubmit}> <div className="relative"> <textarea ref={textareaRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onFocus={handleFocus} onBlur={handleBlur} placeholder={placeholder} className={cn( "w-full text-white px-2 py-2 pr-5 focus:outline-none resize-none overflow-auto bg-zinc-800/50" )} style={{ fontSize: "13px" }} rows={2} /> <Tooltip content="Attach files" placement="top" divClassName="absolute bottom-6.5 right-1"> <button type="button" onClick={handleUploadClick} className={cn( "w-5 h-5 transition-colors flex items-center justify-center text-gray-400 hover:text-accent cursor-pointer" )} > <i className="fa fa-paperclip text-sm"></i> </button> </Tooltip> {status === "streaming" ? ( <Tooltip content="Stop Response" placement="top" divClassName="absolute bottom-1.5 right-1"> <button type="button" onClick={() => model.stopResponse()} className={cn( "w-5 h-5 transition-colors flex items-center justify-center", "text-green-500 hover:text-green-400 cursor-pointer" )} > <i className="fa fa-square text-sm"></i> </button> </Tooltip> ) : ( <Tooltip content="Send message (Enter)" placement="top" divClassName="absolute bottom-1.5 right-1"> <button type="submit" disabled={status !== "ready" || !input.trim()} className={cn( "w-5 h-5 transition-colors flex items-center justify-center", status !== "ready" || !input.trim() ? "text-gray-400" : "text-accent/80 hover:text-accent cursor-pointer" )} > <i className="fa fa-paper-plane text-sm"></i> </button> </Tooltip> )} </div> </form> </div> ); }); AIPanelInput.displayName = "AIPanelInput"; ================================================ FILE: frontend/app/aipanel/aipanelmessages.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useAtomValue } from "jotai"; import { memo, useEffect, useRef, useState } from "react"; import { AIMessage } from "./aimessage"; import { AIModeDropdown } from "./aimode"; import { type WaveUIMessage } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; interface AIPanelMessagesProps { messages: WaveUIMessage[]; status: string; onContextMenu?: (e: React.MouseEvent) => void; } export const AIPanelMessages = memo(({ messages, status, onContextMenu }: AIPanelMessagesProps) => { const model = WaveAIModel.getInstance(); const isPanelOpen = useAtomValue(model.getPanelVisibleAtom()); const messagesEndRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null); const prevStatusRef = useRef<string>(status); const [shouldAutoScroll, setShouldAutoScroll] = useState(true); const checkIfAtBottom = () => { const container = messagesContainerRef.current; if (!container) return true; const threshold = 50; const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight; return scrollBottom <= threshold; }; const handleScroll = () => { const atBottom = checkIfAtBottom(); setShouldAutoScroll(atBottom); }; const scrollToBottom = () => { const container = messagesContainerRef.current; if (container) { container.scrollTop = container.scrollHeight; container.scrollLeft = 0; setShouldAutoScroll(true); } }; useEffect(() => { const container = messagesContainerRef.current; if (!container) return; container.addEventListener("scroll", handleScroll); return () => container.removeEventListener("scroll", handleScroll); }, []); useEffect(() => { model.registerScrollToBottom(scrollToBottom); }, [model]); useEffect(() => { if (shouldAutoScroll) { scrollToBottom(); } }, [messages, shouldAutoScroll]); useEffect(() => { if (isPanelOpen) { scrollToBottom(); } }, [isPanelOpen]); useEffect(() => { const wasStreaming = prevStatusRef.current === "streaming"; const isNowNotStreaming = status !== "streaming"; if (wasStreaming && isNowNotStreaming) { requestAnimationFrame(() => { scrollToBottom(); }); } prevStatusRef.current = status; }, [status]); return ( <div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-2 space-y-4" onContextMenu={onContextMenu}> <div className="mb-2"> <AIModeDropdown compatibilityMode={true} /> </div> {messages.map((message, index) => { const isLastMessage = index === messages.length - 1; const isStreaming = status === "streaming" && isLastMessage && message.role === "assistant"; return <AIMessage key={message.id} message={message} isStreaming={isStreaming} />; })} {status === "streaming" && (messages.length === 0 || messages[messages.length - 1].role !== "assistant") && ( <AIMessage key="last-message" message={{ role: "assistant", parts: [], id: "last-message" } as any} isStreaming={true} /> )} <div ref={messagesEndRef} /> </div> ); }); AIPanelMessages.displayName = "AIPanelMessages"; ================================================ FILE: frontend/app/aipanel/airatelimitstrip.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { atoms } from "@/app/store/global"; import * as jotai from "jotai"; import { memo, useEffect, useState } from "react"; const GetMoreButton = memo(({ variant, showClose = true }: { variant: "yellow" | "red"; showClose?: boolean }) => { const isYellow = variant === "yellow"; const bgColor = isYellow ? "bg-yellow-900/30" : "bg-red-900/30"; const hoverBg = isYellow ? "hover:bg-yellow-700/60" : "hover:bg-red-700/60"; const borderColor = isYellow ? "border-yellow-700/50" : "border-red-700/50"; const textColor = isYellow ? "text-yellow-200" : "text-red-200"; const iconColor = isYellow ? "text-yellow-400" : "text-red-400"; const iconHoverBg = showClose && isYellow ? "hover:has-[.close:hover]:bg-yellow-900/30" : showClose ? "hover:has-[.close:hover]:bg-red-900/30" : ""; if (true as boolean) { // disable now until we have modal return null; } return ( <div className="pl-2 pb-1.5"> <button className={`flex items-center gap-1.5 ${showClose ? "pl-1" : "pl-2"} pr-2 py-1 ${bgColor} ${iconHoverBg} ${hoverBg} rounded-b border border-t-0 ${borderColor} text-[11px] ${textColor} cursor-pointer transition-colors`} > {showClose && ( <i className={`close fa fa-xmark ${iconColor}/60 hover:${iconColor} transition-colors`}></i> )} <span>Get More</span> <i className={`fa fa-arrow-right ${iconColor}`}></i> </button> </div> ); }); GetMoreButton.displayName = "GetMoreButton"; function formatTimeRemaining(expirationEpoch: number): string { const now = Math.floor(Date.now() / 1000); const secondsRemaining = expirationEpoch - now; if (secondsRemaining <= 0) { return "soon"; } const hours = Math.floor(secondsRemaining / 3600); const minutes = Math.floor((secondsRemaining % 3600) / 60); if (hours > 0) { return `${hours}h`; } return `${minutes}m`; } const AIRateLimitStripComponent = memo(() => { let rateLimitInfo = jotai.useAtomValue(atoms.waveAIRateLimitInfoAtom); // rateLimitInfo = { req: 0, reqlimit: 200, preq: 0, preqlimit: 50, resetepoch: 1759374575 + 45 * 60 }; // testing const [, forceUpdate] = useState({}); const shouldShow = rateLimitInfo && !rateLimitInfo.unknown && (rateLimitInfo.preq <= 5 || rateLimitInfo.req === 0); useEffect(() => { if (!shouldShow) { return; } const interval = setInterval(() => { forceUpdate({}); }, 60000); return () => clearInterval(interval); }, [shouldShow]); if (!rateLimitInfo || rateLimitInfo.unknown || !shouldShow) { return null; } const { req, reqlimit, preq, preqlimit, resetepoch } = rateLimitInfo; const timeRemaining = formatTimeRemaining(resetepoch); const totalLimit = preqlimit + reqlimit; if (preq > 0 && preq <= 5) { return ( <div> <div className="bg-yellow-900/30 border-b border-yellow-700/50 px-2 py-1.5 flex items-center gap-1 text-[11px] text-yellow-200"> <i className="fa fa-sparkles text-yellow-400"></i> <span> {preqlimit - preq}/{preqlimit} Premium Used </span> <div className="flex-1"></div> <span className="text-yellow-300/80">Resets in {timeRemaining}</span> </div> <GetMoreButton variant="yellow" /> </div> ); } if (preq === 0 && req > 0) { return ( <div> <div className="bg-yellow-900/30 border-b border-yellow-700/50 px-2 pr-1 py-1.5 flex items-center gap-1 text-[11px] text-yellow-200"> <i className="fa fa-check text-yellow-400"></i> <span> {preqlimit}/{preqlimit} Premium </span> <span className="text-yellow-400">•</span> <span className="font-medium">Now on Basic</span> <div className="flex-1"></div> <span className="text-yellow-300/80">Resets in {timeRemaining}</span> </div> <GetMoreButton variant="yellow" /> </div> ); } if (req === 0 && preq === 0) { return ( <div> <div className="bg-red-900/30 border-b border-red-700/50 px-2 py-1.5 flex items-center gap-2 text-[11px] text-red-200"> <i className="fa fa-check text-red-400"></i> <span> {totalLimit}/{totalLimit} Reqs </span> <span className="text-red-400">•</span> <span className="font-medium">Limit Reached</span> <div className="flex-1"></div> <span className="text-red-300/80">Resets in {timeRemaining}</span> </div> <GetMoreButton variant="red" showClose={false} /> </div> ); } return null; }); AIRateLimitStripComponent.displayName = "AIRateLimitStrip"; export { AIRateLimitStripComponent as AIRateLimitStrip }; ================================================ FILE: frontend/app/aipanel/aitooluse.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockModel } from "@/app/block/block-model"; import { Modal } from "@/app/modals/modal"; import { recordTEvent } from "@/app/store/global"; import { cn, fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo, useEffect, useRef, useState } from "react"; import { WaveUIMessagePart } from "./aitypes"; import { RestoreBackupModal } from "./restorebackupmodal"; import { WaveAIModel } from "./waveai-model"; // matches pkg/filebackup/filebackup.go const BackupRetentionDays = 5; interface ToolDescLineProps { text: string; } const ToolDescLine = memo(({ text }: ToolDescLineProps) => { let displayText = text; if (displayText.startsWith("* ")) { displayText = "• " + displayText.slice(2); } const parts: React.ReactNode[] = []; let lastIndex = 0; const regex = /(?<!\w)([+-])(\d+)(?!\w)/g; let match; while ((match = regex.exec(displayText)) !== null) { if (match.index > lastIndex) { parts.push(displayText.slice(lastIndex, match.index)); } const sign = match[1]; const number = match[2]; const colorClass = sign === "+" ? "text-green-600" : "text-red-600"; parts.push( <span key={match.index} className={colorClass}> {sign} {number} </span> ); lastIndex = match.index + match[0].length; } if (lastIndex < displayText.length) { parts.push(displayText.slice(lastIndex)); } return <div>{parts.length > 0 ? parts : displayText}</div>; }); ToolDescLine.displayName = "ToolDescLine"; interface ToolDescProps { text: string | string[]; className?: string; } const ToolDesc = memo(({ text, className }: ToolDescProps) => { const lines = Array.isArray(text) ? text : text.split("\n"); if (lines.length === 0) return null; return ( <div className={className}> {lines.map((line, idx) => ( <ToolDescLine key={idx} text={line} /> ))} </div> ); }); ToolDesc.displayName = "ToolDesc"; function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string { return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; } interface AIToolApprovalButtonsProps { count: number; onApprove: () => void; onDeny: () => void; } const AIToolApprovalButtons = memo(({ count, onApprove, onDeny }: AIToolApprovalButtonsProps) => { const approveText = count > 1 ? `Approve All (${count})` : "Approve"; const denyText = count > 1 ? "Deny All" : "Deny"; return ( <div className="mt-2 flex gap-2"> <button onClick={onApprove} className="px-3 py-1 border border-gray-600 text-gray-300 hover:border-gray-500 hover:text-white text-sm rounded cursor-pointer transition-colors" > {approveText} </button> <button onClick={onDeny} className="px-3 py-1 border border-gray-600 text-gray-300 hover:border-gray-500 hover:text-white text-sm rounded cursor-pointer transition-colors" > {denyText} </button> </div> ); }); AIToolApprovalButtons.displayName = "AIToolApprovalButtons"; interface AIToolUseBatchItemProps { part: WaveUIMessagePart & { type: "data-tooluse" }; effectiveApproval: string; } const AIToolUseBatchItem = memo(({ part, effectiveApproval }: AIToolUseBatchItemProps) => { const statusIcon = part.data.status === "completed" ? "✓" : part.data.status === "error" ? "✗" : "•"; const statusColor = part.data.status === "completed" ? "text-success" : part.data.status === "error" ? "text-error" : "text-gray-400"; const effectiveErrorMessage = part.data.errormessage || (effectiveApproval === "timeout" ? "Not approved" : null); return ( <div className="text-sm pl-2 flex items-start gap-1.5"> <span className={cn("font-bold flex-shrink-0", statusColor)}>{statusIcon}</span> <div className="flex-1"> <span className="text-gray-400">{part.data.tooldesc}</span> {effectiveErrorMessage && <div className="text-red-300 mt-0.5">{effectiveErrorMessage}</div>} </div> </div> ); }); AIToolUseBatchItem.displayName = "AIToolUseBatchItem"; interface AIToolUseBatchProps { parts: Array<WaveUIMessagePart & { type: "data-tooluse" }>; isStreaming: boolean; } const AIToolUseBatch = memo(({ parts, isStreaming }: AIToolUseBatchProps) => { const [userApprovalOverride, setUserApprovalOverride] = useState<string | null>(null); const firstTool = parts[0].data; const baseApproval = userApprovalOverride || firstTool.approval; const effectiveApproval = getEffectiveApprovalStatus(baseApproval, isStreaming); const handleApprove = () => { setUserApprovalOverride("user-approved"); parts.forEach((part) => { WaveAIModel.getInstance().toolUseSendApproval(part.data.toolcallid, "user-approved"); }); }; const handleDeny = () => { setUserApprovalOverride("user-denied"); parts.forEach((part) => { WaveAIModel.getInstance().toolUseSendApproval(part.data.toolcallid, "user-denied"); }); }; return ( <div className="flex items-start gap-2 p-2 rounded bg-zinc-800/60 border border-zinc-700"> <div className="flex-1"> <div className="font-semibold">Reading Files</div> <div className="mt-1 space-y-0.5"> {parts.map((part, idx) => ( <AIToolUseBatchItem key={idx} part={part} effectiveApproval={effectiveApproval} /> ))} </div> {effectiveApproval === "needs-approval" && ( <AIToolApprovalButtons count={parts.length} onApprove={handleApprove} onDeny={handleDeny} /> )} </div> </div> ); }); AIToolUseBatch.displayName = "AIToolUseBatch"; interface AIToolUseProps { part: WaveUIMessagePart & { type: "data-tooluse" }; isStreaming: boolean; } const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { const toolData = part.data; const [userApprovalOverride, setUserApprovalOverride] = useState<string | null>(null); const model = WaveAIModel.getInstance(); const restoreModalToolCallId = useAtomValue(model.restoreBackupModalToolCallId); const showRestoreModal = restoreModalToolCallId === toolData.toolcallid; const highlightTimeoutRef = useRef<NodeJS.Timeout | null>(null); const highlightedBlockIdRef = useRef<string | null>(null); const statusIcon = toolData.status === "completed" ? "✓" : toolData.status === "error" ? "✗" : "•"; const statusColor = toolData.status === "completed" ? "text-success" : toolData.status === "error" ? "text-error" : "text-gray-400"; const baseApproval = userApprovalOverride || toolData.approval; const effectiveApproval = getEffectiveApprovalStatus(baseApproval, isStreaming); const isFileWriteTool = toolData.toolname === "write_text_file" || toolData.toolname === "edit_text_file"; useEffect(() => { return () => { if (highlightTimeoutRef.current) { clearTimeout(highlightTimeoutRef.current); } }; }, []); const handleApprove = () => { setUserApprovalOverride("user-approved"); WaveAIModel.getInstance().toolUseSendApproval(toolData.toolcallid, "user-approved"); }; const handleDeny = () => { setUserApprovalOverride("user-denied"); WaveAIModel.getInstance().toolUseSendApproval(toolData.toolcallid, "user-denied"); }; const handleMouseEnter = () => { if (!toolData.blockid) return; if (highlightTimeoutRef.current) { clearTimeout(highlightTimeoutRef.current); } highlightedBlockIdRef.current = toolData.blockid; BlockModel.getInstance().setBlockHighlight({ blockId: toolData.blockid, icon: "sparkles", }); highlightTimeoutRef.current = setTimeout(() => { if (highlightedBlockIdRef.current === toolData.blockid) { BlockModel.getInstance().setBlockHighlight(null); highlightedBlockIdRef.current = null; } }, 2000); }; const handleMouseLeave = () => { if (!toolData.blockid) return; if (highlightTimeoutRef.current) { clearTimeout(highlightTimeoutRef.current); highlightTimeoutRef.current = null; } if (highlightedBlockIdRef.current === toolData.blockid) { BlockModel.getInstance().setBlockHighlight(null); highlightedBlockIdRef.current = null; } }; const handleOpenDiff = () => { recordTEvent("waveai:showdiff"); fireAndForget(() => WaveAIModel.getInstance().openDiff(toolData.inputfilename, toolData.toolcallid)); }; return ( <div className={cn("flex flex-col gap-1 p-2 rounded bg-zinc-800/60 border border-zinc-700", statusColor)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > <div className="flex items-center gap-2"> <span className="font-bold">{statusIcon}</span> <div className="font-semibold">{toolData.toolname}</div> <div className="flex-1" /> {isFileWriteTool && toolData.inputfilename && toolData.writebackupfilename && toolData.runts && Date.now() - toolData.runts < BackupRetentionDays * 24 * 60 * 60 * 1000 && ( <button onClick={() => { recordTEvent("waveai:revertfile", { "waveai:action": "revertfile:open" }); model.openRestoreBackupModal(toolData.toolcallid); }} className="flex-shrink-0 px-1.5 py-0.5 border border-zinc-600 hover:border-zinc-500 hover:bg-zinc-700 rounded cursor-pointer transition-colors flex items-center gap-1 text-zinc-400" title="Restore backup file" > <span className="text-xs">Revert File</span> <i className="fa fa-clock-rotate-left text-xs"></i> </button> )} {isFileWriteTool && toolData.inputfilename && ( <button onClick={handleOpenDiff} className="flex-shrink-0 px-1.5 py-0.5 border border-zinc-600 hover:border-zinc-500 hover:bg-zinc-700 rounded cursor-pointer transition-colors flex items-center gap-1 text-zinc-400" title="Open in diff viewer" > <span className="text-xs">Show Diff</span> <i className="fa fa-arrow-up-right-from-square text-xs"></i> </button> )} </div> {toolData.tooldesc && <ToolDesc text={toolData.tooldesc} className="text-sm text-gray-400 pl-6" />} {(toolData.errormessage || effectiveApproval === "timeout") && ( <div className="text-sm text-red-300 pl-6">{toolData.errormessage || "Not approved"}</div> )} {effectiveApproval === "needs-approval" && ( <div className="pl-6"> <AIToolApprovalButtons count={1} onApprove={handleApprove} onDeny={handleDeny} /> </div> )} {showRestoreModal && <RestoreBackupModal part={part} />} </div> ); }); AIToolUse.displayName = "AIToolUse"; interface AIToolProgressProps { part: WaveUIMessagePart & { type: "data-toolprogress" }; } const AIToolProgress = memo(({ part }: AIToolProgressProps) => { const progressData = part.data; return ( <div className="flex flex-col gap-1 p-2 rounded bg-zinc-800/60 border border-zinc-700"> <div className="flex items-center gap-2"> <i className="fa fa-spinner fa-spin text-gray-400"></i> <div className="font-semibold">{progressData.toolname}</div> </div> {progressData.statuslines && progressData.statuslines.length > 0 && ( <ToolDesc text={progressData.statuslines} className="text-sm text-gray-400 pl-6 space-y-0.5" /> )} </div> ); }); AIToolProgress.displayName = "AIToolProgress"; interface AIToolUseGroupProps { parts: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }>; isStreaming: boolean; } type ToolGroupItem = | { type: "batch"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" }> } | { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } } | { type: "progress"; part: WaveUIMessagePart & { type: "data-toolprogress" } }; export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => { const tooluseParts = parts.filter((p) => p.type === "data-tooluse") as Array< WaveUIMessagePart & { type: "data-tooluse" } >; const toolprogressParts = parts.filter((p) => p.type === "data-toolprogress") as Array< WaveUIMessagePart & { type: "data-toolprogress" } >; const tooluseCallIds = new Set(tooluseParts.map((p) => p.data.toolcallid)); const filteredProgressParts = toolprogressParts.filter((p) => !tooluseCallIds.has(p.data.toolcallid)); const isFileOp = (part: WaveUIMessagePart & { type: "data-tooluse" }) => { const toolName = part.data?.toolname; return toolName === "read_text_file" || toolName === "read_dir"; }; const needsApproval = (part: WaveUIMessagePart & { type: "data-tooluse" }) => { return getEffectiveApprovalStatus(part.data?.approval, isStreaming) === "needs-approval"; }; const readFileNeedsApproval: Array<WaveUIMessagePart & { type: "data-tooluse" }> = []; const readFileOther: Array<WaveUIMessagePart & { type: "data-tooluse" }> = []; for (const part of tooluseParts) { if (isFileOp(part)) { if (needsApproval(part)) { readFileNeedsApproval.push(part); } else { readFileOther.push(part); } } } const groupedItems: ToolGroupItem[] = []; let addedApprovalBatch = false; let addedOtherBatch = false; for (const part of tooluseParts) { const isFileOpPart = isFileOp(part); const partNeedsApproval = needsApproval(part); if (isFileOpPart && partNeedsApproval) { if (!addedApprovalBatch) { groupedItems.push({ type: "batch", parts: readFileNeedsApproval }); addedApprovalBatch = true; } } else if (isFileOpPart && !partNeedsApproval) { if (!addedOtherBatch) { groupedItems.push({ type: "batch", parts: readFileOther }); addedOtherBatch = true; } } else { groupedItems.push({ type: "single", part }); } } filteredProgressParts.forEach((part) => { groupedItems.push({ type: "progress", part }); }); return ( <> {groupedItems.map((item, idx) => { if (item.type === "batch") { return ( <div key={idx} className="mt-2"> <AIToolUseBatch parts={item.parts} isStreaming={isStreaming} /> </div> ); } else if (item.type === "progress") { return ( <div key={idx} className="mt-2"> <AIToolProgress part={item.part} /> </div> ); } else { return ( <div key={idx} className="mt-2"> <AIToolUse part={item.part} isStreaming={isStreaming} /> </div> ); } })} </> ); }); AIToolUseGroup.displayName = "AIToolUseGroup"; ================================================ FILE: frontend/app/aipanel/aitypes.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ChatRequestOptions, FileUIPart, UIMessage, UIMessagePart } from "ai"; type WaveUIDataTypes = { // pkg/aiusechat/uctypes/uctypes.go UIMessageDataUserFile userfile: { filename: string; size: number; mimetype: string; previewurl?: string; }; // pkg/aiusechat/uctypes/uctypes.go UIMessageDataToolUse tooluse: { toolcallid: string; toolname: string; tooldesc: string; status: "pending" | "error" | "completed"; runts?: number; errormessage?: string; approval?: "needs-approval" | "user-approved" | "user-denied" | "auto-approved" | "timeout"; blockid?: string; writebackupfilename?: string; inputfilename?: string; }; toolprogress: { toolcallid: string; toolname: string; statuslines: string[]; }; }; export type WaveUIMessage = UIMessage<unknown, WaveUIDataTypes, any>; export type WaveUIMessagePart = UIMessagePart<WaveUIDataTypes, any>; export type UseChatSetMessagesType = ( messages: WaveUIMessage[] | ((messages: WaveUIMessage[]) => WaveUIMessage[]) ) => void; export type UseChatSendMessageType = ( message?: | (Omit<WaveUIMessage, "id" | "role"> & { id?: string; role?: "system" | "user" | "assistant"; } & { text?: never; files?: never; messageId?: string; }) | { text: string; files?: FileList | FileUIPart[]; metadata?: unknown; parts?: never; messageId?: string; } | { files: FileList | FileUIPart[]; metadata?: unknown; parts?: never; messageId?: string; }, options?: ChatRequestOptions ) => Promise<void>; ================================================ FILE: frontend/app/aipanel/byokannouncement.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WaveAIModel } from "./waveai-model"; const BYOKAnnouncement = () => { const model = WaveAIModel.getInstance(); const handleOpenConfig = async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "action:other", props: { "action:type": "waveai:configuremodes:panel", }, }, { noresponse: true } ); await model.openWaveAIConfig(); }; const handleViewDocs = () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "action:other", props: { "action:type": "waveai:viewdocs:panel", }, }, { noresponse: true } ); }; return ( <div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 mt-4"> <div className="flex items-start gap-3"> <i className="fa fa-key text-blue-400 text-lg mt-0.5"></i> <div className="text-left flex-1"> <div className="text-blue-400 font-medium mb-1">New: BYOK & Local AI Support</div> <div className="text-secondary text-sm mb-3"> Wave AI now supports bring-your-own-key (BYOK) with OpenAI, Google Gemini, Azure, and OpenRouter, plus local models via Ollama, LM Studio, and other OpenAI-compatible providers. </div> <div className="flex items-center gap-3"> <button onClick={handleOpenConfig} className="border border-blue-400 text-blue-400 hover:bg-blue-500/10 hover:text-blue-300 px-3 py-1.5 rounded-md text-sm font-medium cursor-pointer transition-colors" > Configure AI Modes </button> <a href="https://docs.waveterm.dev/waveai-modes" target="_blank" rel="noopener noreferrer" onClick={handleViewDocs} className="text-blue-400! hover:text-blue-300! hover:underline text-sm cursor-pointer transition-colors flex items-center gap-1" > View Docs <i className="fa fa-external-link text-xs"></i> </a> </div> </div> </div> </div> ); }; BYOKAnnouncement.displayName = "BYOKAnnouncement"; export { BYOKAnnouncement }; ================================================ FILE: frontend/app/aipanel/restorebackupmodal.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Modal } from "@/app/modals/modal"; import { recordTEvent } from "@/app/store/global"; import { useAtomValue } from "jotai"; import { memo } from "react"; import { WaveUIMessagePart } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; interface RestoreBackupModalProps { part: WaveUIMessagePart & { type: "data-tooluse" }; } export const RestoreBackupModal = memo(({ part }: RestoreBackupModalProps) => { const model = WaveAIModel.getInstance(); const toolData = part.data; const status = useAtomValue(model.restoreBackupStatus); const error = useAtomValue(model.restoreBackupError); const formatTimestamp = (ts: number) => { if (!ts) return ""; const date = new Date(ts); return date.toLocaleString(); }; const handleConfirm = () => { recordTEvent("waveai:revertfile", { "waveai:action": "revertfile:confirm" }); model.restoreBackup(toolData.toolcallid, toolData.writebackupfilename, toolData.inputfilename); }; const handleCancel = () => { recordTEvent("waveai:revertfile", { "waveai:action": "revertfile:cancel" }); model.closeRestoreBackupModal(); }; const handleClose = () => { model.closeRestoreBackupModal(); }; if (status === "success") { return ( <Modal className="restore-backup-modal pb-5 pr-5" onClose={handleClose} onOk={handleClose} okLabel="Close"> <div className="flex flex-col gap-4 pt-4 pb-4 max-w-xl"> <div className="font-semibold text-lg text-green-500">Backup Successfully Restored</div> <div className="text-sm text-gray-300 leading-relaxed"> The file <span className="font-mono text-white break-all">{toolData.inputfilename}</span> has been restored to its previous state. </div> </div> </Modal> ); } if (status === "error") { return ( <Modal className="restore-backup-modal pb-5 pr-5" onClose={handleClose} onOk={handleClose} okLabel="Close"> <div className="flex flex-col gap-4 pt-4 pb-4 max-w-xl"> <div className="font-semibold text-lg text-red-500">Failed to Restore Backup</div> <div className="text-sm text-gray-300 leading-relaxed"> An error occurred while restoring the backup: </div> <div className="text-sm text-red-400 font-mono bg-zinc-800 p-3 rounded break-all">{error}</div> </div> </Modal> ); } const isProcessing = status === "processing"; return ( <Modal className="restore-backup-modal pb-5 pr-5" onClose={handleCancel} onCancel={handleCancel} onOk={handleConfirm} okLabel={isProcessing ? "Restoring..." : "Confirm Restore"} cancelLabel="Cancel" okDisabled={isProcessing} cancelDisabled={isProcessing} > <div className="flex flex-col gap-4 pt-4 pb-4 max-w-xl"> <div className="font-semibold text-lg">Restore File Backup</div> <div className="text-sm text-gray-300 leading-relaxed"> This will restore <span className="font-mono text-white break-all">{toolData.inputfilename}</span>{" "} to its state before this edit was made {toolData.runts && <span> ({formatTimestamp(toolData.runts)})</span>}. </div> <div className="text-sm text-gray-300 leading-relaxed"> Any changes made by this edit and subsequent edits will be lost. </div> </div> </Modal> ); }); RestoreBackupModal.displayName = "RestoreBackupModal"; ================================================ FILE: frontend/app/aipanel/telemetryrequired.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { cn } from "@/util/util"; import { useState } from "react"; import { WaveAIModel } from "./waveai-model"; interface TelemetryRequiredMessageProps { className?: string; } const TelemetryRequiredMessage = ({ className }: TelemetryRequiredMessageProps) => { const [isEnabling, setIsEnabling] = useState(false); const handleEnableTelemetry = async () => { setIsEnabling(true); try { await RpcApi.WaveAIEnableTelemetryCommand(TabRpcClient); setTimeout(() => { WaveAIModel.getInstance().focusInput(); }, 100); } catch (error) { console.error("Failed to enable telemetry:", error); setIsEnabling(false); } }; return ( <div className={cn("flex flex-col h-full", className)}> <div className="flex-grow"></div> <div className="flex items-center justify-center p-8 text-center"> <div className="max-w-md space-y-6"> <div className="space-y-4"> <i className="fa fa-sparkles text-accent text-5xl"></i> <h2 className="text-2xl font-semibold text-foreground">Wave AI</h2> <p className="text-secondary leading-relaxed"> Wave AI is free to use and provides integrated AI chat that can interact with your widgets, help you with code, analyze files, and assist with your terminal workflows. </p> </div> <div className="bg-blue-900/20 border border-blue-500 rounded-lg p-4"> <div className="flex items-start gap-3"> <i className="fa fa-info-circle text-blue-400 text-lg mt-0.5"></i> <div className="text-left"> <div className="text-blue-400 font-medium mb-1">Telemetry keeps Wave AI free</div> <div className="text-secondary text-sm mb-3"> <p className="mb-2"> To keep Wave AI free for everyone, we require a small amount of <i>anonymous</i>{" "} usage data (app version, feature usage, system info). </p> <p className="mb-2"> This helps us block abuse by automated systems and ensure it's used by real people like you. </p> <p className="mb-2"> We never collect your files, prompts, keystrokes, hostnames, or personally identifying information. Wave AI is powered by OpenAI's APIs, please refer to OpenAI's privacy policy for details on how they handle your data. </p> <p> For information about BYOK and local model support, see{" "} <a href="https://docs.waveterm.dev/waveai-modes" target="_blank" rel="noopener noreferrer" className="!text-secondary hover:!text-accent/80 cursor-pointer" > https://docs.waveterm.dev/waveai-modes </a> . </p> </div> <button onClick={handleEnableTelemetry} disabled={isEnabling} className="bg-accent/80 hover:bg-accent disabled:bg-accent/50 text-background px-4 py-2 rounded-lg font-medium cursor-pointer disabled:cursor-not-allowed" > {isEnabling ? "Enabling..." : "Enable Telemetry and Continue"} </button> </div> </div> </div> <div className="text-xs text-secondary"> <a href="https://waveterm.dev/privacy" target="_blank" rel="noopener noreferrer" className="!text-secondary hover:!text-accent/80 cursor-pointer" > Privacy Policy </a> </div> </div> </div> <div className="flex-grow-[2]"></div> </div> ); }; TelemetryRequiredMessage.displayName = "TelemetryRequiredMessage"; export { TelemetryRequiredMessage }; ================================================ FILE: frontend/app/aipanel/waveai-focus-utils.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export function findWaveAIPanel(element: HTMLElement): HTMLElement | null { let current: HTMLElement = element; while (current) { if (current.hasAttribute("data-waveai-panel")) { return current; } current = current.parentElement; } return null; } export function waveAIHasFocusWithin(focusTarget?: Element | null): boolean { if (focusTarget !== undefined) { if (focusTarget instanceof HTMLElement) { return findWaveAIPanel(focusTarget) != null; } return false; } const focused = document.activeElement; if (focused instanceof HTMLElement) { const waveAIPanel = findWaveAIPanel(focused); if (waveAIPanel) return true; } const sel = document.getSelection(); if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { let anchor = sel.anchorNode; if (anchor instanceof Text) { anchor = anchor.parentElement; } if (anchor instanceof HTMLElement) { const waveAIPanel = findWaveAIPanel(anchor); if (waveAIPanel) return true; } } return false; } export function waveAIHasSelection(): boolean { const sel = document.getSelection(); if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { return false; } let anchor = sel.anchorNode; if (anchor instanceof Text) { anchor = anchor.parentElement; } if (anchor instanceof HTMLElement) { return findWaveAIPanel(anchor) != null; } return false; } ================================================ FILE: frontend/app/aipanel/waveai-model.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { UseChatSendMessageType, UseChatSetMessagesType, WaveUIMessage, WaveUIMessagePart, } from "@/app/aipanel/aitypes"; import { FocusManager } from "@/app/store/focusManager"; import { atoms, createBlock, getOrefMetaKeyAtom, getSettingsKeyAtom } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { isBuilderWindow } from "@/app/store/windowtype"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { getWebServerEndpoint } from "@/util/endpoints"; import { base64ToArrayBuffer } from "@/util/util"; import { ChatStatus } from "ai"; import * as jotai from "jotai"; import type React from "react"; import { createDataUrl, createImagePreview, formatFileSizeError, isAcceptableFile, normalizeMimeType, resizeImage, validateFileSizeFromInfo, } from "./ai-utils"; import type { AIPanelInputRef } from "./aipanelinput"; export interface DroppedFile { id: string; file: File; name: string; type: string; size: number; previewUrl?: string; } export class WaveAIModel { private static instance: WaveAIModel | null = null; inputRef: React.RefObject<AIPanelInputRef> | null = null; scrollToBottomCallback: (() => void) | null = null; useChatSendMessage: UseChatSendMessageType | null = null; useChatSetMessages: UseChatSetMessagesType | null = null; useChatStatus: ChatStatus = "ready"; useChatStop: (() => void) | null = null; // Used for injecting Wave-specific message data into DefaultChatTransport's prepareSendMessagesRequest realMessage: AIMessage | null = null; orefContext: ORef; inBuilder: boolean = false; isAIStreaming = jotai.atom(false); widgetAccessAtom!: jotai.Atom<boolean>; droppedFiles: jotai.PrimitiveAtom<DroppedFile[]> = jotai.atom([]); chatId!: jotai.PrimitiveAtom<string>; currentAIMode!: jotai.PrimitiveAtom<string>; aiModeConfigs!: jotai.Atom<Record<string, AIModeConfigType>>; hasPremiumAtom!: jotai.Atom<boolean>; defaultModeAtom!: jotai.Atom<string>; errorMessage: jotai.PrimitiveAtom<string> = jotai.atom(null) as jotai.PrimitiveAtom<string>; containerWidth: jotai.PrimitiveAtom<number> = jotai.atom(0); codeBlockMaxWidth!: jotai.Atom<number>; inputAtom: jotai.PrimitiveAtom<string> = jotai.atom(""); isLoadingChatAtom: jotai.PrimitiveAtom<boolean> = jotai.atom(false); isChatEmptyAtom: jotai.PrimitiveAtom<boolean> = jotai.atom(true); isWaveAIFocusedAtom!: jotai.Atom<boolean>; panelVisibleAtom!: jotai.Atom<boolean>; restoreBackupModalToolCallId: jotai.PrimitiveAtom<string | null> = jotai.atom(null) as jotai.PrimitiveAtom< string | null >; restoreBackupStatus: jotai.PrimitiveAtom<"idle" | "processing" | "success" | "error"> = jotai.atom("idle"); restoreBackupError: jotai.PrimitiveAtom<string> = jotai.atom(null) as jotai.PrimitiveAtom<string>; private constructor(orefContext: ORef, inBuilder: boolean) { this.orefContext = orefContext; this.inBuilder = inBuilder; this.chatId = jotai.atom(null) as jotai.PrimitiveAtom<string>; this.aiModeConfigs = atoms.waveaiModeConfigAtom; this.hasPremiumAtom = jotai.atom((get) => { const rateLimitInfo = get(atoms.waveAIRateLimitInfoAtom); return !rateLimitInfo || rateLimitInfo.unknown || rateLimitInfo.preq > 0; }); this.widgetAccessAtom = jotai.atom((get) => { if (this.inBuilder) { return true; } const widgetAccessMetaAtom = getOrefMetaKeyAtom(this.orefContext, "waveai:widgetcontext"); const value = get(widgetAccessMetaAtom); return value ?? true; }); this.codeBlockMaxWidth = jotai.atom((get) => { const width = get(this.containerWidth); return width > 0 ? width - 35 : 0; }); this.isWaveAIFocusedAtom = jotai.atom((get) => { if (this.inBuilder) { return get(BuilderFocusManager.getInstance().focusType) === "waveai"; } return get(FocusManager.getInstance().focusType) === "waveai"; }); this.panelVisibleAtom = jotai.atom((get) => { if (this.inBuilder) { return true; } return get(WorkspaceLayoutModel.getInstance().panelVisibleAtom); }); this.defaultModeAtom = jotai.atom((get) => { const telemetryEnabled = get(getSettingsKeyAtom("telemetry:enabled")) ?? false; if (this.inBuilder) { return telemetryEnabled ? "waveai@balanced" : "invalid"; } const aiModeConfigs = get(this.aiModeConfigs); if (!telemetryEnabled) { let mode = get(getSettingsKeyAtom("waveai:defaultmode")); if (mode == null || mode.startsWith("waveai@")) { return "unknown"; } return mode; } const hasPremium = get(this.hasPremiumAtom); const waveFallback = hasPremium ? "waveai@balanced" : "waveai@quick"; let mode = get(getSettingsKeyAtom("waveai:defaultmode")) ?? waveFallback; if (!hasPremium && mode.startsWith("waveai@")) { mode = "waveai@quick"; } const modeExists = aiModeConfigs != null && mode in aiModeConfigs; if (!modeExists) { mode = waveFallback; } return mode; }); const defaultMode = globalStore.get(this.defaultModeAtom); this.currentAIMode = jotai.atom(defaultMode); } getPanelVisibleAtom(): jotai.Atom<boolean> { return this.panelVisibleAtom; } static getInstance(): WaveAIModel { if (!WaveAIModel.instance) { let orefContext: ORef; if (isBuilderWindow()) { const builderId = globalStore.get(atoms.builderId); orefContext = WOS.makeORef("builder", builderId); } else { const tabId = globalStore.get(atoms.staticTabId); orefContext = WOS.makeORef("tab", tabId); } WaveAIModel.instance = new WaveAIModel(orefContext, isBuilderWindow()); (window as any).WaveAIModel = WaveAIModel.instance; } return WaveAIModel.instance; } static resetInstance(): void { WaveAIModel.instance = null; } getUseChatEndpointUrl(): string { return `${getWebServerEndpoint()}/api/post-chat-message`; } async addFile(file: File): Promise<DroppedFile> { // Resize images before storing const processedFile = await resizeImage(file); const droppedFile: DroppedFile = { id: crypto.randomUUID(), file: processedFile, name: processedFile.name, type: processedFile.type, size: processedFile.size, }; // Create 128x128 preview data URL for images if (processedFile.type.startsWith("image/")) { const previewDataUrl = await createImagePreview(processedFile); if (previewDataUrl) { droppedFile.previewUrl = previewDataUrl; } } const currentFiles = globalStore.get(this.droppedFiles); globalStore.set(this.droppedFiles, [...currentFiles, droppedFile]); return droppedFile; } async addFileFromRemoteUri(draggedFile: DraggedFile): Promise<void> { if (draggedFile.isDir) { this.setError("Cannot add directories to Wave AI. Please select a file."); return; } try { const fileInfo = await RpcApi.FileInfoCommand(TabRpcClient, { info: { path: draggedFile.uri } }, null); if (fileInfo.notfound) { this.setError(`File not found: ${draggedFile.relName}`); return; } if (fileInfo.isdir) { this.setError("Cannot add directories to Wave AI. Please select a file."); return; } const mimeType = fileInfo.mimetype || "application/octet-stream"; const fileSize = fileInfo.size || 0; const sizeError = validateFileSizeFromInfo(draggedFile.relName, fileSize, mimeType); if (sizeError) { this.setError(formatFileSizeError(sizeError)); return; } const fileData = await RpcApi.FileReadCommand(TabRpcClient, { info: { path: draggedFile.uri } }, null); if (!fileData.data64) { this.setError(`Failed to read file: ${draggedFile.relName}`); return; } const buffer = base64ToArrayBuffer(fileData.data64); const file = new File([buffer], draggedFile.relName, { type: mimeType }); if (!isAcceptableFile(file)) { this.setError( `File type not supported: ${draggedFile.relName}. Supported: images, PDFs, and text/code files.` ); return; } await this.addFile(file); } catch (error) { console.error("Error handling FILE_ITEM drop:", error); const errorMsg = error instanceof Error ? error.message : String(error); this.setError(`Failed to add file: ${errorMsg}`); } } removeFile(fileId: string) { const currentFiles = globalStore.get(this.droppedFiles); const updatedFiles = currentFiles.filter((f) => f.id !== fileId); globalStore.set(this.droppedFiles, updatedFiles); } clearFiles() { const currentFiles = globalStore.get(this.droppedFiles); // Cleanup all preview URLs currentFiles.forEach((file) => { if (file.previewUrl) { URL.revokeObjectURL(file.previewUrl); } }); globalStore.set(this.droppedFiles, []); } clearChat() { this.useChatStop?.(); this.clearFiles(); this.clearError(); globalStore.set(this.isChatEmptyAtom, true); const newChatId = crypto.randomUUID(); globalStore.set(this.chatId, newChatId); RpcApi.SetRTInfoCommand(TabRpcClient, { oref: this.orefContext, data: { "waveai:chatid": newChatId }, }); this.useChatSetMessages?.([]); } setError(message: string) { globalStore.set(this.errorMessage, message); } clearError() { globalStore.set(this.errorMessage, null); } registerInputRef(ref: React.RefObject<AIPanelInputRef>) { this.inputRef = ref; } registerScrollToBottom(callback: () => void) { this.scrollToBottomCallback = callback; } registerUseChatData( sendMessage: UseChatSendMessageType, setMessages: UseChatSetMessagesType, status: ChatStatus, stop: () => void ) { this.useChatSendMessage = sendMessage; this.useChatSetMessages = setMessages; this.useChatStatus = status; this.useChatStop = stop; } scrollToBottom() { this.scrollToBottomCallback?.(); } focusInput() { if (!this.inBuilder && !WorkspaceLayoutModel.getInstance().getAIPanelVisible()) { WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); } if (this.inputRef?.current) { this.inputRef.current.focus(); } } async reloadChatFromBackend(chatIdValue: string): Promise<WaveUIMessage[]> { const chatData = await RpcApi.GetWaveAIChatCommand(TabRpcClient, { chatid: chatIdValue }); const messages: UIMessage[] = chatData?.messages ?? []; globalStore.set(this.isChatEmptyAtom, messages.length === 0); return messages as WaveUIMessage[]; } async stopResponse() { this.useChatStop?.(); await new Promise((resolve) => setTimeout(resolve, 500)); const chatIdValue = globalStore.get(this.chatId); if (!chatIdValue) { return; } try { const messages = await this.reloadChatFromBackend(chatIdValue); this.useChatSetMessages?.(messages); } catch (error) { console.error("Failed to reload chat after stop:", error); } } getAndClearMessage(): AIMessage | null { const msg = this.realMessage; this.realMessage = null; return msg; } hasNonEmptyInput(): boolean { const input = globalStore.get(this.inputAtom); return input != null && input.trim().length > 0; } appendText(text: string, newLine?: boolean, opts?: { scrollToBottom?: boolean }) { const currentInput = globalStore.get(this.inputAtom); let newInput = currentInput; if (newInput.length > 0) { if (newLine) { if (!newInput.endsWith("\n")) { newInput += "\n"; } } else if (!newInput.endsWith(" ") && !newInput.endsWith("\n")) { newInput += " "; } } newInput += text; globalStore.set(this.inputAtom, newInput); if (opts?.scrollToBottom && this.inputRef?.current) { setTimeout(() => this.inputRef.current.scrollToBottom(), 10); } } setModel(model: string) { RpcApi.SetMetaCommand(TabRpcClient, { oref: this.orefContext, meta: { "waveai:model": model }, }); } setWidgetAccess(enabled: boolean) { RpcApi.SetMetaCommand(TabRpcClient, { oref: this.orefContext, meta: { "waveai:widgetcontext": enabled }, }); } isValidMode(mode: string): boolean { const telemetryEnabled = globalStore.get(getSettingsKeyAtom("telemetry:enabled")) ?? false; if (mode.startsWith("waveai@") && !telemetryEnabled) { return false; } const aiModeConfigs = globalStore.get(this.aiModeConfigs); if (aiModeConfigs == null || !(mode in aiModeConfigs)) { return false; } return true; } setAIMode(mode: string) { if (!this.isValidMode(mode)) { this.setAIModeToDefault(); } else { globalStore.set(this.currentAIMode, mode); RpcApi.SetRTInfoCommand(TabRpcClient, { oref: this.orefContext, data: { "waveai:mode": mode }, }); } } setAIModeToDefault() { const defaultMode = globalStore.get(this.defaultModeAtom); globalStore.set(this.currentAIMode, defaultMode); RpcApi.SetRTInfoCommand(TabRpcClient, { oref: this.orefContext, data: { "waveai:mode": null }, }); } async fixModeAfterConfigChange(): Promise<void> { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: this.orefContext, }); const mode = rtInfo?.["waveai:mode"]; if (mode == null || !this.isValidMode(mode)) { this.setAIModeToDefault(); } } async getRTInfo(): Promise<Record<string, any>> { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: this.orefContext, }); return rtInfo ?? {}; } async loadInitialChat(): Promise<WaveUIMessage[]> { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: this.orefContext, }); let chatIdValue = rtInfo?.["waveai:chatid"]; if (chatIdValue == null) { chatIdValue = crypto.randomUUID(); RpcApi.SetRTInfoCommand(TabRpcClient, { oref: this.orefContext, data: { "waveai:chatid": chatIdValue }, }); } globalStore.set(this.chatId, chatIdValue); const aiModeValue = rtInfo?.["waveai:mode"]; if (aiModeValue == null) { const defaultMode = globalStore.get(this.defaultModeAtom); globalStore.set(this.currentAIMode, defaultMode); } else if (this.isValidMode(aiModeValue)) { globalStore.set(this.currentAIMode, aiModeValue); } else { this.setAIModeToDefault(); } try { return await this.reloadChatFromBackend(chatIdValue); } catch (error) { console.error("Failed to load chat:", error); this.setError("Failed to load chat. Starting new chat..."); this.clearChat(); return []; } } async handleSubmit() { const input = globalStore.get(this.inputAtom); const droppedFiles = globalStore.get(this.droppedFiles); if (input.trim() === "/clear" || input.trim() === "/new") { this.clearChat(); globalStore.set(this.inputAtom, ""); return; } if ( (!input.trim() && droppedFiles.length === 0) || (this.useChatStatus !== "ready" && this.useChatStatus !== "error") || globalStore.get(this.isLoadingChatAtom) ) { return; } this.clearError(); const aiMessageParts: AIMessagePart[] = []; const uiMessageParts: WaveUIMessagePart[] = []; if (input.trim()) { aiMessageParts.push({ type: "text", text: input.trim() }); uiMessageParts.push({ type: "text", text: input.trim() }); } for (const droppedFile of droppedFiles) { const normalizedMimeType = normalizeMimeType(droppedFile.file); const dataUrl = await createDataUrl(droppedFile.file); aiMessageParts.push({ type: "file", filename: droppedFile.name, mimetype: normalizedMimeType, url: dataUrl, size: droppedFile.file.size, previewurl: droppedFile.previewUrl, }); uiMessageParts.push({ type: "data-userfile", data: { filename: droppedFile.name, mimetype: normalizedMimeType, size: droppedFile.file.size, previewurl: droppedFile.previewUrl, }, }); } const realMessage: AIMessage = { messageid: crypto.randomUUID(), parts: aiMessageParts, }; this.realMessage = realMessage; // console.log("SUBMIT MESSAGE", realMessage); this.useChatSendMessage?.({ parts: uiMessageParts }); globalStore.set(this.isChatEmptyAtom, false); globalStore.set(this.inputAtom, ""); this.clearFiles(); } async uiLoadInitialChat() { globalStore.set(this.isLoadingChatAtom, true); const messages = await this.loadInitialChat(); this.useChatSetMessages?.(messages); globalStore.set(this.isLoadingChatAtom, false); setTimeout(() => { this.scrollToBottom(); }, 100); } async ensureRateLimitSet() { const currentInfo = globalStore.get(atoms.waveAIRateLimitInfoAtom); if (currentInfo != null) { return; } try { const rateLimitInfo = await RpcApi.GetWaveAIRateLimitCommand(TabRpcClient); if (rateLimitInfo != null) { globalStore.set(atoms.waveAIRateLimitInfoAtom, rateLimitInfo); } } catch (error) { console.error("Failed to fetch rate limit info:", error); } } handleAIFeedback(feedback: "good" | "bad") { RpcApi.RecordTEventCommand( TabRpcClient, { event: "waveai:feedback", props: { "waveai:feedback": feedback, }, }, { noresponse: true } ); } requestWaveAIFocus() { if (this.inBuilder) { BuilderFocusManager.getInstance().setWaveAIFocused(); } else { FocusManager.getInstance().requestWaveAIFocus(); } } requestNodeFocus() { if (this.inBuilder) { BuilderFocusManager.getInstance().setAppFocused(); } else { FocusManager.getInstance().requestNodeFocus(); } } getChatId(): string { return globalStore.get(this.chatId); } toolUseSendApproval(toolcallid: string, approval: string) { RpcApi.WaveAIToolApproveCommand(TabRpcClient, { toolcallid: toolcallid, approval: approval, }); } async openDiff(fileName: string, toolcallid: string) { const chatId = this.getChatId(); if (!chatId || !fileName) { console.error("Missing chatId or fileName for opening diff", chatId, fileName); return; } const blockDef: BlockDef = { meta: { view: "aifilediff", file: fileName, "aifilediff:chatid": chatId, "aifilediff:toolcallid": toolcallid, }, }; await createBlock(blockDef, false, true); } async openWaveAIConfig() { const blockDef: BlockDef = { meta: { view: "waveconfig", file: "waveai.json", }, }; await createBlock(blockDef, false, true); } openRestoreBackupModal(toolcallid: string) { globalStore.set(this.restoreBackupModalToolCallId, toolcallid); } closeRestoreBackupModal() { globalStore.set(this.restoreBackupModalToolCallId, null); globalStore.set(this.restoreBackupStatus, "idle"); globalStore.set(this.restoreBackupError, null); } async restoreBackup(toolcallid: string, backupFilePath: string, restoreToFileName: string) { globalStore.set(this.restoreBackupStatus, "processing"); globalStore.set(this.restoreBackupError, null); try { await RpcApi.FileRestoreBackupCommand(TabRpcClient, { backupfilepath: backupFilePath, restoretofilename: restoreToFileName, }); console.log("Backup restored successfully:", { toolcallid, backupFilePath, restoreToFileName }); globalStore.set(this.restoreBackupStatus, "success"); } catch (error) { console.error("Failed to restore backup:", error); const errorMsg = error?.message || String(error); globalStore.set(this.restoreBackupError, errorMsg); globalStore.set(this.restoreBackupStatus, "error"); } } canCloseWaveAIPanel(): boolean { if (this.inBuilder) { return false; } return true; } closeWaveAIPanel() { if (this.inBuilder) { return; } WorkspaceLayoutModel.getInstance().setAIPanelVisible(false); } } ================================================ FILE: frontend/app/app-bg.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { computeBgStyleFromMeta } from "@/util/waveutil"; import useResizeObserver from "@react-hook/resize-observer"; import { useAtomValue } from "jotai"; import { CSSProperties, useCallback, useLayoutEffect, useRef } from "react"; import { debounce } from "throttle-debounce"; import { atoms, getApi, WOS } from "./store/global"; import { useWaveObjectValue } from "./store/wos"; export function AppBackground() { const bgRef = useRef<HTMLDivElement>(null); const tabId = useAtomValue(atoms.staticTabId); const [tabData] = useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId)); const style: CSSProperties = computeBgStyleFromMeta(tabData?.meta, 0.5) ?? {}; const getAvgColor = useCallback( debounce(30, () => { if ( bgRef.current && PLATFORM !== PlatformMacOS && bgRef.current && "windowControlsOverlay" in window.navigator ) { const titlebarRect: Dimensions = (window.navigator.windowControlsOverlay as any).getTitlebarAreaRect(); const bgRect = bgRef.current.getBoundingClientRect(); if (titlebarRect && bgRect) { const windowControlsLeft = titlebarRect.width - titlebarRect.height; const windowControlsRect: Dimensions = { top: titlebarRect.top, left: windowControlsLeft, height: titlebarRect.height, width: bgRect.width - bgRect.left - windowControlsLeft, }; getApi().updateWindowControlsOverlay(windowControlsRect); } } }), [bgRef, style] ); useLayoutEffect(getAvgColor, [getAvgColor]); useResizeObserver(bgRef, getAvgColor); return <div ref={bgRef} className="pointer-events-none absolute top-0 left-0 w-full h-full z-[var(--zindex-app-background)]" style={style} />; } ================================================ FILE: frontend/app/app.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 @use "reset.scss"; @use "theme.scss"; html { overflow: hidden; } body { display: flex; flex-direction: row; width: 100vw; height: 100vh; color: var(--main-text-color); font: var(--base-font); overflow: hidden; background: rgb(from var(--main-bg-color) r g b / var(--window-opacity)); -webkit-font-smoothing: auto; backface-visibility: hidden; transform: translateZ(0); } .is-transparent { background-color: transparent; } *::-webkit-scrollbar { width: 4px; height: 4px; } *::-webkit-scrollbar-track { background-color: var(--scrollbar-background-color); } *::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb-color); border-radius: 4px; margin: 0 1px 0 1px; } *::-webkit-scrollbar-thumb:hover { background-color: var(--scrollbar-thumb-hover-color); } .flex-spacer { flex-grow: 1; } .text-fixed { font: var(--fixed-font); } .error-boundary { white-space: pre-wrap; color: var(--error-color); } .error-color { color: var(--error-color); } /* OverlayScrollbars styling */ .os-scrollbar { --os-handle-bg: var(--scrollbar-thumb-color); --os-handle-bg-hover: var(--scrollbar-thumb-hover-color); --os-handle-bg-active: var(--scrollbar-thumb-active-color); } .scrollbar-hide-until-hover { *::-webkit-scrollbar-thumb, *::-webkit-scrollbar-track { display: none; } *::-webkit-scrollbar-corner { display: none; } *:hover::-webkit-scrollbar-thumb { display: block; } } a { color: var(--accent-color); } .prefers-reduced-motion { * { transition-duration: none !important; transition-timing-function: none !important; transition-property: none !important; transition-delay: none !important; } } ================================================ FILE: frontend/app/app.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { clearBadgesForBlockOnFocus, clearBadgesForTabOnFocus, getBadgeAtom, getBlockBadgeAtom, } from "@/app/store/badge"; import { ClientModel } from "@/app/store/client-model"; import { GlobalModel } from "@/app/store/global-model"; import { globalStore } from "@/app/store/jotaiStore"; import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; import { WaveEnvContext } from "@/app/waveenv/waveenv"; import { makeWaveEnvImpl } from "@/app/waveenv/waveenvimpl"; import { Workspace } from "@/app/workspace/workspace"; import { getLayoutModelForStaticTab } from "@/layout/index"; import { ContextMenuModel } from "@/store/contextmenu"; import { atoms, createBlock, getSettingsPrefixAtom } from "@/store/global"; import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel"; import { getElemAsStr } from "@/util/focusutil"; import * as keyutil from "@/util/keyutil"; import { PLATFORM } from "@/util/platformutil"; import * as util from "@/util/util"; import clsx from "clsx"; import debug from "debug"; import { Provider, useAtomValue } from "jotai"; import "overlayscrollbars/overlayscrollbars.css"; import { useEffect, useRef } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { AppBackground } from "./app-bg"; import { CenteredDiv } from "./element/quickelems"; import "./app.scss"; // tailwindsetup.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports) import "../tailwindsetup.css"; const dlog = debug("wave:app"); const focusLog = debug("wave:focus"); const App = ({ onFirstRender }: { onFirstRender: () => void }) => { const tabId = useAtomValue(atoms.staticTabId); const waveEnvRef = useRef(makeWaveEnvImpl()); useEffect(() => { onFirstRender(); }, []); return ( <Provider store={globalStore}> <WaveEnvContext.Provider value={waveEnvRef.current}> <TabModelContext.Provider value={getTabModelByTabId(tabId)}> <AppInner /> </TabModelContext.Provider> </WaveEnvContext.Provider> </Provider> ); }; function isContentEditableBeingEdited(): boolean { const activeElement = document.activeElement; return ( activeElement && activeElement.getAttribute("contenteditable") !== null && activeElement.getAttribute("contenteditable") !== "false" ); } function canEnablePaste(): boolean { const activeElement = document.activeElement; return activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || isContentEditableBeingEdited(); } function canEnableCopy(): boolean { const sel = window.getSelection(); return !util.isBlank(sel?.toString()); } function canEnableCut(): boolean { const sel = window.getSelection(); if (document.activeElement?.classList.contains("xterm-helper-textarea")) { return false; } return !util.isBlank(sel?.toString()) && canEnablePaste(); } async function getClipboardURL(): Promise<URL> { try { const clipboardText = await navigator.clipboard.readText(); if (clipboardText == null) { return null; } const url = new URL(clipboardText); if (!url.protocol.startsWith("http")) { return null; } return url; } catch (e) { return null; } } async function handleContextMenu(e: React.MouseEvent<HTMLDivElement>) { e.preventDefault(); const canPaste = canEnablePaste(); const canCopy = canEnableCopy(); const canCut = canEnableCut(); const clipboardURL = await getClipboardURL(); if (!canPaste && !canCopy && !canCut && !clipboardURL) { return; } const menu: ContextMenuItem[] = []; if (canCut) { menu.push({ label: "Cut", role: "cut" }); } if (canCopy) { menu.push({ label: "Copy", role: "copy" }); } if (canPaste) { menu.push({ label: "Paste", role: "paste" }); } if (clipboardURL) { menu.push({ type: "separator" }); menu.push({ label: "Open Clipboard URL (" + clipboardURL.hostname + ")", click: () => { createBlock({ meta: { view: "web", url: clipboardURL.toString(), }, }); }, }); } ContextMenuModel.getInstance().showContextMenu(menu, e); } function AppSettingsUpdater() { const windowSettingsAtom = getSettingsPrefixAtom("window"); const windowSettings = useAtomValue(windowSettingsAtom); useEffect(() => { const isTransparentOrBlur = (windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false; const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1); const baseBgColor = windowSettings?.["window:bgcolor"]; const mainDiv = document.getElementById("main"); // console.log("window settings", windowSettings, isTransparentOrBlur, opacity, baseBgColor, mainDiv); if (isTransparentOrBlur) { mainDiv.classList.add("is-transparent"); if (opacity != null) { document.body.style.setProperty("--window-opacity", `${opacity}`); } else { document.body.style.removeProperty("--window-opacity"); } } else { mainDiv.classList.remove("is-transparent"); document.body.style.removeProperty("--window-opacity"); } if (baseBgColor != null) { document.body.style.setProperty("--main-bg-color", baseBgColor); } else { document.body.style.removeProperty("--main-bg-color"); } }, [windowSettings]); return null; } function appFocusIn(e: FocusEvent) { focusLog("focusin", getElemAsStr(e.target), "<=", getElemAsStr(e.relatedTarget)); } function appFocusOut(e: FocusEvent) { focusLog("focusout", getElemAsStr(e.target), "=>", getElemAsStr(e.relatedTarget)); } function appSelectionChange(e: Event) { const selection = document.getSelection(); focusLog("selectionchange", getElemAsStr(selection.anchorNode)); } function AppFocusHandler() { return null; // for debugging useEffect(() => { document.addEventListener("focusin", appFocusIn); document.addEventListener("focusout", appFocusOut); document.addEventListener("selectionchange", appSelectionChange); const ivId = setInterval(() => { const activeElement = document.activeElement; if (activeElement instanceof HTMLElement) { focusLog("activeElement", getElemAsStr(activeElement)); } }, 2000); return () => { document.removeEventListener("focusin", appFocusIn); document.removeEventListener("focusout", appFocusOut); document.removeEventListener("selectionchange", appSelectionChange); clearInterval(ivId); }; }); return null; } const AppKeyHandlers = () => { useEffect(() => { const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); const staticMouseDownHandler = (e: MouseEvent) => { keyboardMouseDownHandler(e); GlobalModel.getInstance().setIsActive(); }; document.addEventListener("keydown", staticKeyDownHandler); document.addEventListener("mousedown", staticMouseDownHandler); return () => { document.removeEventListener("keydown", staticKeyDownHandler); document.removeEventListener("mousedown", staticMouseDownHandler); }; }, []); return null; }; const BadgeAutoClearing = () => { const tabId = useAtomValue(atoms.staticTabId); const documentHasFocus = useAtomValue(atoms.documentHasFocus); const layoutModel = getLayoutModelForStaticTab(); const focusedNode = useAtomValue(layoutModel.focusedNode); const focusedBlockId = focusedNode?.data?.blockId; const badge = useAtomValue(getBlockBadgeAtom(focusedBlockId)); const tabTransientBadge = useAtomValue(getBadgeAtom(tabId != null ? `tab:${tabId}` : null)); const prevFocusedBlockIdRef = useRef<string>(null); const prevDocHasFocusRef = useRef<boolean>(false); const prevTabDocHasFocusRef = useRef<boolean>(false); useEffect(() => { if (!focusedBlockId || !badge || !documentHasFocus) { prevFocusedBlockIdRef.current = focusedBlockId; prevDocHasFocusRef.current = documentHasFocus; return; } const focusSwitched = prevFocusedBlockIdRef.current !== focusedBlockId || prevDocHasFocusRef.current !== documentHasFocus; prevFocusedBlockIdRef.current = focusedBlockId; prevDocHasFocusRef.current = documentHasFocus; const delay = focusSwitched ? 500 : 3000; const timeoutId = setTimeout(() => { if (!document.hasFocus()) { return; } const currentFocusedNode = globalStore.get(layoutModel.focusedNode); if (currentFocusedNode?.data?.blockId === focusedBlockId) { clearBadgesForBlockOnFocus(focusedBlockId); } }, delay); return () => clearTimeout(timeoutId); }, [focusedBlockId, badge, documentHasFocus]); useEffect(() => { if (!tabId || !tabTransientBadge || !documentHasFocus) { prevTabDocHasFocusRef.current = documentHasFocus; return; } const focusSwitched = prevTabDocHasFocusRef.current !== documentHasFocus; prevTabDocHasFocusRef.current = documentHasFocus; const delay = focusSwitched ? 500 : 3000; const timeoutId = setTimeout(() => { if (!document.hasFocus()) { return; } clearBadgesForTabOnFocus(tabId); }, delay); return () => clearTimeout(timeoutId); }, [tabId, tabTransientBadge, documentHasFocus]); return null; }; const AppInner = () => { const prefersReducedMotion = useAtomValue(atoms.prefersReducedMotionAtom); const client = useAtomValue(ClientModel.getInstance().clientAtom); const windowData = useAtomValue(GlobalModel.getInstance().windowDataAtom); const isFullScreen = useAtomValue(atoms.isFullScreen); if (client == null || windowData == null) { return ( <div className="flex flex-col w-full h-full"> <AppBackground /> <CenteredDiv>invalid configuration, client or window was not loaded</CenteredDiv> </div> ); } return ( <div className={clsx("flex flex-col w-full h-full", PLATFORM, { fullscreen: isFullScreen, "prefers-reduced-motion": prefersReducedMotion, })} onContextMenu={handleContextMenu} > <AppBackground /> <AppKeyHandlers /> <AppFocusHandler /> <AppSettingsUpdater /> <BadgeAutoClearing /> <DndProvider backend={HTML5Backend}> <Workspace /> </DndProvider> </div> ); }; export { App }; ================================================ FILE: frontend/app/block/block-model.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import * as jotai from "jotai"; export interface BlockHighlightType { blockId: string; icon: string; } export class BlockModel { private static instance: BlockModel | null = null; private blockHighlightAtomCache = new Map<string, jotai.Atom<BlockHighlightType | null>>(); blockHighlightAtom: jotai.PrimitiveAtom<BlockHighlightType> = jotai.atom(null) as jotai.PrimitiveAtom<BlockHighlightType>; private constructor() { // Empty for now } getBlockHighlightAtom(blockId: string): jotai.Atom<BlockHighlightType | null> { let atom = this.blockHighlightAtomCache.get(blockId); if (!atom) { atom = jotai.atom((get) => { const highlight = get(this.blockHighlightAtom); if (highlight?.blockId === blockId) { return highlight; } return null; }); this.blockHighlightAtomCache.set(blockId, atom); } return atom; } setBlockHighlight(highlight: BlockHighlightType | null) { globalStore.set(this.blockHighlightAtom, highlight); } static getInstance(): BlockModel { if (!BlockModel.instance) { BlockModel.instance = new BlockModel(); } return BlockModel.instance; } static resetInstance(): void { BlockModel.instance = null; } } ================================================ FILE: frontend/app/block/block.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .block { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; overflow: hidden; min-height: 0; border-radius: var(--block-border-radius); .block-frame-icon { margin-right: 0.5em; } .block-content { position: relative; display: flex; flex-grow: 1; width: 100%; overflow: hidden; min-height: 0; padding: 5px; &.block-no-padding { padding: 0; } } .block-focuselem { height: 0; width: 0; input { width: 0; height: 0; opacity: 0; pointer-events: none; } } .block-header-animation-wrap { max-height: 0; transition: max-height 0.3s ease-out, opacity 0.3s ease-out; overflow: hidden; position: absolute; top: 0; width: 100%; height: 30px; z-index: var(--zindex-header-hover); &.is-showing { max-height: 30px; } } &.block-preview.block-frame-default .block-frame-default-inner .block-frame-default-header { background-color: rgb(from var(--block-bg-color) r g b / 70%); } &.block-frame-default { position: relative; padding: 1px; .block-frame-default-inner { background-color: var(--block-bg-color); width: 100%; height: 100%; border-radius: var(--block-border-radius); display: flex; flex-direction: column; .block-frame-default-header { max-height: var(--header-height); min-height: var(--header-height); display: flex; padding: 4px 5px 4px 7px; align-items: center; gap: 8px; font: var(--header-font); border-bottom: 1px solid var(--border-color); border-radius: var(--block-border-radius) var(--block-border-radius) 0 0; .block-frame-default-header-iconview { display: flex; flex-shrink: 3; min-width: 17px; align-items: center; gap: 8px; overflow-x: hidden; .block-frame-view-icon { font-size: var(--header-icon-size); opacity: 0.5; width: var(--header-icon-width); i { margin-right: 0; } } .block-frame-view-type { overflow-x: hidden; text-wrap: nowrap; text-overflow: ellipsis; flex-shrink: 1; min-width: 0; } .block-frame-blockid { opacity: 0.5; } } .block-frame-text { font: var(--fixed-font); font-size: 11px; opacity: 0.7; flex-grow: 1; &.flex-nogrow { flex-grow: 0; } &.preview-filename { direction: rtl; text-align: left; span { cursor: pointer; &:hover { background: var(--highlight-bg-color); } } } } .connection-button { display: flex; align-items: center; flex-wrap: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; font-weight: 400; color: var(--main-text-color); border-radius: 2px; padding: auto; &:hover { background-color: var(--highlight-bg-color); } .connection-icon-box { flex: 1 1 auto; overflow: hidden; } .connection-name { flex: 1 2 auto; overflow: hidden; padding-right: 4px; } .connecting-svg { position: relative; top: 5px; left: 9px; svg { fill: var(--warning-color); } } } .block-frame-textelems-wrapper { display: flex; flex: 1 2 auto; min-width: 0; gap: 8px; align-items: center; .block-frame-div { display: flex; width: 100%; height: 100%; justify-content: space-between; align-items: center; .input-wrapper { flex-grow: 1; input { background-color: transparent; outline: none; border: none; color: var(--main-text-color); width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-sizing: border-box; opacity: 0.7; font-weight: 500; } } .wave-button { margin-left: 3px; } // webview specific. for refresh button .wave-iconbutton { height: 100%; width: 27px; display: flex; align-items: center; justify-content: center; } } .menubutton { .wave-button { font-size: 11px; } } } .block-frame-end-icons { display: flex; flex-shrink: 0; .wave-iconbutton { display: flex; width: 24px; padding: 4px 6px; align-items: center; } .block-frame-magnify { justify-content: center; align-items: center; padding: 0; svg { #arrow1, #arrow2 { fill: var(--main-text-color); } } } } } .block-frame-preview { background-color: rgb(from var(--block-bg-color) r g b / 70%); width: 100%; flex-grow: 1; border-bottom-left-radius: var(--block-border-radius); border-bottom-right-radius: var(--block-border-radius); display: flex; align-items: center; justify-content: center; .wave-iconbutton { opacity: 0.7; font-size: 45px; margin: -30px 0 0 0; } } } --magnified-block-opacity: 0.6; --magnified-block-blur: 10px; &.magnified, &.ephemeral { background-color: rgb(from var(--block-bg-color) r g b / var(--magnified-block-opacity)); backdrop-filter: blur(var(--magnified-block-blur)); } .connstatus-overlay { position: absolute; top: calc(var(--header-height) + 6px); left: 8px; right: 8px; z-index: var(--zindex-block-mask-inner); display: flex; align-items: center; justify-content: flex-start; flex-direction: column; overflow: hidden; background: var(--conn-status-overlay-bg-color); backdrop-filter: blur(50px); border-radius: 6px; box-shadow: 0px 13px 16px 0px rgb(from var(--block-bg-color) r g b / 40%); opacity: 0.9; .connstatus-content { display: flex; flex-direction: row; justify-content: space-between; padding: 10px 8px 10px 12px; width: 100%; font: var(--base-font); color: var(--secondary-text-color); .connstatus-status-icon-wrapper { display: flex; flex-direction: row; align-items: center; gap: 12px; flex-grow: 1; min-width: 0; &.has-error { align-items: flex-start; } > i { color: #e6ba1e; font-size: 16px; } .connstatus-status { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; flex-grow: 1; width: 100%; .connstatus-status-text { max-width: 100%; font-size: 11px; font-style: normal; font-weight: 600; line-height: 16px; letter-spacing: 0.11px; color: white; } .connstatus-error { font-size: 11px; font-style: normal; font-weight: 400; line-height: 15px; letter-spacing: 0.11px; text-wrap: wrap; max-height: 80px; border-radius: 8px; padding: 5px; padding-left: 0; position: relative; .copy-button { visibility: hidden; display: flex; position: sticky; top: 0; right: 4px; float: right; border-radius: 4px; backdrop-filter: blur(8px); padding: 0.286em; align-items: center; justify-content: flex-end; gap: 0.286em; } &:hover .copy-button { visibility: visible; } } } } .connstatus-actions { display: flex; align-items: flex-start; justify-content: center; gap: 6px; button { i { font-size: 11px; opacity: 0.7; } } .wave-button:last-child { margin-top: 1.5px; } } } } .block-mask { position: absolute; top: 0; left: 0; right: 0; bottom: 0; border: 2px solid transparent; pointer-events: none; padding: 2px; border-radius: var(--block-border-radius); z-index: var(--zindex-block-mask-inner); &.show-block-mask { user-select: none; pointer-events: auto; } &.show-block-mask .block-mask-inner { margin-top: var(--header-height); // TODO fix this magic background-color: rgb(from var(--block-bg-color) r g b / 50%); height: calc(100% - var(--header-height)); width: 100%; display: flex; align-items: center; justify-content: center; .bignum { margin-top: -15%; font-size: 60px; font-weight: bold; opacity: 0.7; } } } &.block-focused { .block-mask { border: 2px solid var(--accent-color); } &.block-no-highlight, &.block-preview { .block-mask { border: 2px solid rgb(from var(--border-color) r g b / 10%) !important; } } } } } ================================================ FILE: frontend/app/block/block.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockComponentModel2, BlockNodeModel, BlockProps, FullBlockProps, FullSubBlockProps, SubBlockProps, } from "@/app/block/blocktypes"; import type { TabModel } from "@/app/store/tab-model"; import { useTabModel } from "@/app/store/tab-model"; import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview-model"; import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; import { VDomModel } from "@/app/view/vdom/vdom-model"; import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { useDebouncedNodeInnerRect } from "@/layout/index"; import { counterInc } from "@/store/counters"; import { getBlockComponentModel, registerBlockComponentModel, unregisterBlockComponentModel } from "@/store/global"; import { makeORef } from "@/store/wos"; import { focusedBlockId, getElemAsStr } from "@/util/focusutil"; import { isBlank, useAtomValueSafe } from "@/util/util"; import { HelpViewModel } from "@/view/helpview/helpview"; import { TermViewModel } from "@/view/term/term-model"; import { WaveAiModel } from "@/view/waveai/waveai"; import { WebViewModel } from "@/view/webview/webview"; import clsx from "clsx"; import { atom, useAtomValue } from "jotai"; import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; import "./block.scss"; import { BlockEnv } from "./blockenv"; import { BlockFrame } from "./blockframe"; import { blockViewToIcon, blockViewToName } from "./blockutil"; const BlockRegistry: Map<string, ViewModelClass> = new Map(); BlockRegistry.set("term", TermViewModel); BlockRegistry.set("preview", PreviewModel); BlockRegistry.set("web", WebViewModel); BlockRegistry.set("waveai", WaveAiModel); BlockRegistry.set("cpuplot", SysinfoViewModel); BlockRegistry.set("sysinfo", SysinfoViewModel); BlockRegistry.set("vdom", VDomModel); BlockRegistry.set("tips", QuickTipsViewModel); BlockRegistry.set("help", HelpViewModel); BlockRegistry.set("launcher", LauncherViewModel); BlockRegistry.set("tsunami", TsunamiViewModel); BlockRegistry.set("aifilediff", AiFileDiffViewModel); BlockRegistry.set("waveconfig", WaveConfigViewModel); function makeViewModel( blockId: string, blockView: string, nodeModel: BlockNodeModel, tabModel: TabModel, waveEnv: WaveEnv ): ViewModel { const ctor = BlockRegistry.get(blockView); if (ctor != null) { return new ctor({ blockId, nodeModel, tabModel, waveEnv }); } return makeDefaultViewModel(blockView); } function getViewElem( blockId: string, blockRef: React.RefObject<HTMLDivElement>, contentRef: React.RefObject<HTMLDivElement>, blockView: string, viewModel: ViewModel ): React.ReactElement { if (isBlank(blockView)) { return <CenteredDiv>No View</CenteredDiv>; } if (viewModel.viewComponent == null) { return <CenteredDiv>No View Component</CenteredDiv>; } const VC = viewModel.viewComponent; return <VC key={blockId} blockId={blockId} blockRef={blockRef} contentRef={contentRef} model={viewModel} />; } function makeDefaultViewModel(viewType: string): ViewModel { const viewModel: ViewModel = { viewType: viewType, viewIcon: atom(blockViewToIcon(viewType)), viewName: atom(blockViewToName(viewType)), preIconButton: atom(null), endIconButtons: atom(null), viewComponent: null, }; return viewModel; } const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => { const waveEnv = useWaveEnv<BlockEnv>(); const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId))); if (blockIsNull) { return null; } return ( <BlockFrame key={nodeModel.blockId} nodeModel={nodeModel} preview={true} blockModel={null} viewModel={viewModel} /> ); }); const BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => { const waveEnv = useWaveEnv<BlockEnv>(); const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId))); const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? ""; const blockRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null); const viewElem = useMemo( () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel), [nodeModel.blockId, blockView, viewModel] ); const noPadding = useAtomValueSafe(viewModel.noPadding); if (blockIsNull) { return null; } return ( <div key="content" className={clsx("block-content", { "block-no-padding": noPadding })} ref={contentRef}> <ErrorBoundary> <Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</Suspense> </ErrorBoundary> </div> ); }); const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { counterInc("render-BlockFull"); const waveEnv = useWaveEnv<BlockEnv>(); const focusElemRef = useRef<HTMLInputElement>(null); const blockRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null); const [blockClicked, setBlockClicked] = useState(false); const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? ""; const isFocused = useAtomValue(nodeModel.isFocused); const disablePointerEvents = useAtomValue(nodeModel.disablePointerEvents); const isResizing = useAtomValue(nodeModel.isResizing); const isMagnified = useAtomValue(nodeModel.isMagnified); const anyMagnified = useAtomValue(nodeModel.anyMagnified); const modalOpen = useAtomValue(waveEnv.atoms.modalOpen); const focusFollowsCursorMode = useAtomValue(waveEnv.getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; const innerRect = useDebouncedNodeInnerRect(nodeModel); const noPadding = useAtomValueSafe(viewModel.noPadding); useLayoutEffect(() => { setBlockClicked(isFocused); }, [isFocused]); useLayoutEffect(() => { if (!blockClicked) { return; } setBlockClicked(false); const focusWithin = focusedBlockId() == nodeModel.blockId; if (!focusWithin) { setFocusTarget(); } if (!isFocused) { nodeModel.focusNode(); } }, [blockClicked, isFocused]); const setBlockClickedTrue = useCallback(() => { setBlockClicked(true); }, []); const [blockContentOffset, setBlockContentOffset] = useState<Dimensions>(); useEffect(() => { if (blockRef.current && contentRef.current) { const blockRect = blockRef.current.getBoundingClientRect(); const contentRect = contentRef.current.getBoundingClientRect(); setBlockContentOffset({ top: 0, left: 0, width: blockRect.width - contentRect.width, height: blockRect.height - contentRect.height, }); } }, [blockRef, contentRef]); const blockContentStyle = useMemo<React.CSSProperties>(() => { const retVal: React.CSSProperties = { pointerEvents: disablePointerEvents ? "none" : undefined, }; if (innerRect?.width && innerRect.height && blockContentOffset) { retVal.width = `calc(${innerRect?.width} - ${blockContentOffset.width}px)`; retVal.height = `calc(${innerRect?.height} - ${blockContentOffset.height}px)`; } return retVal; }, [innerRect, disablePointerEvents, blockContentOffset]); const viewElem = useMemo( () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel), [nodeModel.blockId, blockView, viewModel] ); const handleChildFocus = useCallback( (event: React.FocusEvent<HTMLDivElement, Element>) => { console.log("setFocusedChild", nodeModel.blockId, getElemAsStr(event.target)); if (!isFocused) { console.log("focusedChild focus", nodeModel.blockId); nodeModel.focusNode(); } }, [isFocused] ); const setFocusTarget = useCallback(() => { const ok = viewModel?.giveFocus?.(); if (ok) { return; } focusElemRef.current?.focus({ preventScroll: true }); }, [viewModel]); const focusFromPointerEnter = useCallback( (event: React.PointerEvent<HTMLDivElement>) => { const focusFollowsCursorEnabled = focusFollowsCursorMode === "on" || (focusFollowsCursorMode === "term" && blockView === "term"); if (!focusFollowsCursorEnabled || event.pointerType === "touch" || event.buttons > 0) { return; } if (modalOpen || disablePointerEvents || isResizing || (anyMagnified && !isMagnified)) { return; } if (isFocused && focusedBlockId() === nodeModel.blockId) { return; } setFocusTarget(); if (!isFocused) { nodeModel.focusNode(); } }, [ focusFollowsCursorMode, blockView, modalOpen, disablePointerEvents, isResizing, isMagnified, anyMagnified, isFocused, nodeModel, setFocusTarget, ] ); const blockModel = useMemo<BlockComponentModel2>( () => ({ onClick: setBlockClickedTrue, onPointerEnter: focusFromPointerEnter, onFocusCapture: handleChildFocus, blockRef: blockRef, }), [setBlockClickedTrue, focusFromPointerEnter, handleChildFocus, blockRef] ); return ( <BlockFrame key={nodeModel.blockId} nodeModel={nodeModel} preview={false} blockModel={blockModel} viewModel={viewModel} > <div key="focuselem" className="block-focuselem"> <input type="text" value="" ref={focusElemRef} id={`${nodeModel.blockId}-dummy-focus`} // don't change this name (used in refocusNode) className="dummy-focus" onChange={() => {}} /> </div> <div key="content" className={clsx("block-content", { "block-no-padding": noPadding })} ref={contentRef} style={blockContentStyle} > <ErrorBoundary> <Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</Suspense> </ErrorBoundary> </div> </BlockFrame> ); }); const BlockInner = memo((props: BlockProps & { viewType: string }) => { counterInc("render-Block"); counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); const tabModel = useTabModel(); const waveEnv = useWaveEnv(); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; if (viewModel == null) { // viewModel gets the full waveEnv viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv); registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); } useEffect(() => { return () => { unregisterBlockComponentModel(props.nodeModel.blockId); viewModel?.dispose?.(); }; }, []); if (props.preview) { return <BlockPreview {...props} viewModel={viewModel} />; } return <BlockFull {...props} viewModel={viewModel} />; }); BlockInner.displayName = "BlockInner"; const Block = memo((props: BlockProps) => { const waveEnv = useWaveEnv<BlockEnv>(); const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", props.nodeModel.blockId))); const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, "view")) ?? ""; if (isNull || isBlank(props.nodeModel.blockId)) { return null; } return <BlockInner key={props.nodeModel.blockId + ":" + viewType} {...props} viewType={viewType} />; }); const SubBlockInner = memo((props: SubBlockProps & { viewType: string }) => { counterInc("render-Block"); counterInc("render-Block-" + props.nodeModel.blockId?.substring(0, 8)); const tabModel = useTabModel(); const waveEnv = useWaveEnv(); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; if (viewModel == null) { // viewModel gets the full waveEnv viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv); registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); } useEffect(() => { return () => { unregisterBlockComponentModel(props.nodeModel.blockId); viewModel?.dispose?.(); }; }, []); return <BlockSubBlock {...props} viewModel={viewModel} />; }); SubBlockInner.displayName = "SubBlockInner"; const SubBlock = memo((props: SubBlockProps) => { const waveEnv = useWaveEnv<BlockEnv>(); const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", props.nodeModel.blockId))); const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, "view")) ?? ""; if (isNull || isBlank(props.nodeModel.blockId)) { return null; } return <SubBlockInner key={props.nodeModel.blockId + ":" + viewType} {...props} viewType={viewType} />; }); export { Block, SubBlock }; ================================================ FILE: frontend/app/block/blockenv.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockMetaKeyAtomFnType, ConnConfigKeyAtomFnType, SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset, } from "@/app/waveenv/waveenv"; export type BlockEnv = WaveEnvSubset<{ getSettingsKeyAtom: SettingsKeyAtomFnType< | "app:focusfollowscursor" | "app:showoverlayblocknums" | "window:magnifiedblockblurprimarypx" | "window:magnifiedblockopacity" >; showContextMenu: WaveEnv["showContextMenu"]; atoms: { modalOpen: WaveEnv["atoms"]["modalOpen"]; controlShiftDelayAtom: WaveEnv["atoms"]["controlShiftDelayAtom"]; }; electron: { openExternal: WaveEnv["electron"]["openExternal"]; }; rpc: { ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; ConnDisconnectCommand: WaveEnv["rpc"]["ConnDisconnectCommand"]; ConnConnectCommand: WaveEnv["rpc"]["ConnConnectCommand"]; SetConnectionsConfigCommand: WaveEnv["rpc"]["SetConnectionsConfigCommand"]; DismissWshFailCommand: WaveEnv["rpc"]["DismissWshFailCommand"]; }; wos: WaveEnv["wos"]; getConnStatusAtom: WaveEnv["getConnStatusAtom"]; getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"]; getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType< | "frame:text" | "frame:activebordercolor" | "frame:bordercolor" | "view" | "connection" | "icon:color" | "frame:title" | "frame:icon" >; }>; ================================================ FILE: frontend/app/block/blockframe-header.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { blockViewToIcon, blockViewToName, getViewIconElem, OptMagnifyButton, renderHeaderElements, } from "@/app/block/blockutil"; import { ConnectionButton } from "@/app/block/connectionbutton"; import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; import { getBlockBadgeAtom } from "@/app/store/badge"; import { recordTEvent, refocusNode } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { uxCloseBlock } from "@/app/store/keymodel"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { IconButton } from "@/element/iconbutton"; import { NodeModel } from "@/layout/index"; import * as util from "@/util/util"; import { cn, makeIconClass } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; import { BlockEnv } from "./blockenv"; import { BlockFrameProps } from "./blocktypes"; function handleHeaderContextMenu( e: React.MouseEvent<HTMLDivElement>, blockId: string, viewModel: ViewModel, nodeModel: NodeModel, blockEnv: BlockEnv ) { e.preventDefault(); e.stopPropagation(); const magnified = globalStore.get(nodeModel.isMagnified); const menu: ContextMenuItem[] = [ { label: magnified ? "Un-Magnify Block" : "Magnify Block", click: () => { nodeModel.toggleMagnify(); }, }, { type: "separator" }, { label: "Copy BlockId", click: () => { navigator.clipboard.writeText(blockId); }, }, ]; const extraItems = viewModel?.getSettingsMenuItems?.(); if (extraItems && extraItems.length > 0) menu.push({ type: "separator" }, ...extraItems); menu.push( { type: "separator" }, { label: "Close Block", click: () => uxCloseBlock(blockId), } ); blockEnv.showContextMenu(menu, e); } type HeaderTextElemsProps = { viewModel: ViewModel; blockId: string; preview: boolean; error?: Error; }; const HeaderTextElems = React.memo(({ viewModel, blockId, preview, error }: HeaderTextElemsProps) => { const waveEnv = useWaveEnv<BlockEnv>(); const frameTextAtom = waveEnv.getBlockMetaKeyAtom(blockId, "frame:text"); const frameText = jotai.useAtomValue(frameTextAtom); let headerTextUnion = util.useAtomValueSafe(viewModel?.viewText); headerTextUnion = frameText ?? headerTextUnion; const headerTextElems: React.ReactElement[] = []; if (typeof headerTextUnion === "string") { if (!util.isBlank(headerTextUnion)) { headerTextElems.push( <div key="text" className="block-frame-text ellipsis"> ‎{headerTextUnion} </div> ); } } else if (Array.isArray(headerTextUnion)) { headerTextElems.push(...renderHeaderElements(headerTextUnion, preview)); } if (error != null) { const copyHeaderErr = () => { navigator.clipboard.writeText(error.message + "\n" + error.stack); }; headerTextElems.push( <div className="iconbutton disabled" key="controller-status" onClick={copyHeaderErr}> <i className="fa-sharp fa-solid fa-triangle-exclamation" title={"Error Rendering View Header: " + error.message} /> </div> ); } return <div className="block-frame-textelems-wrapper">{headerTextElems}</div>; }); HeaderTextElems.displayName = "HeaderTextElems"; type HeaderEndIconsProps = { viewModel: ViewModel; nodeModel: NodeModel; blockId: string; }; const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndIconsProps) => { const blockEnv = useWaveEnv<BlockEnv>(); const endIconButtons = util.useAtomValueSafe(viewModel?.endIconButtons); const magnified = jotai.useAtomValue(nodeModel.isMagnified); const ephemeral = jotai.useAtomValue(nodeModel.isEphemeral); const numLeafs = jotai.useAtomValue(nodeModel.numLeafs); const magnifyDisabled = numLeafs <= 1; const endIconsElem: React.ReactElement[] = []; if (endIconButtons && endIconButtons.length > 0) { endIconsElem.push(...endIconButtons.map((button, idx) => <IconButton key={idx} decl={button} />)); } const settingsDecl: IconButtonDecl = { elemtype: "iconbutton", icon: "cog", title: "Settings", click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel, blockEnv), }; endIconsElem.push(<IconButton key="settings" decl={settingsDecl} className="block-frame-settings" />); if (ephemeral) { const addToLayoutDecl: IconButtonDecl = { elemtype: "iconbutton", icon: "circle-plus", title: "Add to Layout", click: () => { nodeModel.addEphemeralNodeToLayout(); }, }; endIconsElem.push(<IconButton key="add-to-layout" decl={addToLayoutDecl} />); } else { endIconsElem.push( <OptMagnifyButton key="unmagnify" magnified={magnified} toggleMagnify={() => { nodeModel.toggleMagnify(); setTimeout(() => refocusNode(blockId), 50); }} disabled={magnifyDisabled} /> ); } const closeDecl: IconButtonDecl = { elemtype: "iconbutton", icon: "xmark-large", title: "Close", click: () => uxCloseBlock(nodeModel.blockId), }; endIconsElem.push(<IconButton key="close" decl={closeDecl} className="block-frame-default-close" />); return <div className="block-frame-end-icons">{endIconsElem}</div>; }); HeaderEndIcons.displayName = "HeaderEndIcons"; const BlockFrame_Header = ({ nodeModel, viewModel, preview, connBtnRef, changeConnModalAtom, error, }: BlockFrameProps & { changeConnModalAtom: jotai.PrimitiveAtom<boolean>; error?: Error }) => { const waveEnv = useWaveEnv<BlockEnv>(); const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")); const metaFrameTitle = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:title")); const metaFrameIcon = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:icon")); const metaConnection = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); let viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(metaView); let viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView); const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton); const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader); const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable); const hideViewName = util.useAtomValueSafe(viewModel?.hideViewName); const badge = jotai.useAtomValue(getBlockBadgeAtom(useTermHeader ? nodeModel.blockId : null)); const magnified = jotai.useAtomValue(nodeModel.isMagnified); const prevMagifiedState = React.useRef(magnified); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "icon:color")); const dragHandleRef = preview ? null : nodeModel.dragHandleRef; const isTerminalBlock = metaView === "term"; viewName = metaFrameTitle ?? viewName; viewIconUnion = metaFrameIcon ?? viewIconUnion; React.useEffect(() => { if (magnified && !preview && !prevMagifiedState.current) { waveEnv.rpc.ActivityCommand(TabRpcClient, { nummagnify: 1 }); recordTEvent("action:magnify", { "block:view": viewName }); } prevMagifiedState.current = magnified; }, [magnified]); const viewIconElem = getViewIconElem(viewIconUnion, iconColor); return ( <div className={cn("block-frame-default-header", useTermHeader && "!pl-[2px]")} data-role="block-header" ref={dragHandleRef} onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv)} > {!useTermHeader && ( <> {preIconButton && <IconButton decl={preIconButton} className="block-frame-preicon-button" />} <div className="block-frame-default-header-iconview"> {viewIconElem} {viewName && !hideViewName && <div className="block-frame-view-type">{viewName}</div>} </div> </> )} {manageConnection && ( <ConnectionButton ref={connBtnRef} key="connbutton" connection={metaConnection} changeConnModalAtom={changeConnModalAtom} isTerminalBlock={isTerminalBlock} /> )} {useTermHeader && termConfigedDurable != null && ( <DurableSessionFlyover key="durable-status" blockId={nodeModel.blockId} viewModel={viewModel} placement="bottom" divClassName="iconbutton disabled text-[13px] ml-[-4px]" /> )} {useTermHeader && badge && ( <div className="pointer-events-none flex items-center px-1" style={{ color: badge.color || "#fbbf24" }}> <i className={makeIconClass(badge.icon, true, { defaultIcon: "circle-small" })} /> </div> )} <HeaderTextElems viewModel={viewModel} blockId={nodeModel.blockId} preview={preview} error={error} /> <HeaderEndIcons viewModel={viewModel} nodeModel={nodeModel} blockId={nodeModel.blockId} /> </div> ); }; export { BlockFrame_Header }; ================================================ FILE: frontend/app/block/blockframe.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockModel } from "@/app/block/block-model"; import { BlockFrame_Header } from "@/app/block/blockframe-header"; import { blockViewToIcon, getViewIconElem } from "@/app/block/blockutil"; import { ConnStatusOverlay } from "@/app/block/connstatusoverlay"; import { ChangeConnectionBlockModal } from "@/app/modals/conntypeahead"; import { getBlockComponentModel, globalStore, useBlockAtom } from "@/app/store/global"; import { useTabModel } from "@/app/store/tab-model"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { ErrorBoundary } from "@/element/errorboundary"; import { NodeModel } from "@/layout/index"; import { makeORef } from "@/store/wos"; import * as util from "@/util/util"; import { makeIconClass } from "@/util/util"; import { computeBgStyleFromMeta } from "@/util/waveutil"; import clsx from "clsx"; import * as jotai from "jotai"; import * as React from "react"; import { BlockEnv } from "./blockenv"; import { BlockFrameProps } from "./blocktypes"; const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { const waveEnv = useWaveEnv<BlockEnv>(); const tabModel = useTabModel(); const isFocused = jotai.useAtomValue(nodeModel.isFocused); const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); const blockNum = jotai.useAtomValue(nodeModel.blockNum); const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom); const showOverlayBlockNums = jotai.useAtomValue(waveEnv.getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; const blockHighlight = jotai.useAtomValue(BlockModel.getInstance().getBlockHighlightAtom(nodeModel.blockId)); const frameActiveBorderColor = jotai.useAtomValue( waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:activebordercolor") ); const frameBorderColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:bordercolor")); const tabActiveBorderColor = jotai.useAtomValue(tabModel.getTabMetaAtom("bg:activebordercolor")); const tabBorderColor = jotai.useAtomValue(tabModel.getTabMetaAtom("bg:bordercolor")); const style: React.CSSProperties = {}; let showBlockMask = false; if (isFocused) { if (tabActiveBorderColor) { style.borderColor = tabActiveBorderColor; } if (frameActiveBorderColor) { style.borderColor = frameActiveBorderColor; } } else { if (tabBorderColor) { style.borderColor = tabBorderColor; } if (frameBorderColor) { style.borderColor = frameBorderColor; } if (isEphemeral && !style.borderColor) { style.borderColor = "rgba(255, 255, 255, 0.7)"; } } if (blockHighlight && !style.borderColor) { style.borderColor = "rgb(59, 130, 246)"; } let innerElem = null; if (isLayoutMode && showOverlayBlockNums) { showBlockMask = true; innerElem = ( <div className="block-mask-inner"> <div className="bignum">{blockNum}</div> </div> ); } else if (blockHighlight) { showBlockMask = true; const iconClass = makeIconClass(blockHighlight.icon, false); innerElem = ( <div className="block-mask-inner"> <i className={iconClass} style={{ fontSize: "48px", opacity: 0.5 }} /> </div> ); } return ( <div className={clsx("block-mask", { "show-block-mask": showBlockMask, "bg-blue-500/10": blockHighlight })} style={style} > {innerElem} </div> ); }); const BlockFrame_Default_Component = (props: BlockFrameProps) => { const waveEnv = useWaveEnv<BlockEnv>(); const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props; const isFocused = jotai.useAtomValue(nodeModel.isFocused); const aiPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")); const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView); const customBg = util.useAtomValueSafe(viewModel?.blockBg); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); const changeConnModalAtom = useBlockAtom(nodeModel.blockId, "changeConn", () => { return jotai.atom(false); }) as jotai.PrimitiveAtom<boolean>; const connModalOpen = jotai.useAtomValue(changeConnModalAtom); const isMagnified = jotai.useAtomValue(nodeModel.isMagnified); const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); const [magnifiedBlockBlurAtom] = React.useState(() => waveEnv.getSettingsKeyAtom("window:magnifiedblockblurprimarypx")); const magnifiedBlockBlur = jotai.useAtomValue(magnifiedBlockBlurAtom); const [magnifiedBlockOpacityAtom] = React.useState(() => waveEnv.getSettingsKeyAtom("window:magnifiedblockopacity")); const magnifiedBlockOpacity = jotai.useAtomValue(magnifiedBlockOpacityAtom); const connBtnRef = React.useRef<HTMLDivElement>(null); const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "icon:color")); const noHeader = util.useAtomValueSafe(viewModel?.noHeader); React.useEffect(() => { if (!manageConnection) { return; } const bcm = getBlockComponentModel(nodeModel.blockId); if (bcm != null) { bcm.openSwitchConnection = () => { globalStore.set(changeConnModalAtom, true); }; } return () => { const bcm = getBlockComponentModel(nodeModel.blockId); if (bcm != null) { bcm.openSwitchConnection = null; } }; }, [manageConnection]); React.useEffect(() => { // on mount, if manageConnection, call ConnEnsure if (!manageConnection || preview) { return; } if (!util.isLocalConnName(connName)) { console.log("ensure conn", nodeModel.blockId, connName); waveEnv.rpc .ConnEnsureCommand(TabRpcClient, { connname: connName, logblockid: nodeModel.blockId }, { timeout: 60000 }) .catch((e) => { console.log("error ensuring connection", nodeModel.blockId, connName, e); }); } }, [manageConnection, connName]); const viewIconElem = getViewIconElem(viewIconUnion, iconColor); let innerStyle: React.CSSProperties = {}; if (!preview) { innerStyle = computeBgStyleFromMeta(customBg); } const previewElem = <div className="block-frame-preview">{viewIconElem}</div>; const headerElem = ( <BlockFrame_Header {...props} connBtnRef={connBtnRef} changeConnModalAtom={changeConnModalAtom} /> ); const headerElemNoView = React.cloneElement(headerElem, { viewModel: null }); return ( <div className={clsx("block", "block-frame-default", "block-" + nodeModel.blockId, { "block-focused": isFocused || preview, "block-preview": preview, "block-no-highlight": numBlocksInTab === 1 && !aiPanelVisible, ephemeral: isEphemeral, magnified: isMagnified, })} data-blockid={nodeModel.blockId} onClick={blockModel?.onClick} onPointerEnter={blockModel?.onPointerEnter} onFocusCapture={blockModel?.onFocusCapture} ref={blockModel?.blockRef} style={ { "--magnified-block-opacity": magnifiedBlockOpacity, "--magnified-block-blur": `${magnifiedBlockBlur}px`, } as React.CSSProperties } inert={preview || undefined} > <BlockMask nodeModel={nodeModel} /> {preview || viewModel == null || !manageConnection ? null : ( <ConnStatusOverlay nodeModel={nodeModel} viewModel={viewModel} changeConnModalAtom={changeConnModalAtom} /> )} <div className="block-frame-default-inner" style={innerStyle}> {noHeader || <ErrorBoundary fallback={headerElemNoView}>{headerElem}</ErrorBoundary>} {preview ? previewElem : children} </div> {preview || viewModel == null || !connModalOpen ? null : ( <ChangeConnectionBlockModal blockId={nodeModel.blockId} nodeModel={nodeModel} viewModel={viewModel} blockRef={blockModel?.blockRef} changeConnModalAtom={changeConnModalAtom} connBtnRef={connBtnRef} /> )} </div> ); }; const BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof BlockFrame_Default_Component; const BlockFrame = React.memo((props: BlockFrameProps) => { const waveEnv = useWaveEnv<BlockEnv>(); const tabModel = useTabModel(); const blockId = props.nodeModel.blockId; const blockIsNull = jotai.useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", blockId))); const numBlocks = jotai.useAtomValue(tabModel.tabNumBlocksAtom); if (!blockId || blockIsNull) { return null; } return <BlockFrame_Default {...props} numBlocksInTab={numBlocks} />; }); export { BlockFrame }; ================================================ FILE: frontend/app/block/blocktypes.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { NodeModel } from "@/layout/index"; import { Atom } from "jotai"; export interface BlockNodeModel { blockId: string; isFocused: Atom<boolean>; isMagnified: Atom<boolean>; onClose: () => void; focusNode: () => void; toggleMagnify: () => void; } export type FullBlockProps = { preview: boolean; nodeModel: NodeModel; viewModel: ViewModel; }; export interface BlockProps { preview: boolean; nodeModel: NodeModel; } export type FullSubBlockProps = { nodeModel: BlockNodeModel; viewModel: ViewModel; }; export interface SubBlockProps { nodeModel: BlockNodeModel; } export interface BlockComponentModel2 { onClick?: () => void; onPointerEnter?: React.PointerEventHandler<HTMLDivElement>; onFocusCapture?: React.FocusEventHandler<HTMLDivElement>; blockRef?: React.RefObject<HTMLDivElement>; } export interface BlockFrameProps { blockModel?: BlockComponentModel2; nodeModel?: NodeModel; viewModel?: ViewModel; preview: boolean; numBlocksInTab?: number; children?: React.ReactNode; connBtnRef?: React.RefObject<HTMLDivElement>; } ================================================ FILE: frontend/app/block/blockutil.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; import { IconButton, ToggleIconButton } from "@/element/iconbutton"; import { MagnifyIcon } from "@/element/magnify"; import { MenuButton } from "@/element/menubutton"; import * as util from "@/util/util"; import clsx from "clsx"; import * as React from "react"; export const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/; export const NumActiveConnColors = 8; export function blockViewToIcon(view: string): string { if (view == "term") { return "terminal"; } if (view == "preview") { return "file"; } if (view == "web") { return "globe"; } if (view == "waveai") { return "sparkles"; } if (view == "help") { return "circle-question"; } if (view == "tips") { return "lightbulb"; } return "square"; } export function blockViewToName(view: string): string { if (util.isBlank(view)) { return "(No View)"; } if (view == "term") { return "Terminal"; } if (view == "preview") { return "Preview"; } if (view == "web") { return "Web"; } if (view == "waveai") { return "WaveAI"; } if (view == "help") { return "Help"; } if (view == "tips") { return "Tips"; } return view; } export function processTitleString(titleString: string): React.ReactNode[] { if (titleString == null) { return null; } const tagRegex = /<(\/)?([a-z]+)(?::([#a-z0-9@-]+))?>/g; let lastIdx = 0; let match; const partsStack = [[]]; while ((match = tagRegex.exec(titleString)) != null) { const lastPart = partsStack[partsStack.length - 1]; const before = titleString.substring(lastIdx, match.index); lastPart.push(before); lastIdx = match.index + match[0].length; const [_, isClosing, tagName, tagParam] = match; if (tagName == "icon" && !isClosing) { if (tagParam == null) { continue; } const iconClass = util.makeIconClass(tagParam, false); if (iconClass == null) { continue; } lastPart.push(<i key={match.index} className={iconClass} />); continue; } if (tagName == "c" || tagName == "color") { if (isClosing) { if (partsStack.length <= 1) { continue; } partsStack.pop(); continue; } if (tagParam == null) { continue; } if (!tagParam.match(colorRegex)) { continue; } const children = []; const rtag = React.createElement("span", { key: match.index, style: { color: tagParam } }, children); lastPart.push(rtag); partsStack.push(children); continue; } if (tagName == "i" || tagName == "b") { if (isClosing) { if (partsStack.length <= 1) { continue; } partsStack.pop(); continue; } const children = []; const rtag = React.createElement(tagName, { key: match.index }, children); lastPart.push(rtag); partsStack.push(children); continue; } } partsStack[partsStack.length - 1].push(titleString.substring(lastIdx)); return partsStack[0]; } export function getBlockHeaderIcon(blockIcon: string, overrideIconColor?: string): React.ReactNode { let blockIconElem: React.ReactNode = null; if (util.isBlank(blockIcon)) { blockIcon = "square"; } let iconColor = overrideIconColor; if (iconColor && !iconColor.match(colorRegex)) { iconColor = null; } let iconStyle = null; if (!util.isBlank(iconColor)) { iconStyle = { color: iconColor }; } const iconClass = util.makeIconClass(blockIcon, true); if (iconClass != null) { blockIconElem = <i key="icon" style={iconStyle} className={clsx(`block-frame-icon`, iconClass)} />; } return blockIconElem; } export function getViewIconElem( viewIconUnion: string | IconButtonDecl, overrideIconColor?: string ): React.ReactElement { if (viewIconUnion == null || typeof viewIconUnion === "string") { const viewIcon = viewIconUnion as string; return <div className="block-frame-view-icon">{getBlockHeaderIcon(viewIcon, overrideIconColor)}</div>; } else { return <IconButton decl={viewIconUnion} className="block-frame-view-icon" />; } } export const Input = React.memo( ({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => { const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl; return ( <div className="input-wrapper"> <input ref={ !preview ? ref : undefined /* don't wire up the input field if the preview block is being rendered */ } disabled={isDisabled} className={className} value={value} onChange={(e) => onChange(e)} onKeyDown={(e) => onKeyDown(e)} onFocus={(e) => onFocus(e)} onBlur={(e) => onBlur(e)} onDragStart={(e) => e.preventDefault()} /> </div> ); } ); export const OptMagnifyButton = React.memo( ({ magnified, toggleMagnify, disabled }: { magnified: boolean; toggleMagnify: () => void; disabled: boolean }) => { const magnifyDecl: IconButtonDecl = { elemtype: "iconbutton", icon: <MagnifyIcon enabled={magnified} />, title: magnified ? "Minimize" : "Magnify", click: toggleMagnify, disabled, }; return <IconButton key="magnify" decl={magnifyDecl} className="block-frame-magnify" />; } ); export const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => { if (elem.elemtype == "iconbutton") { return <IconButton decl={elem} className={clsx("block-frame-header-iconbutton", elem.className)} />; } else if (elem.elemtype == "toggleiconbutton") { return <ToggleIconButton decl={elem} className={clsx("block-frame-header-iconbutton", elem.className)} />; } else if (elem.elemtype == "input") { return <Input decl={elem} className={clsx("block-frame-input", elem.className)} preview={preview} />; } else if (elem.elemtype == "text") { return ( <div className={clsx("block-frame-text ellipsis", elem.className, { "flex-nogrow": elem.noGrow })}> <span ref={preview ? null : elem.ref} onClick={(e) => elem?.onClick(e)}> ‎{elem.text} </span> </div> ); } else if (elem.elemtype == "textbutton") { return ( <Button className={elem.className} onClick={(e) => elem.onClick(e)} title={elem.title}> {elem.text} </Button> ); } else if (elem.elemtype == "div") { return ( <div className={clsx("block-frame-div", elem.className)} onMouseOver={elem.onMouseOver} onMouseOut={elem.onMouseOut} > {elem.children.map((child, childIdx) => ( <HeaderTextElem elem={child} key={childIdx} preview={preview} /> ))} </div> ); } else if (elem.elemtype == "menubutton") { return <MenuButton className="block-frame-menubutton" {...(elem as MenuButtonProps)} />; } return null; }); export function renderHeaderElements(headerTextUnion: HeaderElem[], preview: boolean): React.ReactElement[] { const headerTextElems: React.ReactElement[] = []; for (let idx = 0; idx < headerTextUnion.length; idx++) { const elem = headerTextUnion[idx]; const renderedElement = <HeaderTextElem elem={elem} key={idx} preview={preview} />; if (renderedElement) { headerTextElems.push(renderedElement); } } return headerTextElems; } export function computeConnColorNum(connStatus: ConnStatus): number { const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors; if (connColorNum == 0) { return NumActiveConnColors; } return connColorNum; } ================================================ FILE: frontend/app/block/connectionbutton.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { computeConnColorNum } from "@/app/block/blockutil"; import { recordTEvent } from "@/app/store/global"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { IconButton } from "@/element/iconbutton"; import * as util from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; import DotsSvg from "../asset/dots-anim-4.svg"; import { BlockEnv } from "./blockenv"; interface ConnectionButtonProps { connection: string; changeConnModalAtom: jotai.PrimitiveAtom<boolean>; isTerminalBlock?: boolean; } export const ConnectionButton = React.memo( React.forwardRef<HTMLDivElement, ConnectionButtonProps>( ({ connection, changeConnModalAtom, isTerminalBlock }: ConnectionButtonProps, ref) => { const waveEnv = useWaveEnv<BlockEnv>(); const [_connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom); const isLocal = util.isLocalConnName(connection); const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connection)); const localName = jotai.useAtomValue(waveEnv.getLocalHostDisplayNameAtom()); let showDisconnectedSlash = false; let connIconElem: React.ReactNode = null; const connColorNum = computeConnColorNum(connStatus); let color = `var(--conn-icon-color-${connColorNum})`; const clickHandler = function () { recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "mouse" }); setConnModalOpen(true); }; let titleText = null; let shouldSpin = false; let connDisplayName: string = null; let extraDisplayNameClassName = ""; if (isLocal) { color = "var(--color-secondary)"; if (connection === "local:gitbash") { titleText = "Connected to Git Bash"; connDisplayName = "Git Bash"; } else { titleText = "Connected to Local Machine"; if (localName) { titleText += ` (${localName})`; } if (isTerminalBlock) { connDisplayName = localName; extraDisplayNameClassName = "text-muted group-hover:text-secondary"; } } connIconElem = ( <i className={util.cn(util.makeIconClass("laptop", false), "fa-stack-1x mr-[2px]")} style={{ color: color }} /> ); } else { titleText = "Connected to " + connection; let iconName = "arrow-right-arrow-left"; let iconSvg = null; if (connStatus?.status == "connecting") { color = "var(--warning-color)"; titleText = "Connecting to " + connection; shouldSpin = false; iconSvg = ( <div className="relative top-[5px] left-[9px] [&_svg]:fill-warning"> <DotsSvg /> </div> ); } else if (connStatus?.status == "error") { color = "var(--error-color)"; titleText = "Error connecting to " + connection; if (connStatus?.error != null) { titleText += " (" + connStatus.error + ")"; } showDisconnectedSlash = true; } else if (!connStatus?.connected) { color = "var(--grey-text-color)"; titleText = "Disconnected from " + connection; showDisconnectedSlash = true; } else if (connStatus?.connhealthstatus === "degraded" || connStatus?.connhealthstatus === "stalled") { color = "var(--warning-color)"; iconName = "signal-bars-slash"; if (connStatus.connhealthstatus === "degraded") { titleText = "Connection degraded: " + connection; } else { titleText = "Connection stalled: " + connection; } } if (iconSvg != null) { connIconElem = iconSvg; } else { connIconElem = ( <i className={util.cn(util.makeIconClass(iconName, false), "fa-stack-1x mr-[2px]")} style={{ color: color }} /> ); } } const wshProblem = connection && !connStatus?.wshenabled && connStatus?.status == "connected"; const showNoWshButton = wshProblem && !isLocal; return ( <> <div ref={ref} className="group flex items-center flex-nowrap overflow-hidden text-ellipsis min-w-0 font-normal text-primary rounded-sm hover:bg-highlightbg cursor-pointer" onClick={clickHandler} title={titleText} > <span className={util.cn( "fa-stack flex-[1_1_auto] overflow-hidden", shouldSpin ? "fa-spin" : null )} > {connIconElem} <i className={util.cn( "fa-slash fa-solid fa-stack-1x mr-[2px] [text-shadow:0_1px_black,0_1.5px_black]", showDisconnectedSlash ? "opacity-100" : "opacity-0" )} style={{ color: color }} /> </span> {connDisplayName ? ( <div className={util.cn( "flex-[1_2_auto] overflow-hidden pr-1 ellipsis", extraDisplayNameClassName )} > {connDisplayName} </div> ) : isLocal ? null : ( <div className="flex-[1_2_auto] overflow-hidden pr-1 ellipsis">{connection}</div> )} </div> {showNoWshButton && ( <IconButton decl={{ elemtype: "iconbutton", icon: "link-slash", title: "wsh is not installed for this connection", }} /> )} </> ); } ) ); ConnectionButton.displayName = "ConnectionButton"; ================================================ FILE: frontend/app/block/connstatusoverlay.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; import { CopyButton } from "@/app/element/copybutton"; import { useDimensionsWithCallbackRef } from "@/app/hook/useDimensions"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { NodeModel } from "@/layout/index"; import * as util from "@/util/util"; import clsx from "clsx"; import * as jotai from "jotai"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import * as React from "react"; import { BlockEnv } from "./blockenv"; function formatElapsedTime(elapsedMs: number): string { if (elapsedMs <= 0) { return ""; } const elapsedSeconds = Math.floor(elapsedMs / 1000); if (elapsedSeconds < 60) { return `${elapsedSeconds}s`; } const elapsedMinutes = Math.floor(elapsedSeconds / 60); if (elapsedMinutes < 60) { return `${elapsedMinutes}m`; } const elapsedHours = Math.floor(elapsedMinutes / 60); const remainingMinutes = elapsedMinutes % 60; if (elapsedHours < 24) { if (remainingMinutes === 0) { return `${elapsedHours}h`; } return `${elapsedHours}h${remainingMinutes}m`; } return "more than a day"; } const StalledOverlay = React.memo( ({ connName, connStatus, overlayRefCallback, }: { connName: string; connStatus: ConnStatus; overlayRefCallback: (el: HTMLDivElement | null) => void; }) => { const [elapsedTime, setElapsedTime] = React.useState<string>(""); const waveEnv = useWaveEnv<BlockEnv>(); const handleDisconnect = React.useCallback(() => { const prtn = waveEnv.rpc.ConnDisconnectCommand(TabRpcClient, connName, { timeout: 5000 }); prtn.catch((e) => console.log("error disconnecting", connName, e)); }, [connName, waveEnv]); React.useEffect(() => { if (!connStatus.lastactivitybeforestalledtime) { return; } const updateElapsed = () => { const now = Date.now(); const lastActivity = connStatus.lastactivitybeforestalledtime!; const elapsed = now - lastActivity; setElapsedTime(formatElapsedTime(elapsed)); }; updateElapsed(); const interval = setInterval(updateElapsed, 1000); return () => clearInterval(interval); }, [connStatus.lastactivitybeforestalledtime]); return ( <div className="@container absolute top-[calc(var(--header-height)+6px)] left-1.5 right-1.5 z-[var(--zindex-block-mask-inner)] overflow-hidden rounded-md bg-[var(--conn-status-overlay-bg-color)] backdrop-blur-[50px] shadow-lg opacity-90" ref={overlayRefCallback} > <div className="flex items-center gap-3 w-full pt-2.5 pb-2.5 pr-2 pl-3"> <i className="fa-solid fa-triangle-exclamation text-warning text-base shrink-0" title="Connection Stalled" ></i> <div className="text-[11px] font-semibold leading-4 tracking-[0.11px] text-white min-w-0 flex-1 break-words @max-xxs:hidden"> Connection to "{connName}" is stalled {elapsedTime && ` (no activity for ${elapsedTime})`} </div> <div className="flex-1 hidden @max-xxs:block"></div> <Button className="outlined grey text-[11px] py-[3px] px-[7px] @max-w350:text-[12px] @max-w350:py-[5px] @max-w350:px-[6px]" onClick={handleDisconnect} title="Disconnect" > <span className="@max-w350:hidden!">Disconnect</span> <i className="fa-solid fa-link-slash hidden! @max-w350:inline!"></i> </Button> </div> </div> ); } ); StalledOverlay.displayName = "StalledOverlay"; export const ConnStatusOverlay = React.memo( ({ nodeModel, viewModel, changeConnModalAtom, }: { nodeModel: NodeModel; viewModel: ViewModel; changeConnModalAtom: jotai.PrimitiveAtom<boolean>; }) => { const waveEnv = useWaveEnv<BlockEnv>(); const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); const [connModalOpen] = jotai.useAtom(changeConnModalAtom); const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName)); const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom); const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); const width = domRect?.width; const [showError, setShowError] = React.useState(false); const wshConfigEnabled = jotai.useAtomValue(waveEnv.getConnConfigKeyAtom(connName, "conn:wshenabled")) ?? true; const [showWshError, setShowWshError] = React.useState(false); React.useEffect(() => { if (width) { const hasError = !util.isBlank(connStatus.error); const showError = hasError && width >= 250 && connStatus.status == "error"; setShowError(showError); } }, [width, connStatus, setShowError]); const handleTryReconnect = React.useCallback(() => { const prtn = waveEnv.rpc.ConnConnectCommand( TabRpcClient, { host: connName, logblockid: nodeModel.blockId }, { timeout: 60000 } ); prtn.catch((e) => console.log("error reconnecting", connName, e)); }, [connName, nodeModel.blockId, waveEnv]); const handleDisableWsh = React.useCallback(async () => { const metamaptype: unknown = { "conn:wshenabled": false, }; const data: ConnConfigRequest = { host: connName, metamaptype: metamaptype, }; try { await waveEnv.rpc.SetConnectionsConfigCommand(TabRpcClient, data); } catch (e) { console.log("problem setting connection config: ", e); } }, [connName, waveEnv]); const handleRemoveWshError = React.useCallback(async () => { try { await waveEnv.rpc.DismissWshFailCommand(TabRpcClient, connName); } catch (e) { console.log("unable to dismiss wsh error: ", e); } }, [connName, waveEnv]); let statusText = `Disconnected from "${connName}"`; let showReconnect = true; if (connStatus.status == "connecting") { statusText = `Connecting to "${connName}"...`; showReconnect = false; } if (connStatus.status == "connected") { showReconnect = false; } let reconDisplay = null; let reconClassName = "outlined grey"; if (width && width < 350) { reconDisplay = <i className="fa-sharp fa-solid fa-rotate-right"></i>; reconClassName = clsx(reconClassName, "text-[12px] py-[5px] px-[6px]"); } else { reconDisplay = "Reconnect"; reconClassName = clsx(reconClassName, "text-[11px] py-[3px] px-[7px]"); } const showIcon = connStatus.status != "connecting"; React.useEffect(() => { const showWshErrorTemp = connStatus.status == "connected" && connStatus.wsherror && connStatus.wsherror != "" && wshConfigEnabled; setShowWshError(showWshErrorTemp); }, [connStatus, wshConfigEnabled]); const handleCopy = React.useCallback( async (e: React.MouseEvent) => { const errTexts = []; if (showError) { errTexts.push(`error: ${connStatus.error}`); } if (showWshError) { errTexts.push(`unable to use wsh: ${connStatus.wsherror}`); } const textToCopy = errTexts.join("\n"); await navigator.clipboard.writeText(textToCopy); }, [showError, showWshError, connStatus.error, connStatus.wsherror] ); const showStalled = connStatus.status == "connected" && connStatus.connhealthstatus == "stalled"; if (!showWshError && !showStalled && (isLayoutMode || connStatus.status == "connected" || connModalOpen)) { return null; } if (showStalled && !showWshError) { return ( <StalledOverlay connName={connName} connStatus={connStatus} overlayRefCallback={overlayRefCallback} /> ); } return ( <div className="connstatus-overlay" ref={overlayRefCallback}> <div className="connstatus-content"> <div className={clsx("connstatus-status-icon-wrapper", { "has-error": showError || showWshError })}> {showIcon && <i className="fa-solid fa-triangle-exclamation"></i>} <div className="connstatus-status ellipsis"> <div className="connstatus-status-text">{statusText}</div> {(showError || showWshError) && ( <OverlayScrollbarsComponent className="connstatus-error" options={{ scrollbars: { autoHide: "leave" } }} > <CopyButton className="copy-button" onClick={handleCopy} title="Copy" /> {showError ? <div>error: {connStatus.error}</div> : null} {showWshError ? <div>unable to use wsh: {connStatus.wsherror}</div> : null} </OverlayScrollbarsComponent> )} {showWshError && ( <Button className={reconClassName} onClick={handleDisableWsh}> always disable wsh </Button> )} </div> </div> {showReconnect ? ( <div className="connstatus-actions"> <Button className={reconClassName} onClick={handleTryReconnect}> {reconDisplay} </Button> </div> ) : null} {showWshError ? ( <div className="connstatus-actions"> <Button className={`fa-xmark fa-solid ${reconClassName}`} onClick={handleRemoveWshError} /> </div> ) : null} </div> </div> ); } ); ConnStatusOverlay.displayName = "ConnStatusOverlay"; ================================================ FILE: frontend/app/block/durable-session-flyover.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { recordTEvent } from "@/app/store/global"; import { TermViewModel } from "@/app/view/term/term-model"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import * as util from "@/util/util"; import { cn } from "@/util/util"; import { autoUpdate, flip, FloatingPortal, offset, safePolygon, shift, useFloating, useHover, useInteractions, } from "@floating-ui/react"; import * as jotai from "jotai"; import { useEffect, useRef, useState } from "react"; import { BlockEnv } from "./blockenv"; function isTermViewModel(viewModel: ViewModel): viewModel is TermViewModel { return viewModel?.viewType === "term"; } function LearnMoreButton() { const waveEnv = useWaveEnv<BlockEnv>(); return ( <button className="text-muted text-xs hover:underline cursor-pointer text-left" onClick={() => waveEnv.electron.openExternal("https://docs.waveterm.dev/durable-sessions")} > Learn More </button> ); } interface StandardSessionContentProps { viewModel: TermViewModel; onClose: () => void; } function StandardSessionContent({ viewModel, onClose }: StandardSessionContentProps) { const handleRestartAsDurable = () => { recordTEvent("action:termdurable", { "action:type": "restartdurable" }); onClose(); util.fireAndForget(() => viewModel.restartSessionWithDurability(true)); }; return ( <div className="flex flex-col gap-2 max-w-[280px]"> <div className="font-semibold text-sm flex items-center gap-2 text-secondary"> <i className="fa-sharp fa-regular fa-shield text-muted" /> Standard SSH Session </div> <div className="text-xs text-secondary leading-relaxed"> Standard SSH sessions end when the connection drops. Durable sessions keep your shell state, running programs, and history alive through network changes, computer sleep, and Wave restarts. </div> <button className="bg-zinc-700 text-foreground rounded px-3 py-1.5 text-xs font-medium hover:bg-zinc-600 transition-colors cursor-pointer flex items-center justify-center gap-2 mt-1" onClick={handleRestartAsDurable} > <i className="fa-solid fa-shield text-sky-500" /> Restart as Durable </button> <LearnMoreButton /> </div> ); } interface DurableAttachedContentProps { onClose: () => void; } function DurableAttachedContent({ onClose }: DurableAttachedContentProps) { return ( <div className="flex flex-col gap-2 max-w-[280px]"> <div className="font-semibold text-sm flex items-center gap-2 text-secondary"> <i className="fa-sharp fa-solid fa-shield text-sky-500" /> Durable Session (Attached) </div> <div className="text-xs text-secondary leading-relaxed"> Your shell state, running programs, and history are protected. This session will survive network disconnects. </div> <LearnMoreButton /> </div> ); } interface DurableDetachedContentProps { onClose: () => void; } function DurableDetachedContent({ onClose }: DurableDetachedContentProps) { return ( <div className="flex flex-col gap-2 max-w-[280px]"> <div className="font-semibold text-sm flex items-center gap-2 text-secondary"> <i className="fa-sharp fa-solid fa-shield text-sky-300" /> Durable Session (Detached) </div> <div className="text-xs text-secondary leading-relaxed"> Connection lost, but your session is still running on the remote server. Wave will automatically reconnect when the connection is restored. </div> <LearnMoreButton /> </div> ); } interface DurableAwaitingStartProps { connected: boolean; viewModel: TermViewModel; onClose: () => void; } function DurableAwaitingStart({ connected, viewModel, onClose }: DurableAwaitingStartProps) { const handleStartSession = () => { onClose(); util.fireAndForget(() => viewModel.forceRestartController()); }; if (!connected) { return ( <div className="flex flex-col gap-2 max-w-[280px]"> <div className="font-semibold text-sm flex items-center gap-2 text-secondary whitespace-nowrap"> <i className="fa-sharp fa-solid fa-shield text-muted" /> Durable Session (Awaiting Connection) </div> <div className="text-xs text-secondary leading-relaxed"> Configured for a durable session. The session will start when the connection is established. </div> <LearnMoreButton /> </div> ); } return ( <div className="flex flex-col gap-2 max-w-[280px]"> <div className="font-semibold text-sm flex items-center gap-2 text-secondary whitespace-nowrap"> <i className="fa-sharp fa-solid fa-shield text-muted" /> Durable Session (Awaiting Start) </div> <div className="text-xs text-secondary leading-relaxed"> Configured for a durable session, but session hasn't started yet. Click below to start it manually. </div> <button className="bg-zinc-700 text-foreground rounded px-3 py-1.5 text-xs font-medium hover:bg-zinc-600 transition-colors cursor-pointer flex items-center justify-center gap-2 mt-1" onClick={handleStartSession} > <i className="fa-solid fa-shield text-sky-500" /> Start Session </button> <LearnMoreButton /> </div> ); } interface DurableStartingContentProps { onClose: () => void; } function DurableStartingContent({ onClose }: DurableStartingContentProps) { return ( <div className="flex flex-col gap-2 max-w-[280px]"> <div className="font-semibold text-sm flex items-center gap-2 text-secondary"> <i className="fa-sharp fa-solid fa-shield text-sky-300" /> Durable Session (Starting) </div> <div className="text-xs text-secondary leading-relaxed">The durable session is starting.</div> <LearnMoreButton /> </div> ); } interface DurableEndedContentProps { doneReason: string; startupError?: string; viewModel: TermViewModel; onClose: () => void; } function DurableEndedContent({ doneReason, startupError, viewModel, onClose }: DurableEndedContentProps) { const handleRestartSession = () => { onClose(); util.fireAndForget(() => viewModel.forceRestartController()); }; const handleRestartAsStandard = () => { onClose(); util.fireAndForget(() => viewModel.restartSessionWithDurability(false)); }; let titleText = "Durable Session (Ended)"; let descriptionText = "The durable session has ended. This block is still configured for durable sessions."; const showRestartButton = true; if (doneReason === "terminated") { titleText = "Durable Session (Ended, Exited)"; descriptionText = "The shell was terminated and is no longer running. This block is still configured for durable sessions."; } else if (doneReason === "gone") { titleText = "Durable Session (Ended, Lost)"; descriptionText = "The session was lost or not found on the remote server. This may have occurred due to a system reboot or the session being manually terminated."; } else if (doneReason === "startuperror") { titleText = "Durable Session (Failed to Start)"; descriptionText = "The durable session failed to start."; return ( <div className="flex flex-col gap-2 max-w-[280px]"> <div className="font-semibold text-sm flex items-center gap-2 text-secondary"> <i className="fa-sharp fa-solid fa-shield text-muted" /> {titleText} </div> <div className="text-xs text-secondary leading-relaxed">{descriptionText}</div> {startupError && ( <div className="text-[11px] text-error leading-relaxed max-h-[3.5rem] overflow-y-auto"> {startupError} </div> )} <button className="bg-zinc-700 text-foreground rounded px-3 py-1.5 text-xs font-medium hover:bg-zinc-600 transition-colors cursor-pointer flex items-center justify-center gap-2 mt-1" onClick={handleRestartSession} > <i className="fa-solid fa-shield text-sky-500" /> Restart Session </button> <button className="bg-zinc-700 text-foreground rounded px-3 py-1.5 text-xs font-medium hover:bg-zinc-600 transition-colors cursor-pointer flex items-center justify-center gap-2" onClick={handleRestartAsStandard} > <i className="fa-sharp fa-regular fa-shield text-muted" /> Restart as Standard </button> <LearnMoreButton /> </div> ); } return ( <div className="flex flex-col gap-2 max-w-[280px]"> <div className="font-semibold text-sm flex items-center gap-2 text-secondary"> <i className="fa-sharp fa-solid fa-shield text-muted" /> {titleText} </div> <div className="text-xs text-secondary leading-relaxed">{descriptionText}</div> {showRestartButton && ( <button className="bg-zinc-700 text-foreground rounded px-3 py-1.5 text-xs font-medium hover:bg-zinc-600 transition-colors cursor-pointer flex items-center justify-center gap-2 mt-1" onClick={handleRestartSession} > <i className="fa-solid fa-shield text-sky-500" /> Restart Session </button> )} <LearnMoreButton /> </div> ); } function getContentToRender( viewModel: TermViewModel, onClose: () => void, jobStatus: BlockJobStatusData, connStatus: ConnStatus, isConfigedDurable?: boolean | null ): string | React.ReactNode { if (isConfigedDurable === false) { return <StandardSessionContent viewModel={viewModel} onClose={onClose} />; } const status = jobStatus?.status; if (status === "connected") { return <DurableAttachedContent onClose={onClose} />; } else if (status === "disconnected") { return <DurableDetachedContent onClose={onClose} />; } else if (status === "init") { return <DurableStartingContent onClose={onClose} />; } else if (status === "done") { const doneReason = jobStatus?.donereason; const startupError = jobStatus?.startuperror; return ( <DurableEndedContent doneReason={doneReason} startupError={startupError} viewModel={viewModel} onClose={onClose} /> ); } else if (status == null) { return <DurableAwaitingStart connected={!!connStatus?.connected} viewModel={viewModel} onClose={onClose} />; } console.log("DurableSessionFlyover: unexpected jobStatus", jobStatus); return null; } function getIconProps(jobStatus: BlockJobStatusData, connStatus: ConnStatus, isConfigedDurable?: boolean | null) { let color = "text-muted"; let iconType: "fa-solid" | "fa-regular" = "fa-solid"; if (isConfigedDurable === false) { color = "text-muted"; iconType = "fa-regular"; return { color, iconType }; } const status = jobStatus?.status; if (status === "connected") { color = "text-sky-500"; } else if (status === "disconnected") { color = "text-sky-300"; } else if (status === "init") { color = "text-sky-300"; } else if (status === "done") { color = "text-muted"; } else if (status == null) { color = "text-muted"; } return { color, iconType }; } interface DurableSessionFlyoverProps { blockId: string; viewModel: ViewModel; placement?: "top" | "bottom" | "left" | "right"; divClassName?: string; } export function DurableSessionFlyover({ blockId, viewModel, placement = "bottom", divClassName, }: DurableSessionFlyoverProps) { const waveEnv = useWaveEnv<BlockEnv>(); const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(blockId, "connection")); const termDurableStatus = util.useAtomValueSafe(viewModel?.termDurableStatus); const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable); const connStatus = jotai.useAtomValue(waveEnv.getConnStatusAtom(connName)); const { color: durableIconColor, iconType: durableIconType } = getIconProps( termDurableStatus, connStatus, termConfigedDurable ); const [isOpen, setIsOpen] = useState(false); const [isVisible, setIsVisible] = useState(false); const timeoutRef = useRef<number | null>(null); const handleClose = () => { setIsVisible(false); if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); } timeoutRef.current = window.setTimeout(() => { setIsOpen(false); }, 300); }; const { refs, floatingStyles, context } = useFloating({ open: isOpen, onOpenChange: (open) => { if (open) { setIsOpen(true); if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); } timeoutRef.current = window.setTimeout(() => { setIsVisible(true); }, 300); } else { setIsVisible(false); if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); } timeoutRef.current = window.setTimeout(() => { setIsOpen(false); }, 300); } }, placement, middleware: [offset(10), flip(), shift({ padding: 12 })], whileElementsMounted: autoUpdate, }); useEffect(() => { return () => { if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); } }; }, []); const hover = useHover(context, { handleClose: safePolygon(), }); const { getReferenceProps, getFloatingProps } = useInteractions([hover]); if (!isTermViewModel(viewModel)) { return null; } const content = getContentToRender(viewModel, handleClose, termDurableStatus, connStatus, termConfigedDurable); if (content == null) { return null; } return ( <> <div ref={refs.setReference} {...getReferenceProps()} className={divClassName}> <i className={`fa-sharp ${durableIconType} fa-shield ${durableIconColor}`} /> </div> {isOpen && ( <FloatingPortal> <div ref={refs.setFloating} style={{ ...floatingStyles, opacity: isVisible ? 1 : 0, transition: "opacity 200ms ease", }} {...getFloatingProps()} className={cn( "bg-zinc-800 border border-border rounded-md px-3 py-2.5 text-xs text-foreground shadow-xl z-50" )} onMouseDown={(e) => e.stopPropagation()} onFocusCapture={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {content} </div> </FloatingPortal> )} </> ); } ================================================ FILE: frontend/app/element/ansiline.tsx ================================================ export const ANSI_TAILWIND_MAP = { // Reset and modifiers 0: "reset", // special: clear state 1: "font-bold", 2: "opacity-75", 3: "italic", 4: "underline", 8: "invisible", 9: "line-through", // Foreground standard colors 30: "text-ansi-black", 31: "text-ansi-red", 32: "text-ansi-green", 33: "text-ansi-yellow", 34: "text-ansi-blue", 35: "text-ansi-magenta", 36: "text-ansi-cyan", 37: "text-ansi-white", // Foreground bright colors 90: "text-ansi-brightblack", 91: "text-ansi-brightred", 92: "text-ansi-brightgreen", 93: "text-ansi-brightyellow", 94: "text-ansi-brightblue", 95: "text-ansi-brightmagenta", 96: "text-ansi-brightcyan", 97: "text-ansi-brightwhite", // Background standard colors 40: "bg-ansi-black", 41: "bg-ansi-red", 42: "bg-ansi-green", 43: "bg-ansi-yellow", 44: "bg-ansi-blue", 45: "bg-ansi-magenta", 46: "bg-ansi-cyan", 47: "bg-ansi-white", // Background bright colors 100: "bg-ansi-brightblack", 101: "bg-ansi-brightred", 102: "bg-ansi-brightgreen", 103: "bg-ansi-brightyellow", 104: "bg-ansi-brightblue", 105: "bg-ansi-brightmagenta", 106: "bg-ansi-brightcyan", 107: "bg-ansi-brightwhite", }; type InternalStateType = { modifiers: Set<string>; textColor: string | null; bgColor: string | null; reverse: boolean; }; type SegmentType = { text: string; classes: string; }; const makeInitialState: () => InternalStateType = () => ({ modifiers: new Set<string>(), textColor: null, bgColor: null, reverse: false, }); const updateStateWithCodes = (state, codes) => { codes.forEach((code) => { if (code === 0) { // Reset state state.modifiers.clear(); state.textColor = null; state.bgColor = null; state.reverse = false; return; } // Instead of swapping immediately, we set a flag if (code === 7) { state.reverse = true; return; } const tailwindClass = ANSI_TAILWIND_MAP[code]; if (tailwindClass && tailwindClass !== "reset") { if (tailwindClass.startsWith("text-")) { state.textColor = tailwindClass; } else if (tailwindClass.startsWith("bg-")) { state.bgColor = tailwindClass; } else { state.modifiers.add(tailwindClass); } } }); return state; }; const stateToClasses = (state: InternalStateType) => { const classes = []; classes.push(...Array.from(state.modifiers)); // Apply reverse: swap text and background colors if flag is set. let textColor = state.textColor; let bgColor = state.bgColor; if (state.reverse) { [textColor, bgColor] = [bgColor, textColor]; } if (textColor) classes.push(textColor); if (bgColor) classes.push(bgColor); return classes.join(" "); }; // eslint-disable-next-line no-control-regex const ansiRegex = /\x1b\[([0-9;]+)m/g; const AnsiLine = ({ line }) => { const segments: SegmentType[] = []; let lastIndex = 0; let currentState = makeInitialState(); let match: RegExpExecArray; while ((match = ansiRegex.exec(line)) !== null) { if (match.index > lastIndex) { segments.push({ text: line.substring(lastIndex, match.index), classes: stateToClasses(currentState), }); } const codes = match[1].split(";").map(Number); updateStateWithCodes(currentState, codes); lastIndex = ansiRegex.lastIndex; } if (lastIndex < line.length) { segments.push({ text: line.substring(lastIndex), classes: stateToClasses(currentState), }); } return ( <div> {segments.map((seg, idx) => ( <span key={idx} className={seg.classes}> {seg.text} </span> ))} </div> ); }; export default AnsiLine; ================================================ FILE: frontend/app/element/button.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .wave-button { // override default button appearance border: 1px solid transparent; outline: 1px solid transparent; border: 1px solid transparent; cursor: pointer; display: flex; padding-top: 8px; padding-bottom: 8px; padding-left: 20px; padding-right: 20px; align-items: center; gap: 4px; border-radius: 6px; height: auto; line-height: 16px; white-space: nowrap; user-select: none; font-size: 14px; font-weight: normal; transition: all 0.3s ease; &.solid { &.green { color: var(--button-text-color); background-color: var(--accent-color); border: 1px solid var(--button-green-border-color); &:hover { color: var(--button-text-color); background-color: var(--button-green-border-color); } } &.grey { background-color: var(--button-grey-bg); border: 1px solid var(--button-grey-bg); color: var(--main-text-color); &:hover { color: var(--main-text-color); background-color: var(--button-grey-hover-bg); } } &.red { background-color: var(--button-red-bg); border: 1px solid var(--button-red-border-color); color: var(--main-text-color); &:hover { background-color: var(--button-red-hover-bg); } } &.yellow { color: var(--button-text-color); background-color: var(--button-yellow-bg); border: 1px solid var(--button-yellow-hover-bg); &:hover { color: var(--button-text-color); background-color: var(--button-yellow-hover-bg); } } } &.outlined { background-color: transparent; &.green { color: var(--accent-color); border: 1px solid var(--accent-color); &:hover { color: var(--button-green-border-color); border: 1px solid var(--button-green-border-color); } } &.grey { border: 1px solid var(--button-grey-outlined-color); color: var(--button-grey-outlined-color); &:hover { color: var(--main-text-color); border: 1px solid var(--main-text-color); } } &.red { border: 1px solid var(--button-red-bg); color: var(--button-red-bg); &:hover { color: var(--button-red-outlined-color); border: 1px solid var(--button-red-outlined-color); } } &.yellow { color: var(--button-yellow-bg); border: 1px solid var(--button-yellow-bg); &:hover { color: var(--button-yellow-hover-bg); border: 1px solid var(--button-yellow-hover-bg); } } } &.ghost { background-color: transparent; padding-top: 8px; padding-bottom: 8px; padding-left: 8px; padding-right: 8px; &.green { border: none; color: var(--accent-color); &:hover { color: var(--button-green-border-color); } } &.grey { border: none; color: var(--button-grey-outlined-color); &:hover { color: var(--main-text-color); } } &.red { border: none; color: var(--button-red-bg); &:hover { color: var(--button-red-border-color); } } &.yellow { border: none; color: var(--button-yellow-bg); &:hover { color: var(--button-yellow-hover-bg); } } } &.bold { font-weight: bold; } &:disabled { cursor: default; opacity: 0.5; pointer-events: none; } &:focus-visible { outline: 1px solid var(--success-color); outline-offset: 2px; } } ================================================ FILE: frontend/app/element/button.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import clsx from "clsx"; import { forwardRef, memo, ReactNode, useImperativeHandle, useRef } from "react"; import "./button.scss"; interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { className?: string; children?: ReactNode; as?: keyof React.JSX.IntrinsicElements | React.ComponentType<any>; } const Button = memo( forwardRef<HTMLButtonElement, ButtonProps>( ({ children, disabled, className = "", as: Component = "button", ...props }: ButtonProps, ref) => { const btnRef = useRef<HTMLButtonElement>(null); useImperativeHandle(ref, () => btnRef.current as HTMLButtonElement); // Check if the className contains any of the categories: solid, outlined, or ghost const containsButtonCategory = /(solid|outline|ghost)/.test(className); // If no category is present, default to 'solid' const categoryClassName = containsButtonCategory ? className : `solid ${className}`; // Check if the className contains any of the color options: green, grey, red, or yellow const containsColor = /(green|grey|red|yellow)/.test(categoryClassName); // If no color is present, default to 'green' const finalClassName = containsColor ? categoryClassName : `green ${categoryClassName}`; return ( <Component ref={btnRef} tabIndex={disabled ? -1 : 0} className={clsx("wave-button", finalClassName)} disabled={disabled} {...props} > {children} </Component> ); } ) ); Button.displayName = "Button"; export { Button }; ================================================ FILE: frontend/app/element/copybutton.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .copy-button { &.copied { opacity: 1; i { color: var(--success-color); } } } ================================================ FILE: frontend/app/element/copybutton.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import clsx from "clsx"; import { useEffect, useRef, useState } from "react"; import "./copybutton.scss"; import { IconButton } from "./iconbutton"; type CopyButtonProps = { title: string; className?: string; onClick: (e: React.MouseEvent<HTMLButtonElement>) => void; }; const CopyButton = ({ title, className, onClick }: CopyButtonProps) => { const [isCopied, setIsCopied] = useState(false); const timeoutRef = useRef<NodeJS.Timeout | null>(null); const handleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => { if (isCopied) { return; } setIsCopied(true); if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { setIsCopied(false); timeoutRef.current = null; }, 2000); if (onClick) { onClick(e); } }; useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); return ( <IconButton decl={{ elemtype: "iconbutton", icon: isCopied ? "check" : "copy", title, className: clsx("copy-button", { copied: isCopied }), click: handleOnClick, }} className={className} ></IconButton> ); }; export { CopyButton }; ================================================ FILE: frontend/app/element/emojibutton.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { cn, makeIconClass } from "@/util/util"; import { useLayoutEffect, useRef, useState } from "react"; export const EmojiButton = ({ emoji, icon, isClicked, onClick, className, suppressFlyUp, }: { emoji?: string; icon?: string; isClicked: boolean; onClick: () => void; className?: string; suppressFlyUp?: boolean; }) => { const [showFloating, setShowFloating] = useState(false); const prevClickedRef = useRef(isClicked); useLayoutEffect(() => { if (isClicked && !prevClickedRef.current && !suppressFlyUp) { setShowFloating(true); setTimeout(() => setShowFloating(false), 600); } prevClickedRef.current = isClicked; }, [isClicked, suppressFlyUp]); const content = icon ? <i className={makeIconClass(icon, false)} /> : emoji; return ( <div className="relative inline-block"> <button onClick={onClick} className={cn( "px-2 py-1 rounded border cursor-pointer transition-colors", isClicked ? "bg-accent/20 border-accent text-accent" : "bg-transparent border-border/50 text-foreground/70 hover:border-border", className )} > {content} </button> {showFloating && ( <span className="absolute pointer-events-none animate-[float-up_0.6s_ease-out_forwards]" style={{ left: "50%", bottom: "100%", }} > {content} </span> )} </div> ); }; ================================================ FILE: frontend/app/element/emojipalette.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .emoji-palette-content { padding: 10px; max-height: 350px; width: 300px; display: flex; flex-direction: column; } .emoji-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(35px, 1fr)); gap: 10px; padding: 10px 0; width: 100%; height: 300px; overflow-y: auto; } .emoji-button { font-size: 24px; padding: 5px; cursor: pointer; background: none; border: none; transition: background-color 0.3s ease; &:hover { background-color: rgba(0, 0, 0, 0.1); border-radius: 5px; } } .no-emojis { font-size: 14px; color: #888; text-align: center; } ================================================ FILE: frontend/app/element/emojipalette.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { type Placement } from "@floating-ui/react"; import clsx from "clsx"; import { memo, useState } from "react"; import { Button } from "./button"; import { Input, InputGroup, InputLeftElement } from "./input"; import { Popover, PopoverButton, PopoverContent } from "./popover"; import "./emojipalette.scss"; type EmojiItem = { emoji: string; name: string }; const emojiList: EmojiItem[] = [ // Smileys & Emotion { emoji: "😀", name: "grinning face" }, { emoji: "😁", name: "beaming face with smiling eyes" }, { emoji: "😂", name: "face with tears of joy" }, { emoji: "🤣", name: "rolling on the floor laughing" }, { emoji: "😃", name: "grinning face with big eyes" }, { emoji: "😄", name: "grinning face with smiling eyes" }, { emoji: "😅", name: "grinning face with sweat" }, { emoji: "😆", name: "grinning squinting face" }, { emoji: "😉", name: "winking face" }, { emoji: "😊", name: "smiling face with smiling eyes" }, { emoji: "😋", name: "face savoring food" }, { emoji: "😎", name: "smiling face with sunglasses" }, { emoji: "😍", name: "smiling face with heart-eyes" }, { emoji: "😘", name: "face blowing a kiss" }, { emoji: "😗", name: "kissing face" }, { emoji: "😙", name: "kissing face with smiling eyes" }, { emoji: "😚", name: "kissing face with closed eyes" }, { emoji: "🙂", name: "slightly smiling face" }, { emoji: "🤗", name: "hugging face" }, { emoji: "🤔", name: "thinking face" }, { emoji: "😐", name: "neutral face" }, { emoji: "😑", name: "expressionless face" }, { emoji: "😶", name: "face without mouth" }, { emoji: "🙄", name: "face with rolling eyes" }, { emoji: "😏", name: "smirking face" }, { emoji: "😣", name: "persevering face" }, { emoji: "😥", name: "sad but relieved face" }, { emoji: "😮", name: "face with open mouth" }, { emoji: "🤐", name: "zipper-mouth face" }, { emoji: "😯", name: "hushed face" }, { emoji: "😪", name: "sleepy face" }, { emoji: "😫", name: "tired face" }, { emoji: "🥱", name: "yawning face" }, { emoji: "😴", name: "sleeping face" }, { emoji: "😌", name: "relieved face" }, { emoji: "😛", name: "face with tongue" }, { emoji: "😜", name: "winking face with tongue" }, { emoji: "😝", name: "squinting face with tongue" }, { emoji: "🤤", name: "drooling face" }, { emoji: "😒", name: "unamused face" }, { emoji: "😓", name: "downcast face with sweat" }, { emoji: "😔", name: "pensive face" }, { emoji: "😕", name: "confused face" }, { emoji: "🙃", name: "upside-down face" }, { emoji: "🫠", name: "melting face" }, { emoji: "😲", name: "astonished face" }, { emoji: "☹️", name: "frowning face" }, { emoji: "🙁", name: "slightly frowning face" }, { emoji: "😖", name: "confounded face" }, { emoji: "😞", name: "disappointed face" }, { emoji: "😟", name: "worried face" }, { emoji: "😤", name: "face with steam from nose" }, { emoji: "😢", name: "crying face" }, { emoji: "😭", name: "loudly crying face" }, { emoji: "😦", name: "frowning face with open mouth" }, { emoji: "😧", name: "anguished face" }, { emoji: "😨", name: "fearful face" }, { emoji: "😩", name: "weary face" }, { emoji: "🤯", name: "exploding head" }, { emoji: "😬", name: "grimacing face" }, { emoji: "😰", name: "anxious face with sweat" }, { emoji: "😱", name: "face screaming in fear" }, { emoji: "🥵", name: "hot face" }, { emoji: "🥶", name: "cold face" }, { emoji: "😳", name: "flushed face" }, { emoji: "🤪", name: "zany face" }, { emoji: "😵", name: "dizzy face" }, { emoji: "🥴", name: "woozy face" }, { emoji: "😠", name: "angry face" }, { emoji: "😡", name: "pouting face" }, { emoji: "🤬", name: "face with symbols on mouth" }, { emoji: "🤮", name: "face vomiting" }, { emoji: "🤢", name: "nauseated face" }, { emoji: "😷", name: "face with medical mask" }, // Gestures & Hand Signs { emoji: "👋", name: "waving hand" }, { emoji: "🤚", name: "raised back of hand" }, { emoji: "🖐️", name: "hand with fingers splayed" }, { emoji: "✋", name: "raised hand" }, { emoji: "👌", name: "OK hand" }, { emoji: "✌️", name: "victory hand" }, { emoji: "🤞", name: "crossed fingers" }, { emoji: "🤟", name: "love-you gesture" }, { emoji: "🤘", name: "sign of the horns" }, { emoji: "🤙", name: "call me hand" }, { emoji: "👈", name: "backhand index pointing left" }, { emoji: "👉", name: "backhand index pointing right" }, { emoji: "👆", name: "backhand index pointing up" }, { emoji: "👇", name: "backhand index pointing down" }, { emoji: "👍", name: "thumbs up" }, { emoji: "👎", name: "thumbs down" }, { emoji: "👏", name: "clapping hands" }, { emoji: "🙌", name: "raising hands" }, { emoji: "👐", name: "open hands" }, { emoji: "🙏", name: "folded hands" }, // Animals & Nature { emoji: "🐶", name: "dog face" }, { emoji: "🐱", name: "cat face" }, { emoji: "🐭", name: "mouse face" }, { emoji: "🐹", name: "hamster face" }, { emoji: "🐰", name: "rabbit face" }, { emoji: "🦊", name: "fox face" }, { emoji: "🐻", name: "bear face" }, { emoji: "🐼", name: "panda face" }, { emoji: "🐨", name: "koala" }, { emoji: "🐯", name: "tiger face" }, { emoji: "🦁", name: "lion" }, { emoji: "🐮", name: "cow face" }, { emoji: "🐷", name: "pig face" }, { emoji: "🐸", name: "frog face" }, { emoji: "🐵", name: "monkey face" }, { emoji: "🦄", name: "unicorn face" }, { emoji: "🐢", name: "turtle" }, { emoji: "🐍", name: "snake" }, { emoji: "🦋", name: "butterfly" }, { emoji: "🐝", name: "honeybee" }, { emoji: "🐞", name: "lady beetle" }, { emoji: "🦀", name: "crab" }, { emoji: "🐠", name: "tropical fish" }, { emoji: "🐟", name: "fish" }, { emoji: "🐬", name: "dolphin" }, { emoji: "🐳", name: "spouting whale" }, { emoji: "🐋", name: "whale" }, { emoji: "🦈", name: "shark" }, // Food & Drink { emoji: "🍏", name: "green apple" }, { emoji: "🍎", name: "red apple" }, { emoji: "🍐", name: "pear" }, { emoji: "🍊", name: "tangerine" }, { emoji: "🍋", name: "lemon" }, { emoji: "🍌", name: "banana" }, { emoji: "🍉", name: "watermelon" }, { emoji: "🍇", name: "grapes" }, { emoji: "🍓", name: "strawberry" }, { emoji: "🫐", name: "blueberries" }, { emoji: "🍈", name: "melon" }, { emoji: "🍒", name: "cherries" }, { emoji: "🍑", name: "peach" }, { emoji: "🥭", name: "mango" }, { emoji: "🍍", name: "pineapple" }, { emoji: "🥥", name: "coconut" }, { emoji: "🥑", name: "avocado" }, { emoji: "🥦", name: "broccoli" }, { emoji: "🥕", name: "carrot" }, { emoji: "🌽", name: "corn" }, { emoji: "🌶️", name: "hot pepper" }, { emoji: "🍔", name: "hamburger" }, { emoji: "🍟", name: "french fries" }, { emoji: "🍕", name: "pizza" }, { emoji: "🌭", name: "hot dog" }, { emoji: "🥪", name: "sandwich" }, { emoji: "🍿", name: "popcorn" }, { emoji: "🥓", name: "bacon" }, { emoji: "🥚", name: "egg" }, { emoji: "🍰", name: "cake" }, { emoji: "🎂", name: "birthday cake" }, { emoji: "🍦", name: "ice cream" }, { emoji: "🍩", name: "doughnut" }, { emoji: "🍪", name: "cookie" }, { emoji: "🍫", name: "chocolate bar" }, { emoji: "🍬", name: "candy" }, { emoji: "🍭", name: "lollipop" }, // Activities { emoji: "⚽", name: "soccer ball" }, { emoji: "🏀", name: "basketball" }, { emoji: "🏈", name: "american football" }, { emoji: "⚾", name: "baseball" }, { emoji: "🥎", name: "softball" }, { emoji: "🎾", name: "tennis" }, { emoji: "🏐", name: "volleyball" }, { emoji: "🎳", name: "bowling" }, { emoji: "⛳", name: "flag in hole" }, { emoji: "🚴", name: "person biking" }, { emoji: "🎮", name: "video game" }, { emoji: "🎲", name: "game die" }, { emoji: "🎸", name: "guitar" }, { emoji: "🎺", name: "trumpet" }, // Miscellaneous { emoji: "🚀", name: "rocket" }, { emoji: "💖", name: "sparkling heart" }, { emoji: "🎉", name: "party popper" }, { emoji: "🔥", name: "fire" }, { emoji: "🎁", name: "gift" }, { emoji: "❤️", name: "red heart" }, { emoji: "🧡", name: "orange heart" }, { emoji: "💛", name: "yellow heart" }, { emoji: "💚", name: "green heart" }, { emoji: "💙", name: "blue heart" }, { emoji: "💜", name: "purple heart" }, { emoji: "🤍", name: "white heart" }, { emoji: "🤎", name: "brown heart" }, { emoji: "💔", name: "broken heart" }, ]; interface EmojiPaletteProps { className?: string; placement?: Placement; onSelect?: (_: EmojiItem) => void; } const EmojiPalette = memo(({ className, placement, onSelect }: EmojiPaletteProps) => { const [searchTerm, setSearchTerm] = useState(""); const handleSearchChange = (val: string) => { setSearchTerm(val.toLowerCase()); }; const handleSelect = (item: { name: string; emoji: string }) => { onSelect?.(item); }; const filteredEmojis = emojiList.filter((item) => item.name.includes(searchTerm)); return ( <div className={clsx("emoji-palette", className)}> <Popover placement={placement}> <PopoverButton className="ghost grey"> <i className="fa-sharp fa-solid fa-face-smile"></i> </PopoverButton> <PopoverContent className="emoji-palette-content"> <InputGroup> <InputLeftElement> <i className="fa-sharp fa-solid fa-magnifying-glass"></i> </InputLeftElement> <Input placeholder="Search emojis..." value={searchTerm} onChange={handleSearchChange} /> </InputGroup> <div className="emoji-grid"> {filteredEmojis.length > 0 ? ( filteredEmojis.map((item, index) => ( <Button key={index} className="ghost emoji-button" onClick={() => handleSelect(item)}> {item.emoji} </Button> )) ) : ( <div className="no-emojis">No emojis found</div> )} </div> </PopoverContent> </Popover> </div> ); }); EmojiPalette.displayName = "EmojiPalette"; export { EmojiPalette }; export type { EmojiItem }; ================================================ FILE: frontend/app/element/errorboundary.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import React, { ReactNode } from "react"; export class ErrorBoundary extends React.Component< { children: ReactNode; fallback?: React.ReactElement & { error?: Error } }, { error: Error } > { constructor(props) { super(props); this.state = { error: null }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { console.error("ErrorBoundary caught an error:", error, errorInfo); this.setState({ error: error }); } render() { const { fallback } = this.props; const { error } = this.state; if (error) { if (fallback != null) { return React.cloneElement(fallback as any, { error }); } const errorMsg = `Error: ${error?.message}\n\n${error?.stack}`; return <pre className="error-boundary">{errorMsg}</pre>; } else { return <>{this.props.children}</>; } } } export class NullErrorBoundary extends React.Component< { children: React.ReactNode; debugName?: string }, { hasError: boolean } > { constructor(props: { children: React.ReactNode; debugName?: string }) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: Error, info: React.ErrorInfo) { console.error(`${this.props.debugName ?? "NullErrorBoundary"} error boundary caught error`, error, info); } render() { if (this.state.hasError) { return null; } return this.props.children; } } ================================================ FILE: frontend/app/element/expandablemenu.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .expandable-menu { display: flex; flex-direction: column; width: 100%; overflow: visible; } .expandable-menu-item, .expandable-menu-item-group-title { display: flex; align-items: center; padding: 8px 12px; /* Left and right padding, we'll adjust this for the right side */ cursor: pointer; box-sizing: border-box; border-radius: 4px; .label { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } } .expandable-menu-item-group-title { &:hover { background-color: var(--button-grey-hover-bg); } } .expandable-menu-item { &.with-hover-effect { &:hover { background-color: var(--button-grey-hover-bg); } } } .expandable-menu-item-left, .expandable-menu-item-right { display: flex; align-items: center; } .expandable-menu-item-left { margin-right: 8px; /* Space for the left element */ } .expandable-menu-item-right { margin-left: auto; /* This keeps the right element (if any) on the far right */ white-space: nowrap; } .expandable-menu-item-content { flex-grow: 1; /* Ensures the content grows to fill available space between left and right elements */ } .expandable-menu-item-group-content { max-height: 0; overflow: hidden; margin-left: 16px; /* Retaining left indentation */ margin-right: 0; /* Removing right padding */ &.open { max-height: 1000px; /* Ensure large enough max-height for expansion */ } } .no-indent .expandable-menu-item-group-content { margin-left: 0; // Remove left indentation when noIndent is true } ================================================ FILE: frontend/app/element/expandablemenu.tsx ================================================ // Copyright 2025, Command Line // SPDX-License-Identifier: Apache-2.0 import clsx from "clsx"; import { atom, useAtom } from "jotai"; import { Children, ReactElement, ReactNode, cloneElement, isValidElement, useRef } from "react"; import "./expandablemenu.scss"; // Define the global atom for managing open groups const openGroupsAtom = atom<{ [key: string]: boolean }>({}); type BaseExpandableMenuItem = { type: "item" | "group"; id?: string; }; interface ExpandableMenuItemType extends BaseExpandableMenuItem { type: "item"; leftElement?: string | ReactNode; rightElement?: string | ReactNode; content?: React.ReactNode | ((props: any) => React.ReactNode); } interface ExpandableMenuItemGroupTitleType { leftElement?: string | ReactNode; label: string; rightElement?: string | ReactNode; } interface ExpandableMenuItemGroupType extends BaseExpandableMenuItem { type: "group"; title: ExpandableMenuItemGroupTitleType; isOpen?: boolean; children?: ExpandableMenuItemData[]; } type ExpandableMenuItemData = ExpandableMenuItemType | ExpandableMenuItemGroupType; type ExpandableMenuProps = { children: React.ReactNode; className?: string; noIndent?: boolean; singleOpen?: boolean; }; const ExpandableMenu = ({ children, className, noIndent = false, singleOpen = false }: ExpandableMenuProps) => { return ( <div className={clsx("expandable-menu", className, { "no-indent": noIndent })}> {Children.map(children, (child) => { if (isValidElement(child) && child.type === ExpandableMenuItemGroup) { return cloneElement(child as any, { singleOpen }); } return child; })} </div> ); }; type ExpandableMenuItemProps = { children: ReactNode; className?: string; withHoverEffect?: boolean; onClick?: () => void; }; const ExpandableMenuItem = ({ children, className, withHoverEffect = true, onClick }: ExpandableMenuItemProps) => { return ( <div className={clsx("expandable-menu-item", className, { "with-hover-effect": withHoverEffect, })} onClick={onClick} > {children} </div> ); }; type ExpandableMenuItemGroupTitleProps = { children: ReactNode; className?: string; onClick?: () => void; }; const ExpandableMenuItemGroupTitle = ({ children, className, onClick }: ExpandableMenuItemGroupTitleProps) => { return ( <div className={clsx("expandable-menu-item-group-title", className)} onClick={onClick}> {children} </div> ); }; type ExpandableMenuItemGroupProps = { children: React.ReactNode; className?: string; isOpen?: boolean; onToggle?: (isOpen: boolean) => void; singleOpen?: boolean; }; const ExpandableMenuItemGroup = ({ children, className, isOpen, onToggle, singleOpen = false, }: ExpandableMenuItemGroupProps) => { const [openGroups, setOpenGroups] = useAtom(openGroupsAtom); // Generate a unique ID for this group using useRef const idRef = useRef<string>(null); if (!idRef.current) { // Generate a unique ID when the component is first rendered idRef.current = `group-${Math.random().toString(36).substr(2, 9)}`; } const id = idRef.current; // Determine if the component is controlled or uncontrolled const isControlled = isOpen !== undefined; // Get the open state from global atom in uncontrolled mode const actualIsOpen = isControlled ? isOpen : (openGroups[id] ?? false); const toggleOpen = () => { const newIsOpen = !actualIsOpen; if (isControlled) { // If controlled, call the onToggle callback onToggle?.(newIsOpen); } else { // If uncontrolled, update global atom setOpenGroups((prevOpenGroups) => { if (singleOpen) { // Close all other groups and open this one return { [id]: newIsOpen }; } else { // Toggle this group return { ...prevOpenGroups, [id]: newIsOpen }; } }); } }; const renderChildren = Children.map(children, (child: ReactElement) => { if (child && child.type === ExpandableMenuItemGroupTitle) { const childProps = child.props as ExpandableMenuItemGroupTitleProps; return cloneElement(child as ReactElement<ExpandableMenuItemGroupTitleProps>, { ...childProps, onClick: () => { childProps.onClick?.(); toggleOpen(); }, }); } else { return <div className={clsx("expandable-menu-item-group-content", { open: actualIsOpen })}>{child}</div>; } }); return ( <div className={clsx("expandable-menu-item-group", className, { open: actualIsOpen })}>{renderChildren}</div> ); }; type ExpandableMenuItemLeftElementProps = { children: ReactNode; onClick?: () => void; }; const ExpandableMenuItemLeftElement = ({ children, onClick }: ExpandableMenuItemLeftElementProps) => { return ( <div className="expandable-menu-item-left" onClick={onClick}> {children} </div> ); }; type ExpandableMenuItemRightElementProps = { children: ReactNode; onClick?: () => void; }; const ExpandableMenuItemRightElement = ({ children, onClick }: ExpandableMenuItemRightElementProps) => { return ( <div className="expandable-menu-item-right" onClick={onClick}> {children} </div> ); }; export { ExpandableMenu, ExpandableMenuItem, ExpandableMenuItemGroup, ExpandableMenuItemGroupTitle, ExpandableMenuItemLeftElement, ExpandableMenuItemRightElement, }; export type { ExpandableMenuItemData, ExpandableMenuItemGroupTitleType }; ================================================ FILE: frontend/app/element/flyoutmenu.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .menu { position: absolute; z-index: 1000; display: flex; max-width: 400px; min-width: 125px; padding: 2px; flex-direction: column; justify-content: flex-end; align-items: flex-start; gap: 1px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.15); background: #212121; box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3); } .menu-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 6px; cursor: pointer; color: var(--main-text-color); font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; letter-spacing: -0.12px; width: 100%; border-radius: 2px; .label { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-decoration: none; } } .menu-item { color: var(--main-text-color); &:hover { background-color: var(--accent-color); color: var(--button-text-color); border-radius: 2px; } } ================================================ FILE: frontend/app/element/flyoutmenu.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { FloatingPortal, type Placement, useDismiss, useFloating, useInteractions } from "@floating-ui/react"; import clsx from "clsx"; import { createRef, Fragment, memo, ReactNode, useRef, useState } from "react"; import ReactDOM from "react-dom"; import "./flyoutmenu.scss"; type MenuProps = { items: MenuItem[]; className?: string; placement?: Placement; onOpenChange?: (isOpen: boolean) => void; children: ReactNode | ReactNode[]; renderMenu?: (subMenu: React.ReactElement, props: any) => React.ReactElement; renderMenuItem?: (item: MenuItem, props: any) => React.ReactElement; }; const FlyoutMenuComponent = memo( ({ items, children, className, placement, onOpenChange, renderMenu, renderMenuItem }: MenuProps) => { const [visibleSubMenus, setVisibleSubMenus] = useState<{ [key: string]: any }>({}); const [hoveredItems, setHoveredItems] = useState<string[]>([]); const [subMenuPosition, setSubMenuPosition] = useState<{ [key: string]: { top: number; left: number; label: string }; }>({}); const subMenuRefs = useRef<{ [key: string]: React.RefObject<HTMLDivElement> }>({}); const [isOpen, setIsOpen] = useState(false); const onOpenChangeMenu = (isOpen: boolean) => { setIsOpen(isOpen); onOpenChange?.(isOpen); }; const { refs, floatingStyles, context } = useFloating({ placement: placement ?? "bottom-start", open: isOpen, onOpenChange: onOpenChangeMenu, }); const dismiss = useDismiss(context); const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); items.forEach((_, idx) => { const key = `${idx}`; if (!subMenuRefs.current[key]) { subMenuRefs.current[key] = createRef<HTMLDivElement>(); } }); // Position submenus based on available space and scroll position const handleSubMenuPosition = (key: string, itemRect: DOMRect, label: string) => { setTimeout(() => { const subMenuRef = subMenuRefs.current[key]?.current; if (!subMenuRef) return; const scrollTop = window.scrollY || document.documentElement.scrollTop; const scrollLeft = window.scrollX || document.documentElement.scrollLeft; const submenuWidth = subMenuRef.offsetWidth; const submenuHeight = subMenuRef.offsetHeight; let left = itemRect.right + scrollLeft - 2; // Adjust for horizontal scroll let top = itemRect.top - 2 + scrollTop; // Adjust for vertical scroll // Adjust to the left if overflowing the right boundary if (left + submenuWidth > window.innerWidth + scrollLeft) { left = itemRect.left + scrollLeft - submenuWidth; } // Adjust if the submenu overflows the bottom boundary if (top + submenuHeight > window.innerHeight + scrollTop) { top = window.innerHeight + scrollTop - submenuHeight - 10; } setSubMenuPosition((prev) => ({ ...prev, [key]: { top, left, label }, })); }, 0); }; const handleMouseEnterItem = ( event: React.MouseEvent<HTMLDivElement, MouseEvent>, parentKey: string | null, index: number, item: MenuItem ) => { event.stopPropagation(); const key = parentKey ? `${parentKey}-${index}` : `${index}`; setVisibleSubMenus((prev) => { const updatedState = { ...prev }; updatedState[key] = { visible: true, label: item.label }; const ancestors = key.split("-").reduce((acc, part, idx) => { if (idx === 0) return [part]; return [...acc, `${acc[idx - 1]}-${part}`]; }, [] as string[]); ancestors.forEach((ancestorKey) => { if (updatedState[ancestorKey]) { updatedState[ancestorKey].visible = true; } }); for (const pkey in updatedState) { if (!ancestors.includes(pkey) && pkey !== key) { updatedState[pkey].visible = false; } } return updatedState; }); const newHoveredItems = key.split("-").reduce((acc, part, idx) => { if (idx === 0) return [part]; return [...acc, `${acc[idx - 1]}-${part}`]; }, [] as string[]); setHoveredItems(newHoveredItems); const itemRect = event.currentTarget.getBoundingClientRect(); handleSubMenuPosition(key, itemRect, item.label); }; const handleOnClick = (e: React.MouseEvent<HTMLDivElement>, item: MenuItem) => { e.stopPropagation(); onOpenChangeMenu(false); item.onClick?.(e); }; return ( <> <div className="menu-anchor" ref={refs.setReference} {...getReferenceProps()} onClick={() => onOpenChangeMenu(!isOpen)} > {children} </div> {isOpen && ( <FloatingPortal> <div className={clsx("menu", className)} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} > {items.map((item, index) => { const key = `${index}`; const isActive = hoveredItems.includes(key); const menuItemProps = { className: clsx("menu-item", { active: isActive }), onMouseEnter: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => handleMouseEnterItem(event, null, index, item), onClick: (e: React.MouseEvent<HTMLDivElement>) => handleOnClick(e, item), }; const renderedItem = renderMenuItem ? ( renderMenuItem(item, menuItemProps) ) : ( <div key={key} {...menuItemProps}> <span className="label">{item.label}</span> {item.subItems && <i className="fa-sharp fa-solid fa-chevron-right"></i>} </div> ); return ( <Fragment key={key}> {renderedItem} {visibleSubMenus[key]?.visible && item.subItems && ( <SubMenu subItems={item.subItems} parentKey={key} subMenuPosition={subMenuPosition} visibleSubMenus={visibleSubMenus} hoveredItems={hoveredItems} handleMouseEnterItem={handleMouseEnterItem} handleOnClick={handleOnClick} subMenuRefs={subMenuRefs} renderMenu={renderMenu} renderMenuItem={renderMenuItem} /> )} </Fragment> ); })} </div> </FloatingPortal> )} </> ); } ); const FlyoutMenu = memo(FlyoutMenuComponent) as typeof FlyoutMenuComponent; type SubMenuProps = { subItems: MenuItem[]; parentKey: string; subMenuPosition: { [key: string]: { top: number; left: number; label: string }; }; visibleSubMenus: { [key: string]: any }; hoveredItems: string[]; subMenuRefs: React.RefObject<{ [key: string]: React.RefObject<HTMLDivElement> }>; handleMouseEnterItem: ( event: React.MouseEvent<HTMLDivElement, MouseEvent>, parentKey: string | null, index: number, item: MenuItem ) => void; handleOnClick: (e: React.MouseEvent<HTMLDivElement>, item: MenuItem) => void; renderMenu?: (subMenu: React.ReactElement, props: any) => React.ReactElement; renderMenuItem?: (item: MenuItem, props: any) => React.ReactElement; }; const SubMenu = memo( ({ subItems, parentKey, subMenuPosition, visibleSubMenus, hoveredItems, subMenuRefs, handleMouseEnterItem, handleOnClick, renderMenu, renderMenuItem, }: SubMenuProps) => { subItems.forEach((_, idx) => { const newKey = `${parentKey}-${idx}`; if (!subMenuRefs.current[newKey]) { subMenuRefs.current[newKey] = createRef<HTMLDivElement>(); } }); const position = subMenuPosition[parentKey]; const isPositioned = position && position.top !== undefined && position.left !== undefined; const subMenu = ( <div className="menu sub-menu" ref={subMenuRefs.current[parentKey]} style={{ top: position?.top || 0, left: position?.left || 0, position: "absolute", zIndex: 1000, visibility: visibleSubMenus[parentKey]?.visible && isPositioned ? "visible" : "hidden", }} > {subItems.map((item, idx) => { const newKey = `${parentKey}-${idx}`; const isActive = hoveredItems.includes(newKey); const menuItemProps = { className: clsx("menu-item", { active: isActive }), onMouseEnter: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => handleMouseEnterItem(event, parentKey, idx, item), onClick: (e: React.MouseEvent<HTMLDivElement>) => handleOnClick(e, item), }; const renderedItem = renderMenuItem ? ( renderMenuItem(item, menuItemProps) // Remove portal here ) : ( <div key={newKey} {...menuItemProps}> <span className="label">{item.label}</span> {item.subItems && <i className="fa-sharp fa-solid fa-chevron-right"></i>} </div> ); return ( <Fragment key={newKey}> {renderedItem} {visibleSubMenus[newKey]?.visible && item.subItems && ( <SubMenu subItems={item.subItems} parentKey={newKey} subMenuPosition={subMenuPosition} visibleSubMenus={visibleSubMenus} hoveredItems={hoveredItems} handleMouseEnterItem={handleMouseEnterItem} handleOnClick={handleOnClick} subMenuRefs={subMenuRefs} renderMenu={renderMenu} renderMenuItem={renderMenuItem} /> )} </Fragment> ); })} </div> ); return ReactDOM.createPortal(renderMenu ? renderMenu(subMenu, { parentKey }) : subMenu, document.body); } ); export { FlyoutMenu }; ================================================ FILE: frontend/app/element/iconbutton.scss ================================================ .wave-iconbutton { display: flex; cursor: pointer; opacity: 0.7; align-items: center; background: none; border: none; padding: 0; font: inherit; outline: inherit; &.bulb { color: var(--bulb-color); opacity: 1; &:hover i::before { content: "\f672"; position: relative; left: -1px; } } &:hover { opacity: 1; } &.no-action { cursor: default; } &.disabled { cursor: default; opacity: 0.45 !important; } &.toggle { border-radius: 3px; padding: 1px; &.active { opacity: 1; border: 1px solid var(--accent-color); padding: 0; } &:hover { background: var(--highlight-bg-color); } } } ================================================ FILE: frontend/app/element/iconbutton.tsx ================================================ // Copyright 2023, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useLongClick } from "@/app/hook/useLongClick"; import { makeIconClass } from "@/util/util"; import clsx from "clsx"; import { atom, useAtom } from "jotai"; import { CSSProperties, forwardRef, memo, useMemo, useRef } from "react"; import "./iconbutton.scss"; type IconButtonProps = { decl: IconButtonDecl; className?: string }; export const IconButton = memo( forwardRef<HTMLButtonElement, IconButtonProps>(({ decl, className }, ref) => { ref = ref ?? useRef<HTMLButtonElement>(null); const spin = decl.iconSpin ?? false; useLongClick(ref, decl.click, decl.longClick, decl.disabled); const disabled = decl.disabled ?? false; const styleVal: CSSProperties = {}; if (decl.iconColor) { styleVal.color = decl.iconColor; } return ( <button ref={ref} className={clsx("wave-iconbutton", className, decl.className, { disabled, "no-action": decl.noAction, })} title={decl.title} aria-label={decl.title} style={styleVal} disabled={disabled} > {typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon} </button> ); }) ); type ToggleIconButtonProps = { decl: ToggleIconButtonDecl; className?: string }; export const ToggleIconButton = memo( forwardRef<HTMLButtonElement, ToggleIconButtonProps>(({ decl, className }, ref) => { const activeAtom = useMemo(() => decl.active ?? atom(false), [decl.active]); const [active, setActive] = useAtom(activeAtom); ref = ref ?? useRef<HTMLButtonElement>(null); const spin = decl.iconSpin ?? false; const title = `${decl.title}${active ? " (Active)" : ""}`; const disabled = decl.disabled ?? false; return ( <button ref={ref} className={clsx("wave-iconbutton", "toggle", className, decl.className, { active, disabled, "no-action": decl.noAction, })} title={title} aria-label={title} style={{ color: decl.iconColor ?? "inherit" }} onClick={() => setActive(!active)} disabled={disabled} > {typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon} </button> ); }) ); ================================================ FILE: frontend/app/element/input.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .input { width: 100%; border: none; font-size: 12px; outline: none; background-color: transparent; color: var(--form-element-text-color); background: var(--form-element-bg-color); border: 2px solid var(--form-element-border-color); border-radius: 6px; padding: 4px 7px; &:focus { border-color: var(--form-element-primary-color); } &.disabled { opacity: 0.75; } &.error { border-color: var(--form-element-error-color); } } /* Styles when an InputGroup is present */ .input-group { display: flex; align-items: center; border-radius: 6px; position: relative; width: 100%; border: 2px solid var(--form-element-border-color); background: var(--form-element-bg-color); /* Focus style for InputGroup */ &.focused { border-color: var(--form-element-primary-color); } /* Error state for InputGroup */ &.error { border-color: var(--form-element-error-color); } /* Disabled state for InputGroup */ &.disabled { opacity: 0.75; } &:hover { cursor: text; } .input-left-element, .input-right-element { padding: 0 5px; display: flex; align-items: center; justify-content: center; } .input { border: none; flex-grow: 1; border-radius: none; &:focus { border-color: transparent; } &.error { border-color: transparent; } } } ================================================ FILE: frontend/app/element/input.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import clsx from "clsx"; import React, { forwardRef, memo, useImperativeHandle, useRef, useState } from "react"; import "./input.scss"; interface InputGroupProps { children: React.ReactNode; className?: string; } const InputGroup = memo( forwardRef<HTMLDivElement, InputGroupProps>(({ children, className }: InputGroupProps, ref) => { const [isFocused, setIsFocused] = useState(false); const manageFocus = (focused: boolean) => { setIsFocused(focused); }; return ( <div ref={ref} className={clsx("input-group", className, { focused: isFocused, })} > {React.Children.map(children, (child) => { if (React.isValidElement(child)) { return React.cloneElement(child as any, { manageFocus }); } return child; })} </div> ); }) ); interface InputLeftElementProps { children: React.ReactNode; className?: string; } const InputLeftElement = memo(({ children, className }: InputLeftElementProps) => { return <div className={clsx("input-left-element", className)}>{children}</div>; }); interface InputRightElementProps { children: React.ReactNode; className?: string; } const InputRightElement = memo(({ children, className }: InputRightElementProps) => { return <div className={clsx("input-right-element", className)}>{children}</div>; }); interface InputProps { value?: string; className?: string; onChange?: (value: string) => void; onKeyDown?: (event: React.KeyboardEvent<any>) => void; onFocus?: () => void; onBlur?: () => void; placeholder?: string; defaultValue?: string; required?: boolean; maxLength?: number; autoFocus?: boolean; autoSelect?: boolean; disabled?: boolean; isNumber?: boolean; inputRef?: React.RefObject<any>; manageFocus?: (isFocused: boolean) => void; } const Input = memo( forwardRef<HTMLInputElement, InputProps>( ( { value, className, onChange, onKeyDown, onFocus, onBlur, placeholder, defaultValue = "", required, maxLength, autoFocus, autoSelect, disabled, isNumber, manageFocus, }: InputProps, ref ) => { const [internalValue, setInternalValue] = useState(defaultValue); const inputRef = useRef<HTMLInputElement>(null); useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); const handleInputChange = (e: React.ChangeEvent<any>) => { const inputValue = e.target.value; if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) { return; } if (value === undefined) { setInternalValue(inputValue); } onChange?.(inputValue); }; const handleFocus = () => { if (autoSelect) { inputRef.current?.select(); } manageFocus?.(true); onFocus?.(); }; const handleBlur = () => { manageFocus?.(false); onBlur?.(); }; const inputValue = value ?? internalValue; return ( <input className={clsx("input", className, { disabled: disabled, })} ref={inputRef} value={inputValue} onChange={handleInputChange} onKeyDown={onKeyDown} onFocus={handleFocus} onBlur={handleBlur} placeholder={placeholder} maxLength={maxLength} autoFocus={autoFocus} disabled={disabled} /> ); } ) ); export { Input, InputGroup, InputLeftElement, InputRightElement }; export type { InputGroupProps, InputLeftElementProps, InputProps, InputRightElementProps }; ================================================ FILE: frontend/app/element/linkbutton.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .link-button { text-decoration: none; } ================================================ FILE: frontend/app/element/linkbutton.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import clsx from "clsx"; import * as React from "react"; import "./linkbutton.scss"; interface LinkButtonProps { href: string; rel?: string; target?: string; children: React.ReactNode; disabled?: boolean; style?: React.CSSProperties; autoFocus?: boolean; className?: string; termInline?: boolean; title?: string; onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void; } const LinkButton = ({ children, className, ...rest }: LinkButtonProps) => { return ( <a {...rest} className={clsx("button grey solid link-button", className)}> <span className="button-inner">{children}</span> </a> ); }; export { LinkButton }; ================================================ FILE: frontend/app/element/magnify.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .magnify-icon { display: inline-block; width: 15px; height: 15px; svg { #arrow1 { transform: rotate(180deg); transform-origin: calc(29.167% + 4px) calc(70.833% + 4px); // account for path offset in the svg itself } #arrow2 { transform: rotate(-180deg); transform-origin: calc(70.833% + 4px) calc(29.167% + 4px); } #arrow1, #arrow2 { transition: transform 300ms ease-in; transition-delay: 100ms; } } &.enabled { svg { #arrow1, #arrow2 { transform: rotate(0deg); } } } } ================================================ FILE: frontend/app/element/magnify.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import clsx from "clsx"; import MagnifySVG from "../asset/magnify.svg"; import "./magnify.scss"; interface MagnifyIconProps { enabled: boolean; } export function MagnifyIcon({ enabled }: MagnifyIconProps) { return ( <div className={clsx("magnify-icon", { enabled })}> <MagnifySVG /> </div> ); } ================================================ FILE: frontend/app/element/markdown-contentblock-plugin.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { Paragraph, Root, Text } from "mdast"; import { visit } from "unist-util-visit"; import { type MarkdownContentBlockType } from "./markdown-util"; interface ContentBlockPluginOptions { blocks: Map<string, MarkdownContentBlockType>; } export function createContentBlockPlugin(opts: ContentBlockPluginOptions) { const { blocks } = opts; return function transformer(tree: Root) { visit(tree, "paragraph", (node: Paragraph) => { if (!node.children?.length) return; const newChildren = []; for (const child of node.children) { if (child.type !== "text") { newChildren.push(child); continue; } const text = (child as Text).value; let lastIndex = 0; const parts = []; // Find all inline blocks const regex = /!!!(\w+\[.*?\])!!!/g; let match; while ((match = regex.exec(text)) !== null) { // Add text before the match if (match.index > lastIndex) { parts.push({ type: "text", value: text.slice(lastIndex, match.index), }); } const key = match[1]; const block = blocks.get(key); if (block) { parts.push({ type: "waveblock", data: { hName: "waveblock", hProperties: { blockkey: key, }, }, block: block, }); } else { parts.push({ type: "text", value: match[0], }); } lastIndex = match.index + match[0].length; } // Add remaining text if (lastIndex < text.length) { parts.push({ type: "text", value: text.slice(lastIndex), }); } newChildren.push(...parts); } node.children = newChildren; }); }; } ================================================ FILE: frontend/app/element/markdown-util.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getWebServerEndpoint } from "@/util/endpoints"; import { formatRemoteUri } from "@/util/waveutil"; import parseSrcSet from "parse-srcset"; export type MarkdownContentBlockType = { type: string; id: string; content: string; opts?: Record<string, any>; }; const idMatchRe = /^("(?:[^"\\]|\\.)*")/; function formatInlineContentBlock(block: MarkdownContentBlockType): string { return `!!!${block.type}[${block.id}]!!!`; } function parseOptions(str: string): Record<string, any> { const trimmed = str.trim(); if (!trimmed) return null; try { const parsed = JSON.parse(trimmed); // Ensure it's an object (not array or primitive) if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null; } return parsed; } catch { return null; } } function makeMarkdownWaveBlockKey(block: MarkdownContentBlockType): string { return `${block.type}[${block.id}]`; } export function transformBlocks(content: string): { content: string; blocks: Map<string, MarkdownContentBlockType> } { const lines = content.split("\n"); const blocks = new Map(); let currentBlock = null; let currentContent = []; let processedLines = []; for (const line of lines) { // Check for start marker if (line.startsWith("@@@start ")) { // Already in a block? Add as content if (currentBlock) { processedLines.push(line); continue; } // Parse the start line const [, type, rest] = line.slice(9).match(/^(\w+)\s+(.*)/) || []; if (!type || !rest) { // Invalid format - treat as regular content processedLines.push(line); continue; } // Get the ID (everything between first set of quotes) const idMatch = rest.match(idMatchRe); if (!idMatch) { processedLines.push(line); continue; } // Parse options if any exist after the ID const afterId = rest.slice(idMatch[0].length).trim(); const opts = parseOptions(afterId); currentBlock = { type, id: idMatch[1], opts, }; continue; } // Check for end marker if (line.startsWith("@@@end ")) { // If we're not in a block, treat as content if (!currentBlock) { processedLines.push(line); continue; } // Parse the end line const [, type, rest] = line.slice(7).match(/^(\w+)\s+(.*)/) || []; if (!type || !rest) { currentContent.push(line); continue; } // Get the ID const idMatch = rest.match(idMatchRe); if (!idMatch) { currentContent.push(line); continue; } const endId = idMatch[1]; // If this doesn't match our current block, treat as content if (type !== currentBlock.type || endId !== currentBlock.id) { currentContent.push(line); continue; } // Found matching end - store block and add placeholder const key = makeMarkdownWaveBlockKey(currentBlock); blocks.set(key, { type: currentBlock.type, id: currentBlock.id, opts: currentBlock.opts, content: currentContent.join("\n"), }); processedLines.push(formatInlineContentBlock(currentBlock)); currentBlock = null; currentContent = []; continue; } // Regular line - add to current block or processed lines if (currentBlock) { currentContent.push(line); } else { processedLines.push(line); } } // Handle unclosed block - add what we have so far if (currentBlock) { const key = makeMarkdownWaveBlockKey(currentBlock); blocks.set(key, { type: currentBlock.type, id: currentBlock.id, opts: currentBlock.opts, content: currentContent.join("\n"), }); processedLines.push(formatInlineContentBlock(currentBlock)); } return { content: processedLines.join("\n"), blocks: blocks, }; } export const resolveRemoteFile = async (filepath: string, resolveOpts: MarkdownResolveOpts): Promise<string | null> => { if (!filepath || filepath.startsWith("http://") || filepath.startsWith("https://")) { return filepath; } try { const baseDirUri = formatRemoteUri(resolveOpts.baseDir, resolveOpts.connName); const fileInfo = await RpcApi.FileJoinCommand(TabRpcClient, [baseDirUri, filepath]); const remoteUri = formatRemoteUri(fileInfo.path, resolveOpts.connName); // console.log("markdown resolve", resolveOpts, filepath, "=>", baseDirUri, remoteUri); const usp = new URLSearchParams(); usp.set("path", remoteUri); return getWebServerEndpoint() + "/wave/stream-file?" + usp.toString(); } catch (err) { console.warn("Failed to resolve remote file:", filepath, err); return null; } }; export const resolveSrcSet = async (srcSet: string, resolveOpts: MarkdownResolveOpts): Promise<string> => { if (!srcSet) return null; // Parse the srcset const candidates = parseSrcSet(srcSet); // Resolve each URL in the array of candidates const resolvedCandidates = await Promise.all( candidates.map(async (candidate) => { const resolvedUrl = await resolveRemoteFile(candidate.url, resolveOpts); return { ...candidate, url: resolvedUrl, }; }) ); // Reconstruct the srcset string return resolvedCandidates .map((candidate) => { let part = candidate.url; if (candidate.w) part += ` ${candidate.w}w`; if (candidate.h) part += ` ${candidate.h}h`; if (candidate.d) part += ` ${candidate.d}x`; return part; }) .join(", "); }; ================================================ FILE: frontend/app/element/markdown.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 @import url("../../../node_modules/highlight.js/scss/github-dark-dimmed.scss"); .markdown { display: flex; flex-direction: row; overflow: hidden; height: 100%; width: 100%; .content { height: 100%; width: 100%; overflow: scroll; line-height: 1.5; color: var(--main-text-color); font-family: var(--markdown-font-family); font-size: var(--markdown-font-size); overflow-wrap: break-word; &.non-scrollable { overflow: hidden; } *:last-child { margin-bottom: 0 !important; } .heading:not(.heading ~ .heading) { margin-top: 0 !important; } .heading { color: var(--main-text-color); margin-top: 1.143em; margin-bottom: 0.571em; font-weight: semibold; padding-top: 0.429em; &.is-1 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.429em; font-size: 2em; } &.is-2 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.429em; font-size: 1.5em; } &.is-3 { font-size: 1.25em; } &.is-4 { font-size: 1em; } &.is-5 { font-size: 0.875em; } &.is-6 { font-size: 0.85em; } } .paragraph { margin-top: 0; margin-bottom: 10px; } img { border-style: none; max-width: 100%; box-sizing: content-box; &[align="right"] { padding-left: 20px; } &[align="left"] { padding-right: 20px; } } strong { color: var(--main-text-color); } a { color: #32afff; } ul { list-style-type: disc; list-style-position: outside; margin-left: 1em; } ol { list-style-position: outside; margin-left: 1.2em; } blockquote { margin: 0.286em 0.714em; border-radius: 4px; background-color: var(--panel-bg-color); padding: 0.143em 0.286em 0.143em 0.429em; } pre.codeblock { background-color: var(--panel-bg-color); margin: 0.286em 0.714em; padding: 0.4em 0.7em; border-radius: 4px; position: relative; code { line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; overflow: auto; overflow: hidden; background-color: transparent; } .codeblock-actions { visibility: hidden; display: flex; position: absolute; top: 0; right: 0; border-radius: 4px; backdrop-filter: blur(8px); margin: 0.143em; padding: 0.286em; align-items: center; justify-content: flex-end; gap: 0.286em; } &:hover .codeblock-actions { visibility: visible; } } code { color: var(--main-text-color); font: var(--fixed-font); font-size: var(--markdown-fixed-font-size); border-radius: 4px; } pre.selected { outline: 2px solid var(--accent-color); } .waveblock { margin: 1.143em 0; .wave-block-content { display: flex; align-items: center; padding: 0.857em; background-color: var(--highlight-bg-color); border: 1px solid var(--border-color); border-radius: 8px; transition: background-color 0.2s ease; } .wave-block-icon { display: flex; align-items: center; justify-content: center; width: 2.857em; height: 2.857em; background-color: black; border-radius: 8px; margin-right: 0.857em; } .wave-block-icon i { font-size: 1.125em; color: var(--secondary-text-color); } .wave-block-info { display: flex; flex-direction: column; } .wave-block-filename { font-size: 1em; font-weight: 500; color: var(--main-text-color); } .wave-block-size { font-size: 0.857em; color: var(--secondary-text-color); } } } .toc { max-width: 40%; height: 100%; overflow: scroll; border-left: 1px solid var(--border-color); .toc-inner { height: fit-content; position: sticky; top: 0; display: flex; flex-direction: column; gap: 0.357em; text-wrap: wrap; h4 { padding-left: 0.357em; } .toc-item { cursor: pointer; --indent-factor: 1; // The offset in the padding will ensure that when the text in the item wraps, it indents slightly. // The indent factor is set in the React code and denotes the depth of the item in the TOC tree. padding-left: calc((var(--indent-factor) - 1) * 0.714em + 0.357em); text-indent: -0.357em; } } } } ================================================ FILE: frontend/app/element/markdown.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { CopyButton } from "@/app/element/copybutton"; import { createContentBlockPlugin } from "@/app/element/markdown-contentblock-plugin"; import { MarkdownContentBlockType, resolveRemoteFile, resolveSrcSet, transformBlocks, } from "@/app/element/markdown-util"; import remarkMermaidToTag from "@/app/element/remark-mermaid-to-tag"; import { boundNumber, useAtomValueSafe, cn } from "@/util/util"; import clsx from "clsx"; import { Atom } from "jotai"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import { useEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown, { Components } from "react-markdown"; import rehypeHighlight from "rehype-highlight"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import rehypeSlug from "rehype-slug"; import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc"; import remarkGfm from "remark-gfm"; import { openLink } from "../store/global"; import { IconButton } from "./iconbutton"; import "./markdown.scss"; let mermaidInitialized = false; let mermaidInstance: any = null; const initializeMermaid = async () => { if (!mermaidInitialized) { const mermaid = await import("mermaid"); mermaidInstance = mermaid.default; mermaidInstance.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); mermaidInitialized = true; } }; const Link = ({ setFocusedHeading, props, }: { props: React.AnchorHTMLAttributes<HTMLAnchorElement>; setFocusedHeading: (href: string) => void; }) => { const onClick = (e: React.MouseEvent) => { e.preventDefault(); if (props.href.startsWith("#")) { setFocusedHeading(props.href); } else { openLink(props.href); } }; return ( <a href={props.href} onClick={onClick} className="text-accent hover:underline"> {props.children} </a> ); }; const Heading = ({ props, hnum }: { props: React.HTMLAttributes<HTMLHeadingElement>; hnum: number }) => { return ( <div id={props.id} className={clsx("heading", `is-${hnum}`)}> {props.children} </div> ); }; const Mermaid = ({ chart }: { chart: string }) => { const ref = useRef<HTMLDivElement>(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { const renderMermaid = async () => { try { setIsLoading(true); setError(null); await initializeMermaid(); if (!ref.current || !mermaidInstance) { return; } // Normalize the chart text let normalizedChart = chart .replace(/<br\s*\/?>/gi, "\n") // Convert <br/> and <br> to newlines .replace(/\r\n?/g, "\n") // Normalize \r \r\n to \n .replace(/\n+$/, ""); // Remove final newline ref.current.removeAttribute("data-processed"); ref.current.textContent = normalizedChart; // console.log("mermaid", normalizedChart); await mermaidInstance.run({ nodes: [ref.current] }); setIsLoading(false); } catch (err) { console.error("Error rendering mermaid diagram:", err); setError(`Failed to render diagram: ${err.message || err}`); setIsLoading(false); } }; renderMermaid(); }, [chart]); useEffect(() => { if (!ref.current) return; if (error) { ref.current.textContent = `Error: ${error}`; ref.current.className = "mermaid error"; } else if (isLoading) { ref.current.textContent = "Loading diagram..."; ref.current.className = "mermaid"; } else { ref.current.className = "mermaid"; } }, [isLoading, error]); return <div className="mermaid" ref={ref} />; }; const Code = ({ className = "", children }: { className?: string; children: React.ReactNode }) => { if (/\blanguage-mermaid\b/.test(className)) { const text = Array.isArray(children) ? children.join("") : String(children ?? ""); return <Mermaid chart={text} />; } return <code className={className}>{children}</code>; }; type CodeBlockProps = { children: React.ReactNode; onClickExecute?: (cmd: string) => void; }; const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => { const getTextContent = (children: any): string => { if (typeof children === "string") { return children; } else if (Array.isArray(children)) { return children.map(getTextContent).join(""); } else if (children.props && children.props.children) { return getTextContent(children.props.children); } return ""; }; const handleCopy = async (e: React.MouseEvent) => { let textToCopy = getTextContent(children); textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline await navigator.clipboard.writeText(textToCopy); }; const handleExecute = (e: React.MouseEvent) => { let textToCopy = getTextContent(children); textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline if (onClickExecute) { onClickExecute(textToCopy); return; } }; return ( <pre className="codeblock"> {children} <div className="codeblock-actions"> <CopyButton onClick={handleCopy} title="Copy" /> {onClickExecute && ( <IconButton decl={{ elemtype: "iconbutton", icon: "regular@square-terminal", click: handleExecute, }} /> )} </div> </pre> ); }; const MarkdownSource = ({ props, resolveOpts, }: { props: React.HTMLAttributes<HTMLSourceElement> & { srcSet?: string; media?: string; }; resolveOpts: MarkdownResolveOpts; }) => { const [resolvedSrcSet, setResolvedSrcSet] = useState<string>(props.srcSet); const [resolving, setResolving] = useState<boolean>(true); useEffect(() => { const resolvePath = async () => { const resolved = await resolveSrcSet(props.srcSet, resolveOpts); setResolvedSrcSet(resolved); setResolving(false); }; resolvePath(); }, [props.srcSet]); if (resolving) { return null; } return <source srcSet={resolvedSrcSet} media={props.media} />; }; interface WaveBlockProps { blockkey: string; blockmap: Map<string, MarkdownContentBlockType>; } function WaveBlock(props: WaveBlockProps) { const { blockkey, blockmap } = props; const block = blockmap.get(blockkey); if (block == null) { return null; } const sizeInKB = Math.round((block.content.length / 1024) * 10) / 10; const displayName = block.id.replace(/^"|"$/g, ""); return ( <div className="waveblock"> <div className="wave-block-content"> <div className="wave-block-icon"> <i className="fas fa-file-code"></i> </div> <div className="wave-block-info"> <span className="wave-block-filename">{displayName}</span> <span className="wave-block-size">{sizeInKB} KB</span> </div> </div> </div> ); } const MarkdownImg = ({ props, resolveOpts, }: { props: React.ImgHTMLAttributes<HTMLImageElement>; resolveOpts: MarkdownResolveOpts; }) => { const [resolvedSrc, setResolvedSrc] = useState<string>(props.src); const [resolvedSrcSet, setResolvedSrcSet] = useState<string>(props.srcSet); const [resolvedStr, setResolvedStr] = useState<string>(null); const [resolving, setResolving] = useState<boolean>(true); useEffect(() => { if (props.src.startsWith("data:image/")) { setResolving(false); setResolvedSrc(props.src); setResolvedStr(null); return; } if (resolveOpts == null) { setResolving(false); setResolvedSrc(null); setResolvedStr(`[img:${props.src}]`); return; } const resolveFn = async () => { const [resolvedSrc, resolvedSrcSet] = await Promise.all([ resolveRemoteFile(props.src, resolveOpts), resolveSrcSet(props.srcSet, resolveOpts), ]); setResolvedSrc(resolvedSrc); setResolvedSrcSet(resolvedSrcSet); setResolvedStr(null); setResolving(false); }; resolveFn(); }, [props.src, props.srcSet]); if (resolving) { return null; } if (resolvedStr != null) { return <span>{resolvedStr}</span>; } if (resolvedSrc != null) { return <img {...props} src={resolvedSrc} srcSet={resolvedSrcSet} />; } return <span>[img]</span>; }; type MarkdownProps = { text?: string; textAtom?: Atom<string> | Atom<Promise<string>>; showTocAtom?: Atom<boolean>; style?: React.CSSProperties; className?: string; contentClassName?: string; onClickExecute?: (cmd: string) => void; resolveOpts?: MarkdownResolveOpts; scrollable?: boolean; rehype?: boolean; fontSizeOverride?: number; fixedFontSizeOverride?: number; }; const Markdown = ({ text, textAtom, showTocAtom, style, className, contentClassName, resolveOpts, fontSizeOverride, fixedFontSizeOverride, scrollable = true, rehype = true, onClickExecute, }: MarkdownProps) => { const textAtomValue = useAtomValueSafe<string>(textAtom); const tocRef = useRef<TocItem[]>([]); const showToc = useAtomValueSafe(showTocAtom) ?? false; const contentsOsRef = useRef<OverlayScrollbarsComponentRef>(null); const [focusedHeading, setFocusedHeading] = useState<string>(null); // Ensure uniqueness of ids between MD preview instances. const [idPrefix] = useState<string>(crypto.randomUUID()); text = textAtomValue ?? text ?? ""; const transformedOutput = transformBlocks(text); const transformedText = transformedOutput.content; const contentBlocksMap = transformedOutput.blocks; useEffect(() => { if (focusedHeading && contentsOsRef.current && contentsOsRef.current.osInstance()) { const { viewport } = contentsOsRef.current.osInstance().elements(); const heading = document.getElementById(idPrefix + focusedHeading.slice(1)); if (heading) { const headingBoundingRect = heading.getBoundingClientRect(); const viewportBoundingRect = viewport.getBoundingClientRect(); const headingTop = headingBoundingRect.top - viewportBoundingRect.top; viewport.scrollBy({ top: headingTop }); } } }, [focusedHeading]); const markdownComponents: Partial<Components> = { a: (props: React.HTMLAttributes<HTMLAnchorElement>) => ( <Link props={props} setFocusedHeading={setFocusedHeading} /> ), p: (props: React.HTMLAttributes<HTMLParagraphElement>) => <div className="paragraph" {...props} />, h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={1} />, h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={2} />, h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={3} />, h4: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={4} />, h5: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={5} />, h6: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={6} />, img: (props: React.HTMLAttributes<HTMLImageElement>) => <MarkdownImg props={props} resolveOpts={resolveOpts} />, source: (props: React.HTMLAttributes<HTMLSourceElement>) => ( <MarkdownSource props={props} resolveOpts={resolveOpts} /> ), code: Code, pre: (props: React.HTMLAttributes<HTMLPreElement>) => ( <CodeBlock children={props.children} onClickExecute={onClickExecute} /> ), }; markdownComponents["waveblock"] = (props: any) => <WaveBlock {...props} blockmap={contentBlocksMap} />; markdownComponents["mermaidblock"] = (props: any) => { const getTextContent = (children: any): string => { if (typeof children === "string") { return children; } else if (Array.isArray(children)) { return children.map(getTextContent).join(""); } else if (children && typeof children === "object" && children.props && children.props.children) { return getTextContent(children.props.children); } return String(children || ""); }; const chartText = getTextContent(props.children); return <Mermaid chart={chartText} />; }; const toc = useMemo(() => { if (showToc) { if (tocRef.current.length > 0) { return tocRef.current.map((item) => { return ( <a key={item.href} className="toc-item text-accent hover:underline" style={{ "--indent-factor": item.depth } as React.CSSProperties} onClick={() => setFocusedHeading(item.href)} > {item.value} </a> ); }); } else { return ( <div className="toc-item toc-empty text-secondary" style={{ "--indent-factor": 2 } as React.CSSProperties} > No sub-headings found </div> ); } } }, [showToc, tocRef]); let rehypePlugins = null; if (rehype) { rehypePlugins = [ rehypeRaw, rehypeHighlight, () => rehypeSanitize({ ...defaultSchema, attributes: { ...defaultSchema.attributes, span: [ ...(defaultSchema.attributes?.span || []), // Allow all class names starting with `hljs-`. ["className", /^hljs-./], ["srcset"], ["media"], ["type"], // Alternatively, to allow only certain class names: // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] ], waveblock: [["blockkey"]], }, tagNames: [ ...(defaultSchema.tagNames || []), "span", "waveblock", "picture", "source", "mermaidblock", ], }), () => rehypeSlug({ prefix: idPrefix }), ]; } const remarkPlugins: any = [ remarkMermaidToTag, remarkGfm, [RemarkFlexibleToc, { tocRef: tocRef.current }], [createContentBlockPlugin, { blocks: contentBlocksMap }], ]; const ScrollableMarkdown = () => { return ( <OverlayScrollbarsComponent ref={contentsOsRef} className={cn("content", contentClassName)} options={{ scrollbars: { autoHide: "leave" } }} > <ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents} > {transformedText} </ReactMarkdown> </OverlayScrollbarsComponent> ); }; const NonScrollableMarkdown = () => { return ( <div className={cn("content non-scrollable", contentClassName)}> <ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents} > {transformedText} </ReactMarkdown> </div> ); }; const mergedStyle = { ...style }; if (fontSizeOverride != null) { mergedStyle["--markdown-font-size"] = `${boundNumber(fontSizeOverride, 6, 64)}px`; } if (fixedFontSizeOverride != null) { mergedStyle["--markdown-fixed-font-size"] = `${boundNumber(fixedFontSizeOverride, 6, 64)}px`; } return ( <div className={clsx("markdown", className)} style={mergedStyle}> {scrollable ? <ScrollableMarkdown /> : <NonScrollableMarkdown />} {toc && ( <OverlayScrollbarsComponent className="toc mt-1" options={{ scrollbars: { autoHide: "leave" } }}> <div className="toc-inner"> <h4 className="font-bold">Table of Contents</h4> {toc} </div> </OverlayScrollbarsComponent> )} </div> ); }; export { Markdown }; ================================================ FILE: frontend/app/element/menubutton.scss ================================================ .menubutton { overflow: hidden; .menu-anchor { width: 100%; .wave-button { width: 100%; div { max-width: 100%; text-overflow: ellipsis; overflow: hidden; flex-shrink: 1; } } } } ================================================ FILE: frontend/app/element/menubutton.tsx ================================================ import clsx from "clsx"; import { memo, useState } from "react"; import { Button } from "./button"; import { FlyoutMenu } from "./flyoutmenu"; import "./menubutton.scss"; const MenuButtonComponent = ({ items, className, text, title }: MenuButtonProps) => { const [isOpen, setIsOpen] = useState(false); return ( <div className={clsx("menubutton", className)}> <FlyoutMenu items={items} onOpenChange={setIsOpen}> <Button className="grey rounded-[3px] py-[2px] px-[2px]" style={{ borderColor: isOpen ? "var(--accent-color)" : "transparent" }} title={title} > <div>{text}</div> <i className="fa-sharp fa-solid fa-angle-down"></i> </Button> </FlyoutMenu> </div> ); }; export const MenuButton = memo(MenuButtonComponent) as typeof MenuButtonComponent; ================================================ FILE: frontend/app/element/modal.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .modal-container { position: absolute; top: 0; left: 0; width: 100vw; height: 100%; z-index: var(--zindex-elem-modal); background-color: rgba(21, 23, 21, 0.7); .modal { display: flex; flex-direction: column; border-radius: 10px; padding: 0; width: 80%; margin-top: 25vh; margin-left: auto; margin-right: auto; background: var(--main-bg-color); border: 1px solid var(--border-color); .modal-header { display: flex; flex-direction: column; padding: 20px 20px 10px; border-bottom: 1px solid var(--border-color); .modal-title { margin: 0 0 5px; color: var(--main-text-color); font-size: var(--title-font-size); } p { margin: 0; font-size: 0.8rem; color: var(--secondary-text-color); } } .modal-content { padding: 20px; overflow: auto; } .modal-footer { display: flex; flex-direction: row; justify-content: flex-end; padding: 15px 20px; gap: 20px; } } } ================================================ FILE: frontend/app/element/modal.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/element/button"; import React from "react"; import "./modal.scss"; interface ModalProps { id?: string; children: React.ReactNode; onClickOut: () => void; } function Modal({ children, onClickOut, id = "modal", ...otherProps }: ModalProps) { const handleOutsideClick = (e: React.SyntheticEvent<HTMLDivElement>) => { if (typeof onClickOut === "function" && (e.target as Element).className === "modal-container") { onClickOut(); } }; return ( <div className="modal-container" onClick={handleOutsideClick}> <dialog {...otherProps} id={id} className="modal"> {children} </dialog> </div> ); } interface ModalContentProps { children: React.ReactNode; } function ModalContent({ children }: ModalContentProps) { return <div className="modal-content">{children}</div>; } interface ModalHeaderProps { title: React.ReactNode; description?: string; } function ModalHeader({ title, description }: ModalHeaderProps) { return ( <header className="modal-header"> {typeof title === "string" ? <h3 className="modal-title">{title}</h3> : title} {description && <p>{description}</p>} </header> ); } interface ModalFooterProps { children: React.ReactNode; } function ModalFooter({ children }: ModalFooterProps) { return <footer className="modal-footer">{children}</footer>; } interface WaveModalProps { title: string; description?: string; id?: string; onSubmit: () => void; onCancel: () => void; buttonLabel?: string; children: React.ReactNode; } function WaveModal({ title, description, onSubmit, onCancel, buttonLabel = "Ok", children }: WaveModalProps) { return ( <Modal onClickOut={onCancel}> <ModalHeader title={title} description={description} /> <ModalContent>{children}</ModalContent> <ModalFooter> <Button onClick={onSubmit}>{buttonLabel}</Button> </ModalFooter> </Modal> ); } export { WaveModal }; ================================================ FILE: frontend/app/element/multilineinput.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .multiline-input { flex-grow: 1; box-shadow: none; box-sizing: border-box; background-color: transparent; resize: none; overflow-y: auto; line-height: 1.5; color: var(--form-element-text-color); vertical-align: top; height: auto; padding: 0; border: 1px solid var(--form-element-border-color); padding: 5px; border-radius: 4px; min-height: 26px; &:focus-visible { outline: none; } } ================================================ FILE: frontend/app/element/multilineinput.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import clsx from "clsx"; import React, { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from "react"; import "./multilineinput.scss"; interface MultiLineInputProps { value?: string; className?: string; onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; onFocus?: () => void; onBlur?: () => void; placeholder?: string; defaultValue?: string; maxLength?: number; autoFocus?: boolean; disabled?: boolean; rows?: number; maxRows?: number; manageFocus?: (isFocused: boolean) => void; } const MultiLineInput = memo( forwardRef<HTMLTextAreaElement, MultiLineInputProps>( ( { value, className, onChange, onKeyDown, onFocus, onBlur, placeholder, defaultValue = "", maxLength, autoFocus, disabled, rows = 1, maxRows = 5, manageFocus, }: MultiLineInputProps, ref ) => { const textareaRef = useRef<HTMLTextAreaElement>(null); const [internalValue, setInternalValue] = useState(defaultValue); const [lineHeight, setLineHeight] = useState(24); // Default line height fallback of 24px const [paddingTop, setPaddingTop] = useState(0); const [paddingBottom, setPaddingBottom] = useState(0); useImperativeHandle(ref, () => textareaRef.current as HTMLTextAreaElement); // Function to count the number of lines in the textarea value const countLines = (text: string) => { return text.split("\n").length; }; const adjustTextareaHeight = () => { if (textareaRef.current) { textareaRef.current.style.height = "auto"; // Reset height to auto first const maxHeight = maxRows * lineHeight + paddingTop + paddingBottom; // Max height based on maxRows const currentLines = countLines(textareaRef.current.value); // Count the number of lines const newHeight = Math.min(textareaRef.current.scrollHeight, maxHeight); // Calculate new height // If the number of lines is less than or equal to maxRows, set height accordingly const calculatedHeight = currentLines <= maxRows ? `${lineHeight * currentLines + paddingTop + paddingBottom}px` : `${newHeight}px`; textareaRef.current.style.height = calculatedHeight; } }; const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { setInternalValue(e.target.value); onChange?.(e); // Adjust the height of the textarea after text change adjustTextareaHeight(); }; const handleFocus = () => { manageFocus?.(true); onFocus?.(); }; const handleBlur = () => { manageFocus?.(false); onBlur?.(); }; useEffect(() => { if (textareaRef.current) { const computedStyle = window.getComputedStyle(textareaRef.current); const detectedLineHeight = parseFloat(computedStyle.lineHeight); const detectedPaddingTop = parseFloat(computedStyle.paddingTop); const detectedPaddingBottom = parseFloat(computedStyle.paddingBottom); setLineHeight(detectedLineHeight); setPaddingTop(detectedPaddingTop); setPaddingBottom(detectedPaddingBottom); } }, [textareaRef]); useEffect(() => { adjustTextareaHeight(); }, [value, maxRows, lineHeight, paddingTop, paddingBottom]); const inputValue = value ?? internalValue; return ( <textarea className={clsx("multiline-input", className)} ref={textareaRef} value={inputValue} onChange={handleInputChange} onKeyDown={onKeyDown} onFocus={handleFocus} onBlur={handleBlur} placeholder={placeholder} maxLength={maxLength} autoFocus={autoFocus} disabled={disabled} rows={rows} style={{ overflowY: textareaRef.current && textareaRef.current.scrollHeight > maxRows * lineHeight + paddingTop + paddingBottom ? "auto" : "hidden", }} /> ); } ) ); export { MultiLineInput }; export type { MultiLineInputProps }; ================================================ FILE: frontend/app/element/popover.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .popover-content { min-width: 100px; min-height: 150px; position: absolute; z-index: 1000; // TODO: put this in theme.scss display: flex; padding: 2px; gap: 1px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.15); background: #212121; box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3); } ================================================ FILE: frontend/app/element/popover.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/element/button"; import { autoUpdate, FloatingPortal, Middleware, offset as offsetMiddleware, useClick, useDismiss, useFloating, useInteractions, type OffsetOptions, type Placement, } from "@floating-ui/react"; import clsx from "clsx"; import { Children, cloneElement, forwardRef, isValidElement, JSXElementConstructor, memo, ReactElement, ReactNode, useState, } from "react"; import "./popover.scss"; interface PopoverProps { children: ReactNode; className?: string; placement?: Placement; offset?: OffsetOptions; onDismiss?: () => void; middleware?: Middleware[]; } const isPopoverButton = ( element: ReactElement ): element is ReactElement<PopoverButtonProps, JSXElementConstructor<PopoverButtonProps>> => { return element.type === PopoverButton; }; const isPopoverContent = ( element: ReactElement ): element is ReactElement<PopoverContentProps, JSXElementConstructor<PopoverContentProps>> => { return element.type === PopoverContent; }; const Popover = memo( forwardRef<HTMLDivElement, PopoverProps>( ({ children, className, placement = "bottom-start", offset = 3, onDismiss, middleware }, ref) => { const [isOpen, setIsOpen] = useState(false); const handleOpenChange = (open: boolean) => { setIsOpen(open); if (!open && onDismiss) { onDismiss(); } }; if (offset === undefined) { offset = 3; } middleware ??= []; middleware.push(offsetMiddleware(offset)); const { refs, floatingStyles, context } = useFloating({ placement, open: isOpen, onOpenChange: handleOpenChange, middleware: middleware, whileElementsMounted: autoUpdate, }); const click = useClick(context); const dismiss = useDismiss(context); const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]); const renderChildren = Children.map(children, (child) => { if (isValidElement(child)) { if (isPopoverButton(child)) { return cloneElement(child as any, { isActive: isOpen, ref: refs.setReference, getReferenceProps, // Do not overwrite onClick }); } if (isPopoverContent(child)) { return isOpen ? cloneElement(child as any, { ref: refs.setFloating, style: floatingStyles, getFloatingProps, }) : null; } } return child; }); return ( <div ref={ref} className={clsx("popover", className)}> {renderChildren} </div> ); } ) ); Popover.displayName = "Popover"; interface PopoverButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { isActive?: boolean; children: React.ReactNode; getReferenceProps?: () => any; as?: keyof React.JSX.IntrinsicElements | React.ComponentType<any>; } const PopoverButton = forwardRef<HTMLButtonElement | HTMLDivElement, PopoverButtonProps>( ( { isActive, children, onClick: userOnClick, // Destructured from props getReferenceProps, className, as: Component = "button", ...props // The rest of the props, without onClick }, ref ) => { const referenceProps = getReferenceProps?.() || {}; const popoverOnClick = referenceProps.onClick; // Remove onClick from referenceProps to prevent it from overwriting our combinedOnClick const { onClick: refOnClick, ...restReferenceProps } = referenceProps; const combinedOnClick = (event: React.MouseEvent) => { if (userOnClick) { userOnClick(event as any); // Our custom onClick logic } if (popoverOnClick) { popoverOnClick(event); // Popover's onClick logic } }; return ( <Button ref={ref} className={clsx("popover-button", className, { "is-active": isActive })} {...props} // Spread the rest of the props {...restReferenceProps} // Spread referenceProps without onClick onClick={combinedOnClick} // Assign combined onClick after spreading > {children} </Button> ); } ); interface PopoverContentProps extends React.HTMLAttributes<HTMLDivElement> { children: React.ReactNode; getFloatingProps?: () => any; } const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>( ({ children, className, getFloatingProps, style, ...props }, ref) => { return ( <FloatingPortal> <div ref={ref} className={clsx("popover-content", className)} style={style} {...getFloatingProps?.()} {...props} > {children} </div> </FloatingPortal> ); } ); Popover.displayName = "Popover"; PopoverButton.displayName = "PopoverButton"; PopoverContent.displayName = "PopoverContent"; export { Popover, PopoverButton, PopoverContent }; export type { PopoverButtonProps, PopoverContentProps }; ================================================ FILE: frontend/app/element/progressbar.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .progress-bar-container { width: 100%; display: flex; align-items: center; justify-content: space-between; --progress-bar-radius: 9px; .outer { position: relative; border: 1px solid rgb(from var(--main-text-color) r g b / 15%); border-radius: var(--progress-bar-radius); background-color: var(--main-bg-color); flex-grow: 1; height: 10px; .progress-bar-fill { position: absolute; top: 0; left: 0; height: 100%; transition: width 0.3s ease-in-out; background-color: var(--accent-color); border-radius: var(--progress-bar-radius); width: 100%; } } .progress-bar-label { width: 40px; flex-shrink: 0; font-size: 0.9rem; color: var(--main-text-color); font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; text-align: right; } } ================================================ FILE: frontend/app/element/progressbar.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { boundNumber } from "@/util/util"; import "./progressbar.scss"; type ProgressBarProps = { progress: number; label?: string; }; const ProgressBar = ({ progress, label = "Progress" }: ProgressBarProps) => { const progressWidth = boundNumber(progress, 0, 100); return ( <div className="progress-bar-container" role="progressbar" aria-valuenow={progressWidth} aria-valuemin={0} aria-valuemax={100} aria-label={label} > <div className="outer"> <div className="progress-bar-fill" style={{ width: `${progressWidth}%` }}></div> </div> <span className="progress-bar-label">{progressWidth}%</span> </div> ); }; export { ProgressBar }; ================================================ FILE: frontend/app/element/quickelems.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .centered-div { display: flex; flex-direction: row; align-items: center; justify-content: center; width: 100%; height: 100%; overflow: hidden; } ================================================ FILE: frontend/app/element/quickelems.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import React from "react"; import "./quickelems.scss"; function CenteredLoadingDiv() { return <CenteredDiv>loading...</CenteredDiv>; } function CenteredDiv({ children }: { children: React.ReactNode }) { return ( <div className="centered-div"> <div>{children}</div> </div> ); } export { CenteredDiv, CenteredLoadingDiv }; ================================================ FILE: frontend/app/element/quicktips.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { MagnifyIcon } from "@/app/element/magnify"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { cn } from "@/util/util"; const KeyCap = ({ children }: { children: React.ReactNode }) => { return ( <div className="inline-block px-2 py-1 mx-[1px] font-mono text-[0.85em] text-foreground bg-highlightbg rounded-[3px] border border-gray-700 whitespace-nowrap"> {children} </div> ); }; const IconBox = ({ children, variant = "accent" }: { children: React.ReactNode; variant?: "accent" | "secondary" }) => { const colorClasses = variant === "secondary" ? "text-secondary bg-white/5 border-white/10 [&_svg]:fill-secondary [&_svg_#arrow1]:fill-primary [&_svg_#arrow2]:fill-primary" : "text-accent-400 bg-accent-400/10 border-accent-400/20 [&_svg]:fill-accent-400 [&_svg_#arrow1]:fill-accent-400 [&_svg_#arrow2]:fill-accent-400"; return ( <div className={cn( "text-[20px] min-w-[32px] h-[32px] flex items-center justify-center rounded-md border [&_svg]:h-[16px]", colorClasses )} > {children} </div> ); }; const KeyBinding = ({ keyDecl }: { keyDecl: string }) => { const chordParts = keyDecl.split("+"); const chordElems: React.ReactNode[] = []; for (let chordIdx = 0; chordIdx < chordParts.length; chordIdx++) { const parts = chordParts[chordIdx].trim().split(":"); const elems: React.ReactNode[] = []; for (let part of parts) { if (part === "Cmd") { if (PLATFORM === PlatformMacOS) { elems.push(<KeyCap key={`${chordIdx}-cmd`}>⌘ Cmd</KeyCap>); } else { elems.push(<KeyCap key={`${chordIdx}-alt`}>Alt</KeyCap>); } continue; } if (part == "Ctrl") { elems.push(<KeyCap key={`${chordIdx}-ctrl`}>^ Ctrl</KeyCap>); continue; } if (part == "Shift") { elems.push(<KeyCap key={`${chordIdx}-shift`}>⇧ Shift</KeyCap>); continue; } if (part == "Arrows") { elems.push(<KeyCap key={`${chordIdx}-arrows1`}>←</KeyCap>); elems.push(<KeyCap key={`${chordIdx}-arrows2`}>→</KeyCap>); elems.push(<KeyCap key={`${chordIdx}-arrows3`}>↑</KeyCap>); elems.push(<KeyCap key={`${chordIdx}-arrows4`}>↓</KeyCap>); continue; } if (part == "Digit") { elems.push(<KeyCap key={`${chordIdx}-digit`}>Number (1-9)</KeyCap>); continue; } if (part == "[" || part == "]") { elems.push(<KeyCap key={`${chordIdx}-${part}`}>{part}</KeyCap>); continue; } elems.push(<KeyCap key={`${chordIdx}-${part}`}>{part.toUpperCase()}</KeyCap>); } chordElems.push( <div key={`chord-${chordIdx}`} className="flex flex-row items-center gap-1"> {elems} </div> ); if (chordIdx < chordParts.length - 1) { chordElems.push( <span key={`plus-${chordIdx}`} className="text-secondary mx-1"> + </span> ); } } return <div className="flex flex-row items-center">{chordElems}</div>; }; const QuickTips = () => { return ( <div className="flex flex-col w-full gap-6 @container"> <div className="flex flex-col gap-4 p-5 bg-gradient-to-br from-highlightbg/30 to-transparent hover:from-accent-400/5 rounded-lg border border-white/10 hover:border-accent-400/20 transition-all duration-300"> <div className="flex items-center gap-2 text-xl font-bold"> <div className="w-1 h-6 bg-accent-400 rounded-full"></div> <span className="text-foreground">Header Icons</span> </div> <div className="grid grid-cols-1 @lg:grid-cols-2 gap-3"> <div className="flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors"> <IconBox variant="secondary"> <MagnifyIcon enabled={false} /> </IconBox> <div className="flex flex-col gap-0.5 flex-1"> <span className="text-[15px]">Magnify a Block</span> <KeyBinding keyDecl="Cmd:m" /> </div> </div> <div className="flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors"> <IconBox variant="secondary"> <i className="fa-solid fa-sharp fa-laptop fa-fw" /> </IconBox> <div className="flex flex-col gap-0.5 flex-1"> <span className="text-[15px]">Connect to a remote server</span> <KeyBinding keyDecl="Cmd:g" /> </div> </div> <div className="flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors"> <IconBox variant="secondary"> <i className="fa-solid fa-sharp fa-cog fa-fw" /> </IconBox> <span className="text-[15px]">Block Settings</span> </div> <div className="flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors"> <IconBox variant="secondary"> <i className="fa-solid fa-sharp fa-xmark-large fa-fw" /> </IconBox> <div className="flex flex-col gap-0.5 flex-1"> <span className="text-[15px]">Close Block</span> <KeyBinding keyDecl="Cmd:w" /> </div> </div> </div> </div> <div className="flex flex-col gap-4 p-5 bg-gradient-to-br from-highlightbg/30 to-transparent hover:from-accent-400/5 rounded-lg border border-white/10 hover:border-accent-400/20 transition-all duration-300"> <div className="flex items-center gap-2 text-xl font-bold"> <div className="w-1 h-6 bg-accent-400 rounded-full"></div> <span className="text-foreground">Important Keybindings</span> </div> <div className="grid grid-cols-1 @lg:grid-cols-2 gap-x-5 gap-y-6"> <div className="flex flex-col gap-1.5"> <div className="text-sm text-accent-400 font-semibold uppercase tracking-wide mb-1"> Main Keybindings </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">New Tab</span> <KeyBinding keyDecl="Cmd:t" /> </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">New Terminal Block</span> <KeyBinding keyDecl="Cmd:n" /> </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">Open Wave AI Panel</span> <KeyBinding keyDecl="Cmd:Shift:a" /> </div> </div> <div className="flex flex-col gap-1.5"> <div className="text-sm text-accent-400 font-semibold uppercase tracking-wide mb-1"> Tab Switching ({PLATFORM === PlatformMacOS ? "Cmd" : "Alt"}) </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">Switch To Nth Tab</span> <KeyBinding keyDecl="Cmd:Digit" /> </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">Previous Tab</span> <KeyBinding keyDecl="Cmd:[" /> </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">Next Tab</span> <KeyBinding keyDecl="Cmd:]" /> </div> </div> <div className="flex flex-col gap-1.5"> <div className="text-sm text-accent-400 font-semibold uppercase tracking-wide mb-1"> Block Navigation (Ctrl-Shift) </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">Navigate Between Blocks</span> <KeyBinding keyDecl="Ctrl:Shift:Arrows" /> </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">Focus Nth Block</span> <KeyBinding keyDecl="Ctrl:Shift:Digit" /> </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">Focus Wave AI</span> <KeyBinding keyDecl="Ctrl:Shift:0" /> </div> </div> <div className="flex flex-col gap-1.5"> <div className="text-sm text-accent-400 font-semibold uppercase tracking-wide mb-1"> Split Blocks </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">Split Right</span> <KeyBinding keyDecl="Cmd:d" /> </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">Split Below</span> <KeyBinding keyDecl="Cmd:Shift:d" /> </div> <div className="flex flex-col gap-0.5 p-2 rounded-md hover:bg-white/5 transition-colors"> <span className="text-[15px]">Split in Direction</span> <KeyBinding keyDecl="Ctrl:Shift:s + Arrows" /> </div> </div> </div> </div> <div className="flex flex-col gap-4 p-5 bg-gradient-to-br from-highlightbg/30 to-transparent hover:from-accent-400/5 rounded-lg border border-white/10 hover:border-accent-400/20 transition-all duration-300"> <div className="flex items-center gap-2 text-xl font-bold"> <div className="w-1 h-6 bg-accent-400 rounded-full"></div> <span className="text-foreground">wsh commands</span> </div> <div className="grid grid-cols-1 @md:grid-cols-2 gap-4"> <div className="flex flex-col gap-2 p-4 bg-black/20 rounded-lg border border-accent-400/30 hover:border-accent-400/50 transition-colors"> <code className="font-mono text-sm"> <span className="text-secondary">> </span> <span className="text-accent-400 font-semibold">wsh view</span> <span className="text-muted"> [filename|url]</span> </code> <div className="text-secondary text-sm mt-1">Preview files, directories, or web URLs</div> </div> <div className="flex flex-col gap-2 p-4 bg-black/20 rounded-lg border border-accent-400/30 hover:border-accent-400/50 transition-colors"> <code className="font-mono text-sm"> <span className="text-secondary">> </span> <span className="text-accent-400 font-semibold">wsh edit</span> <span className="text-muted"> [filename]</span> </code> <div className="text-secondary text-sm mt-1">Edit config and code files</div> </div> </div> </div> <div className="flex flex-col gap-4 p-5 bg-gradient-to-br from-highlightbg/30 to-transparent hover:from-accent-400/5 rounded-lg border border-white/10 hover:border-accent-400/20 transition-all duration-300"> <div className="flex items-center gap-2 text-xl font-bold"> <div className="w-1 h-6 bg-accent-400 rounded-full"></div> <span className="text-foreground">More Tips</span> </div> <div className="flex flex-col gap-2"> <div className="flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors"> <IconBox variant="secondary"> <i className="fa-solid fa-sharp fa-computer-mouse fa-fw" /> </IconBox> <span> <b>Tabs</b> - Right click any tab to change backgrounds or rename. </span> </div> <div className="flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors"> <IconBox variant="secondary"> <i className="fa-solid fa-sharp fa-cog fa-fw" /> </IconBox> <span> <b>Web View</b> - Click the gear in the web view to set your homepage </span> </div> <div className="flex items-center gap-3 p-2 rounded-md hover:bg-white/5 transition-colors"> <IconBox variant="secondary"> <i className="fa-solid fa-sharp fa-cog fa-fw" /> </IconBox> <span> <b>Terminal</b> - Click the gear in the terminal to set your terminal theme and font size </span> </div> </div> </div> <div className="flex flex-col gap-4 p-5 bg-gradient-to-br from-highlightbg/30 to-transparent hover:from-accent-400/5 rounded-lg border border-white/10 hover:border-accent-400/20 transition-all duration-300"> <div className="flex items-center gap-2 text-xl font-bold"> <div className="w-1 h-6 bg-accent-400 rounded-full"></div> <span className="text-foreground">Need More Help?</span> </div> <div className="grid grid-cols-1 @sm:grid-cols-2 gap-2"> <div className="flex items-center gap-3 p-3 rounded-md bg-black/20 hover:bg-black/30 transition-colors cursor-pointer"> <IconBox variant="secondary"> <i className="fa-brands fa-discord fa-fw" /> </IconBox> <a target="_blank" href="https://discord.gg/XfvZ334gwU" rel="noopener" className="hover:text-accent-400 hover:underline transition-colors font-medium" > Join Our Discord </a> </div> <div className="flex items-center gap-3 p-3 rounded-md bg-black/20 hover:bg-black/30 transition-colors cursor-pointer"> <IconBox variant="secondary"> <i className="fa-solid fa-sharp fa-sliders fa-fw" /> </IconBox> <a target="_blank" href="https://docs.waveterm.dev/config" rel="noopener" className="hover:text-accent-400 hover:underline transition-colors font-medium" > Configuration Options </a> </div> <div className="flex items-center gap-3 p-3 rounded-md bg-black/20 hover:bg-black/30 transition-colors cursor-pointer"> <IconBox variant="secondary"> <i className="fa-solid fa-sharp fa-keyboard fa-fw" /> </IconBox> <a target="_blank" href="https://docs.waveterm.dev/keybindings" rel="noopener" className="hover:text-accent-400 hover:underline transition-colors font-medium" > All Keybindings </a> </div> <div className="flex items-center gap-3 p-3 rounded-md bg-black/20 hover:bg-black/30 transition-colors cursor-pointer"> <IconBox variant="secondary"> <i className="fa-solid fa-sharp fa-book fa-fw" /> </IconBox> <a target="_blank" href="https://docs.waveterm.dev" rel="noopener" className="hover:text-accent-400 hover:underline transition-colors font-medium" > Full Documentation </a> </div> </div> </div> </div> ); }; export { KeyBinding, QuickTips }; ================================================ FILE: frontend/app/element/remark-mermaid-to-tag.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { Code, Content, Html, Root } from "mdast"; import type { Plugin } from "unified"; import type { Parent } from "unist"; import { SKIP, visit } from "unist-util-visit"; import type { VFile } from "vfile"; const escapeHTML = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); const remarkMermaidToTag: Plugin<[], Root> = function () { return (tree: Root, _file: VFile) => { visit(tree, "code", (node: Code, index: number | null, parent: Parent | null) => { if (!parent || index === null) return; if ((node.lang ?? "").toLowerCase() !== "mermaid") return; const htmlNode: Html = { type: "html", value: `<mermaidblock>${escapeHTML(node.value ?? "")}</mermaidblock>`, }; (parent.children as Content[])[index] = htmlNode as Content; return SKIP; }); }; }; export default remarkMermaidToTag; ================================================ FILE: frontend/app/element/search.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .search-container { display: flex; flex-direction: row; background-color: var(--modal-bg-color); border: 1px solid var(--accent-color); border-radius: var(--modal-border-radius); box-shadow: var(--modal-box-shadow); color: var(--main-text-color); padding: 5px 5px 5px 10px; gap: 5px; width: 50%; max-width: 300px; min-width: 200px; input { flex: 1 1 auto; border: none; font-size: 14px; height: 100%; padding: 0; border-radius: 0; } .search-results { font-size: 12px; margin: auto 0; color: var(--secondary-text-color); &.hidden { display: none; } } .right-buttons, .additional-buttons { display: flex; border-left: 1px solid var(--modal-border-color); } .right-buttons { gap: 5px; padding-left: 4px; button { font-size: 12px; } } .additional-buttons { gap: 1px; padding-left: 5px; button { font-size: 14px; } } } ================================================ FILE: frontend/app/element/search.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { autoUpdate, FloatingPortal, Middleware, offset, useFloating } from "@floating-ui/react"; import clsx from "clsx"; import { atom, useAtom, WritableAtom } from "jotai"; import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { IconButton, ToggleIconButton } from "./iconbutton"; import { Input } from "./input"; import "./search.scss"; type SearchProps = SearchAtoms & { anchorRef?: React.RefObject<HTMLElement>; offsetX?: number; offsetY?: number; onSearch?: (search: string) => void; onNext?: () => void; onPrev?: () => void; }; const SearchComponent = ({ searchValue: searchAtom, resultsIndex: indexAtom, resultsCount: numResultsAtom, regex: regexAtom, caseSensitive: caseSensitiveAtom, wholeWord: wholeWordAtom, isOpen: isOpenAtom, focusInput: focusInputAtom, anchorRef, offsetX = 10, offsetY = 10, onSearch, onNext, onPrev, }: SearchProps) => { const [isOpen, setIsOpen] = useAtom<boolean>(isOpenAtom); const [search, setSearch] = useAtom<string>(searchAtom); const [index, setIndex] = useAtom<number>(indexAtom); const [numResults, setNumResults] = useAtom<number>(numResultsAtom); const [focusInputCounter, setFocusInputCounter] = useAtom<number>(focusInputAtom); const inputRef = useRef<HTMLInputElement>(null); const handleOpenChange = useCallback((open: boolean) => { setIsOpen(open); }, []); useEffect(() => { if (!isOpen) { setSearch(""); setIndex(0); setNumResults(0); setFocusInputCounter(0); } }, [isOpen]); useEffect(() => { setIndex(0); setNumResults(0); onSearch?.(search); }, [search]); // When activateSearch fires while already open, it increments focusInputCounter // to signal this specific instance to grab focus (avoids global DOM queries). useEffect(() => { if (focusInputCounter > 0 && isOpen) { inputRef.current?.focus(); inputRef.current?.select(); } }, [focusInputCounter]); const middleware: Middleware[] = []; const offsetCallback = useCallback( ({ rects }) => { const docRect = document.documentElement.getBoundingClientRect(); let yOffsetCalc = -rects.floating.height - offsetY; let xOffsetCalc = -offsetX; const floatingBottom = rects.reference.y + rects.floating.height + offsetY; const floatingLeft = rects.reference.x + rects.reference.width - (rects.floating.width + offsetX); if (floatingBottom > docRect.bottom) { yOffsetCalc -= docRect.bottom - floatingBottom; } if (floatingLeft < 5) { xOffsetCalc += 5 - floatingLeft; } return { mainAxis: yOffsetCalc, crossAxis: xOffsetCalc, }; }, [offsetX, offsetY] ); middleware.push(offset(offsetCallback)); const { refs, floatingStyles } = useFloating({ placement: "top-end", open: isOpen, onOpenChange: handleOpenChange, whileElementsMounted: autoUpdate, middleware, elements: { reference: anchorRef!.current, }, }); const onPrevWrapper = useCallback( () => (onPrev ? onPrev() : setIndex((index - 1) % numResults)), [onPrev, index, numResults] ); const onNextWrapper = useCallback( () => (onNext ? onNext() : setIndex((index + 1) % numResults)), [onNext, index, numResults] ); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { if (e.shiftKey) { onPrevWrapper(); } else { onNextWrapper(); } e.preventDefault(); } }, [onPrevWrapper, onNextWrapper, setIsOpen] ); const prevDecl: IconButtonDecl = { elemtype: "iconbutton", icon: "chevron-up", title: "Previous Result (Shift+Enter)", disabled: numResults === 0, click: onPrevWrapper, }; const nextDecl: IconButtonDecl = { elemtype: "iconbutton", icon: "chevron-down", title: "Next Result (Enter)", disabled: numResults === 0, click: onNextWrapper, }; const closeDecl: IconButtonDecl = { elemtype: "iconbutton", icon: "xmark-large", title: "Close (Esc)", click: () => setIsOpen(false), }; const regexDecl = createToggleButtonDecl(regexAtom, "custom@regex", "Regular Expression"); const wholeWordDecl = createToggleButtonDecl(wholeWordAtom, "custom@whole-word", "Whole Word"); const caseSensitiveDecl = createToggleButtonDecl(caseSensitiveAtom, "custom@case-sensitive", "Case Sensitive"); return ( <> {isOpen && ( <FloatingPortal> <div className="search-container" style={{ ...floatingStyles }} ref={refs.setFloating}> <Input ref={inputRef} placeholder="Search" value={search} onChange={setSearch} onKeyDown={onKeyDown} autoFocus /> <div className={clsx("search-results", { hidden: numResults === 0 })} aria-live="polite" aria-label="Search Results" > {index + 1}/{numResults} </div> {(caseSensitiveDecl || wholeWordDecl || regexDecl) && ( <div className="additional-buttons"> {caseSensitiveDecl && <ToggleIconButton decl={caseSensitiveDecl} />} {wholeWordDecl && <ToggleIconButton decl={wholeWordDecl} />} {regexDecl && <ToggleIconButton decl={regexDecl} />} </div> )} <div className="right-buttons"> <IconButton decl={prevDecl} /> <IconButton decl={nextDecl} /> <IconButton decl={closeDecl} /> </div> </div> </FloatingPortal> )} </> ); }; export const Search = memo(SearchComponent) as typeof SearchComponent; type SearchOptions = { anchorRef?: React.RefObject<HTMLElement>; viewModel?: ViewModel; regex?: boolean; caseSensitive?: boolean; wholeWord?: boolean; }; export function useSearch(options?: SearchOptions): SearchProps { const searchAtoms: SearchAtoms = useMemo( () => ({ searchValue: atom(""), resultsIndex: atom(0), resultsCount: atom(0), isOpen: atom(false), focusInput: atom(0), regex: options?.regex !== undefined ? atom(options.regex) : undefined, caseSensitive: options?.caseSensitive !== undefined ? atom(options.caseSensitive) : undefined, wholeWord: options?.wholeWord !== undefined ? atom(options.wholeWord) : undefined, }), [] ); const anchorRef = options?.anchorRef ?? useRef(null); useEffect(() => { if (options?.viewModel) { options.viewModel.searchAtoms = searchAtoms; return () => { options.viewModel.searchAtoms = undefined; }; } }, [options?.viewModel]); return { ...searchAtoms, anchorRef }; } const createToggleButtonDecl = ( atom: WritableAtom<boolean, [boolean], void> | undefined, icon: string, title: string ): ToggleIconButtonDecl => atom ? { elemtype: "toggleiconbutton", icon, title, active: atom, } : null; ================================================ FILE: frontend/app/element/streamdown.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { CopyButton } from "@/app/element/copybutton"; import { IconButton } from "@/app/element/iconbutton"; import { cn, useAtomValueSafe } from "@/util/util"; import type { Atom } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { bundledLanguages, codeToHtml } from "shiki/bundle/web"; import { Streamdown } from "streamdown"; import { throttle } from "throttle-debounce"; const ShikiTheme = "github-dark-high-contrast"; function extractText(node: React.ReactNode): string { if (node == null || typeof node === "boolean") return ""; if (typeof node === "string" || typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(extractText).join(""); // @ts-expect-error props exists on ReactElement if (typeof node === "object" && node.props) return extractText(node.props.children); return ""; } function CodePlain({ className = "", isCodeBlock, text }: { className?: string; isCodeBlock: boolean; text: string }) { if (isCodeBlock) { return <code className={cn("font-mono text-[12px]", className)}>{text}</code>; } return ( <code className={cn("text-secondary font-mono text-[12px] rounded-sm bg-zinc-800/80 px-1.5 py-0.5", className)}> {text} </code> ); } function CodeHighlight({ className = "", lang, text }: { className?: string; lang: string; text: string }) { const [html, setHtml] = useState<string>(""); const [hasError, setHasError] = useState(false); const codeRef = useRef<HTMLElement>(null); const seqRef = useRef(0); const highlightCode = useCallback( async (textToHighlight: string, language: string, disposedRef: { current: boolean }, seq: number) => { try { const full = await codeToHtml(textToHighlight, { lang: language, theme: ShikiTheme }); const start = full.indexOf("<code"); const open = full.indexOf(">", start); const end = full.lastIndexOf("</code>"); const inner = start !== -1 && open !== -1 && end !== -1 ? full.slice(open + 1, end) : ""; if (!disposedRef.current && seq === seqRef.current) { setHtml(inner); setHasError(false); } } catch (e) { if (!disposedRef.current && seq === seqRef.current) { setHasError(true); } console.warn(`Shiki highlight failed for ${language}`, e); } }, [] ); const throttledHighlight = useMemo(() => throttle(300, highlightCode, { noLeading: false }), [highlightCode]); useEffect(() => { const disposedRef = { current: false }; if (!text) { setHtml(""); return; } seqRef.current++; const currentSeq = seqRef.current; throttledHighlight(text, lang, disposedRef, currentSeq); return () => { disposedRef.current = true; }; }, [text, lang, throttledHighlight]); if (hasError) { return ( <code ref={codeRef} className={cn("font-mono text-[12px]", className)}> {text} </code> ); } if (!html && text) { return ( <code ref={codeRef} className={cn("font-mono text-[12px] text-transparent", className)}> {text} </code> ); } return ( <code ref={codeRef} className={cn("font-mono text-[12px]", className)} dangerouslySetInnerHTML={{ __html: html }} /> ); } export function Code({ className = "", children }: { className?: string; children: React.ReactNode }) { const m = className?.match(/language-([\w+-]+)/i); const isCodeBlock = !!m; const lang = m?.[1] || "text"; const text = extractText(children); if (isCodeBlock && lang in bundledLanguages) { return <CodeHighlight className={className} lang={lang} text={text} />; } return <CodePlain className={className} isCodeBlock={isCodeBlock} text={text} />; } type CodeBlockProps = { children: React.ReactNode; onClickExecute?: (cmd: string) => void; codeBlockMaxWidthAtom?: Atom<number>; }; const CodeBlock = ({ children, onClickExecute, codeBlockMaxWidthAtom }: CodeBlockProps) => { const codeBlockMaxWidth = useAtomValueSafe(codeBlockMaxWidthAtom); const getLanguage = (children: any): string => { if (children?.props?.className) { const match = children.props.className.match(/language-([\w+-]+)/i); if (match) return match[1]; } return "text"; }; const handleCopy = async (e: React.MouseEvent) => { const textToCopy = extractText(children).replace(/\n$/, ""); await navigator.clipboard.writeText(textToCopy); }; const handleExecute = (e: React.MouseEvent) => { const cmd = extractText(children).replace(/\n$/, ""); if (onClickExecute) { onClickExecute(cmd); return; } }; const language = getLanguage(children); return ( <div className={cn("rounded-lg overflow-hidden bg-black my-4", codeBlockMaxWidth && "max-w-full")} style={ codeBlockMaxWidth ? { maxWidth: codeBlockMaxWidth, minWidth: Math.min(400, codeBlockMaxWidth) } : undefined } > <div className="flex items-center justify-between pl-3 pr-2 pt-2 pb-1.5"> <span className="text-[11px] text-white/50">{language}</span> <div className="flex items-center gap-2"> <CopyButton onClick={handleCopy} title="Copy" /> {onClickExecute && ( <IconButton decl={{ elemtype: "iconbutton", icon: "regular@square-terminal", click: handleExecute, }} /> )} </div> </div> <pre className="px-4 pb-2 pt-0 overflow-x-auto m-0 text-secondary max-w-full">{children}</pre> </div> ); }; function Collapsible({ title, children, defaultOpen = false }) { const [isOpen, setIsOpen] = useState(defaultOpen); return ( <div className="my-3"> <button className="flex items-center gap-2 cursor-pointer bg-transparent border-0 p-0 font-medium text-secondary hover:text-primary" onClick={() => setIsOpen(!isOpen)} > <span className="text-[0.65rem] text-primary transition-transform duration-200 inline-block w-3"> {isOpen ? "\u25BC" : "\u25B6"} {/* ▼ ▶ */} </span> <span>{title}</span> </button> {isOpen && <div className="mt-2 ml-1 pl-3.5 border-l-2 border-border text-secondary">{children}</div>} </div> ); } interface WaveStreamdownProps { text: string; parseIncompleteMarkdown?: boolean; className?: string; onClickExecute?: (cmd: string) => void; codeBlockMaxWidthAtom?: Atom<number>; } export const WaveStreamdown = ({ text, parseIncompleteMarkdown, className, onClickExecute, codeBlockMaxWidthAtom, }: WaveStreamdownProps) => { const components = useMemo( () => ({ code: Code, pre: (props: React.HTMLAttributes<HTMLPreElement>) => ( <CodeBlock children={props.children} onClickExecute={onClickExecute} codeBlockMaxWidthAtom={codeBlockMaxWidthAtom} /> ), p: (props: React.HTMLAttributes<HTMLParagraphElement>) => <p {...props} className="text-secondary" />, h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => ( <h1 {...props} className="text-2xl font-bold text-primary mt-6 mb-3" /> ), h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => ( <h2 {...props} className="text-xl font-bold text-primary mt-5 mb-2" /> ), h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => ( <h3 {...props} className="text-lg font-bold text-primary mt-4 mb-2" /> ), h4: (props: React.HTMLAttributes<HTMLHeadingElement>) => ( <h4 {...props} className="text-base font-semibold text-primary mt-3 mb-1" /> ), h5: (props: React.HTMLAttributes<HTMLHeadingElement>) => ( <h5 {...props} className="text-sm font-semibold text-primary mt-2 mb-1" /> ), h6: (props: React.HTMLAttributes<HTMLHeadingElement>) => ( <h6 {...props} className="text-sm text-primary mt-2 mb-1" /> ), table: (props: React.HTMLAttributes<HTMLTableElement>) => ( <table {...props} className="w-full border-collapse my-4" /> ), thead: (props: React.HTMLAttributes<HTMLTableSectionElement>) => ( <thead {...props} className="border-b border-border" /> ), tbody: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />, tr: (props: React.HTMLAttributes<HTMLTableRowElement>) => ( <tr {...props} className="border-b border-border/50 last:border-0" /> ), th: (props: React.HTMLAttributes<HTMLTableCellElement>) => ( <th {...props} className="text-left font-semibold px-2 py-1.5 text-sm text-primary" /> ), td: (props: React.HTMLAttributes<HTMLTableCellElement>) => ( <td {...props} className="px-2 py-1.5 text-sm text-secondary" /> ), ul: (props: React.HTMLAttributes<HTMLUListElement>) => ( <ul {...props} className="list-disc list-outside pl-6 mt-1 mb-2 text-secondary [&_ul]:my-1 [&_ol]:my-1" /> ), ol: (props: React.HTMLAttributes<HTMLOListElement>) => ( <ol {...props} className="list-decimal list-outside pl-6 mt-1 mb-2 text-secondary [&_ul]:my-1 [&_ol]:my-1" /> ), li: (props: React.HTMLAttributes<HTMLLIElement>) => ( <li {...props} className="text-secondary leading-snug" /> ), blockquote: (props: React.HTMLAttributes<HTMLQuoteElement>) => ( <blockquote {...props} className="border-l-2 border-border pl-4 my-2 text-secondary italic" /> ), details: ({ children, ...props }) => { const childArray = Array.isArray(children) ? children : [children]; // Extract summary text and content const summary = childArray.find((c) => c?.props?.node?.tagName === "summary"); const summaryText = summary?.props?.children || "Details"; const content = childArray.filter((c) => c?.props?.node?.tagName !== "summary"); return ( <Collapsible title={summaryText} defaultOpen={props.open}> {content} </Collapsible> ); }, summary: () => null, // Don't render summary separately a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => ( <a {...props} className="text-accent hover:underline" /> ), strong: (props: React.HTMLAttributes<HTMLElement>) => ( <strong {...props} className="font-semibold text-secondary" /> ), em: (props: React.HTMLAttributes<HTMLElement>) => <em {...props} className="italic text-secondary" />, }), [onClickExecute, codeBlockMaxWidthAtom] ); return ( <Streamdown parseIncompleteMarkdown={parseIncompleteMarkdown} className={cn( "wave-streamdown text-secondary [&>*:first-child]:mt-0 [&>*:first-child>*:first-child]:mt-0 space-y-2", className )} shikiTheme={[ShikiTheme, ShikiTheme]} controls={{ code: false, table: false, mermaid: true, }} mermaid={{ config: { theme: "dark", darkMode: true, }, }} components={components} > {text} </Streamdown> ); }; ================================================ FILE: frontend/app/element/toggle.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .check-toggle-wrapper { user-select: none; display: flex; height: 100%; align-items: center; justify-content: center; .checkbox-toggle { position: relative; display: inline-block; width: 32px; height: 20px; input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; content: ""; top: 0; bottom: 0; left: 0; right: 0; background-color: var(--toggle-bg-color); transition: 0.5s; border-radius: 33px; } .slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 2px; background-color: var(--toggle-thumb-color); transition: 0.5s; border-radius: 50%; } input:checked + .slider { background-color: var(--toggle-checked-bg-color); } input:checked + .slider:before { transform: translateX(11px); } } label, .toggle-label { cursor: pointer; padding: 0 5px; } } ================================================ FILE: frontend/app/element/toggle.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useRef } from "react"; import { cn } from "@/util/util"; import "./toggle.scss"; interface ToggleProps { checked: boolean; onChange: (value: boolean) => void; label?: string; id?: string; className?: string; } const Toggle = ({ checked, onChange, label, id, className }: ToggleProps) => { const inputRef = useRef<HTMLInputElement>(null); const handleChange = (e: any) => { if (onChange != null) { onChange(e.target.checked); } }; const handleLabelClick = () => { if (inputRef.current) { inputRef.current.click(); } }; const inputId = id || `toggle-${Math.random().toString(36).substr(2, 9)}`; return ( <div className={cn("check-toggle-wrapper", className)}> <label htmlFor={inputId} className="checkbox-toggle"> <input id={inputId} type="checkbox" checked={checked} onChange={handleChange} ref={inputRef} /> <span className="slider" /> </label> {label && ( <span className="toggle-label" onClick={handleLabelClick}> {label} </span> )} </div> ); }; export { Toggle }; ================================================ FILE: frontend/app/element/tooltip.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { cn } from "@/util/util"; import { FloatingPortal, autoUpdate, flip, offset, shift, useFloating, useHover, useInteractions, } from "@floating-ui/react"; import { useCallback, useEffect, useRef, useState } from "react"; interface TooltipProps { children: React.ReactNode; content: React.ReactNode; placement?: "top" | "bottom" | "left" | "right"; forceOpen?: boolean; disable?: boolean; openDelay?: number; divClassName?: string; divStyle?: React.CSSProperties; divOnClick?: (e: React.MouseEvent<HTMLDivElement>) => void; divRef?: React.RefObject<HTMLDivElement>; hideOnClick?: boolean; } function TooltipInner({ children, content, placement = "top", forceOpen = false, openDelay = 300, divClassName, divStyle, divOnClick, divRef, hideOnClick = false, }: Omit<TooltipProps, "disable">) { const [isOpen, setIsOpen] = useState(forceOpen); const [isVisible, setIsVisible] = useState(false); const [clickDisabled, setClickDisabled] = useState(false); const timeoutRef = useRef<number | null>(null); const prevForceOpenRef = useRef<boolean>(forceOpen); const { refs, floatingStyles, context } = useFloating({ open: isOpen, onOpenChange: (open) => { if (!open && forceOpen) { return; } if (open) { setIsOpen(true); if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); } timeoutRef.current = window.setTimeout(() => { setIsVisible(true); }, openDelay); } else { setIsVisible(false); if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); } timeoutRef.current = window.setTimeout(() => { setIsOpen(false); }, 300); } }, placement, middleware: [offset(10), flip(), shift({ padding: 12 })], whileElementsMounted: autoUpdate, }); useEffect(() => { if (forceOpen) { setIsOpen(true); setIsVisible(true); if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); timeoutRef.current = null; } } else { if (context.open && !prevForceOpenRef.current) { // Keep it open if it's being hovered and wasn't forced open before } else { setIsVisible(false); if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); } timeoutRef.current = window.setTimeout(() => { setIsOpen(false); }, 300); } } prevForceOpenRef.current = forceOpen; }, [forceOpen, context.open]); useEffect(() => { return () => { if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); } }; }, []); const hover = useHover(context, { enabled: !clickDisabled }); const { getReferenceProps, getFloatingProps } = useInteractions([hover]); const handleClick = useCallback( (e: React.MouseEvent<HTMLDivElement>) => { if (hideOnClick) { setIsVisible(false); setIsOpen(false); if (timeoutRef.current !== null) { window.clearTimeout(timeoutRef.current); } setClickDisabled(true); } divOnClick?.(e); }, [hideOnClick, divOnClick] ); const handlePointerEnter = useCallback(() => { if (hideOnClick && clickDisabled) { setClickDisabled(false); } }, [hideOnClick, clickDisabled]); return ( <> <div ref={(node) => { refs.setReference(node); if (divRef) { divRef.current = node; } }} {...getReferenceProps({ onClick: handleClick, onPointerEnter: handlePointerEnter })} className={divClassName} style={divStyle} > {children} </div> {isOpen && ( <FloatingPortal> <div ref={refs.setFloating} style={{ ...floatingStyles, opacity: isVisible ? 1 : 0, transition: "opacity 200ms ease", }} {...getFloatingProps()} className={cn( "bg-zinc-800 border border-border rounded-md px-2 py-1 text-xs text-foreground shadow-xl z-50" )} > {content} </div> </FloatingPortal> )} </> ); } export function Tooltip({ children, content, placement = "top", forceOpen = false, disable = false, openDelay = 300, divClassName, divStyle, divOnClick, divRef, hideOnClick = false, }: TooltipProps) { if (disable) { return ( <div ref={divRef} className={divClassName} style={divStyle} onClick={divOnClick}> {children} </div> ); } return ( <TooltipInner children={children} content={content} placement={placement} forceOpen={forceOpen} openDelay={openDelay} divClassName={divClassName} divStyle={divStyle} divOnClick={divOnClick} divRef={divRef} hideOnClick={hideOnClick} /> ); } ================================================ FILE: frontend/app/element/typingindicator.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 $dot-width: 11px; $dot-color: var(--success-color); $speed: 1.5s; .typing { position: relative; height: $dot-width; span { content: ""; animation: blink $speed infinite; animation-fill-mode: both; height: $dot-width; width: $dot-width; background: $dot-color; position: absolute; left: 0; top: 0; border-radius: 50%; &:nth-child(2) { animation-delay: 0.2s; margin-left: $dot-width * 1.5; } &:nth-child(3) { animation-delay: 0.4s; margin-left: $dot-width * 3; } } } @keyframes blink { 0% { opacity: 0.1; } 20% { opacity: 1; } 100% { opacity: 0.1; } } ================================================ FILE: frontend/app/element/typingindicator.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import clsx from "clsx"; import "./typingindicator.scss"; type TypingIndicatorProps = { className?: string; }; const TypingIndicator = ({ className }: TypingIndicatorProps) => { return ( <div className={clsx("typing", className)}> <span></span> <span></span> <span></span> </div> ); }; export { TypingIndicator }; ================================================ FILE: frontend/app/hook/useDimensions.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as React from "react"; import { useCallback, useState } from "react"; import { debounce } from "throttle-debounce"; // returns a callback ref, a ref object (that is set from the callback), and the width // pass debounceMs of null to not debounce export function useDimensionsWithCallbackRef<T extends HTMLElement>( debounceMs: number = null ): [(node: T) => void, React.RefObject<T>, DOMRectReadOnly] { const [domRect, setDomRect] = useState<DOMRectReadOnly>(null); const [htmlElem, setHtmlElem] = useState<T>(null); const rszObjRef = React.useRef<ResizeObserver>(null); const oldHtmlElem = React.useRef<T>(null); const ref = React.useRef<T>(null); const refCallback = useCallback( (node: T) => { if (ref) { setHtmlElem(node); ref.current = node; } }, [ref] ); const setDomRectDebounced = React.useCallback(debounceMs == null ? setDomRect : debounce(debounceMs, setDomRect), [ debounceMs, setDomRect, ]); React.useEffect(() => { if (!rszObjRef.current) { rszObjRef.current = new ResizeObserver((entries) => { for (const entry of entries) { if (domRect == null) { setDomRect(entry.contentRect); } else { setDomRectDebounced(entry.contentRect); } } }); } if (htmlElem) { rszObjRef.current.observe(htmlElem); oldHtmlElem.current = htmlElem; } return () => { if (oldHtmlElem.current) { rszObjRef.current?.unobserve(oldHtmlElem.current); oldHtmlElem.current = null; } }; }, [htmlElem]); React.useEffect(() => { return () => { rszObjRef.current?.disconnect(); }; }, []); return [refCallback, ref, domRect]; } export function useOnResize<T extends HTMLElement>( ref: React.RefObject<T>, callback: (domRect: DOMRectReadOnly) => void, debounceMs: number = null ) { const isFirst = React.useRef(true); const rszObjRef = React.useRef<ResizeObserver>(null); const oldHtmlElem = React.useRef<T>(null); const setDomRectDebounced = React.useCallback(debounceMs == null ? callback : debounce(debounceMs, callback), [ debounceMs, callback, ]); React.useEffect(() => { if (!rszObjRef.current) { rszObjRef.current = new ResizeObserver((entries) => { for (const entry of entries) { if (isFirst.current) { isFirst.current = false; callback(entry.contentRect); } else { setDomRectDebounced(entry.contentRect); } } }); } if (ref.current) { rszObjRef.current.observe(ref.current); oldHtmlElem.current = ref.current; } return () => { if (oldHtmlElem.current) { rszObjRef.current?.unobserve(oldHtmlElem.current); oldHtmlElem.current = null; } }; }, [ref.current, callback]); React.useEffect(() => { return () => { rszObjRef.current?.disconnect(); }; }, []); } // will not react to ref changes // pass debounceMs of null to not debounce export function useDimensionsWithExistingRef<T extends HTMLElement>( ref?: React.RefObject<T>, debounceMs: number = null ): DOMRectReadOnly { const [domRect, setDomRect] = useState<DOMRectReadOnly>(null); const rszObjRef = React.useRef<ResizeObserver>(null); const oldHtmlElem = React.useRef<T>(null); const setDomRectDebounced = React.useCallback(debounceMs == null ? setDomRect : debounce(debounceMs, setDomRect), [ debounceMs, setDomRect, ]); React.useEffect(() => { if (!rszObjRef.current) { rszObjRef.current = new ResizeObserver((entries) => { for (const entry of entries) { if (domRect == null) { setDomRect(entry.contentRect); } else { setDomRectDebounced(entry.contentRect); } } }); } if (ref?.current) { rszObjRef.current.observe(ref.current); oldHtmlElem.current = ref.current; } return () => { if (oldHtmlElem.current) { rszObjRef.current?.unobserve(oldHtmlElem.current); oldHtmlElem.current = null; } }; }, [ref?.current]); React.useEffect(() => { return () => { rszObjRef.current?.disconnect(); }; }, []); if (ref?.current != null) { return ref.current.getBoundingClientRect(); } return null; } ================================================ FILE: frontend/app/hook/useLongClick.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useCallback, useEffect, useRef, useState } from "react"; export const useLongClick = (ref, onClick, onLongClick, disabled = false, ms = 300) => { const timerRef = useRef(null); const [longClickTriggered, setLongClickTriggered] = useState(false); const startPress = useCallback( (e: React.MouseEvent<any>) => { if (onLongClick == null) { return; } setLongClickTriggered(false); timerRef.current = setTimeout(() => { setLongClickTriggered(true); onLongClick?.(e); }, ms); }, [onLongClick, ms] ); const stopPress = useCallback(() => { clearTimeout(timerRef.current); }, []); const handleClick = useCallback( (e: React.MouseEvent<any>) => { if (longClickTriggered) { e.preventDefault(); e.stopPropagation(); return; } onClick?.(e); }, [longClickTriggered, onClick] ); useEffect(() => { const element = ref.current; if (!element || disabled) return; element.addEventListener("mousedown", startPress); element.addEventListener("mouseup", stopPress); element.addEventListener("mouseleave", stopPress); element.addEventListener("click", handleClick); return () => { element.removeEventListener("mousedown", startPress); element.removeEventListener("mouseup", stopPress); element.removeEventListener("mouseleave", stopPress); element.removeEventListener("click", handleClick); }; }, [ref.current, startPress, stopPress, handleClick]); return ref; }; ================================================ FILE: frontend/app/modals/about.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; import { OnboardingGradientBg } from "@/app/onboarding/onboarding-common"; import { modalsModel } from "@/app/store/modalmodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { isDev } from "@/util/isdev"; import { fireAndForget } from "@/util/util"; import { useEffect, useState } from "react"; import { getApi } from "../store/global"; import { Modal } from "./modal"; interface AboutModalVProps { versionString: string; updaterChannel: string; onClose: () => void; } const AboutModalV = ({ versionString, updaterChannel, onClose }: AboutModalVProps) => { const currentDate = new Date(); return ( <Modal className="pt-[34px] pb-[34px] overflow-hidden w-[450px]" onClose={onClose}> <OnboardingGradientBg /> <div className="flex flex-col gap-[26px] w-full relative z-10"> <div className="flex flex-col items-center justify-center gap-4 self-stretch w-full text-center"> <Logo /> <div className="text-[25px]">Wave Terminal</div> <div className="leading-5"> Open-Source AI-Integrated Terminal <br /> Built for Seamless Workflows </div> </div> <div className="items-center gap-4 self-stretch w-full text-center"> Client Version {versionString} <br /> Update Channel: {updaterChannel} </div> <div className="grid grid-cols-2 gap-[10px] self-stretch w-full"> <a href="https://github.com/wavetermdev/waveterm?ref=about" target="_blank" rel="noopener" className="inline-flex items-center justify-center px-4 py-2 rounded border border-border hover:bg-hoverbg transition-colors duration-200" > <i className="fa-brands fa-github mr-2"></i>GitHub </a> <a href="https://www.waveterm.dev/?ref=about" target="_blank" rel="noopener" className="inline-flex items-center justify-center px-4 py-2 rounded border border-border hover:bg-hoverbg transition-colors duration-200" > <i className="fa-sharp fa-light fa-globe mr-2"></i>Website </a> <a href="https://github.com/wavetermdev/waveterm/blob/main/ACKNOWLEDGEMENTS.md" target="_blank" rel="noopener" className="inline-flex items-center justify-center px-4 py-2 rounded border border-border hover:bg-hoverbg transition-colors duration-200" > <i className="fa-sharp fa-light fa-book mr-2"></i>Open Source </a> <a href="https://github.com/sponsors/wavetermdev" target="_blank" rel="noopener" className="inline-flex items-center justify-center px-4 py-2 rounded border border-border hover:bg-hoverbg transition-colors duration-200" > <i className="fa-sharp fa-light fa-heart mr-2"></i>Sponsor </a> </div> <div className="items-center gap-4 self-stretch w-full text-center"> © {currentDate.getFullYear()} Command Line Inc. </div> </div> </Modal> ); }; AboutModalV.displayName = "AboutModalV"; const AboutModal = () => { const [details] = useState(() => getApi().getAboutModalDetails()); const [updaterChannel] = useState(() => getApi().getUpdaterChannel()); const versionString = `${details.version} (${isDev() ? "dev-" : ""}${details.buildTime})`; useEffect(() => { fireAndForget(async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "action:other", props: { "action:type": "about" } }, { noresponse: true } ); }); }, []); return ( <AboutModalV versionString={versionString} updaterChannel={updaterChannel} onClose={() => modalsModel.popModal()} /> ); }; AboutModal.displayName = "AboutModal"; export { AboutModal, AboutModalV }; ================================================ FILE: frontend/app/modals/conntypeahead.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { computeConnColorNum } from "@/app/block/blockutil"; import { TypeAheadModal } from "@/app/modals/typeaheadmodal"; import { ConnectionsModel } from "@/app/store/connections-model"; import { atoms, createBlock, getConnStatusAtom, getLocalHostDisplayNameAtom, globalStore, WOS, } from "@/app/store/global"; import { globalRefocusWithTimeout } from "@/app/store/keymodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { NodeModel } from "@/layout/index"; import * as keyutil from "@/util/keyutil"; import * as util from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; // newConnList -> connList => filteredList -> remoteItems -> sortedRemoteItems => remoteSuggestion // filteredList -> createNew function filterConnections( connList: Array<string>, connSelected: string, fullConfig: FullConfigType, filterOutNowsh: boolean ): Array<string> { const connectionsConfig = fullConfig.connections; return connList.filter((conn) => { const hidden = connectionsConfig?.[conn]?.["display:hidden"] ?? false; const wshEnabled = connectionsConfig?.[conn]?.["conn:wshenabled"] ?? true; return conn.includes(connSelected) && !hidden && (wshEnabled || !filterOutNowsh); }); } function sortConnSuggestionItems( connSuggestions: Array<SuggestionConnectionItem>, fullConfig: FullConfigType ): Array<SuggestionConnectionItem> { const connectionsConfig = fullConfig.connections; return connSuggestions.sort((itemA: SuggestionConnectionItem, itemB: SuggestionConnectionItem) => { const connNameA = itemA.value; const connNameB = itemB.value; const valueA = connectionsConfig?.[connNameA]?.["display:order"] ?? 0; const valueB = connectionsConfig?.[connNameB]?.["display:order"] ?? 0; return valueA - valueB; }); } function createRemoteSuggestionItems( filteredList: Array<string>, connection: string, connStatusMap: Map<string, ConnStatus> ): Array<SuggestionConnectionItem> { return filteredList.map((connName) => { const connStatus = connStatusMap.get(connName); const connColorNum = computeConnColorNum(connStatus); const item: SuggestionConnectionItem = { status: "connected", icon: "arrow-right-arrow-left", iconColor: connStatus?.status == "connected" ? `var(--conn-icon-color-${connColorNum})` : "var(--grey-text-color)", value: connName, label: connName, current: connName == connection, }; return item; }); } function createWslSuggestionItems( filteredList: Array<string>, connection: string, connStatusMap: Map<string, ConnStatus> ): Array<SuggestionConnectionItem> { return filteredList.map((connName) => { const connStatus = connStatusMap.get(`wsl://${connName}`); const connColorNum = computeConnColorNum(connStatus); const item: SuggestionConnectionItem = { status: "connected", icon: "arrow-right-arrow-left", iconColor: connStatus?.status == "connected" ? `var(--conn-icon-color-${connColorNum})` : "var(--grey-text-color)", value: "wsl://" + connName, label: "wsl://" + connName, current: "wsl://" + connName == connection, }; return item; }); } function createFilteredLocalSuggestionItem( localName: string, connection: string, connSelected: string ): Array<SuggestionConnectionItem> { if (localName.includes(connSelected)) { const localSuggestion: SuggestionConnectionItem = { status: "connected", icon: "laptop", iconColor: "var(--grey-text-color)", value: "", label: localName, current: util.isBlank(connection), }; return [localSuggestion]; } return []; } function getReconnectItem( connStatus: ConnStatus, connSelected: string, blockId: string, changeConnModalAtom: jotai.PrimitiveAtom<boolean> ): SuggestionConnectionItem | null { if (connSelected != "" || (connStatus.status != "disconnected" && connStatus.status != "error")) { return null; } const reconnectSuggestionItem: SuggestionConnectionItem = { status: "connected", icon: "arrow-right-arrow-left", iconColor: "var(--grey-text-color)", label: `Reconnect to ${connStatus.connection}`, value: "", onSelect: async (_: string) => { globalStore.set(changeConnModalAtom, false); const prtn = RpcApi.ConnConnectCommand( TabRpcClient, { host: connStatus.connection, logblockid: blockId }, { timeout: 60000 } ); prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e)); }, }; return reconnectSuggestionItem; } function getLocalSuggestions( localName: string, connList: Array<string>, connection: string, connSelected: string, connStatusMap: Map<string, ConnStatus>, fullConfig: FullConfigType, filterOutNowsh: boolean, hasGitBash: boolean ): SuggestionConnectionScope | null { const wslFiltered = filterConnections(connList, connSelected, fullConfig, filterOutNowsh); const wslSuggestionItems = createWslSuggestionItems(wslFiltered, connection, connStatusMap); const localSuggestionItem = createFilteredLocalSuggestionItem(localName, connection, connSelected); const gitBashItems: Array<SuggestionConnectionItem> = []; if (hasGitBash && "Git Bash".toLowerCase().includes(connSelected.toLowerCase())) { gitBashItems.push({ status: "connected", icon: "laptop", iconColor: "var(--grey-text-color)", value: "local:gitbash", label: "Git Bash", current: connection === "local:gitbash", }); } const combinedSuggestionItems = [...localSuggestionItem, ...gitBashItems, ...wslSuggestionItems]; const sortedSuggestionItems = sortConnSuggestionItems(combinedSuggestionItems, fullConfig); if (sortedSuggestionItems.length == 0) { return null; } const localSuggestions: SuggestionConnectionScope = { headerText: "Local", items: sortedSuggestionItems, }; return localSuggestions; } function getRemoteSuggestions( connList: Array<string>, connection: string, connSelected: string, connStatusMap: Map<string, ConnStatus>, fullConfig: FullConfigType, filterOutNowsh: boolean ): SuggestionConnectionScope | null { const filtered = filterConnections(connList, connSelected, fullConfig, filterOutNowsh); const suggestionItems = createRemoteSuggestionItems(filtered, connection, connStatusMap); const sortedSuggestionItems = sortConnSuggestionItems(suggestionItems, fullConfig); if (sortedSuggestionItems.length == 0) { return null; } const remoteSuggestions: SuggestionConnectionScope = { headerText: "Remote", items: sortedSuggestionItems, }; return remoteSuggestions; } function getDisconnectItem( connection: string, connStatusMap: Map<string, ConnStatus>, changeConnModalAtom: jotai.PrimitiveAtom<boolean> ): SuggestionConnectionItem | null { if (util.isLocalConnName(connection)) { return null; } const connStatus = connStatusMap.get(connection); if (!connStatus || connStatus.status != "connected") { return null; } const disconnectSuggestionItem: SuggestionConnectionItem = { status: "connected", icon: "xmark", iconColor: "var(--grey-text-color)", label: `Disconnect ${connStatus.connection}`, value: "", onSelect: async (_: string) => { globalStore.set(changeConnModalAtom, false); const prtn = RpcApi.ConnDisconnectCommand(TabRpcClient, connection, { timeout: 60000 }); prtn.catch((e) => console.log("error disconnecting", connStatus.connection, e)); }, }; return disconnectSuggestionItem; } function getConnectionsEditItem( changeConnModalAtom: jotai.PrimitiveAtom<boolean>, connSelected: string ): SuggestionConnectionItem | null { if (connSelected != "") { return null; } const connectionsEditItem: SuggestionConnectionItem = { status: "disconnected", icon: "gear", iconColor: "var(--grey-text-color)", value: "Edit Connections", label: "Edit Connections", onSelect: () => { util.fireAndForget(async () => { globalStore.set(changeConnModalAtom, false); const blockDef: BlockDef = { meta: { view: "waveconfig", file: "connections.json", }, }; await createBlock(blockDef, false, true); }); }, }; return connectionsEditItem; } function getNewConnectionSuggestionItem( connSelected: string, localName: string, remoteConns: Array<string>, wslConns: Array<string>, changeConnection: (connName: string) => Promise<void>, changeConnModalAtom: jotai.PrimitiveAtom<boolean> ): SuggestionConnectionItem | null { const allCons = ["", localName, ...remoteConns, ...wslConns]; if (allCons.includes(connSelected)) { // do not offer to create a new connection if one // with the exact name already exists return null; } const newConnectionSuggestion: SuggestionConnectionItem = { status: "connected", icon: "plus", iconColor: "var(--grey-text-color)", label: `${connSelected} (New Connection)`, value: "", onSelect: (_: string) => { changeConnection(connSelected); globalStore.set(changeConnModalAtom, false); }, }; return newConnectionSuggestion; } const ChangeConnectionBlockModal = React.memo( ({ blockId, viewModel, blockRef, connBtnRef, changeConnModalAtom, nodeModel, }: { blockId: string; viewModel: ViewModel; blockRef: React.RefObject<HTMLDivElement>; connBtnRef: React.RefObject<HTMLDivElement>; changeConnModalAtom: jotai.PrimitiveAtom<boolean>; nodeModel: NodeModel; }) => { const [connSelected, setConnSelected] = React.useState(""); const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const isNodeFocused = jotai.useAtomValue(nodeModel.isFocused); const connection = blockData?.meta?.connection; const connStatusAtom = getConnStatusAtom(connection); const connStatus = jotai.useAtomValue(connStatusAtom); const [connList, setConnList] = React.useState<Array<string>>([]); const [wslList, setWslList] = React.useState<Array<string>>([]); const allConnStatus = jotai.useAtomValue(atoms.allConnStatus); const [rowIndex, setRowIndex] = React.useState(0); const connStatusMap = new Map<string, ConnStatus>(); const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom); let filterOutNowsh = util.useAtomValueSafe(viewModel.filterOutNowsh) ?? true; const hasGitBash = jotai.useAtomValue(ConnectionsModel.getInstance().hasGitBashAtom); const localName = jotai.useAtomValue(getLocalHostDisplayNameAtom()); let maxActiveConnNum = 1; for (const conn of allConnStatus) { if (conn.activeconnnum > maxActiveConnNum) { maxActiveConnNum = conn.activeconnnum; } connStatusMap.set(conn.connection, conn); } React.useEffect(() => { if (!changeConnModalOpen) { setConnList([]); return; } const prtn = RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 }); prtn.then((newConnList) => { setConnList(newConnList ?? []); }).catch((e) => console.log("unable to load conn list from backend. using blank list: ", e)); const p2rtn = RpcApi.WslListCommand(TabRpcClient, { timeout: 2000 }); p2rtn .then((newWslList) => { console.log(newWslList); setWslList(newWslList ?? []); }) .catch((e) => { // removing this log and failing silentyly since it will happen // if a system isn't using the wsl. and would happen every time the // typeahead was opened. good candidate for verbose log level. //console.log("unable to load wsl list from backend. using blank list: ", e) }); }, [changeConnModalOpen]); const changeConnection = React.useCallback( async (connName: string) => { if (connName == "") { connName = null; } if (connName == blockData?.meta?.connection) { return; } const oldFile = blockData?.meta?.file ?? ""; const newFile = oldFile == "" ? "" : "~"; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { connection: connName, file: newFile, "cmd:cwd": null }, }); try { await RpcApi.ConnEnsureCommand( TabRpcClient, { connname: connName, logblockid: blockId }, { timeout: 60000 } ); } catch (e) { console.log("error connecting", blockId, connName, e); } }, [blockId, blockData] ); const reconnectSuggestionItem = getReconnectItem(connStatus, connSelected, blockId, changeConnModalAtom); const localSuggestions = getLocalSuggestions( localName, wslList, connection, connSelected, connStatusMap, fullConfig, filterOutNowsh, hasGitBash ); const remoteSuggestions = getRemoteSuggestions( connList, connection, connSelected, connStatusMap, fullConfig, filterOutNowsh ); const connectionsEditItem = getConnectionsEditItem(changeConnModalAtom, connSelected); const disconnectItem = getDisconnectItem(connection, connStatusMap, changeConnModalAtom); const newConnectionSuggestionItem = getNewConnectionSuggestionItem( connSelected, localName, connList, wslList, changeConnection, changeConnModalAtom ); const suggestions: Array<SuggestionsType> = [ ...(reconnectSuggestionItem ? [reconnectSuggestionItem] : []), ...(localSuggestions ? [localSuggestions] : []), ...(remoteSuggestions ? [remoteSuggestions] : []), ...(disconnectItem ? [disconnectItem] : []), ...(connectionsEditItem ? [connectionsEditItem] : []), ...(newConnectionSuggestionItem ? [newConnectionSuggestionItem] : []), ]; let selectionList: Array<SuggestionConnectionItem> = suggestions.flatMap((item) => { if ("items" in item) { return item.items; } return item; }); // quick way to change icon color when highlighted selectionList = selectionList.map((item, index) => { if (index == rowIndex && item.iconColor == "var(--grey-text-color)") { item.iconColor = "var(--main-text-color)"; } return item; }); const handleTypeAheadKeyDown = React.useCallback( (waveEvent: WaveKeyboardEvent): boolean => { if (keyutil.checkKeyPressed(waveEvent, "Enter")) { const rowItem = selectionList[rowIndex]; if ("onSelect" in rowItem && rowItem.onSelect) { rowItem.onSelect(rowItem.value); } else { changeConnection(rowItem.value); globalStore.set(changeConnModalAtom, false); globalRefocusWithTimeout(10); } setRowIndex(0); return true; } if (keyutil.checkKeyPressed(waveEvent, "Escape")) { globalStore.set(changeConnModalAtom, false); setConnSelected(""); globalRefocusWithTimeout(10); return true; } if (keyutil.checkKeyPressed(waveEvent, "ArrowUp")) { setRowIndex((idx) => Math.max(idx - 1, 0)); return true; } if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) { setRowIndex((idx) => Math.min(idx + 1, selectionList.length - 1)); return true; } setRowIndex(0); return false; }, [changeConnModalAtom, viewModel, blockId, connSelected, selectionList] ); React.useEffect(() => { // this is specifically for the case when the list shrinks due // to a search filter setRowIndex((idx) => Math.min(idx, selectionList.flat().length - 1)); }, [selectionList, setRowIndex]); // this check was also moved to BlockFrame to prevent all the above code from running unnecessarily if (!changeConnModalOpen) { return null; } return ( <TypeAheadModal blockRef={blockRef} anchorRef={connBtnRef} suggestions={suggestions} onSelect={(selected: string) => { changeConnection(selected); globalStore.set(changeConnModalAtom, false); globalRefocusWithTimeout(10); }} selectIndex={rowIndex} autoFocus={isNodeFocused} onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)} onChange={(current: string) => setConnSelected(current)} value={connSelected} label="Connect to (username@host)..." onClickBackdrop={() => globalStore.set(changeConnModalAtom, false)} /> ); } ); export { ChangeConnectionBlockModal }; ================================================ FILE: frontend/app/modals/messagemodal.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .message-modal { min-width: 400px; footer { padding: 10px; } } ================================================ FILE: frontend/app/modals/messagemodal.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Modal } from "@/app/modals/modal"; import { modalsModel } from "@/app/store/modalmodel"; import { ReactNode } from "react"; import "./messagemodal.scss"; const MessageModal = ({ children }: { children: ReactNode }) => { function closeModal() { modalsModel.popModal(); } return ( <Modal className="message-modal" onOk={() => closeModal()} onClose={() => closeModal()}> {children} </Modal> ); }; MessageModal.displayName = "MessageModal"; export { MessageModal }; ================================================ FILE: frontend/app/modals/modal.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .modal-wrapper { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; justify-content: center; align-items: center; z-index: var(--zindex-modal-wrapper); .modal-backdrop { position: fixed; top: 36px; left: 0; right: 0; bottom: 0; background-color: rgba(21, 23, 21, 0.7); z-index: var(--zindex-modal-backdrop); } } .modal { position: relative; z-index: var(--zindex-modal); display: flex; flex-direction: column; align-items: flex-start; border-radius: 8px; border: 0.5px solid var(--modal-border-color); background: var(--modal-bg-color); box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25); .modal-close-btn { position: absolute; right: 8px; top: 8px; padding: 8px 12px; i { font-size: 18px; } } .content-wrapper { display: flex; flex-direction: column; gap: 8px; width: 100%; .modal-content { width: 100%; padding: 0px 20px; } } .modal-footer { display: flex; justify-content: flex-end; width: 100%; padding-top: 16px; border-top: 1px solid rgba(255, 255, 255, 0.1); .wave-button:last-child { margin-left: 8px; } } } ================================================ FILE: frontend/app/modals/modal.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; import { cn } from "@/util/util"; import clsx from "clsx"; import { forwardRef } from "react"; import ReactDOM from "react-dom"; import "./modal.scss"; interface ModalProps { children?: React.ReactNode; okLabel?: string; cancelLabel?: string; className?: string; onClickBackdrop?: () => void; onOk?: () => void; onCancel?: () => void; onClose?: () => void; okDisabled?: boolean; cancelDisabled?: boolean; } const Modal = forwardRef<HTMLDivElement, ModalProps>( ( { children, className, cancelLabel, okLabel, onCancel, onOk, onClose, onClickBackdrop, okDisabled, cancelDisabled, }: ModalProps, ref ) => { const renderBackdrop = (onClick) => <div className="modal-backdrop" onClick={onClick}></div>; const renderFooter = () => { return onOk || onCancel; }; const renderModal = () => ( <div className="modal-wrapper"> {renderBackdrop(onClickBackdrop)} <div ref={ref} className={clsx(`modal`, className)}> <Button className="grey ghost modal-close-btn" onClick={onClose} title="Close (ESC)"> <i className="fa-sharp fa-solid fa-xmark"></i> </Button> <div className="content-wrapper"> <ModalContent>{children}</ModalContent> </div> {renderFooter() && ( <ModalFooter onCancel={onCancel} onOk={onOk} cancelLabel={cancelLabel} okLabel={okLabel} okDisabled={okDisabled} cancelDisabled={cancelDisabled} /> )} </div> </div> ); return ReactDOM.createPortal(renderModal(), document.getElementById("main")); } ); interface ModalContentProps { children: React.ReactNode; } function ModalContent({ children }: ModalContentProps) { return <div className="modal-content">{children}</div>; } interface ModalFooterProps { okLabel?: string; cancelLabel?: string; onOk?: () => void; onCancel?: () => void; okDisabled?: boolean; cancelDisabled?: boolean; } const ModalFooter = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok", okDisabled, cancelDisabled, }: ModalFooterProps) => { return ( <footer className="modal-footer"> {onCancel && ( <Button className="grey ghost" onClick={onCancel} disabled={cancelDisabled}> {cancelLabel} </Button> )} {onOk && ( <Button onClick={onOk} disabled={okDisabled}> {okLabel} </Button> )} </footer> ); }; interface FlexiModalProps { children?: React.ReactNode; className?: string; onClickBackdrop?: () => void; } interface FlexiModalComponent extends React.ForwardRefExoticComponent< FlexiModalProps & React.RefAttributes<HTMLDivElement> > { Content: typeof ModalContent; Footer: typeof ModalFooter; } const FlexiModal = forwardRef<HTMLDivElement, FlexiModalProps>( ({ children, className, onClickBackdrop }: FlexiModalProps, ref) => { const renderBackdrop = (onClick: () => void) => <div className="modal-backdrop" onClick={onClick}></div>; const renderModal = () => ( <div className="modal-wrapper"> {renderBackdrop(onClickBackdrop)} <div className={cn("modal pt-6 px-4 pb-4", className)} ref={ref}> {children} </div> </div> ); return ReactDOM.createPortal(renderModal(), document.getElementById("main")!); } ); (FlexiModal as FlexiModalComponent).Content = ModalContent; (FlexiModal as FlexiModalComponent).Footer = ModalFooter; export { FlexiModal, Modal }; ================================================ FILE: frontend/app/modals/modalregistry.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { MessageModal } from "@/app/modals/messagemodal"; import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; import { UpgradeOnboardingPatch } from "@/app/onboarding/onboarding-upgrade-patch"; import { DeleteFileModal, PublishAppModal, RenameFileModal } from "@/builder/builder-apppanel"; import { SetSecretDialog } from "@/builder/tabs/builder-secrettab"; import { AboutModal } from "./about"; import { UserInputModal } from "./userinputmodal"; const modalRegistry: { [key: string]: React.ComponentType<any> } = { [NewInstallOnboardingModal.displayName || "NewInstallOnboardingModal"]: NewInstallOnboardingModal, [UpgradeOnboardingModal.displayName || "UpgradeOnboardingModal"]: UpgradeOnboardingModal, [UpgradeOnboardingPatch.displayName || "UpgradeOnboardingPatch"]: UpgradeOnboardingPatch, [UserInputModal.displayName || "UserInputModal"]: UserInputModal, [AboutModal.displayName || "AboutModal"]: AboutModal, [MessageModal.displayName || "MessageModal"]: MessageModal, [PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal, [RenameFileModal.displayName || "RenameFileModal"]: RenameFileModal, [DeleteFileModal.displayName || "DeleteFileModal"]: DeleteFileModal, [SetSecretDialog.displayName || "SetSecretDialog"]: SetSecretDialog, }; export const getModalComponent = (key: string): React.ComponentType<any> | undefined => { return modalRegistry[key]; }; ================================================ FILE: frontend/app/modals/modalsrenderer.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { CurrentOnboardingVersion } from "@/app/onboarding/onboarding-common"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; import { ClientModel } from "@/app/store/client-model"; import { globalStore } from "@/app/store/jotaiStore"; import { atoms, globalPrimaryTabStartup } from "@/store/global"; import { modalsModel } from "@/store/modalmodel"; import * as jotai from "jotai"; import { useEffect } from "react"; import * as semver from "semver"; import { getModalComponent } from "./modalregistry"; const ModalsRenderer = () => { const clientData = jotai.useAtomValue(ClientModel.getInstance().clientAtom); const [newInstallOnboardingOpen, setNewInstallOnboardingOpen] = jotai.useAtom(modalsModel.newInstallOnboardingOpen); const [upgradeOnboardingOpen, setUpgradeOnboardingOpen] = jotai.useAtom(modalsModel.upgradeOnboardingOpen); const [modals] = jotai.useAtom(modalsModel.modalsAtom); const rtn: React.ReactElement[] = []; for (const modal of modals) { const ModalComponent = getModalComponent(modal.displayName); if (ModalComponent) { rtn.push(<ModalComponent key={modal.displayName} {...modal.props} />); } } if (newInstallOnboardingOpen) { rtn.push(<NewInstallOnboardingModal key={NewInstallOnboardingModal.displayName} />); } if (upgradeOnboardingOpen) { rtn.push(<UpgradeOnboardingModal key={UpgradeOnboardingModal.displayName} />); } useEffect(() => { if (!clientData.tosagreed) { setNewInstallOnboardingOpen(true); } }, [clientData]); useEffect(() => { if (!globalPrimaryTabStartup) { return; } if (!clientData.tosagreed) { return; } const lastVersion = clientData.meta?.["onboarding:lastversion"] ?? "v0.0.0"; if (semver.lt(lastVersion, CurrentOnboardingVersion)) { setUpgradeOnboardingOpen(true); } }, []); useEffect(() => { globalStore.set(atoms.modalOpen, rtn.length > 0); }, [rtn]); return <>{rtn}</>; }; export { ModalsRenderer }; ================================================ FILE: frontend/app/modals/typeaheadmodal.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .type-ahead-modal-backdrop { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: transparent; z-index: var(--zindex-typeahead-modal-backdrop); } .type-ahead-modal { position: absolute; z-index: var(--zindex-typeahead-modal); display: flex; flex-direction: column; align-items: flex-start; border-radius: 6px; border: 1px solid var(--modal-border-color); background: var(--modal-bg-color); box-shadow: 0px 13px 16px 0px rgba(0, 0, 0, 0.4); padding: 6px; flex-direction: column; .label { opacity: 0.5; font-size: 13px; white-space: nowrap; } .input { border: none; border-bottom: none; height: 24px; border-radius: 0; input { width: 100%; flex-shrink: 0; padding: 4px 6px; height: 24px; } .input-decoration.end-position { margin: 6px; i { opacity: 0.3; } } } &.has-suggestions { .input { border-bottom: 1px solid rgba(255, 255, 255, 0.08); } } .suggestions-wrapper { width: 100%; overflow: hidden; display: flex; flex-direction: column; gap: 10px; .suggestion-header { font-size: 11px; font-style: normal; font-weight: 500; line-height: 12px; opacity: 0.7; letter-spacing: 0.11px; padding: 4px 0px 0px 4px; } .suggestion-item { width: 100%; cursor: pointer; display: flex; padding: 6px 8px; align-items: center; gap: 8px; align-self: stretch; border-radius: 4px; &.selected { background-color: rgb(from var(--accent-color) r g b / 0.5); color: var(--main-text-color); } &:hover:not(.selected) { background-color: var(--highlight-bg-color); } .typeahead-item-name { display: flex; gap: 8px; font-size: 11px; font-weight: 400; line-height: 14px; i { display: inline-block; position: relative; top: 2px; } } .typeahead-current-checkbox { margin-left: auto; } } } } ================================================ FILE: frontend/app/modals/typeaheadmodal.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Input, InputGroup, InputRightElement } from "@/app/element/input"; import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; import { makeIconClass } from "@/util/util"; import clsx from "clsx"; import React, { forwardRef, useLayoutEffect, useRef } from "react"; import ReactDOM from "react-dom"; import "./typeaheadmodal.scss"; interface SuggestionsProps { suggestions?: SuggestionsType[]; onSelect?: (_: string) => void; selectIndex: number; } const Suggestions = forwardRef<HTMLDivElement, SuggestionsProps>( ({ suggestions, onSelect, selectIndex }: SuggestionsProps, ref) => { const renderIcon = (icon: string | React.ReactNode, color: string) => { if (typeof icon === "string") { return <i className={makeIconClass(icon, false)} style={{ color: color }}></i>; } return icon; }; const renderItem = (item: SuggestionBaseItem | SuggestionConnectionItem, index: number) => ( <div key={index} onClick={() => { if ("onSelect" in item && item.onSelect) { item.onSelect(item.value); } else { onSelect(item.value); } }} className={clsx("suggestion-item", { selected: selectIndex === index })} > <div className="typeahead-item-name ellipsis"> {item.icon && renderIcon(item.icon, "iconColor" in item && item.iconColor ? item.iconColor : "inherit")} {item.label} </div> {"current" in item && item.current && ( <i className={clsx(makeIconClass("check", false), "typeahead-current-checkbox")} /> )} </div> ); let fullIndex = -1; return ( <div ref={ref} className="suggestions"> {suggestions.map((item, index) => { if ("headerText" in item) { return ( <div key={index}> {item.headerText && <div className="suggestion-header">{item.headerText}</div>} {item.items.map((subItem, subIndex) => { fullIndex += 1; return renderItem(subItem, fullIndex); })} </div> ); } fullIndex += 1; return renderItem(item as SuggestionBaseItem, fullIndex); })} </div> ); } ); interface TypeAheadModalProps { anchorRef: React.RefObject<HTMLElement>; blockRef?: React.RefObject<HTMLDivElement>; suggestions?: SuggestionsType[]; label?: string; className?: string; value?: string; onChange?: (_: string) => void; onSelect?: (_: string) => void; onClickBackdrop?: () => void; onKeyDown?: (_) => void; giveFocusRef?: React.RefObject<() => boolean>; autoFocus?: boolean; selectIndex?: number; } const TypeAheadModal = ({ className, suggestions, label, anchorRef, blockRef, value, onChange, onSelect, onKeyDown, onClickBackdrop, giveFocusRef, autoFocus, selectIndex, }: TypeAheadModalProps) => { const domRect = useDimensionsWithExistingRef(blockRef, 30); const width = domRect?.width ?? 0; const height = domRect?.height ?? 0; const modalRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement>(null); const inputGroupRef = useRef<HTMLDivElement>(null); const suggestionsWrapperRef = useRef<HTMLDivElement>(null); const suggestionsRef = useRef<HTMLDivElement>(null); useLayoutEffect(() => { if (!modalRef.current || !inputGroupRef.current || !suggestionsRef.current || !suggestionsWrapperRef.current) { return; } const modalStyles = window.getComputedStyle(modalRef.current); const paddingTop = parseFloat(modalStyles.paddingTop) || 0; const paddingBottom = parseFloat(modalStyles.paddingBottom) || 0; const borderTop = parseFloat(modalStyles.borderTopWidth) || 0; const borderBottom = parseFloat(modalStyles.borderBottomWidth) || 0; const modalPadding = paddingTop + paddingBottom; const modalBorder = borderTop + borderBottom; const suggestionsWrapperStyles = window.getComputedStyle(suggestionsWrapperRef.current); const suggestionsWrapperMarginTop = parseFloat(suggestionsWrapperStyles.marginTop) || 0; const inputHeight = inputGroupRef.current.getBoundingClientRect().height; let suggestionsTotalHeight = 0; const suggestionItems = suggestionsRef.current.children; for (let i = 0; i < suggestionItems.length; i++) { suggestionsTotalHeight += suggestionItems[i].getBoundingClientRect().height; } const totalHeight = modalPadding + modalBorder + inputHeight + suggestionsTotalHeight + suggestionsWrapperMarginTop; const maxHeight = height * 0.8; const computedHeight = totalHeight > maxHeight ? maxHeight : totalHeight; modalRef.current.style.height = `${computedHeight}px`; suggestionsWrapperRef.current.style.height = `${computedHeight - inputHeight - modalPadding - modalBorder - suggestionsWrapperMarginTop}px`; }, [height, suggestions]); useLayoutEffect(() => { if (!blockRef.current || !modalRef.current) return; const blockRect = blockRef.current.getBoundingClientRect(); const anchorRect = anchorRef.current.getBoundingClientRect(); const minGap = 20; const availableWidth = blockRect.width - minGap * 2; let modalWidth = 300; if (modalWidth > availableWidth) { modalWidth = availableWidth; } let leftPosition = anchorRect.left - blockRect.left; const modalRightEdge = leftPosition + modalWidth; const blockRightEdge = blockRect.width - (minGap - 4); if (modalRightEdge > blockRightEdge) { leftPosition -= modalRightEdge - blockRightEdge; } if (leftPosition < minGap) { leftPosition = minGap; } modalRef.current.style.width = `${modalWidth}px`; modalRef.current.style.left = `${leftPosition}px`; }, [width]); useLayoutEffect(() => { if (giveFocusRef) { giveFocusRef.current = () => { inputRef.current?.focus(); return true; }; } return () => { if (giveFocusRef) { giveFocusRef.current = null; } }; }, []); useLayoutEffect(() => { if (anchorRef.current && modalRef.current) { const parentElement = anchorRef.current.closest(".block-frame-default-header"); modalRef.current.style.top = `${parentElement?.getBoundingClientRect().height}px`; } }, []); const renderBackdrop = (onClick) => <div className="type-ahead-modal-backdrop" onClick={onClick}></div>; const handleKeyDown = (e) => { onKeyDown?.(e); }; const handleChange = (value) => { onChange?.(value); }; const handleSelect = (value) => { onSelect?.(value); }; const renderModal = () => ( <div className="type-ahead-modal-wrapper" onKeyDown={handleKeyDown}> {renderBackdrop(onClickBackdrop)} <div ref={modalRef} className={clsx("type-ahead-modal", className, { "has-suggestions": suggestions?.length > 0 })} > <InputGroup ref={inputGroupRef}> <Input ref={inputRef} onChange={handleChange} value={value} autoFocus={autoFocus} placeholder={label} /> <InputRightElement> <i className="fa-regular fa-magnifying-glass"></i> </InputRightElement> </InputGroup> <div ref={suggestionsWrapperRef} className="suggestions-wrapper" style={{ marginTop: suggestions?.length > 0 ? "8px" : "0", overflowY: "auto", }} > {suggestions?.length > 0 && ( <Suggestions ref={suggestionsRef} suggestions={suggestions} onSelect={handleSelect} selectIndex={selectIndex} /> )} </div> </div> </div> ); if (blockRef && blockRef.current == null) { return null; } return ReactDOM.createPortal(renderModal(), blockRef.current); }; export { TypeAheadModal }; ================================================ FILE: frontend/app/modals/userinputmodal.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Modal } from "@/app/modals/modal"; import { Markdown } from "@/element/markdown"; import { modalsModel } from "@/store/modalmodel"; import * as keyutil from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { UserInputService } from "../store/services"; const UserInputModal = (userInputRequest: UserInputRequest) => { const [responseText, setResponseText] = useState(""); const [countdown, setCountdown] = useState(Math.floor(userInputRequest.timeoutms / 1000)); const checkboxRef = useRef<HTMLInputElement>(null); const handleSendErrResponse = useCallback(() => { fireAndForget(() => UserInputService.SendUserInputResponse({ type: "userinputresp", requestid: userInputRequest.requestid, errormsg: "Canceled by the user", }) ); modalsModel.popModal(); }, [responseText, userInputRequest]); const handleSendText = useCallback(() => { fireAndForget(() => UserInputService.SendUserInputResponse({ type: "userinputresp", requestid: userInputRequest.requestid, text: responseText, checkboxstat: checkboxRef?.current?.checked ?? false, }) ); modalsModel.popModal(); }, [responseText, userInputRequest]); const handleSendConfirm = useCallback( (response: boolean) => { fireAndForget(() => UserInputService.SendUserInputResponse({ type: "userinputresp", requestid: userInputRequest.requestid, confirm: response, checkboxstat: checkboxRef?.current?.checked ?? false, }) ); modalsModel.popModal(); }, [userInputRequest] ); const handleSubmit = useCallback(() => { switch (userInputRequest.responsetype) { case "text": handleSendText(); break; case "confirm": handleSendConfirm(true); break; } }, [handleSendConfirm, handleSendText, userInputRequest.responsetype]); const handleKeyDown = useCallback( (waveEvent: WaveKeyboardEvent): boolean => { if (keyutil.checkKeyPressed(waveEvent, "Escape")) { handleSendErrResponse(); return true; } if (keyutil.checkKeyPressed(waveEvent, "Enter")) { handleSubmit(); return true; } return false; }, [handleSendErrResponse, handleSubmit] ); const queryText = useMemo(() => { if (userInputRequest.markdown) { return <Markdown text={userInputRequest.querytext} />; } return <span>{userInputRequest.querytext}</span>; }, [userInputRequest.markdown, userInputRequest.querytext]); const inputBox = useMemo(() => { if (userInputRequest.responsetype === "confirm") { return <></>; } return ( <input type={userInputRequest.publictext ? "text" : "password"} onChange={(e) => setResponseText(e.target.value)} value={responseText} maxLength={400} className="resize-none bg-panel rounded-md border border-border py-1.5 pl-4 min-h-[30px] text-inherit cursor-text focus:ring-2 focus:ring-accent focus:outline-none" autoFocus={true} onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)} /> ); }, [userInputRequest.responsetype, userInputRequest.publictext, responseText, handleKeyDown, setResponseText]); const optionalCheckbox = useMemo(() => { if (userInputRequest.checkboxmsg == "") { return <></>; } return ( <div className="flex flex-col gap-1.5"> <div className="flex items-center gap-1.5"> <input type="checkbox" id={`uicheckbox-${userInputRequest.requestid}`} className="accent-accent cursor-pointer" ref={checkboxRef} /> <label htmlFor={`uicheckbox-${userInputRequest.requestid}`} className="cursor-pointer">{userInputRequest.checkboxmsg}</label> </div> </div> ); }, []); useEffect(() => { let timeout: ReturnType<typeof setTimeout>; if (countdown <= 0) { timeout = setTimeout(() => { handleSendErrResponse(); }, 300); } else { timeout = setTimeout(() => { setCountdown(countdown - 1); }, 1000); } return () => clearTimeout(timeout); }, [countdown]); const handleNegativeResponse = useCallback(() => { switch (userInputRequest.responsetype) { case "text": handleSendErrResponse(); break; case "confirm": handleSendConfirm(false); break; } }, [userInputRequest.responsetype, handleSendErrResponse, handleSendConfirm]); return ( <Modal className="pt-6 pb-4 px-5" onOk={() => handleSubmit()} onCancel={() => handleNegativeResponse()} onClose={() => handleSendErrResponse()} okLabel={userInputRequest.oklabel} cancelLabel={userInputRequest.cancellabel} > <div className="font-bold text-primary mx-4 pb-2.5">{userInputRequest.title + ` (${countdown}s)`}</div> <div className="flex flex-col justify-between gap-4 mx-4 mb-4 max-w-[500px] font-mono text-primary"> {queryText} {inputBox} {optionalCheckbox} </div> </Modal> ); }; UserInputModal.displayName = "UserInputModal"; export { UserInputModal }; ================================================ FILE: frontend/app/monaco/monaco-env.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as monaco from "monaco-editor"; import "monaco-editor/esm/vs/language/css/monaco.contribution"; import "monaco-editor/esm/vs/language/html/monaco.contribution"; import "monaco-editor/esm/vs/language/json/monaco.contribution"; import "monaco-editor/esm/vs/language/typescript/monaco.contribution"; import { configureMonacoYaml } from "monaco-yaml"; import { MonacoSchemas } from "@/app/monaco/schemaendpoints"; import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"; import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; import ymlWorker from "./yamlworker?worker"; let monacoConfigured = false; window.MonacoEnvironment = { getWorker(_, label) { if (label === "json") { return new jsonWorker(); } if (label === "css" || label === "scss" || label === "less") { return new cssWorker(); } if (label === "yaml" || label === "yml") { return new ymlWorker(); } if (label === "html" || label === "handlebars" || label === "razor") { return new htmlWorker(); } if (label === "typescript" || label === "javascript") { return new tsWorker(); } return new editorWorker(); }, }; export function loadMonaco() { if (monacoConfigured) { return; } monacoConfigured = true; monaco.editor.defineTheme("wave-theme-dark", { base: "vs-dark", inherit: true, rules: [], colors: { "editor.background": "#00000000", "editorStickyScroll.background": "#00000055", "minimap.background": "#00000077", focusBorder: "#00000000", }, }); monaco.editor.defineTheme("wave-theme-light", { base: "vs", inherit: true, rules: [], colors: { "editor.background": "#fefefe", focusBorder: "#00000000", }, }); configureMonacoYaml(monaco, { validate: true, schemas: [], }); monaco.editor.setTheme("wave-theme-dark"); // Disable default validation errors for typescript and javascript monaco.typescript.typescriptDefaults.setDiagnosticsOptions({ noSemanticValidation: true, }); monaco.json.jsonDefaults.setDiagnosticsOptions({ validate: true, allowComments: false, enableSchemaRequest: true, schemas: MonacoSchemas, }); } ================================================ FILE: frontend/app/monaco/monaco-react.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { loadMonaco } from "@/app/monaco/monaco-env"; import type * as MonacoTypes from "monaco-editor"; import * as monaco from "monaco-editor"; import { useEffect, useRef } from "react"; import { debounce } from "throttle-debounce"; function createModel(value: string, path: string, language?: string) { const uri = monaco.Uri.parse(`wave://editor/${encodeURIComponent(path)}`); return monaco.editor.createModel(value, language, uri); } type CodeEditorProps = { text: string; readonly: boolean; language?: string; onChange?: (text: string) => void; onMount?: (editor: MonacoTypes.editor.IStandaloneCodeEditor, monacoApi: typeof monaco) => () => void; path: string; options: MonacoTypes.editor.IEditorOptions; }; export function MonacoCodeEditor({ text, readonly, language, onChange, onMount, path, options }: CodeEditorProps) { const divRef = useRef<HTMLDivElement>(null); const editorRef = useRef<MonacoTypes.editor.IStandaloneCodeEditor | null>(null); const onUnmountRef = useRef<(() => void) | null>(null); const applyingFromProps = useRef(false); useEffect(() => { loadMonaco(); const el = divRef.current; if (!el) return; const model = createModel(text, path, language); console.log("[monaco] CREATE MODEL", path, model); const editor = monaco.editor.create(el, { ...options, readOnly: readonly, model, }); editorRef.current = editor; const sub = model.onDidChangeContent(() => { if (applyingFromProps.current) return; onChange?.(model.getValue()); }); if (onMount) { onUnmountRef.current = onMount(editor, monaco); } return () => { sub.dispose(); if (onUnmountRef.current) onUnmountRef.current(); editor.setModel(null); editor.dispose(); model.dispose(); console.log("[monaco] dispose model"); editorRef.current = null; }; // mount/unmount only }, []); useEffect(() => { const editor = editorRef.current; const el = divRef.current; if (!editor || !el) return; const debouncedLayout = debounce(100, () => { editor.layout(); }); const resizeObserver = new ResizeObserver(debouncedLayout); resizeObserver.observe(el); return () => { resizeObserver.disconnect(); debouncedLayout.cancel(); }; }, []); // Keep model value in sync with props useEffect(() => { const editor = editorRef.current; if (!editor) return; const model = editor.getModel(); if (!model) return; const current = model.getValue(); if (current === text) return; applyingFromProps.current = true; model.pushEditOperations([], [{ range: model.getFullModelRange(), text }], () => null); applyingFromProps.current = false; }, [text]); // Keep options in sync useEffect(() => { const editor = editorRef.current; if (!editor) return; editor.updateOptions({ ...options, readOnly: readonly }); }, [options, readonly]); // Keep language in sync useEffect(() => { const editor = editorRef.current; if (!editor) return; const model = editor.getModel(); if (!model || !language) return; monaco.editor.setModelLanguage(model, language); }, [language]); return <div className="flex flex-col h-full w-full" ref={divRef} />; } type DiffViewerProps = { original: string; modified: string; language?: string; path: string; options: MonacoTypes.editor.IDiffEditorOptions; }; export function MonacoDiffViewer({ original, modified, language, path, options }: DiffViewerProps) { const divRef = useRef<HTMLDivElement>(null); const diffRef = useRef<MonacoTypes.editor.IStandaloneDiffEditor | null>(null); // Create once useEffect(() => { loadMonaco(); const el = divRef.current; if (!el) return; const origUri = monaco.Uri.parse(`wave://diff/${encodeURIComponent(path)}.orig`); const modUri = monaco.Uri.parse(`wave://diff/${encodeURIComponent(path)}.mod`); const originalModel = monaco.editor.createModel(original, language, origUri); const modifiedModel = monaco.editor.createModel(modified, language, modUri); const diff = monaco.editor.createDiffEditor(el, options); diffRef.current = diff; diff.setModel({ original: originalModel, modified: modifiedModel }); return () => { diff.setModel(null); diff.dispose(); originalModel.dispose(); modifiedModel.dispose(); diffRef.current = null; }; }, []); useEffect(() => { const diff = diffRef.current; const el = divRef.current; if (!diff || !el) return; const debouncedLayout = debounce(100, () => { diff.layout(); }); const resizeObserver = new ResizeObserver(debouncedLayout); resizeObserver.observe(el); return () => { resizeObserver.disconnect(); debouncedLayout.cancel(); }; }, []); // Update models on prop change useEffect(() => { const diff = diffRef.current; if (!diff) return; const model = diff.getModel(); if (!model) return; if (model.original.getValue() !== original) model.original.setValue(original); if (model.modified.getValue() !== modified) model.modified.setValue(modified); if (language) { monaco.editor.setModelLanguage(model.original, language); monaco.editor.setModelLanguage(model.modified, language); } }, [original, modified, language]); useEffect(() => { const diff = diffRef.current; if (!diff) return; diff.updateOptions(options); }, [options]); return <div className="flex flex-col h-full w-full" ref={divRef} />; } ================================================ FILE: frontend/app/monaco/schemaendpoints.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import settingsSchema from "../../../schema/settings.json"; import connectionsSchema from "../../../schema/connections.json"; import aipresetsSchema from "../../../schema/aipresets.json"; import bgpresetsSchema from "../../../schema/bgpresets.json"; import waveaiSchema from "../../../schema/waveai.json"; import widgetsSchema from "../../../schema/widgets.json"; type SchemaInfo = { uri: string; fileMatch: Array<string>; schema: object; }; const MonacoSchemas: SchemaInfo[] = [ { uri: "wave://schema/settings.json", fileMatch: ["*/WAVECONFIGPATH/settings.json"], schema: settingsSchema, }, { uri: "wave://schema/connections.json", fileMatch: ["*/WAVECONFIGPATH/connections.json"], schema: connectionsSchema, }, { uri: "wave://schema/aipresets.json", fileMatch: ["*/WAVECONFIGPATH/presets/ai.json"], schema: aipresetsSchema, }, { uri: "wave://schema/bgpresets.json", fileMatch: ["*/WAVECONFIGPATH/presets/bg.json"], schema: bgpresetsSchema, }, { uri: "wave://schema/waveai.json", fileMatch: ["*/WAVECONFIGPATH/waveai.json"], schema: waveaiSchema, }, { uri: "wave://schema/widgets.json", fileMatch: ["*/WAVECONFIGPATH/widgets.json"], schema: widgetsSchema, }, ]; export { MonacoSchemas }; ================================================ FILE: frontend/app/monaco/yamlworker.js ================================================ import "monaco-yaml/yaml.worker.js"; ================================================ FILE: frontend/app/onboarding/fakechat.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveStreamdown } from "@/app/element/streamdown"; import { memo, useEffect, useRef, useState } from "react"; interface ChatConfig { userPrompt: string; toolName: string; toolDescription: string; markdownResponse: string; } const chatConfigs: ChatConfig[] = [ { userPrompt: "Check out ~/waveterm and summarize the project — what it does and how it's organized.", toolName: "read_dir", toolDescription: 'reading directory "~/waveterm"', markdownResponse: `Here's a quick, file-structure–driven overview of this repo (Wave Terminal): ## What it is - Electron + React front end with a Go backend ("wavesrv"). Provides a terminal with GUI widgets, previews, web, and AI. (README.md) - Licensed Apache-2.0. (LICENSE) ## Architecture at a glance - **Electron main process:** \`emain/*.ts\` configures windows, menus, preload scripts, updater, and ties into the Go backend via local RPC. (\`emain/\`) - **Renderer UI:** React/TS built with Vite, Tailwind. (\`frontend/\`, \`index.html\`, \`electron.vite.config.ts\`) - **Go backend ("wavesrv"):** starts services, web and websocket listeners, telemetry loops, config watcher, local RPC, filestore and SQLite-backed object store. (\`cmd/server/main-server.go\`, \`pkg/*\`) - **CLI/helper ("wsh"):** built for multiple OS/arch; used for shell integration and remote operations. (\`cmd/wsh/\`, \`Taskfile.yml build:wsh\`) ## Key directories - **cmd/:** entrypoints and generators - \`server/\`: wavesrv main - \`generategs/\`, \`generatego/\`: TS/Go bindings generation - \`wsh/\`: shell helper - **pkg/:** backend packages - \`wcore/\`: startup coordination, initial data, window/workspace creation - \`web/\`: HTTP+WS servers, Unix listener - \`waveai/\`: AI backends (OpenAI, Anthropic, Google, Perplexity) - \`wshrpc\`, \`wshutil\`, \`wslconn\`, \`remote/*\`: local/remote RPC, WSL, SSH, fileshare - \`wstore/\`: persistent store (SQLite via sqlx); \`waveobj/\`: object model - \`filestore/\`: local file cache/storage - \`telemetry/\`: metrics/events, periodic reporting`, }, { userPrompt: "can you analyze the log output in my terminal?", toolName: "term_get_scrollback", toolDescription: "reading terminal output from widget", markdownResponse: `Here's what your log shows: - Normal startup/init at 18:06:51 (router/register, DBGetAllObjsByType, Stat/Read). - Many FocusWindow events — normal UI activity. - 18:08:28.558 WebSocket ReadPump error: close 1001 (going away) — usually a tab/app reload, sleep, or brief network hiccup. - 18:08:29 re-init + new WebSocket connection — successful auto-reconnect. - SetMetaCommand and periodic filestore flushes with err: — normal; no failures. - No panics/fatal errors; just the transient disconnect. If that disconnect was unexpected, check for a tab reload/sleep at 18:08:28, and watch for repeats. Want deeper triage? Run these and paste results: \`\`\`bash # 1) Scan recent logs for problems grep -Ei 'error|panic|fatal|websocket|1006|1011' \\ "$HOME/Library/Application Support/waveterm-dev/waveapp.log" | tail -n 200 # 2) Inspect around the disconnect window awk '($0 ~ /2025-10-10 18:08:2[0-9]/){print}' \\ "$HOME/Library/Application Support/waveterm-dev/waveapp.log" # 3) Live follow for recurring drops tail -f "$HOME/Library/Application Support/waveterm-dev/waveapp.log" \\ | grep -Ei 'error|panic|fatal|websocket|close' \`\`\` Need me to look at a longer slice (e.g., last 1000 lines) or a different time range?`, }, ]; const AIThinking = memo(() => ( <div className="flex items-center gap-2"> <div className="animate-pulse flex items-center"> <i className="fa fa-circle text-[10px]"></i> <i className="fa fa-circle text-[10px] mx-1"></i> <i className="fa fa-circle text-[10px]"></i> </div> <span className="text-sm text-gray-400">AI is thinking...</span> </div> )); AIThinking.displayName = "AIThinking"; const FakeToolCall = memo(({ toolName, toolDescription }: { toolName: string; toolDescription: string }) => { return ( <div className="flex items-start gap-1 p-2 rounded bg-zinc-800 border border-gray-700 text-success"> <span className="font-bold">✓</span> <div className="flex-1"> <div className="font-semibold">{toolName}</div> <div className="text-sm text-gray-400">{toolDescription}</div> </div> </div> ); }); FakeToolCall.displayName = "FakeToolCall"; const FakeUserMessage = memo(({ userPrompt }: { userPrompt: string }) => { return ( <div className="flex justify-end"> <div className="px-2 py-2 rounded-lg bg-zinc-700 text-white max-w-[calc(100%-20px)]"> <div className="whitespace-pre-wrap break-words">{userPrompt}</div> </div> </div> ); }); FakeUserMessage.displayName = "FakeUserMessage"; const FakeAssistantMessage = memo(({ config, onComplete }: { config: ChatConfig; onComplete?: () => void }) => { const [phase, setPhase] = useState<"thinking" | "tool" | "streaming">("thinking"); const [streamedText, setStreamedText] = useState(""); useEffect(() => { const timeouts: NodeJS.Timeout[] = []; let streamInterval: NodeJS.Timeout | null = null; const runAnimation = () => { setPhase("thinking"); setStreamedText(""); timeouts.push( setTimeout(() => { setPhase("tool"); }, 2000) ); timeouts.push( setTimeout(() => { setPhase("streaming"); }, 4000) ); timeouts.push( setTimeout(() => { let currentIndex = 0; streamInterval = setInterval(() => { if (currentIndex >= config.markdownResponse.length) { if (streamInterval) { clearInterval(streamInterval); streamInterval = null; } if (onComplete) { onComplete(); } return; } currentIndex += 10; setStreamedText(config.markdownResponse.slice(0, currentIndex)); }, 100); }, 4000) ); }; runAnimation(); return () => { timeouts.forEach(clearTimeout); if (streamInterval) { clearInterval(streamInterval); } }; }, [config.markdownResponse, onComplete]); return ( <div className="flex justify-start"> <div className="px-2 py-2 rounded-lg"> {phase === "thinking" && <AIThinking />} {phase === "tool" && ( <> <div className="mb-2"> <FakeToolCall toolName={config.toolName} toolDescription={config.toolDescription} /> </div> <AIThinking /> </> )} {phase === "streaming" && ( <> <div className="mb-2"> <FakeToolCall toolName={config.toolName} toolDescription={config.toolDescription} /> </div> <WaveStreamdown text={streamedText} parseIncompleteMarkdown={true} className="text-gray-100" /> </> )} </div> </div> ); }); FakeAssistantMessage.displayName = "FakeAssistantMessage"; const FakeAIPanelHeader = memo(() => { return ( <div className="py-2 pl-3 pr-1 border-b border-gray-600 flex items-center justify-between min-w-0 bg-zinc-900"> <h2 className="text-white text-sm font-semibold flex items-center gap-2 flex-shrink-0 whitespace-nowrap"> <i className="fa fa-sparkles text-accent"></i> Wave AI </h2> <div className="flex items-center flex-shrink-0 whitespace-nowrap"> <div className="flex items-center text-sm whitespace-nowrap"> <span className="text-gray-300 mr-1 text-[12px]">Context</span> <button className="relative inline-flex h-6 w-14 items-center rounded-full transition-colors bg-accent-600" title="Widget Access ON" > <span className="absolute inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-8" /> <span className="relative z-10 text-xs text-white transition-all ml-2.5 mr-6 text-left font-bold"> ON </span> </button> </div> <button className="text-gray-400 transition-colors p-1 rounded flex-shrink-0 ml-2 focus:outline-none" title="More options" > <i className="fa fa-ellipsis-vertical"></i> </button> </div> </div> ); }); FakeAIPanelHeader.displayName = "FakeAIPanelHeader"; export const FakeChat = memo(() => { const scrollRef = useRef<HTMLDivElement>(null); const [chatIndex, setChatIndex] = useState(1); const config = chatConfigs[chatIndex] || chatConfigs[0]; useEffect(() => { const interval = setInterval(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, 1000); return () => clearInterval(interval); }, []); const handleComplete = () => { setTimeout(() => { setChatIndex((prev) => (prev + 1) % chatConfigs.length); }, 2000); }; return ( <div className="flex flex-col w-full h-full"> <FakeAIPanelHeader /> <div className="flex-1 overflow-hidden"> <div ref={scrollRef} className="flex flex-col gap-1 p-2 h-full overflow-y-auto bg-zinc-900"> <FakeUserMessage userPrompt={config.userPrompt} /> <FakeAssistantMessage config={config} onComplete={handleComplete} /> </div> </div> </div> ); }); FakeChat.displayName = "FakeChat"; ================================================ FILE: frontend/app/onboarding/onboarding-command.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { FakeBlock } from "./onboarding-layout"; import { FakeTermBlock } from "./onboarding-layout-term"; import waveLogo from "/logos/wave-logo.png"; export type CommandRevealProps = { command: string; typeIntervalMs?: number; onComplete?: () => void; showCursor?: boolean; }; export const CommandReveal = ({ command, typeIntervalMs = 100, onComplete, showCursor: showCursorProp = true, }: CommandRevealProps) => { const [displayedText, setDisplayedText] = useState(""); const [showCursor, setShowCursor] = useState(true); const [isComplete, setIsComplete] = useState(false); useLayoutEffect(() => { let charIndex = 0; const typeInterval = setInterval(() => { if (charIndex < command.length) { setDisplayedText(command.slice(0, charIndex + 1)); charIndex++; } else { clearInterval(typeInterval); setIsComplete(true); setShowCursor(false); if (onComplete) { onComplete(); } } }, typeIntervalMs); const cursorInterval = setInterval(() => { setShowCursor((prev) => !prev); }, 500); return () => { clearInterval(typeInterval); clearInterval(cursorInterval); }; }, [command, typeIntervalMs, onComplete]); return ( <div className="flex items-center gap-2 font-mono text-sm"> <span className="text-accent">></span> <span className="text-foreground/80"> {displayedText} {showCursorProp && !isComplete && showCursor && ( <span className="inline-block w-2 h-4 bg-foreground/80 ml-0.5 align-middle"></span> )} </span> </div> ); }; export type FakeCommandProps = { command: string; typeIntervalMs?: number; onComplete?: () => void; children: React.ReactNode; }; export const FakeCommand = ({ command, typeIntervalMs = 100, onComplete, children }: FakeCommandProps) => { const [commandComplete, setCommandComplete] = useState(false); const handleCommandComplete = useCallback(() => { setCommandComplete(true); if (onComplete) { onComplete(); } }, [onComplete]); return ( <div className="w-full h-[400px] bg-background rounded border border-border/50 p-4 flex flex-col gap-4"> <CommandReveal command={command} onComplete={handleCommandComplete} typeIntervalMs={typeIntervalMs} /> {commandComplete && <div className="flex-1 min-h-0">{children}</div>} </div> ); }; export const ViewShortcutsCommand = ({ isMac, onComplete }: { isMac: boolean; onComplete?: () => void }) => { const modKey = isMac ? "⌘ Cmd" : "Alt"; const markdown = `### Keyboard Shortcuts **Switch Tabs** Press ${modKey} + Number (1-9) to quickly switch between tabs. **Navigate Blocks** Use Ctrl-Shift + Arrow Keys (←→↑↓) to move between blocks in the current tab. Use Ctrl-Shift + Number (1-9) to focus a specific block by its position.`; return ( <FakeCommand command="wsh view keyboard-shortcuts.md" onComplete={onComplete}> <FakeBlock icon="file-lines" name="keyboard-shortcuts.md" markdown={markdown} /> </FakeCommand> ); }; export const ViewLogoCommand = ({ onComplete }: { onComplete?: () => void }) => { return ( <FakeCommand command="wsh view public/wave-logo.png" onComplete={onComplete}> <FakeBlock icon="image" name="wave-logo.png" imgsrc={waveLogo} /> </FakeCommand> ); }; export const EditBashrcCommand = ({ onComplete }: { onComplete?: () => void }) => { const fileNameRef = useRef(`${crypto.randomUUID()}/.bashrc`); const bashrcContent = `# Aliases alias ll="ls -lah" alias gst="git status" alias wave="wsh" # Custom prompt PS1="\\[\\e[32m\\]\\u@\\h\\[\\e[0m\\]:\\[\\e[34m\\]\\w\\[\\e[0m\\]\\$ " # PATH export PATH="$HOME/.local/bin:$PATH"`; return ( <FakeCommand command="wsh edit ~/.bashrc" onComplete={onComplete}> <FakeBlock icon="file-lines" name=".bashrc" editorText={bashrcContent} editorFileName={fileNameRef.current} editorLanguage="shell" /> </FakeCommand> ); }; ================================================ FILE: frontend/app/onboarding/onboarding-common.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export const CurrentOnboardingVersion = "v0.14.3"; export function OnboardingGradientBg() { return ( <div className="absolute inset-0 bg-gradient-to-br from-accent/[0.25] via-transparent to-accent/[0.05] pointer-events-none rounded-[10px]" /> ); } ================================================ FILE: frontend/app/onboarding/onboarding-durable.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; import { EmojiButton } from "@/app/element/emojibutton"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useState } from "react"; import { CurrentOnboardingVersion } from "./onboarding-common"; import { OnboardingFooter } from "./onboarding-features-footer"; import { TailDeployLogCommand } from "./onboarding-layout-term"; export const DurableSessionPage = ({ onNext, onSkip, onPrev, }: { onNext: () => void; onSkip: () => void; onPrev?: () => void; }) => { const [fireClicked, setFireClicked] = useState(false); const handleFireClick = () => { setFireClicked(!fireClicked); if (!fireClicked) { RpcApi.RecordTEventCommand(TabRpcClient, { event: "onboarding:fire", props: { "onboarding:feature": "durable", "onboarding:version": CurrentOnboardingVersion, }, }); } }; return ( <div className="flex flex-col h-full"> <header className="flex items-center gap-4 mb-6 w-full unselectable flex-shrink-0"> <div> <Logo /> </div> <div className="text-[25px] font-normal text-foreground">Durable SSH Sessions</div> </header> <div className="flex-1 flex flex-row gap-0 min-h-0"> <div className="flex-1 flex flex-col items-center justify-center gap-8 pr-3 unselectable"> <div className="flex flex-col items-start gap-3 max-w-md"> <div className="flex h-[52px] ml-[-4px] pl-3 pr-3 items-center rounded-lg bg-hover text-[15px]"> <i className="fa-sharp fa-solid fa-shield text-sky-500" /> <span className="font-bold ml-2 text-primary">SSH Sessions, Protected</span> </div> <div className="flex flex-col items-start gap-4 text-secondary"> <p>Close your laptop, switch networks, restart Wave — your remote sessions keep running.</p> <div className="flex items-start gap-3 w-full"> <i className="fa-sharp fa-solid fa-link text-accent text-lg mt-1 flex-shrink-0" /> <p>Shell state, running programs, and terminal history are all preserved</p> </div> <div className="flex items-start gap-3 w-full"> <i className="fa-sharp fa-solid fa-rotate text-accent text-lg mt-1 flex-shrink-0" /> <p>Sessions automatically reconnect when your connection is restored</p> </div> <div className="flex items-start gap-3 w-full"> <i className="fa-sharp fa-solid fa-box text-accent text-lg mt-1 flex-shrink-0" /> <p>Buffered output streams back in, never miss a line</p> </div> <p className="italic"> All the persistence of tmux, built into your terminal. Look for the shield icon to enable durability on any SSH session. </p> <EmojiButton emoji="🔥" isClicked={fireClicked} onClick={handleFireClick} /> </div> </div> </div> <div className="w-[2px] bg-border flex-shrink-0"></div> <div className="flex items-center justify-center pl-6 flex-shrink-0 w-[500px]"> <TailDeployLogCommand /> </div> </div> <OnboardingFooter currentStep={2} totalSteps={4} onNext={onNext} onPrev={onPrev} onSkip={onSkip} /> </div> ); }; ================================================ FILE: frontend/app/onboarding/onboarding-features-footer.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; export const OnboardingFooter = ({ currentStep, totalSteps, onNext, onPrev, onSkip, }: { currentStep: number; totalSteps: number; onNext: () => void; onPrev?: () => void; onSkip?: () => void; }) => { const isLastStep = currentStep === totalSteps; const buttonText = isLastStep ? "Get Started" : "Next"; return ( <footer className="unselectable flex-shrink-0 mt-5 relative"> <div className="absolute left-0 top-1/2 -translate-y-1/2 flex items-center gap-2"> {currentStep > 1 && onPrev && ( <button className="text-muted cursor-pointer hover:text-foreground text-[13px]" onClick={onPrev}> < Prev </button> )} <span className="text-muted text-[13px]"> {currentStep} of {totalSteps} </span> </div> <div className="flex flex-row items-center justify-center [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm"> <Button className="font-[600]" onClick={onNext}> {buttonText} </Button> </div> {!isLastStep && onSkip && ( <button className="absolute right-0 top-1/2 -translate-y-1/2 text-muted cursor-pointer hover:text-muted-hover text-[13px]" onClick={onSkip} > Skip Feature Tour > </button> )} </footer> ); }; ================================================ FILE: frontend/app/onboarding/onboarding-features.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; import { EmojiButton } from "@/app/element/emojibutton"; import { MagnifyIcon } from "@/app/element/magnify"; import { ClientModel } from "@/app/store/client-model"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { isMacOS } from "@/util/platformutil"; import { useEffect, useState } from "react"; import { FakeChat } from "./fakechat"; import { EditBashrcCommand, ViewLogoCommand, ViewShortcutsCommand } from "./onboarding-command"; import { CurrentOnboardingVersion } from "./onboarding-common"; import { DurableSessionPage } from "./onboarding-durable"; import { OnboardingFooter } from "./onboarding-features-footer"; import { FakeLayout } from "./onboarding-layout"; type FeaturePageName = "waveai" | "durable" | "magnify" | "files"; export const WaveAIPage = ({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) => { const isMac = isMacOS(); const shortcutKey = isMac ? "⌘-Shift-A" : "Alt-Shift-A"; const [fireClicked, setFireClicked] = useState(false); const handleFireClick = () => { setFireClicked(!fireClicked); if (!fireClicked) { RpcApi.RecordTEventCommand(TabRpcClient, { event: "onboarding:fire", props: { "onboarding:feature": "waveai", "onboarding:version": CurrentOnboardingVersion, }, }); } }; return ( <div className="flex flex-col h-full"> <header className="flex items-center gap-4 mb-6 w-full unselectable flex-shrink-0"> <div> <Logo /> </div> <div className="text-[25px] font-normal text-foreground">Wave AI</div> </header> <div className="flex-1 flex flex-row gap-0 min-h-0"> <div className="flex-1 flex flex-col items-center justify-center gap-8 pr-6 unselectable"> <div className="flex flex-col items-start gap-6 max-w-md"> <div className="flex h-[52px] px-3 items-center rounded-lg bg-hover text-accent text-[24px]"> <i className="fa fa-sparkles" /> <span className="font-bold ml-2 font-mono">AI</span> </div> <div className="flex flex-col items-start gap-4 text-secondary"> <p> Wave AI is your terminal assistant with context. I can read your terminal output, analyze widgets, read/write files, and help you solve problems faster. </p> <div className="flex items-start gap-3 w-full"> <i className="fa fa-sparkles text-accent text-lg mt-1 flex-shrink-0" /> <p> Toggle the Wave AI panel with the{" "} <span className="inline-flex h-[26px] px-1.5 items-center rounded-md box-border bg-hover text-accent text-[12px] align-middle"> <i className="fa fa-sparkles" /> <span className="font-bold ml-1 font-mono">AI</span> </span>{" "} button in the header (top left) </p> </div> <div className="flex items-start gap-3 w-full"> <i className="fa fa-keyboard text-accent text-lg mt-1 flex-shrink-0" /> <p> Or use the keyboard shortcut{" "} <span className="font-mono font-semibold text-foreground whitespace-nowrap"> {shortcutKey} </span>{" "} to quickly toggle </p> </div> <div className="flex items-start gap-3 w-full"> <i className="fa fa-key text-accent text-lg mt-1 flex-shrink-0" /> <p> Bring your own API keys or run local models with Ollama, LM Studio, and other OpenAI-compatible providers </p> </div> <EmojiButton emoji="🔥" isClicked={fireClicked} onClick={handleFireClick} /> </div> </div> </div> <div className="w-[2px] bg-border flex-shrink-0"></div> <div className="flex items-center justify-center pl-6 flex-shrink-0 w-[400px]"> <div className="w-full h-[400px] bg-background rounded border border-border/50 overflow-hidden"> <FakeChat /> </div> </div> </div> <OnboardingFooter currentStep={1} totalSteps={4} onNext={onNext} onSkip={onSkip} /> </div> ); }; export const MagnifyBlocksPage = ({ onNext, onSkip, onPrev, }: { onNext: () => void; onSkip: () => void; onPrev?: () => void; }) => { const isMac = isMacOS(); const shortcutKey = isMac ? "⌘" : "Alt"; const [fireClicked, setFireClicked] = useState(false); const handleFireClick = () => { setFireClicked(!fireClicked); if (!fireClicked) { RpcApi.RecordTEventCommand(TabRpcClient, { event: "onboarding:fire", props: { "onboarding:feature": "magnify", "onboarding:version": CurrentOnboardingVersion, }, }); } }; return ( <div className="flex flex-col h-full"> <header className="flex items-center gap-4 mb-6 w-full unselectable flex-shrink-0"> <div> <Logo /> </div> <div className="text-[25px] font-normal text-foreground">Magnify Blocks</div> </header> <div className="flex-1 flex flex-row gap-0 min-h-0"> <div className="flex-1 flex flex-col items-center justify-center gap-8 pr-6 unselectable"> <div className="text-6xl font-semibold text-foreground">{shortcutKey}-M</div> <div className="flex flex-col items-start gap-4 text-secondary max-w-md"> <p> Magnify any block to focus on what matters. Expand terminals, editors, and previews for a better view. </p> <p>Use the magnify feature to work with complex outputs and large files more efficiently.</p> <div> You can also magnify a block by clicking on the{" "} <span className="inline-block align-middle [&_svg_path]:!fill-foreground"> <MagnifyIcon enabled={false} /> </span>{" "} icon in the block header. </div> <p> A quick {shortcutKey}-M to magnify and another {shortcutKey}-M to unmagnify </p> <EmojiButton emoji="🔥" isClicked={fireClicked} onClick={handleFireClick} /> </div> </div> <div className="w-[2px] bg-border flex-shrink-0"></div> <div className="flex items-center justify-center pl-6 flex-shrink-0 w-[400px]"> <FakeLayout /> </div> </div> <OnboardingFooter currentStep={3} totalSteps={4} onNext={onNext} onPrev={onPrev} onSkip={onSkip} /> </div> ); }; export const FilesPage = ({ onFinish, onPrev }: { onFinish: () => void; onPrev?: () => void }) => { const [fireClicked, setFireClicked] = useState(false); const isMac = isMacOS(); const [commandIndex, setCommandIndex] = useState(0); const handleFireClick = () => { setFireClicked(!fireClicked); if (!fireClicked) { RpcApi.RecordTEventCommand(TabRpcClient, { event: "onboarding:fire", props: { "onboarding:feature": "wsh", "onboarding:version": CurrentOnboardingVersion, }, }); } }; const commands = [ (onComplete: () => void) => <EditBashrcCommand onComplete={onComplete} />, (onComplete: () => void) => <ViewShortcutsCommand isMac={isMac} onComplete={onComplete} />, (onComplete: () => void) => <ViewLogoCommand onComplete={onComplete} />, ]; const handleCommandComplete = () => { setTimeout(() => { setCommandIndex((prev) => (prev + 1) % commands.length); }, 2500); }; return ( <div className="flex flex-col h-full"> <header className="flex items-center gap-4 mb-6 w-full unselectable flex-shrink-0"> <div> <Logo /> </div> <div className="text-[25px] font-normal text-foreground">Viewing/Editing Files</div> </header> <div className="flex-1 flex flex-row gap-0 min-h-0"> <div className="flex-1 flex flex-col items-center justify-center gap-8 pr-6 unselectable"> <div className="flex flex-col items-start gap-6 max-w-md"> <div className="flex flex-col items-start gap-4 text-secondary"> <p> Wave can preview markdown, images, and video files on both local <i>and remote</i>{" "} machines. </p> <div className="flex items-start gap-3 w-full"> <i className="fa fa-eye text-accent text-lg mt-1 flex-shrink-0" /> <div> <p className="mb-2"> Use{" "} <span className="font-mono font-semibold text-foreground"> wsh view [filename] </span>{" "} to preview files in Wave's graphical viewer </p> </div> </div> <div className="flex items-start gap-3 w-full"> <i className="fa fa-pen-to-square text-accent text-lg mt-1 flex-shrink-0" /> <div> <p className="mb-2"> Use{" "} <span className="font-mono font-semibold text-foreground"> wsh edit [filename] </span>{" "} to open config files or code files in Wave's graphical editor </p> </div> </div> <p> These commands work seamlessly on both local and remote machines, making it easy to view and edit files wherever they are. </p> <EmojiButton emoji="🔥" isClicked={fireClicked} onClick={handleFireClick} /> </div> </div> </div> <div className="w-[2px] bg-border flex-shrink-0"></div> <div className="flex items-center justify-center pl-6 flex-shrink-0 w-[400px]"> {commands[commandIndex](handleCommandComplete)} </div> </div> <OnboardingFooter currentStep={4} totalSteps={4} onNext={onFinish} onPrev={onPrev} /> </div> ); }; export const OnboardingFeatures = ({ onComplete }: { onComplete: () => void }) => { const [currentPage, setCurrentPage] = useState<FeaturePageName>("waveai"); useEffect(() => { const clientId = ClientModel.getInstance().clientId; RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:lastversion": CurrentOnboardingVersion }, }); RpcApi.RecordTEventCommand(TabRpcClient, { event: "onboarding:start", props: { "onboarding:version": CurrentOnboardingVersion, }, }); }, []); const handleNext = () => { if (currentPage === "waveai") { setCurrentPage("durable"); } else if (currentPage === "durable") { setCurrentPage("magnify"); } else if (currentPage === "magnify") { setCurrentPage("files"); } }; const handlePrev = () => { if (currentPage === "durable") { setCurrentPage("waveai"); } else if (currentPage === "magnify") { setCurrentPage("durable"); } else if (currentPage === "files") { setCurrentPage("magnify"); } }; const handleSkip = () => { RpcApi.RecordTEventCommand(TabRpcClient, { event: "onboarding:skip", props: {}, }); onComplete(); }; const handleFinish = () => { onComplete(); }; let pageComp: React.JSX.Element = null; switch (currentPage) { case "waveai": pageComp = <WaveAIPage onNext={handleNext} onSkip={handleSkip} />; break; case "durable": pageComp = <DurableSessionPage onNext={handleNext} onSkip={handleSkip} onPrev={handlePrev} />; break; case "magnify": pageComp = <MagnifyBlocksPage onNext={handleNext} onSkip={handleSkip} onPrev={handlePrev} />; break; case "files": pageComp = <FilesPage onFinish={handleFinish} onPrev={handlePrev} />; break; } return <div className="flex flex-col w-full h-full">{pageComp}</div>; }; ================================================ FILE: frontend/app/onboarding/onboarding-layout-term.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { MagnifyIcon } from "@/app/element/magnify"; import { cn, makeIconClass } from "@/util/util"; import { useCallback, useLayoutEffect, useState } from "react"; import { CommandReveal } from "./onboarding-command"; export type FakeTermBlockProps = { connectionName?: string; durableStatus?: "connected" | "detached" | null; className?: string; command?: string; typeIntervalMs?: number; onComplete?: () => void; children?: React.ReactNode; }; export const FakeTermBlock = ({ connectionName = "ubuntu@remoteserver", durableStatus = null, className, command, typeIntervalMs = 80, onComplete, children, }: FakeTermBlockProps) => { const color = "var(--conn-icon-color-1)"; const durableIconColor = durableStatus === "connected" ? "text-sky-500" : "text-sky-300"; return ( <div className={cn( "w-full h-full bg-background rounded flex flex-col overflow-hidden border-2 border-accent", className )} > <div className="flex items-center gap-2 px-2 py-1.5 bg-border/20 border-b border-border/50 pl-[2px]"> <div className="group flex items-center flex-nowrap overflow-hidden text-ellipsis min-w-0 font-normal text-primary rounded-sm"> <span className="fa-stack flex-[1_1_auto] overflow-hidden"> <i className={cn(makeIconClass("arrow-right-arrow-left", false), "fa-stack-1x mr-[2px]")} style={{ color: color }} /> </span> <div className="flex-[1_2_auto] overflow-hidden pr-1 ellipsis">{connectionName}</div> </div> {durableStatus && ( <div className="iconbutton disabled text-[13px] ml-[-4px]"> <i className={`fa-sharp fa-solid fa-shield ${durableIconColor}`} /> </div> )} <div className="flex-1" /> <span className="inline-block [&_svg]:fill-foreground/50 [&_svg_path]:!fill-foreground/50"> <MagnifyIcon enabled={false} /> </span> <i className={makeIconClass("xmark-large", false) + " text-xs text-foreground/50"} /> </div> <div className="flex-1 overflow-auto p-4"> {children ? ( children ) : command ? ( <div className="font-mono text-sm"> <CommandReveal command={command} typeIntervalMs={typeIntervalMs} onComplete={onComplete} /> </div> ) : ( <div className="flex items-center justify-center h-full"> <i className={makeIconClass("terminal", false) + " text-4xl text-foreground/50"} /> </div> )} </div> </div> ); }; const deployMessages = [ "[1/8] Installing dependencies...", "[2/8] Generating TypeScript types from Go...", "[3/8] Building Go backend (wavesrv)...", "[4/8] Compiling TypeScript frontend...", "[5/8] Bundling Electron renderer...", "[6/8] Packaging application artifacts...", "[7/8] Code signing binaries...", "[8/8] Deploy complete ✓", ]; type OverlayState = null | "disconnected" | "connected"; const ConnectionOverlay = ({ state }: { state: OverlayState }) => { if (!state) return null; const isConnected = state === "connected"; return ( <div className="absolute inset-0 flex items-center justify-center z-10"> <div className="bg-white/20 backdrop-blur-[2px] rounded-lg flex flex-col items-center justify-center gap-4 px-12 py-8 w-[50%]"> <i className={cn( "fa-sharp fa-solid", isConnected ? "fa-wifi text-green-400" : "fa-wifi-slash text-red-400", "text-6xl" )} /> <div className="text-2xl font-semibold text-foreground"> {isConnected ? "Connected" : "Disconnected"} </div> </div> </div> ); }; const DeployLogOutput = ({ onComplete, onOverlayStateChange, }: { onComplete?: () => void; onOverlayStateChange?: (state: OverlayState) => void; }) => { const [key, setKey] = useState(0); const [commandComplete, setCommandComplete] = useState(false); const [visibleLines, setVisibleLines] = useState(0); const [showPrompt, setShowPrompt] = useState(false); const [showCursor, setShowCursor] = useState(false); const [overlayState, setOverlayState] = useState<OverlayState>(null); useLayoutEffect(() => { if (onOverlayStateChange) { onOverlayStateChange(overlayState); } }, [overlayState, onOverlayStateChange]); const handleCommandComplete = useCallback(() => { setCommandComplete(true); }, []); const resetAnimation = useCallback(() => { setCommandComplete(false); setVisibleLines(0); setShowPrompt(false); setShowCursor(false); setOverlayState(null); setKey((prev) => prev + 1); }, []); useLayoutEffect(() => { if (!commandComplete) return; let timeoutId: NodeJS.Timeout; const runSequence = async () => { // Show message 1 setVisibleLines(1); await new Promise((resolve) => { timeoutId = setTimeout(resolve, 1000); }); // Show message 2 setVisibleLines(2); await new Promise((resolve) => { timeoutId = setTimeout(resolve, 1000); }); // Show disconnected overlay setOverlayState("disconnected"); await new Promise((resolve) => { timeoutId = setTimeout(resolve, 2500); }); // Change to connected setOverlayState("connected"); await new Promise((resolve) => { timeoutId = setTimeout(resolve, 1000); }); // Remove overlay and show messages 3-7 instantly setOverlayState(null); setVisibleLines(7); // Show message 8 setVisibleLines(8); await new Promise((resolve) => { timeoutId = setTimeout(resolve, 1000); }); // Show prompt setShowPrompt(true); setShowCursor(true); if (onComplete) { onComplete(); } // Wait 6 seconds then restart await new Promise((resolve) => { timeoutId = setTimeout(resolve, 6000); }); resetAnimation(); }; runSequence(); return () => { if (timeoutId) { clearTimeout(timeoutId); } }; }, [commandComplete, onComplete, resetAnimation]); useLayoutEffect(() => { if (!showPrompt) return; const cursorInterval = setInterval(() => { setShowCursor((prev) => !prev); }, 500); return () => clearInterval(cursorInterval); }, [showPrompt]); return ( <> <div className="font-mono text-sm flex flex-col gap-1"> <CommandReveal key={key} command="tail -f deploy.log" typeIntervalMs={80} onComplete={handleCommandComplete} /> {commandComplete && ( <> {deployMessages.slice(0, visibleLines).map((msg, idx) => ( <div key={idx} className="text-foreground/70"> {msg} </div> ))} {showPrompt && ( <div className="flex items-center gap-2"> <span className="text-accent">></span> {showCursor && ( <span className="inline-block w-2 h-4 bg-foreground/80 align-middle"></span> )} </div> )} </> )} </div> {overlayState && <ConnectionOverlay state={overlayState} />} </> ); }; export const TailDeployLogCommand = ({ onComplete }: { onComplete?: () => void }) => { const [overlayState, setOverlayState] = useState<OverlayState>(null); const durableStatus = overlayState === "disconnected" ? "detached" : "connected"; return ( <FakeTermBlock connectionName="ubuntu@remoteserver" durableStatus={durableStatus} className="relative"> <DeployLogOutput onComplete={onComplete} onOverlayStateChange={setOverlayState} /> </FakeTermBlock> ); }; ================================================ FILE: frontend/app/onboarding/onboarding-layout.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { MagnifyIcon } from "@/app/element/magnify"; import { WaveStreamdown } from "@/app/element/streamdown"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { cn, makeIconClass } from "@/util/util"; import { useLayoutEffect, useRef, useState } from "react"; export type FakeBlockProps = { icon: string; name: string; highlighted?: boolean; className?: string; markdown?: string; imgsrc?: string; editorText?: string; editorFileName?: string; editorLanguage?: string; }; export const FakeBlock = ({ icon, name, highlighted, className, markdown, imgsrc, editorText, editorFileName, editorLanguage, }: FakeBlockProps) => { return ( <div className={cn( "w-full h-full bg-background rounded flex flex-col overflow-hidden border-2", highlighted ? "border-accent" : "border-border/50", className )} > <div className="flex items-center gap-2 px-2 py-1.5 bg-border/20 border-b border-border/50"> <i className={makeIconClass(icon, false) + " text-xs text-foreground/70"} /> <span className="text-xs text-foreground/70 flex-1">{name}</span> <span className="inline-block [&_svg]:fill-foreground/50 [&_svg_path]:!fill-foreground/50"> <MagnifyIcon enabled={false} /> </span> <i className={makeIconClass("xmark-large", false) + " text-xs text-foreground/50"} /> </div> <div className="flex-1 flex items-center justify-center overflow-auto p-4"> {editorText ? ( <div className="w-full h-full"> <CodeEditor blockId="fake-block" text={editorText} readonly={true} fileName={editorFileName} language={editorLanguage ?? "shell"} /> </div> ) : imgsrc ? ( <img src={imgsrc} alt={name} className="max-w-full max-h-full object-contain" /> ) : markdown ? ( <div className="w-full"> <WaveStreamdown text={markdown} /> </div> ) : ( <i className={makeIconClass(icon, false) + " text-4xl text-foreground/50"} /> )} </div> </div> ); }; export const FakeLayout = () => { const layoutRef = useRef<HTMLDivElement>(null); const highlightedContainerRef = useRef<HTMLDivElement>(null); const [blockRect, setBlockRect] = useState<{ left: number; top: number; width: number; height: number } | null>( null ); const [isExpanded, setIsExpanded] = useState(false); useLayoutEffect(() => { if (highlightedContainerRef.current) { const elem = highlightedContainerRef.current; setBlockRect({ left: elem.offsetLeft, top: elem.offsetTop, width: elem.offsetWidth, height: elem.offsetHeight, }); } }, []); useLayoutEffect(() => { if (!blockRect) return; const timeouts: NodeJS.Timeout[] = []; const addTimeout = (callback: () => void, delay: number) => { const id = setTimeout(callback, delay); timeouts.push(id); }; const runAnimationCycle = (isFirstRun: boolean) => { const initialDelay = isFirstRun ? 1500 : 3000; addTimeout(() => { setIsExpanded(true); addTimeout(() => { setIsExpanded(false); addTimeout(() => runAnimationCycle(false), 3000); }, 3200); }, initialDelay); }; runAnimationCycle(true); return () => { timeouts.forEach(clearTimeout); }; }, [blockRect]); const getAnimatedStyle = () => { if (!blockRect || !layoutRef.current) { return { left: blockRect?.left ?? 0, top: blockRect?.top ?? 0, width: blockRect?.width ?? 0, height: blockRect?.height ?? 0, }; } if (isExpanded) { const layoutWidth = layoutRef.current.offsetWidth; const layoutHeight = layoutRef.current.offsetHeight; const targetWidth = layoutWidth * 0.85; const targetHeight = layoutHeight * 0.85; return { left: (layoutWidth - targetWidth) / 2, top: (layoutHeight - targetHeight) / 2, width: targetWidth, height: targetHeight, }; } return { left: blockRect.left, top: blockRect.top, width: blockRect.width, height: blockRect.height, }; }; return ( <div ref={layoutRef} className="w-full h-[400px] flex flex-row gap-2 relative"> <div className="flex-1"> <FakeBlock icon="terminal" name="Terminal" /> </div> <div className="flex-1 flex flex-col gap-2"> <div className="flex-1"> <FakeBlock icon="globe" name="Web" /> </div> <div className="flex-1" ref={highlightedContainerRef}> <FakeBlock icon="terminal" name="Terminal" highlighted={true} className="opacity-0" /> </div> </div> {blockRect && ( <> <div className={cn( "absolute inset-0 bg-black/50 transition-opacity duration-200", isExpanded ? "opacity-100" : "opacity-0" )} /> <div className="absolute transition-all duration-200 ease-in-out" style={getAnimatedStyle()}> <FakeBlock icon="terminal" name="Terminal" highlighted={true} /> </div> </> )} </div> ); }; ================================================ FILE: frontend/app/onboarding/onboarding-starask.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; import { Button } from "@/app/element/button"; import { ClientModel } from "@/app/store/client-model"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; type StarAskPageProps = { onClose: () => void; page?: string; }; export function StarAskPage({ onClose, page = "upgrade" }: StarAskPageProps) { const handleStarClick = async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "onboarding:githubstar", props: { "onboarding:githubstar": "star", "onboarding:page": page }, }, { noresponse: true } ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:githubstar": true }, }); window.open(`https://github.com/wavetermdev/waveterm?ref=${page}`, "_blank"); onClose(); }; const handleAlreadyStarred = async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "onboarding:githubstar", props: { "onboarding:githubstar": "already", "onboarding:page": page }, }, { noresponse: true } ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:githubstar": true }, }); onClose(); }; const handleRepoLinkClick = () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "action:link", props: { "action:type": "githubrepo", "onboarding:page": page }, }, { noresponse: true } ); window.open("https://github.com/wavetermdev/waveterm", "_blank"); }; const handleMaybeLater = async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "onboarding:githubstar", props: { "onboarding:githubstar": "later", "onboarding:page": page }, }, { noresponse: true } ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:githubstar": false }, }); onClose(); }; return ( <div className="flex flex-col h-full"> <header className="flex flex-col gap-2 border-b-0 p-0 mt-1 mb-6 w-full unselectable flex-shrink-0"> <div className="flex justify-center"> <Logo /> </div> <div className="text-center text-[25px] font-normal text-foreground">Support open-source. Star Wave. ⭐</div> </header> <div className="flex-1 flex flex-col items-center justify-center gap-5 unselectable"> <div className="flex flex-col items-center gap-4 max-w-[460px] text-center"> <div className="text-secondary text-sm leading-relaxed"> Wave is free, open-source, and open-model. Stars help us stay visible against closed alternatives. One click makes a difference. </div> <div className="group flex items-center justify-center gap-2 text-secondary text-sm mt-1 cursor-pointer transition-colors" onClick={handleRepoLinkClick} > <i className="fa-brands fa-github text-foreground text-lg group-hover:text-accent transition-colors" /> <span className="text-foreground font-mono text-sm group-hover:text-accent group-hover:underline transition-colors"> wavetermdev/waveterm </span> </div> </div> </div> <footer className="unselectable flex-shrink-0 mt-6"> <div className="flex flex-row items-center justify-center gap-2.5 [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm [&>button]:!h-[37px]"> <Button className="outlined grey font-[600]" onClick={handleAlreadyStarred}> 🙏 Already Starred </Button> <Button className="outlined green font-[600]" onClick={handleStarClick}> ⭐ Star Now </Button> <Button className="outlined grey font-[600]" onClick={handleMaybeLater}> Maybe Later </Button> </div> </footer> </div> ); } ================================================ FILE: frontend/app/onboarding/onboarding-upgrade-minor.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; import { Button } from "@/app/element/button"; import { FlexiModal } from "@/app/modals/modal"; import { CurrentOnboardingVersion, OnboardingGradientBg } from "@/app/onboarding/onboarding-common"; import { OnboardingFeatures } from "@/app/onboarding/onboarding-features"; import { ClientModel } from "@/app/store/client-model"; import { globalStore } from "@/app/store/global"; import { disableGlobalKeybindings, enableGlobalKeybindings, globalRefocus } from "@/app/store/keymodel"; import { modalsModel } from "@/app/store/modalmodel"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { useEffect, useRef, useState } from "react"; import { debounce } from "throttle-debounce"; type UpgradeMinorWelcomePageProps = { onStarClick: () => void; onAlreadyStarred: () => void; onMaybeLater: () => void; }; const UpgradeMinorWelcomePage = ({ onStarClick, onAlreadyStarred, onMaybeLater }: UpgradeMinorWelcomePageProps) => { return ( <div className="flex flex-col h-full"> <header className="flex flex-col gap-2 border-b-0 p-0 mt-1 mb-4 w-full unselectable flex-shrink-0"> <div className="flex justify-center"> <Logo /> </div> <div className="text-center text-[25px] font-normal text-foreground">Welcome to Wave v0.14!</div> </header> <OverlayScrollbarsComponent className="flex-1 overflow-y-auto min-h-0" options={{ scrollbars: { autoHide: "never" } }} > <div className="flex flex-col items-center gap-3 w-full mb-2 unselectable"> <div className="flex flex-col items-center gap-4"> <div className="flex flex-row gap-4 items-center"> <div className="flex h-[52px] px-3 items-center rounded-lg bg-hover text-accent text-[24px]"> <i className="fa fa-sparkles" /> <span className="font-bold ml-2 font-mono">Wave AI</span> </div> <div className="flex h-[52px] px-3 items-center rounded-lg bg-hover text-[18px]"> <i className="fa-sharp fa-solid fa-shield text-sky-500" /> <span className="font-bold ml-2 text-accent">Durable SSH Sessions</span> </div> </div> <div className="text-secondary leading-relaxed max-w-[600px] text-left"> <p className="mb-4"> Wave AI is your terminal assistant with full context. It can read your terminal output, analyze widgets, read and write files, and help you solve problems faster. </p> <p className="mb-4"> <span className="font-semibold text-foreground">New in v0.13:</span> Wave AI now supports local models and bring-your-own-key! Use Ollama, LM Studio, vLLM, OpenRouter, or any OpenAI-compatible provider. </p> <p className="mb-4"> <span className="font-semibold text-foreground">New in v0.14:</span> Durable SSH sessions survive network drops, laptop sleep, and restarts — all without tmux or screen. </p> </div> </div> <div className="w-full max-w-[550px] border-t border-border my-2"></div> <div className="flex flex-col items-center gap-3 text-center max-w-[550px]"> <div className="text-foreground text-base">Thanks for being an early Wave adopter! ⭐</div> <div className="text-secondary text-sm text-left"> A GitHub star shows your support for Wave (and open-source) and helps us reach more developers. </div> </div> </div> </OverlayScrollbarsComponent> <footer className="unselectable flex-shrink-0 mt-4"> <div className="flex flex-row items-center justify-center gap-2.5 [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm [&>button]:!h-[37px]"> <Button className="outlined grey font-[600]" onClick={onAlreadyStarred}> 🙏 Already Starred </Button> <Button className="outlined green font-[600]" onClick={onStarClick}> ⭐ Star Now </Button> <Button className="outlined grey font-[600]" onClick={onMaybeLater}> Maybe Later </Button> </div> </footer> </div> ); }; UpgradeMinorWelcomePage.displayName = "UpgradeMinorWelcomePage"; const UpgradeOnboardingMinor = () => { const modalRef = useRef<HTMLDivElement | null>(null); const [pageName, setPageName] = useState<"welcome" | "features">("welcome"); const [isCompact, setIsCompact] = useState<boolean>(window.innerHeight < 800); const updateModalHeight = () => { const windowHeight = window.innerHeight; setIsCompact(windowHeight < 800); if (modalRef.current) { const modalHeight = modalRef.current.offsetHeight; const maxHeight = windowHeight * 0.9; if (maxHeight < modalHeight) { modalRef.current.style.height = `${maxHeight}px`; } else { modalRef.current.style.height = "auto"; } } }; useEffect(() => { updateModalHeight(); const debouncedUpdateModalHeight = debounce(150, updateModalHeight); window.addEventListener("resize", debouncedUpdateModalHeight); return () => { window.removeEventListener("resize", debouncedUpdateModalHeight); }; }, []); useEffect(() => { disableGlobalKeybindings(); return () => { enableGlobalKeybindings(); }; }, []); const handleStarClick = async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "onboarding:githubstar", props: { "onboarding:githubstar": "star", "onboarding:page": "minorupgrade" }, }, { noresponse: true } ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:githubstar": true }, }); window.open("https://github.com/wavetermdev/waveterm?ref=upgrade", "_blank"); setPageName("features"); }; const handleAlreadyStarred = async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "onboarding:githubstar", props: { "onboarding:githubstar": "already", "onboarding:page": "minorupgrade" }, }, { noresponse: true } ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:githubstar": true }, }); setPageName("features"); }; const handleMaybeLater = async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "onboarding:githubstar", props: { "onboarding:githubstar": "later", "onboarding:page": "minorupgrade" }, }, { noresponse: true } ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:githubstar": false }, }); setPageName("features"); }; const handleFeaturesComplete = () => { const clientId = ClientModel.getInstance().clientId; RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:lastversion": CurrentOnboardingVersion }, }); globalStore.set(modalsModel.upgradeOnboardingOpen, false); setTimeout(() => { globalRefocus(); }, 10); }; let pageComp: React.JSX.Element = null; if (pageName === "welcome") { pageComp = ( <UpgradeMinorWelcomePage onStarClick={handleStarClick} onAlreadyStarred={handleAlreadyStarred} onMaybeLater={handleMaybeLater} /> ); } else if (pageName === "features") { pageComp = <OnboardingFeatures onComplete={handleFeaturesComplete} />; } if (pageComp == null) { return null; } const paddingClass = isCompact ? "!py-3 !px-[30px]" : "!p-[30px]"; const widthClass = pageName === "features" ? "w-[800px]" : "w-[600px]"; return ( <FlexiModal className={`${widthClass} rounded-[10px] ${paddingClass} relative overflow-hidden`} ref={modalRef}> <OnboardingGradientBg /> <div className="flex flex-col w-full h-full relative z-10">{pageComp}</div> </FlexiModal> ); }; UpgradeOnboardingMinor.displayName = "UpgradeOnboardingMinor"; export { UpgradeMinorWelcomePage, UpgradeOnboardingMinor }; ================================================ FILE: frontend/app/onboarding/onboarding-upgrade-patch.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; import { Button } from "@/app/element/button"; import { FlexiModal } from "@/app/modals/modal"; import { CurrentOnboardingVersion, OnboardingGradientBg } from "@/app/onboarding/onboarding-common"; import { StarAskPage } from "@/app/onboarding/onboarding-starask"; import { ClientModel } from "@/app/store/client-model"; import { globalStore } from "@/app/store/global"; import { disableGlobalKeybindings, enableGlobalKeybindings, globalRefocus } from "@/app/store/keymodel"; import { modalsModel } from "@/app/store/modalmodel"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useAtomValue } from "jotai"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { useEffect, useRef, useState } from "react"; import { debounce } from "throttle-debounce"; import { UpgradeOnboardingModal_v0_12_1_Content } from "./onboarding-upgrade-v0121"; import { UpgradeOnboardingModal_v0_12_2_Content } from "./onboarding-upgrade-v0122"; import { UpgradeOnboardingModal_v0_12_3_Content } from "./onboarding-upgrade-v0123"; import { UpgradeOnboardingModal_v0_13_0_Content } from "./onboarding-upgrade-v0130"; import { UpgradeOnboardingModal_v0_13_1_Content } from "./onboarding-upgrade-v0131"; import { UpgradeOnboardingModal_v0_14_0_Content } from "./onboarding-upgrade-v0140"; import { UpgradeOnboardingModal_v0_14_1_Content } from "./onboarding-upgrade-v0141"; import { UpgradeOnboardingModal_v0_14_2_Content } from "./onboarding-upgrade-v0142"; interface VersionConfig { version: string; content: () => React.ReactNode; prevText?: string; nextText?: string; } interface UpgradeOnboardingPatchProps { isReleaseNotes?: boolean; } interface UpgradeOnboardingFooterProps { hasPrev: boolean; hasNext: boolean; prevText?: string; nextText?: string; onPrev?: () => void; onNext?: () => void; onClose: () => void; } export function UpgradeOnboardingFooter({ hasPrev, hasNext, prevText, nextText, onPrev, onNext, onClose, }: UpgradeOnboardingFooterProps) { return ( <footer className="unselectable flex-shrink-0 mt-4"> <div className="flex flex-row items-center justify-between w-full"> <div className="flex-1 flex justify-start"> {hasPrev && ( <div className="text-sm text-secondary"> <button onClick={onPrev} className="cursor-pointer hover:text-foreground transition-colors" > < {prevText} </button> </div> )} </div> <div className="flex flex-row items-center justify-center [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm"> <Button className="font-[600]" onClick={onClose}> Continue </Button> </div> <div className="flex-1 flex justify-end"> {hasNext && ( <div className="text-sm text-secondary"> <button onClick={onNext} className="cursor-pointer hover:text-foreground transition-colors" > {nextText} > </button> </div> )} </div> </div> </footer> ); } export const UpgradeOnboardingVersions: VersionConfig[] = [ { version: "v0.12.1", content: () => <UpgradeOnboardingModal_v0_12_1_Content />, nextText: "Next (v0.12.2)", }, { version: "v0.12.2", content: () => <UpgradeOnboardingModal_v0_12_2_Content />, prevText: "Prev (v0.12.1)", nextText: "Next (v0.12.3)", }, { version: "v0.12.5", content: () => <UpgradeOnboardingModal_v0_12_3_Content />, prevText: "Prev (v0.12.2)", nextText: "Next (v0.13.0)", }, { version: "v0.13.0", content: () => <UpgradeOnboardingModal_v0_13_0_Content />, prevText: "Prev (v0.12.5)", nextText: "Next (v0.13.1)", }, { version: "v0.13.1", content: () => <UpgradeOnboardingModal_v0_13_1_Content />, prevText: "Prev (v0.13.0)", nextText: "Next (v0.14.0)", }, { version: "v0.14.0", content: () => <UpgradeOnboardingModal_v0_14_0_Content />, prevText: "Prev (v0.13.1)", nextText: "Next (v0.14.1)", }, { version: "v0.14.1", content: () => <UpgradeOnboardingModal_v0_14_1_Content />, prevText: "Prev (v0.14.0)", nextText: "Next (v0.14.3)", }, { version: "v0.14.3", content: () => <UpgradeOnboardingModal_v0_14_2_Content />, prevText: "Prev (v0.14.1)", }, ]; const UpgradeOnboardingPatch = ({ isReleaseNotes = false }: UpgradeOnboardingPatchProps) => { const modalRef = useRef<HTMLDivElement | null>(null); const [isCompact, setIsCompact] = useState<boolean>(window.innerHeight < 800); const [currentIndex, setCurrentIndex] = useState<number>(UpgradeOnboardingVersions.length - 1); const [showStarAsk, setShowStarAsk] = useState<boolean>(false); const clientData = useAtomValue(ClientModel.getInstance().clientAtom); const alreadyStarred = clientData?.meta?.["onboarding:githubstar"] === true; const currentVersion = UpgradeOnboardingVersions[currentIndex]; const hasPrev = currentIndex > 0; const hasNext = currentIndex < UpgradeOnboardingVersions.length - 1; const updateModalHeight = () => { const windowHeight = window.innerHeight; setIsCompact(windowHeight < 800); if (modalRef.current) { const modalHeight = modalRef.current.offsetHeight; const maxHeight = windowHeight * 0.9; if (maxHeight < modalHeight) { modalRef.current.style.height = `${maxHeight}px`; } else { modalRef.current.style.height = "auto"; } } }; useEffect(() => { updateModalHeight(); const debouncedUpdateModalHeight = debounce(150, updateModalHeight); window.addEventListener("resize", debouncedUpdateModalHeight); return () => { window.removeEventListener("resize", debouncedUpdateModalHeight); }; }, []); useEffect(() => { disableGlobalKeybindings(); return () => { enableGlobalKeybindings(); }; }, []); const doClose = () => { if (isReleaseNotes) { modalsModel.popModal(); } else { globalStore.set(modalsModel.upgradeOnboardingOpen, false); } setTimeout(() => { globalRefocus(); }, 10); }; const handleClose = () => { if (isReleaseNotes) { doClose(); return; } const clientId = ClientModel.getInstance().clientId; RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:lastversion": CurrentOnboardingVersion }, }); if (alreadyStarred) { doClose(); } else { setShowStarAsk(true); } }; const paddingClass = isCompact ? "!py-3 !px-[30px]" : "!p-[30px]"; const handlePrev = () => { if (hasPrev) { setCurrentIndex(currentIndex - 1); } }; const handleNext = () => { if (hasNext) { setCurrentIndex(currentIndex + 1); } }; if (showStarAsk) { return ( <FlexiModal className="w-[500px] rounded-[10px] !p-[30px] relative overflow-hidden bg-panel" ref={modalRef} > <OnboardingGradientBg /> <div className="relative z-10 flex flex-col w-full h-full"> <StarAskPage onClose={doClose} page="upgrade" /> </div> </FlexiModal> ); } return ( <FlexiModal className={`w-[650px] rounded-[10px] ${paddingClass} relative overflow-hidden`} ref={modalRef}> <OnboardingGradientBg /> <div className="flex flex-col w-full h-full relative z-10"> <div className="flex flex-col h-full"> <header className="flex flex-col gap-2 border-b-0 p-0 mt-1 mb-6 w-full unselectable flex-shrink-0"> <div className="flex justify-center"> <Logo /> </div> <div className="text-center text-[25px] font-normal text-foreground"> Wave {currentVersion.version} Update </div> </header> <OverlayScrollbarsComponent className="flex-1 overflow-y-auto min-h-0" options={{ scrollbars: { autoHide: "never" } }} > {currentVersion.content()} </OverlayScrollbarsComponent> <UpgradeOnboardingFooter hasPrev={hasPrev} hasNext={hasNext} prevText={currentVersion.prevText} nextText={currentVersion.nextText} onPrev={handlePrev} onNext={handleNext} onClose={handleClose} /> </div> </div> </FlexiModal> ); }; UpgradeOnboardingPatch.displayName = "UpgradeOnboardingPatch"; export { UpgradeOnboardingPatch }; ================================================ FILE: frontend/app/onboarding/onboarding-upgrade-v0121.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 const UpgradeOnboardingModal_v0_12_1_Content = () => { return ( <div className="flex flex-col items-start gap-6 w-full mb-4 unselectable"> <div className="text-secondary leading-relaxed"> <p className="mb-0"> Patch release focused on shell integration improvements, Wave AI enhancements, and restoring syntax highlighting in code editor blocks. </p> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-terminal"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]"> Shell Integration & Context </div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>OSC 7 Support</strong> - Wave now automatically tracks and restores your current directory across restarts for bash, zsh, fish, and pwsh shells </li> <li> <strong>Shell Context Tracking</strong> - Tracks when your shell is ready, last command executed, and exit codes for better terminal management </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-sparkles"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]"> Wave AI Improvements </div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li>Display reasoning summaries while waiting for AI responses</li> <li> Enhanced terminal context - AI now has access to shell state, current directory, command history, and exit codes </li> <li>Added feedback buttons (thumbs up/down) for AI responses</li> <li>Added copy button to easily copy AI responses to clipboard</li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-wrench"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Other Changes</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li>Mobile user agent emulation support for web widgets</li> <li>Fixed padding for header buttons in code editor</li> <li>Restored syntax highlighting in code editor preview blocks</li> </ul> </div> </div> </div> </div> ); }; UpgradeOnboardingModal_v0_12_1_Content.displayName = "UpgradeOnboardingModal_v0_12_1_Content"; export { UpgradeOnboardingModal_v0_12_1_Content }; ================================================ FILE: frontend/app/onboarding/onboarding-upgrade-v0122.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 const UpgradeOnboardingModal_v0_12_2_Content = () => { return ( <div className="flex flex-col items-start gap-6 w-full mb-4 unselectable"> <div className="text-secondary leading-relaxed"> <p className="mb-0"> Wave AI can now create and modify files with visual diff previews and easy rollback capabilities. Plus performance improvements and bug fixes. </p> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-file-pen"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Wave AI File Editing</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>File Write Tool</strong> - Wave AI can now create and modify files with your approval </li> <li> <strong>Visual Diff Preview</strong> - See exactly what will change before approving edits </li> <li> <strong>Easy Rollback</strong> - Revert file changes with a simple "Revert File" button </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-sparkles"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]"> Additional AI Improvements </div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li>Drag & drop files from preview viewer directly to Wave AI</li> <li> Directory listings support in <span className="font-mono">`wsh ai`</span> commands </li> <li>Adjustable thinking level and max output tokens per chat</li> <li>Improved tool descriptions and input validations</li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-wrench"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]"> Bug Fixes & Improvements </div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li>Fixed significant memory leak in the RPC system</li> <li>Config file schema validation restored</li> <li>Fixed PowerShell 5.x regression</li> </ul> </div> </div> </div> </div> ); }; UpgradeOnboardingModal_v0_12_2_Content.displayName = "UpgradeOnboardingModal_v0_12_2_Content"; export { UpgradeOnboardingModal_v0_12_2_Content }; ================================================ FILE: frontend/app/onboarding/onboarding-upgrade-v0123.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 const UpgradeOnboardingModal_v0_12_3_Content = () => { return ( <div className="flex flex-col items-start gap-6 w-full mb-4 unselectable"> <div className="text-secondary leading-relaxed"> <p className="mb-0"> Wave AI model upgrade to GPT-5.1, new secret management features, and improved terminal input handling for interactive CLI tools. </p> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-sparkles"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Wave AI Updates</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>GPT-5.1 Model</strong> - Upgraded to OpenAI's GPT-5.1 model for improved responses </li> <li> <strong>Thinking Mode Toggle</strong> - New dropdown to select between Quick, Balanced, and Deep thinking modes </li> <li>Fixed path mismatch issue when restoring AI write file backups</li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-terminal"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Terminal Improvements</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Enhanced Input Handling</strong> - Better support for CLI tools like Claude Code </li> <li> <strong>Image Paste Support</strong> - Paste images directly into terminal (saved to temp files) </li> <li>Shift+Enter now inserts newlines by default for multi-line commands</li> <li>Fixed duplicate text issue when switching input methods (IME)</li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-key"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Secret Store</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Secret Management Widget</strong> - Store and manage sensitive credentials securely </li> <li> Access secrets via CLI with <span className="font-mono">wsh secret list/get/set</span>{" "} commands </li> </ul> </div> </div> </div> </div> ); }; UpgradeOnboardingModal_v0_12_3_Content.displayName = "UpgradeOnboardingModal_v0_12_3_Content"; export { UpgradeOnboardingModal_v0_12_3_Content }; ================================================ FILE: frontend/app/onboarding/onboarding-upgrade-v0130.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 const UpgradeOnboardingModal_v0_13_0_Content = () => { return ( <div className="flex flex-col items-start gap-6 w-full mb-4 unselectable"> <div className="text-secondary leading-relaxed"> <p className="mb-0"> Wave v0.13 brings local AI support, bring-your-own-key (BYOK), a redesigned configuration system, and improved terminal functionality. </p> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-sparkles"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Local AI & BYOK</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>OpenAI-Compatible API</strong> - Connect to Ollama, LM Studio, vLLM, OpenRouter, and other local or hosted models </li> <li> <strong>Google Gemini</strong> - Native support for Gemini models </li> <li> <strong>Provider Presets</strong> - Built-in configs for OpenAI, OpenRouter, Google, Azure, and custom endpoints </li> <li> <strong>Multiple AI Modes</strong> - Easily switch between models and providers </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-sliders"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Configuration Widget</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>New Config Interface</strong> - Dedicated widget accessible from the sidebar </li> <li> <strong>Better Organization</strong> - Browse and edit settings with improved validation and error handling </li> <li> <strong>Integrated Secrets</strong> - Manage API keys and credentials from the config widget </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-terminal"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Terminal Updates</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Bracketed Paste Mode</strong> - Enabled by default for better multi-line paste behavior </li> <li> <strong>Windows Paste Fix</strong> - Ctrl+V now works as standard paste on Windows </li> <li> <strong>SSH Password Storage</strong> - Store SSH passwords in Wave's secret store </li> </ul> </div> </div> </div> </div> ); }; UpgradeOnboardingModal_v0_13_0_Content.displayName = "UpgradeOnboardingModal_v0_13_0_Content"; export { UpgradeOnboardingModal_v0_13_0_Content }; ================================================ FILE: frontend/app/onboarding/onboarding-upgrade-v0131.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 const UpgradeOnboardingModal_v0_13_1_Content = () => { return ( <div className="flex flex-col items-start gap-6 w-full mb-4 unselectable"> <div className="text-secondary leading-relaxed"> <p className="mb-0"> Wave v0.13.1 focuses on Windows platform improvements, Wave AI visual updates, and enhanced terminal navigation. </p> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-brands fa-windows"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]"> Windows Platform Enhancements </div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Integrated Window Layout</strong> - Cleaner interface with controls integrated into the tab-bar header </li> <li> <strong>Git Bash Auto-Detection</strong> - Automatically detects Git Bash installations </li> <li> <strong>SSH Agent Fallback</strong> - Improved SSH agent support on Windows </li> <li> <strong>Updated Focus Keybinding</strong> - Wave AI focus key changed to Alt:0 on Windows </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-sparkles"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Wave AI Updates</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Refreshed Visual Design</strong> - Complete UI refresh with transparency support for custom backgrounds </li> <li> <strong>BYOK Without Telemetry</strong> - Wave AI now works with bring-your-own-key and local models without requiring telemetry </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-terminal"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Terminal Improvements</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>New Scrolling Keybindings</strong> - Added Shift+Home, Shift+End, Shift+PageUp, and Shift+PageDown for better navigation </li> </ul> </div> </div> </div> </div> ); }; UpgradeOnboardingModal_v0_13_1_Content.displayName = "UpgradeOnboardingModal_v0_13_1_Content"; export { UpgradeOnboardingModal_v0_13_1_Content }; ================================================ FILE: frontend/app/onboarding/onboarding-upgrade-v0140.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useWaveEnv } from "@/app/waveenv/waveenv"; const UpgradeOnboardingModal_v0_14_0_Content = () => { const waveEnv = useWaveEnv(); return ( <div className="flex flex-col items-start w-full mb-2 unselectable"> <div className="text-secondary leading-relaxed mb-4"> <p className="mb-0"> Wave v0.14 introduces Durable Sessions. Enable them to keep your remote sessions alive through network interruptions, computer sleep, and restarts — they'll automatically reconnect when your connection is restored. </p> </div> <div className="flex w-full items-start gap-4 mb-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-sky-500 fa-sharp fa-solid fa-shield"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]"> Durable SSH Sessions{" "} <button onClick={() => waveEnv.electron.openExternal("https://docs.waveterm.dev/durable-sessions")} className="text-accent text-sm font-normal cursor-pointer hover:underline" > [see docs] </button> </div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Session Protection</strong> - Programs and shell state survive disconnects </li> <li> <strong>Visual Status Indicators</strong> - Shield icons show status </li> <li> <strong>Flexible Configuration</strong> - Enable globally, per-connection, or per-terminal </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4 mb-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-network-wired"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]"> Enhanced Connection Monitoring </div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Connection Keepalives</strong> - Active monitoring with keepalive probes </li> <li> <strong>Stalled Connection Detection</strong> - Visual feedback for network issues </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4 mb-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-sparkles"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Wave AI Updates</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Image Support</strong> - Vision capabilities for BYOK providers </li> <li> <strong>Stop Generation</strong> - Ability to stop AI responses mid-generation </li> <li> <strong>Improved Auto-scrolling</strong> </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-terminal"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Terminal Improvements</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Enhanced Context Menu</strong> - Quick access to splits, themes, and more </li> <li> <strong>OSC 52 Clipboard Support</strong> - CLI apps can copy to system clipboard </li> </ul> </div> </div> </div> </div> ); }; UpgradeOnboardingModal_v0_14_0_Content.displayName = "UpgradeOnboardingModal_v0_14_0_Content"; export { UpgradeOnboardingModal_v0_14_0_Content }; ================================================ FILE: frontend/app/onboarding/onboarding-upgrade-v0141.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 const UpgradeOnboardingModal_v0_14_1_Content = () => { return ( <div className="flex flex-col items-start w-full mb-2 unselectable"> <div className="text-secondary leading-relaxed mb-4"> <p className="mb-0"> Wave v0.14.1 fixes several high-impact terminal bugs and adds new config options for focus, cursor style, and block navigation. </p> </div> <div className="flex w-full items-start gap-4 mb-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-terminal"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Terminal Fixes</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Claude Code Scroll Fix</strong> - Fixed unexpected terminal scroll jumps </li> <li> <strong>IME Fix</strong> - Fixed Korean/CJK input losing or sticking characters </li> <li> <strong>Scroll Position on Resize</strong> - Terminal stays at bottom across resizes </li> <li> <strong>Terminal Scrollback Save</strong> - New context menu item and{" "} <code>wsh</code> command to save scrollback to a file </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-sliders"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">New Config Options</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Focus Follows Cursor</strong> - New <code>app:focusfollowscursor</code> setting (off/on/term) </li> <li> <strong>Terminal Cursor Style & Blink</strong> - Configure cursor shape and blink per-block </li> <li> <strong>Vim-Style Block Navigation</strong> - Ctrl+Shift+H/J/K/L to navigate blocks </li> <li> <strong>New AI Providers</strong> - Added Groq and NanoGPT as built-in presets </li> </ul> </div> </div> </div> </div> ); }; UpgradeOnboardingModal_v0_14_1_Content.displayName = "UpgradeOnboardingModal_v0_14_1_Content"; export { UpgradeOnboardingModal_v0_14_1_Content }; ================================================ FILE: frontend/app/onboarding/onboarding-upgrade-v0142.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useWaveEnv } from "@/app/waveenv/waveenv"; const UpgradeOnboardingModal_v0_14_2_Content = () => { const waveEnv = useWaveEnv(); return ( <div className="flex flex-col items-start w-full mb-2 unselectable"> <div className="text-secondary leading-relaxed mb-4"> <p className="mb-0"> Wave v0.14.2 introduces a new block badge system for at-a-glance status, along with directory preview improvements and bug fixes. v0.14.3 is a patch release fixing a showstopper bug in onboarding. </p> </div> <div className="flex w-full items-start gap-4 mb-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-bell"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Block & Tab Badges</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>Block Badges Roll Up to Tabs</strong> - Blocks can display icon badges (with color and priority) that are visible in the tab bar for at-a-glance status </li> <li> <strong>Bell Indicator On by Default</strong> - Terminal bell badge now lights up the block and tab when your terminal rings (controlled by <code>term:bellindicator</code>) </li> <li> <strong> <code>wsh badge</code> </strong>{" "} - New command to set or clear badges from the CLI. Supports icons, colors, priorities, and PID-linked badges </li> <li> <strong>Claude Code Integration</strong> - Use <code>wsh badge</code> with Claude Code hooks to surface AI task status as tab bar notifications{" "} <button onClick={() => waveEnv.electron.openExternal("https://docs.waveterm.dev/claude-code") } className="text-accent text-sm font-normal cursor-pointer hover:underline" > [see docs] </button> </li> </ul> </div> </div> </div> <div className="flex w-full items-start gap-4"> <div className="flex-shrink-0"> <i className="text-[24px] text-accent fa-solid fa-folder-open"></i> </div> <div className="flex flex-col items-start gap-2 flex-1"> <div className="text-foreground text-base font-semibold leading-[18px]">Other Changes</div> <div className="text-secondary leading-5"> <ul className="list-disc list-outside space-y-1 pl-5"> <li> <strong>[v0.14.3] </strong>[bugfix] Fixed a showstopper onboarding bug </li> <li> <strong>Directory Preview</strong> - Improved mod time formatting, zebra-striped rows, better default sort, and YAML file support </li> <li> <strong>Search Bar</strong> - Clipboard and focus improvements </li> <li>[bugfix] Fixed "New Window" hanging on GNOME desktops</li> <li>[bugfix] Fixed "Save Session As..." focused window tracking bug</li> </ul> </div> </div> </div> </div> ); }; UpgradeOnboardingModal_v0_14_2_Content.displayName = "UpgradeOnboardingModal_v0_14_2_Content"; export { UpgradeOnboardingModal_v0_14_2_Content }; ================================================ FILE: frontend/app/onboarding/onboarding-upgrade.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ClientModel } from "@/app/store/client-model"; import { globalStore } from "@/app/store/global"; import { modalsModel } from "@/app/store/modalmodel"; import { useAtomValue } from "jotai"; import { useEffect, useRef } from "react"; import * as semver from "semver"; import { CurrentOnboardingVersion } from "./onboarding-common"; import { UpgradeOnboardingMinor } from "./onboarding-upgrade-minor"; import { UpgradeOnboardingPatch } from "./onboarding-upgrade-patch"; const UpgradeOnboardingModal = () => { const clientData = useAtomValue(ClientModel.getInstance().clientAtom); const initialVersionRef = useRef<string | null>(null); if (initialVersionRef.current == null) { initialVersionRef.current = clientData.meta?.["onboarding:lastversion"] ?? "v0.0.0"; } const lastVersion = initialVersionRef.current; useEffect(() => { if (semver.gte(lastVersion, CurrentOnboardingVersion)) { globalStore.set(modalsModel.upgradeOnboardingOpen, false); } }, [lastVersion]); if (semver.gte(lastVersion, CurrentOnboardingVersion)) { return null; } if (semver.gte(lastVersion, "v0.12.0")) { return <UpgradeOnboardingPatch />; } return <UpgradeOnboardingMinor />; }; UpgradeOnboardingModal.displayName = "UpgradeOnboardingModal"; export { UpgradeOnboardingModal }; ================================================ FILE: frontend/app/onboarding/onboarding.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; import { Button } from "@/app/element/button"; import { FlexiModal } from "@/app/modals/modal"; import { OnboardingGradientBg } from "@/app/onboarding/onboarding-common"; import { OnboardingFeatures } from "@/app/onboarding/onboarding-features"; import { ClientModel } from "@/app/store/client-model"; import { useSettingsKeyAtom } from "@/app/store/global"; import { disableGlobalKeybindings, enableGlobalKeybindings, globalRefocus } from "@/app/store/keymodel"; import { modalsModel } from "@/app/store/modalmodel"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import * as services from "@/store/services"; import { fireAndForget } from "@/util/util"; import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { useEffect, useRef, useState } from "react"; import { debounce } from "throttle-debounce"; // Page flow: // init -> (telemetry enabled) -> features // init -> (telemetry disabled) -> notelemetrystar -> features type PageName = "init" | "notelemetrystar" | "features"; const pageNameAtom: PrimitiveAtom<PageName> = atom<PageName>("init"); const InitPage = ({ isCompact, telemetryUpdateFn, }: { isCompact: boolean; telemetryUpdateFn: (value: boolean) => Promise<void>; }) => { const telemetrySetting = useSettingsKeyAtom("telemetry:enabled"); const clientData = useAtomValue(ClientModel.getInstance().clientAtom); const [telemetryEnabled, setTelemetryEnabled] = useState<boolean>(!!telemetrySetting); const setPageName = useSetAtom(pageNameAtom); const handleStarClick = async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "onboarding:githubstar", props: { "onboarding:githubstar": "star", "onboarding:page": "init" }, }, { noresponse: true } ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:githubstar": true }, }); }; const acceptTos = () => { if (!clientData?.tosagreed) { fireAndForget(() => services.ClientService.AgreeTos()); } if (telemetryEnabled) { WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); } setPageName(telemetryEnabled ? "features" : "notelemetrystar"); }; const setTelemetry = (value: boolean) => { fireAndForget(() => telemetryUpdateFn(value).then(() => { setTelemetryEnabled(value); }) ); }; const label = telemetryEnabled ? "Enabled" : "Disabled"; return ( <div className="flex flex-col h-full"> <header className={`flex flex-col gap-2 border-b-0 p-0 ${isCompact ? "mt-1 mb-4" : "mb-9"} w-full unselectable flex-shrink-0`} > <div className={`${isCompact ? "" : "mb-2.5"} flex justify-center`}> <Logo /> </div> <div className="text-center text-[25px] font-normal text-foreground">Welcome to Wave Terminal</div> </header> <OverlayScrollbarsComponent className="flex-1 overflow-y-auto min-h-0" options={{ scrollbars: { autoHide: "never" } }} > <div className="flex flex-col items-start gap-8 w-full mb-5 unselectable"> <div className="flex w-full items-center gap-[18px]"> <div> <a target="_blank" href="https://github.com/wavetermdev/waveterm?ref=install" rel="noopener" className="text-accent" onClick={handleStarClick} > <i className="text-[32px] text-white/50 fa-brands fa-github"></i> </a> </div> <div className="flex flex-col items-start gap-1 flex-1"> <div className="text-foreground text-base leading-[18px]">Support us on GitHub</div> <div className="text-secondary leading-5"> We're <i>open source</i>, <i>open-model</i>, and committed to providing a free terminal for individual users. Please show your support by giving us a star on{" "} <a target="_blank" href="https://github.com/wavetermdev/waveterm?ref=install" rel="noopener" className="text-accent" onClick={handleStarClick} > Github (wavetermdev/waveterm) </a> </div> </div> </div> <div className="flex w-full items-center gap-[18px]"> <div> <a target="_blank" href="https://discord.gg/XfvZ334gwU" rel="noopener" className="text-accent" > <i className="text-[25px] text-white/50 fa-solid fa-people-group"></i> </a> </div> <div className="flex flex-col items-start gap-1 flex-1"> <div className="text-foreground text-base leading-[18px]">Join our Community</div> <div className="text-secondary leading-5"> Get help, submit feature requests, report bugs, or just chat with fellow terminal enthusiasts. <br /> <a target="_blank" href="https://discord.gg/XfvZ334gwU" rel="noopener" className="text-accent" > Join the Wave Discord Channel </a> </div> </div> </div> <div className="flex w-full items-center gap-[18px]"> <div> <i className="text-[32px] text-white/50 fa-solid fa-chart-line"></i> </div> <div className="flex flex-col items-start gap-1 flex-1"> <div className="text-secondary leading-5"> Anonymous usage data helps us improve features you use. <br /> <a className="text-secondary! hover:underline!" target="_blank" href="https://waveterm.dev/privacy" rel="noopener" > Privacy Policy </a> </div> <label className="flex items-center gap-2 cursor-pointer text-secondary"> <input type="checkbox" checked={telemetryEnabled} onChange={(e) => setTelemetry(e.target.checked)} className="cursor-pointer accent-gray-500" /> <span>{label}</span> </label> </div> </div> </div> </OverlayScrollbarsComponent> <footer className={`unselectable flex-shrink-0 ${isCompact ? "mt-2" : "mt-5"}`}> <div className="flex flex-row items-center justify-center [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm [&>button:not(:first-child)]:ml-2.5"> <Button className="font-[600]" onClick={acceptTos}> Continue </Button> </div> </footer> </div> ); }; const NoTelemetryStarPage = ({ isCompact }: { isCompact: boolean }) => { const setPageName = useSetAtom(pageNameAtom); const handleStarClick = async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "onboarding:githubstar", props: { "onboarding:githubstar": "star", "onboarding:page": "notelemetry" }, }, { noresponse: true } ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:githubstar": true }, }); window.open("https://github.com/wavetermdev/waveterm?ref=not", "_blank"); setPageName("features"); }; const handleMaybeLater = async () => { RpcApi.RecordTEventCommand( TabRpcClient, { event: "onboarding:githubstar", props: { "onboarding:githubstar": "later", "onboarding:page": "notelemetry" }, }, { noresponse: true } ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), meta: { "onboarding:githubstar": false }, }); setPageName("features"); }; return ( <div className="flex flex-col h-full"> <header className={`flex flex-col gap-2 border-b-0 p-0 mt-1 mb-4 w-full unselectable flex-shrink-0`}> <div className={`flex justify-center`}> <Logo /> </div> <div className="text-center text-[25px] font-normal text-foreground">Telemetry Disabled ✓</div> </header> <OverlayScrollbarsComponent className="flex-1 overflow-y-auto min-h-0" options={{ scrollbars: { autoHide: "never" } }} > <div className="flex flex-col items-center gap-6 w-full mb-2 unselectable"> <div className="text-center text-secondary leading-relaxed max-w-md"> <p className="mb-4">No problem, we respect your privacy.</p> <p className="mb-4"> But, without usage data, we're flying blind. A GitHub star helps us know Wave is useful and worth maintaining. </p> </div> </div> </OverlayScrollbarsComponent> <footer className={`unselectable flex-shrink-0 mt-2`}> <div className="flex flex-row items-center justify-center gap-2.5 [&>button]:!px-5 [&>button]:!py-2 [&>button]:text-sm [&>button]:!h-[37px]"> <Button className="outlined green font-[600]" onClick={handleStarClick}> ⭐ Star on GitHub </Button> <Button className="outlined grey font-[600]" onClick={handleMaybeLater}> Maybe Later </Button> </div> </footer> </div> ); }; const FeaturesPage = () => { const [newInstallOnboardingOpen, setNewInstallOnboardingOpen] = useAtom(modalsModel.newInstallOnboardingOpen); const handleComplete = () => { setNewInstallOnboardingOpen(false); setTimeout(() => { globalRefocus(); }, 10); }; return <OnboardingFeatures onComplete={handleComplete} />; }; const NewInstallOnboardingModal = () => { const modalRef = useRef<HTMLDivElement | null>(null); const [pageName, setPageName] = useAtom(pageNameAtom); const clientData = useAtomValue(ClientModel.getInstance().clientAtom); const [isCompact, setIsCompact] = useState<boolean>(window.innerHeight < 800); const updateModalHeight = () => { const windowHeight = window.innerHeight; setIsCompact(windowHeight < 800); if (modalRef.current) { const modalHeight = modalRef.current.offsetHeight; const maxHeight = windowHeight * 0.9; if (maxHeight < modalHeight) { modalRef.current.style.height = `${maxHeight}px`; } else { modalRef.current.style.height = "auto"; } } }; useEffect(() => { if (clientData.tosagreed) { setPageName("features"); } return () => { setPageName("init"); }; }, []); useEffect(() => { updateModalHeight(); const debouncedUpdateModalHeight = debounce(150, updateModalHeight); window.addEventListener("resize", debouncedUpdateModalHeight); return () => { window.removeEventListener("resize", debouncedUpdateModalHeight); }; }, []); useEffect(() => { disableGlobalKeybindings(); return () => { enableGlobalKeybindings(); }; }, []); let pageComp: React.JSX.Element = null; switch (pageName) { case "init": pageComp = <InitPage isCompact={isCompact} telemetryUpdateFn={(value) => services.ClientService.TelemetryUpdate(value)} />; break; case "notelemetrystar": pageComp = <NoTelemetryStarPage isCompact={isCompact} />; break; case "features": pageComp = <FeaturesPage />; break; } if (pageComp == null) { return null; } const paddingClass = isCompact ? "!py-3 !px-[30px]" : "!p-[30px]"; const widthClass = pageName === "features" ? "w-[800px]" : "w-[560px]"; return ( <FlexiModal className={`${widthClass} rounded-[10px] ${paddingClass} relative overflow-hidden`} ref={modalRef}> <OnboardingGradientBg /> <div className="flex flex-col w-full h-full relative z-10">{pageComp}</div> </FlexiModal> ); }; NewInstallOnboardingModal.displayName = "NewInstallOnboardingModal"; export { InitPage, NewInstallOnboardingModal, NoTelemetryStarPage }; ================================================ FILE: frontend/app/reset.scss ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; border: 0 solid; } *, ::after, ::before, ::backdrop, ::file-selector-button { margin: 0; padding: 0; } html, :host { -webkit-text-size-adjust: 100%; tab-size: 4; font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; // keep this vertical-align (applies if you change the display attribute) vertical-align: middle; } hr { height: 0; color: inherit; border-top-width: 1px; } b, strong { font-weight: bolder; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } progress { vertical-align: baseline; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } summary { display: list-item; } img, video { max-width: 100%; height: auto; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; /* 1 */ color: color-mix(in oklab, currentColor 50%, transparent); /* 2 */ } textarea { resize: vertical; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden="until-found"])) { display: none !important; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } body { line-height: 1.2; -webkit-font-smoothing: antialiased; } img, picture, video, canvas, svg { display: block; } input, button, textarea, select { font: inherit; } } ================================================ FILE: frontend/app/shadcn/lib/utils.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // // This file is based on components from shadcn/ui, which is licensed under the MIT License. // Original source: https://github.com/shadcn/ui // Modifications made by Command Line Inc. import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } export function formatDate(input: string | number): string { const date = new Date(input); return date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", }); } export function absoluteUrl(path: string) { return `${process.env.NEXT_PUBLIC_APP_URL}${path}`; } ================================================ FILE: frontend/app/store/badge.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { fireAndForget, NullAtom } from "@/util/util"; import { atom, Atom, PrimitiveAtom } from "jotai"; import { v7 as uuidv7, version as uuidVersion } from "uuid"; import { globalStore } from "./jotaiStore"; import * as WOS from "./wos"; import { waveEventSubscribeSingle } from "./wps"; export type BadgeEnv = WaveEnvSubset<{ rpc: { EventPublishCommand: WaveEnv["rpc"]["EventPublishCommand"]; }; }>; export type LoadBadgesEnv = WaveEnvSubset<{ rpc: { GetAllBadgesCommand: WaveEnv["rpc"]["GetAllBadgesCommand"]; }; }>; export type TabBadgesEnv = WaveEnvSubset<{ wos: WaveEnv["wos"]; }>; const BadgeMap = new Map<string, PrimitiveAtom<Badge>>(); const TabBadgeAtomCache = new Map<string, Atom<Badge[]>>(); function publishBadgeEvent(eventData: WaveEvent, env?: BadgeEnv) { if (env != null) { fireAndForget(() => env.rpc.EventPublishCommand(TabRpcClient, eventData)); } else { fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); } } function clearBadgeInternal(oref: string, env?: BadgeEnv) { const eventData: WaveEvent = { event: "badge", scopes: [oref], data: { oref: oref, clear: true, } as BadgeEvent, }; publishBadgeEvent(eventData, env); } function clearBadgesForBlockOnFocus(blockId: string, env?: BadgeEnv) { const oref = WOS.makeORef("block", blockId); const badgeAtom = BadgeMap.get(oref); const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null; if (badge != null && !badge.pidlinked) { clearBadgeInternal(oref, env); } } function clearBadgesForTabOnFocus(tabId: string, env?: BadgeEnv) { const oref = WOS.makeORef("tab", tabId); const badgeAtom = BadgeMap.get(oref); const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null; if (badge != null && !badge.pidlinked) { clearBadgeInternal(oref, env); } } function clearAllBadges(env?: BadgeEnv) { const eventData: WaveEvent = { event: "badge", scopes: [], data: { oref: "", clearall: true, } as BadgeEvent, }; publishBadgeEvent(eventData, env); } function clearBadgesForTab(tabId: string, env?: BadgeEnv) { const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId)); const tab = globalStore.get(tabAtom); const blockIds = (tab as Tab)?.blockids ?? []; for (const blockId of blockIds) { const oref = WOS.makeORef("block", blockId); const badgeAtom = BadgeMap.get(oref); if (badgeAtom != null && globalStore.get(badgeAtom) != null) { clearBadgeInternal(oref, env); } } } function getBadgeAtom(oref: string): PrimitiveAtom<Badge> { if (oref == null) { return NullAtom as PrimitiveAtom<Badge>; } let rtn = BadgeMap.get(oref); if (rtn == null) { rtn = atom(null) as PrimitiveAtom<Badge>; BadgeMap.set(oref, rtn); } return rtn; } function getBlockBadgeAtom(blockId: string): Atom<Badge> { if (blockId == null) { return NullAtom as Atom<Badge>; } const oref = WOS.makeORef("block", blockId); return getBadgeAtom(oref); } function getTabBadgeAtom(tabId: string, env?: TabBadgesEnv): Atom<Badge[]> { if (tabId == null) { return NullAtom as Atom<Badge[]>; } let rtn = TabBadgeAtomCache.get(tabId); if (rtn != null) { return rtn; } const tabOref = WOS.makeORef("tab", tabId); const tabBadgeAtom = getBadgeAtom(tabOref); const tabAtom = env != null ? env.wos.getWaveObjectAtom<Tab>(tabOref) : WOS.getWaveObjectAtom<Tab>(tabOref); rtn = atom((get) => { const tab = get(tabAtom); const blockIds = tab?.blockids ?? []; const badges: Badge[] = []; for (const blockId of blockIds) { const badge = get(getBadgeAtom(WOS.makeORef("block", blockId))); if (badge != null) { badges.push(badge); } } const tabBadge = get(tabBadgeAtom); if (tabBadge != null) { badges.push(tabBadge); } return sortBadgesForTab(badges); }); TabBadgeAtomCache.set(tabId, rtn); return rtn; } async function loadBadges(env?: LoadBadgesEnv) { const rpc = env != null ? env.rpc : RpcApi; const badges = await rpc.GetAllBadgesCommand(TabRpcClient); if (badges == null) { return; } for (const badgeEvent of badges) { if (badgeEvent.oref == null) { continue; } const curAtom = getBadgeAtom(badgeEvent.oref); globalStore.set(curAtom, badgeEvent.badge ?? null); } } function setBadge(blockId: string, badge: Omit<Badge, "badgeid"> & { badgeid?: string }, env?: BadgeEnv) { if (!badge.badgeid) { badge = { ...badge, badgeid: uuidv7() }; } else if (uuidVersion(badge.badgeid) !== 7) { throw new Error(`setBadge: badgeid must be a v7 UUID, got version ${uuidVersion(badge.badgeid)}`); } const oref = WOS.makeORef("block", blockId); const eventData: WaveEvent = { event: "badge", scopes: [oref], data: { oref: oref, badge: badge, } as BadgeEvent, }; publishBadgeEvent(eventData, env); } function clearBadgeById(blockId: string, badgeId: string, env?: BadgeEnv) { const oref = WOS.makeORef("block", blockId); const eventData: WaveEvent = { event: "badge", scopes: [oref], data: { oref: oref, clearbyid: badgeId, } as BadgeEvent, }; publishBadgeEvent(eventData, env); } function setupBadgesSubscription() { waveEventSubscribeSingle({ eventType: "badge", handler: (event) => { const data = event.data; if (data?.clearall) { for (const atom of BadgeMap.values()) { globalStore.set(atom, null); } return; } if (data?.oref == null) { return; } const curAtom = getBadgeAtom(data.oref); if (data.clearbyid) { const existing = globalStore.get(curAtom); if (existing?.badgeid === data.clearbyid) { globalStore.set(curAtom, null); } return; } if (data.clear) { globalStore.set(curAtom, null); return; } if (data.badge == null) { return; } const existing = globalStore.get(curAtom); if (existing == null || cmpBadge(data.badge, existing) > 0) { globalStore.set(curAtom, data.badge); } }, }); } function cmpBadge(a: Badge, b: Badge): number { if (a.priority !== b.priority) { return a.priority > b.priority ? 1 : -1; } if (a.badgeid !== b.badgeid) { return a.badgeid > b.badgeid ? 1 : -1; } return 0; } function sortBadges(badges: Badge[]): Badge[] { return [...badges].sort((a, b) => cmpBadge(b, a)); } function sortBadgesForTab(badges: Badge[]): Badge[] { return [...badges].sort((a, b) => { if (a.priority !== b.priority) { return b.priority - a.priority; } return a.badgeid < b.badgeid ? -1 : a.badgeid > b.badgeid ? 1 : 0; }); } export { clearAllBadges, clearBadgeById, clearBadgesForBlockOnFocus, clearBadgesForTab, clearBadgesForTabOnFocus, getBadgeAtom, getBlockBadgeAtom, getTabBadgeAtom, loadBadges, setBadge, setupBadgesSubscription, sortBadges, sortBadgesForTab, }; ================================================ FILE: frontend/app/store/client-model.ts ================================================ // Copyright 2026, Command Line Inc // SPDX-License-Identifier: Apache-2.0 import * as WOS from "@/app/store/wos"; import { atom, Atom } from "jotai"; class ClientModel { private static instance: ClientModel; clientId: string; clientAtom!: Atom<Client>; private constructor() { // private constructor for singleton pattern } static getInstance(): ClientModel { if (!ClientModel.instance) { ClientModel.instance = new ClientModel(); } return ClientModel.instance; } initialize(clientId: string): void { this.clientId = clientId; this.clientAtom = atom((get) => { if (this.clientId == null) { return null; } return WOS.getObjectValue(WOS.makeORef("client", this.clientId), get); }); } } export { ClientModel }; ================================================ FILE: frontend/app/store/connections-model.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { isWindows } from "@/util/platformutil"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; import { globalStore } from "./jotaiStore"; class ConnectionsModel { private static instance: ConnectionsModel; gitBashPathAtom: PrimitiveAtom<string> = atom("") as PrimitiveAtom<string>; hasGitBashAtom: Atom<boolean>; private constructor() { this.hasGitBashAtom = atom((get) => { if (!isWindows()) { return false; } const path = get(this.gitBashPathAtom); return path !== ""; }); this.loadGitBashPath(); } static getInstance(): ConnectionsModel { if (!ConnectionsModel.instance) { ConnectionsModel.instance = new ConnectionsModel(); } return ConnectionsModel.instance; } async loadGitBashPath(rescan: boolean = false): Promise<void> { if (!isWindows()) { return; } try { const path = await RpcApi.FindGitBashCommand(TabRpcClient, rescan, { timeout: 2000 }); globalStore.set(this.gitBashPathAtom, path); } catch (error) { console.error("Failed to find git bash path:", error); globalStore.set(this.gitBashPathAtom, ""); } } getGitBashPath(): string { return globalStore.get(this.gitBashPathAtom); } } export { ConnectionsModel }; ================================================ FILE: frontend/app/store/contextmenu.test.ts ================================================ import { describe, expect, it, vi } from "vitest"; describe("ContextMenuModel", () => { it("initializes only when getInstance is called", async () => { let contextMenuCallback: (id: string | null) => void; const onContextMenuClick = vi.fn(); onContextMenuClick.mockImplementation((callback) => { contextMenuCallback = callback; }); const getApi = vi.fn(() => ({ onContextMenuClick, showContextMenu: vi.fn(), })); vi.resetModules(); vi.doMock("./global", () => ({ atoms: {}, getApi, globalStore: { get: vi.fn() }, })); const { ContextMenuModel } = await import("./contextmenu"); expect(getApi).not.toHaveBeenCalled(); const firstInstance = ContextMenuModel.getInstance(); const secondInstance = ContextMenuModel.getInstance(); expect(firstInstance).toBe(secondInstance); expect(getApi).toHaveBeenCalledTimes(1); expect(onContextMenuClick).toHaveBeenCalledTimes(1); expect(contextMenuCallback).toBeTypeOf("function"); }); it("runs select and close callbacks after item handler", async () => { let contextMenuCallback: (id: string | null) => void; const showContextMenu = vi.fn(); const onContextMenuClick = vi.fn((callback) => { contextMenuCallback = callback; }); const getApi = vi.fn(() => ({ onContextMenuClick, showContextMenu, })); const workspace = { oid: "workspace-1" }; vi.resetModules(); vi.doMock("./global", () => ({ atoms: { workspace: "workspace", builderId: "builderId" }, getApi, globalStore: { get: vi.fn((atom) => { if (atom === "workspace") { return workspace; } return "builder-1"; }), }, })); const { ContextMenuModel } = await import("./contextmenu"); const model = ContextMenuModel.getInstance(); const order: string[] = []; const itemClick = vi.fn(() => { order.push("item"); }); const onSelect = vi.fn((item) => { order.push(`select:${item.label}`); }); const onClose = vi.fn((item) => { order.push(`close:${item?.label ?? "null"}`); }); model.showContextMenu( [{ label: "Open", click: itemClick }], { stopPropagation: vi.fn() } as any, { onSelect, onClose } ); const menuId = showContextMenu.mock.calls[0][1][0].id; contextMenuCallback(menuId); expect(order).toEqual(["item", "select:Open", "close:Open"]); expect(itemClick).toHaveBeenCalledTimes(1); expect(onSelect).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); }); it("runs cancel and close callbacks when no item is selected", async () => { let contextMenuCallback: (id: string | null) => void; const showContextMenu = vi.fn(); const onContextMenuClick = vi.fn((callback) => { contextMenuCallback = callback; }); const getApi = vi.fn(() => ({ onContextMenuClick, showContextMenu, })); const workspace = { oid: "workspace-1" }; vi.resetModules(); vi.doMock("./global", () => ({ atoms: { workspace: "workspace", builderId: "builderId" }, getApi, globalStore: { get: vi.fn((atom) => { if (atom === "workspace") { return workspace; } return "builder-1"; }), }, })); const { ContextMenuModel } = await import("./contextmenu"); const model = ContextMenuModel.getInstance(); const order: string[] = []; const onCancel = vi.fn(() => { order.push("cancel"); }); const onClose = vi.fn((item) => { order.push(`close:${item == null ? "null" : item.label}`); }); model.showContextMenu( [{ label: "Open", click: vi.fn() }], { stopPropagation: vi.fn() } as any, { onCancel, onClose } ); contextMenuCallback(null); expect(order).toEqual(["cancel", "close:null"]); expect(onCancel).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: frontend/app/store/contextmenu.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { atoms, getApi, globalStore } from "./global"; type ShowContextMenuOpts = { onSelect?: (item: ContextMenuItem) => void; onCancel?: () => void; onClose?: (item: ContextMenuItem | null) => void; }; class ContextMenuModel { private static instance: ContextMenuModel; handlers: Map<string, ContextMenuItem> = new Map(); // id -> item activeOpts: ShowContextMenuOpts | null = null; private constructor() { getApi().onContextMenuClick(this.handleContextMenuClick.bind(this)); } static getInstance(): ContextMenuModel { if (ContextMenuModel.instance == null) { ContextMenuModel.instance = new ContextMenuModel(); } return ContextMenuModel.instance; } handleContextMenuClick(id: string | null): void { const opts = this.activeOpts; this.activeOpts = null; const item = id != null ? this.handlers.get(id) : null; this.handlers.clear(); if (item == null) { opts?.onCancel?.(); opts?.onClose?.(null); return; } item.click?.(); opts?.onSelect?.(item); opts?.onClose?.(item); } _convertAndRegisterMenu(menu: ContextMenuItem[]): ElectronContextMenuItem[] { const electronMenuItems: ElectronContextMenuItem[] = []; for (const item of menu) { const electronItem: ElectronContextMenuItem = { role: item.role, type: item.type, label: item.label, sublabel: item.sublabel, id: crypto.randomUUID(), checked: item.checked, }; if (item.visible === false) { electronItem.visible = false; } if (item.enabled === false) { electronItem.enabled = false; } if (item.click) { this.handlers.set(electronItem.id, item); } if (item.submenu) { electronItem.submenu = this._convertAndRegisterMenu(item.submenu); } electronMenuItems.push(electronItem); } return electronMenuItems; } showContextMenu(menu: ContextMenuItem[], ev: React.MouseEvent<any>, opts?: ShowContextMenuOpts): void { ev.stopPropagation(); this.handlers.clear(); this.activeOpts = opts; const electronMenuItems = this._convertAndRegisterMenu(menu); const workspaceId = globalStore.get(atoms.workspaceId); let oid: string; if (workspaceId != null) { oid = workspaceId; } else { oid = globalStore.get(atoms.builderId); } getApi().showContextMenu(oid, electronMenuItems); } } export { ContextMenuModel }; ================================================ FILE: frontend/app/store/counters.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 const Counters = new Map<string, number>(); function countersClear() { Counters.clear(); } function counterInc(name: string, incAmt: number = 1) { let count = Counters.get(name) ?? 0; count += incAmt; Counters.set(name, count); } function countersPrint() { let outStr = ""; for (const [name, count] of Counters.entries()) { outStr += `${name}: ${count}\n`; } console.log(outStr); } export { counterInc, countersClear, countersPrint }; ================================================ FILE: frontend/app/store/focusManager.ts ================================================ import { waveAIHasFocusWithin } from "@/app/aipanel/waveai-focus-utils"; import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { atoms, getBlockComponentModel } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { focusedBlockId } from "@/util/focusutil"; import { getLayoutModelForStaticTab } from "@/layout/index"; import { Atom, atom, type PrimitiveAtom } from "jotai"; export type FocusStrType = "node" | "waveai"; export class FocusManager { private static instance: FocusManager | null = null; focusType: PrimitiveAtom<FocusStrType> = atom("node"); blockFocusAtom: Atom<string | null>; private constructor() { this.blockFocusAtom = atom((get) => { if (get(this.focusType) == "waveai") { return null; } const layoutModel = getLayoutModelForStaticTab(); const lnode = get(layoutModel.focusedNode); return lnode?.data?.blockId; }); } static getInstance(): FocusManager { if (!FocusManager.instance) { FocusManager.instance = new FocusManager(); } return FocusManager.instance; } setWaveAIFocused(force: boolean = false) { const isAlreadyFocused = globalStore.get(this.focusType) == "waveai"; if (!force && isAlreadyFocused) { return; } globalStore.set(this.focusType, "waveai"); this.refocusNode(); } setBlockFocus(force: boolean = false) { const ftype = globalStore.get(this.focusType); if (!force && ftype == "node") { return; } globalStore.set(this.focusType, "node"); this.refocusNode(); } waveAIFocusWithin(): boolean { return waveAIHasFocusWithin(); } nodeFocusWithin(): boolean { return focusedBlockId() != null; } requestNodeFocus(): void { globalStore.set(this.focusType, "node"); } requestWaveAIFocus(): void { globalStore.set(this.focusType, "waveai"); } getFocusType(): FocusStrType { return globalStore.get(this.focusType); } refocusNode() { const ftype = globalStore.get(this.focusType); if (ftype == "waveai") { WaveAIModel.getInstance().focusInput(); return; } const layoutModel = getLayoutModelForStaticTab(); const lnode = globalStore.get(layoutModel.focusedNode); if (lnode == null || lnode.data?.blockId == null) { return; } layoutModel.focusNode(lnode.id); const blockId = lnode.data.blockId; const bcm = getBlockComponentModel(blockId); const ok = bcm?.viewModel?.giveFocus?.(); if (!ok) { const inputElem = document.getElementById(`${blockId}-dummy-focus`); inputElem?.focus(); } } } ================================================ FILE: frontend/app/store/global-atoms.test.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; import { getAtoms } from "./global-atoms"; describe("global-atoms", () => { it("throws before initialization", () => { expect(() => getAtoms()).toThrow("Global atoms accessed before initialization"); }); }); ================================================ FILE: frontend/app/store/global-atoms.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { atom, Atom, PrimitiveAtom } from "jotai"; import { globalStore } from "./jotaiStore"; import { setWaveWindowType } from "./windowtype"; import * as WOS from "./wos"; let atoms!: GlobalAtomsType; const blockComponentModelMap = new Map<string, BlockComponentModel>(); const ConnStatusMapAtom = atom(new Map<string, PrimitiveAtom<ConnStatus>>()); const orefAtomCache = new Map<string, Map<string, Atom<any>>>(); function initGlobalAtoms(initOpts: GlobalInitOptions) { const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom<string>; const builderIdAtom = atom(initOpts.builderId) as PrimitiveAtom<string>; const builderAppIdAtom = atom<string>(null) as PrimitiveAtom<string>; setWaveWindowType(initOpts.isPreview ? "preview" : initOpts.builderId != null ? "builder" : "tab"); const uiContextAtom = atom((get) => { const uiContext: UIContext = { windowid: initOpts.windowId, activetabid: initOpts.tabId, }; return uiContext; }) as Atom<UIContext>; const isFullScreenAtom = atom(false) as PrimitiveAtom<boolean>; try { getApi().onFullScreenChange((isFullScreen) => { globalStore.set(isFullScreenAtom, isFullScreen); }); } catch (e) { console.log("failed to initialize isFullScreenAtom", e); } const zoomFactorAtom = atom(1.0) as PrimitiveAtom<number>; try { globalStore.set(zoomFactorAtom, getApi().getZoomFactor()); getApi().onZoomFactorChange((zoomFactor) => { globalStore.set(zoomFactorAtom, zoomFactor); }); } catch (e) { console.log("failed to initialize zoomFactorAtom", e); } const workspaceIdAtom: Atom<string> = atom((get) => { const windowData = WOS.getObjectValue<WaveWindow>(WOS.makeORef("window", get(windowIdAtom)), get); return windowData?.workspaceid ?? null; }); const workspaceAtom: Atom<Workspace> = atom((get) => { const workspaceId = get(workspaceIdAtom); if (workspaceId == null) { return null; } return WOS.getObjectValue(WOS.makeORef("workspace", workspaceId), get); }); const fullConfigAtom = atom(null) as PrimitiveAtom<FullConfigType>; const waveaiModeConfigAtom = atom(null) as PrimitiveAtom<Record<string, AIModeConfigType>>; const settingsAtom = atom((get) => { return get(fullConfigAtom)?.settings ?? {}; }) as Atom<SettingsType>; const hasCustomAIPresetsAtom = atom((get) => { const fullConfig = get(fullConfigAtom); if (!fullConfig?.presets) { return false; } for (const presetId in fullConfig.presets) { if (presetId.startsWith("ai@") && presetId !== "ai@global" && presetId !== "ai@wave") { return true; } } return false; }) as Atom<boolean>; const hasConfigErrors = atom((get) => { const fullConfig = get(fullConfigAtom); return fullConfig?.configerrors != null && fullConfig.configerrors.length > 0; }) as Atom<boolean>; // this is *the* tab that this tabview represents. it should never change. const staticTabIdAtom: Atom<string> = atom(initOpts.tabId); const controlShiftDelayAtom = atom(false); const updaterStatusAtom = atom<UpdaterStatus>("up-to-date") as PrimitiveAtom<UpdaterStatus>; try { globalStore.set(updaterStatusAtom, getApi().getUpdaterStatus()); getApi().onUpdaterStatusChange((status) => { globalStore.set(updaterStatusAtom, status); }); } catch (e) { console.log("failed to initialize updaterStatusAtom", e); } const reducedMotionSettingAtom = atom((get) => get(settingsAtom)?.["window:reducedmotion"]); const reducedMotionSystemPreferenceAtom = atom(false); // Composite of the prefers-reduced-motion media query and the window:reducedmotion user setting. const prefersReducedMotionAtom = atom((get) => { const reducedMotionSetting = get(reducedMotionSettingAtom); const reducedMotionSystemPreference = get(reducedMotionSystemPreferenceAtom); return reducedMotionSetting || reducedMotionSystemPreference; }); // Set up a handler for changes to the prefers-reduced-motion media query. if (globalThis.window != null) { const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); globalStore.set(reducedMotionSystemPreferenceAtom, !reducedMotionQuery || reducedMotionQuery.matches); reducedMotionQuery?.addEventListener("change", () => { globalStore.set(reducedMotionSystemPreferenceAtom, reducedMotionQuery.matches); }); } const documentHasFocusAtom = atom(true) as PrimitiveAtom<boolean>; if (globalThis.window != null) { globalStore.set(documentHasFocusAtom, document.hasFocus()); window.addEventListener("focus", () => { globalStore.set(documentHasFocusAtom, true); }); window.addEventListener("blur", () => { globalStore.set(documentHasFocusAtom, false); }); } const modalOpen = atom(false); const allConnStatusAtom = atom<ConnStatus[]>((get) => { const connStatusMap = get(ConnStatusMapAtom); const connStatuses = Array.from(connStatusMap.values()).map((atom) => get(atom)); return connStatuses; }); const reinitVersion = atom(0); const rateLimitInfoAtom = atom(null) as PrimitiveAtom<RateLimitInfo>; atoms = { // initialized in wave.ts (will not be null inside of application) builderId: builderIdAtom, builderAppId: builderAppIdAtom, uiContext: uiContextAtom, workspaceId: workspaceIdAtom, workspace: workspaceAtom, fullConfigAtom, waveaiModeConfigAtom, settingsAtom, hasCustomAIPresetsAtom, hasConfigErrors, staticTabId: staticTabIdAtom, isFullScreen: isFullScreenAtom, zoomFactorAtom, controlShiftDelayAtom, updaterStatusAtom, prefersReducedMotionAtom, documentHasFocus: documentHasFocusAtom, modalOpen, allConnStatus: allConnStatusAtom, reinitVersion, waveAIRateLimitInfoAtom: rateLimitInfoAtom, } as GlobalAtomsType; } function getAtoms(): GlobalAtomsType { if (atoms == null) { throw new Error("Global atoms accessed before initialization"); } return atoms; } function getApi(): ElectronApi { return (window as any).api; } export { atoms, blockComponentModelMap, ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache }; ================================================ FILE: frontend/app/store/global-model.ts ================================================ // Copyright 2025, Command Line Inc // SPDX-License-Identifier: Apache-2.0 import * as WOS from "@/app/store/wos"; import { ClientModel } from "@/app/store/client-model"; import { getApi } from "@/store/global"; import * as util from "@/util/util"; import { atom, Atom } from "jotai"; class GlobalModel { private static instance: GlobalModel; static readonly IsActiveThrottleMs = 5000; windowId: string; builderId: string; platform: NodeJS.Platform; lastSetIsActiveTs = 0; windowDataAtom!: Atom<WaveWindow>; workspaceAtom!: Atom<Workspace>; private constructor() { // private constructor for singleton pattern } static getInstance(): GlobalModel { if (!GlobalModel.instance) { GlobalModel.instance = new GlobalModel(); } return GlobalModel.instance; } async initialize(initOpts: GlobalInitOptions): Promise<void> { ClientModel.getInstance().initialize(initOpts.clientId); this.windowId = initOpts.windowId; this.builderId = initOpts.builderId; this.platform = initOpts.platform; this.windowDataAtom = atom((get) => { if (this.windowId == null) { return null; } return WOS.getObjectValue<WaveWindow>(WOS.makeORef("window", this.windowId), get); }); this.workspaceAtom = atom((get) => { const windowData = get(this.windowDataAtom); if (windowData == null) { return null; } return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get); }); } setIsActive(): void { const now = Date.now(); if (now - this.lastSetIsActiveTs < GlobalModel.IsActiveThrottleMs) { return; } this.lastSetIsActiveTs = now; util.fireAndForget(() => getApi().setIsActive()); } } export { GlobalModel }; ================================================ FILE: frontend/app/store/global.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getLayoutModelForStaticTab, LayoutTreeActionType, LayoutTreeInsertNodeAction, newLayoutNode, } from "@/layout/index"; import { LayoutTreeReplaceNodeAction, LayoutTreeSplitHorizontalAction, LayoutTreeSplitVerticalAction, } from "@/layout/lib/types"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { setPlatform } from "@/util/platformutil"; import { base64ToString, deepCompareReturnPrev, fireAndForget, getPrefixedSettings, isBlank, isLocalConnName, isWslConnName, NullAtom, } from "@/util/util"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; import { setupBadgesSubscription } from "./badge"; import { atoms, blockComponentModelMap, ConnStatusMapAtom, initGlobalAtoms, orefAtomCache } from "./global-atoms"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; import { ClientService, ObjectService } from "./services"; import { isPreviewWindow } from "./windowtype"; import * as WOS from "./wos"; import { getFileSubject, waveEventSubscribeSingle } from "./wps"; let globalPrimaryTabStartup: boolean = false; function initGlobal(initOpts: GlobalInitOptions) { globalPrimaryTabStartup = initOpts.primaryTabStartup ?? false; setPlatform(initOpts.platform); initGlobalAtoms(initOpts); try { getApi().onMenuItemAbout(() => { modalsModel.pushModal("AboutModal"); }); } catch (e) { console.log("failed to initialize onMenuItemAbout handler", e); } } function initGlobalWaveEventSubs(initOpts: WaveInitOpts) { waveEventSubscribeSingle({ eventType: "waveobj:update", handler: (event) => { // console.log("waveobj:update wave event handler", event); WOS.updateWaveObject(event.data); }, }); waveEventSubscribeSingle({ eventType: "config", handler: (event) => { // console.log("config wave event handler", event); globalStore.set(atoms.fullConfigAtom, event.data.fullconfig); }, }); waveEventSubscribeSingle({ eventType: "waveai:modeconfig", handler: (event) => { globalStore.set(atoms.waveaiModeConfigAtom, event.data.configs); }, }); waveEventSubscribeSingle({ eventType: "userinput", handler: (event) => { // console.log("userinput event handler", event); modalsModel.pushModal("UserInputModal", { ...event.data }); }, scope: initOpts.windowId, }); waveEventSubscribeSingle({ eventType: "blockfile", handler: (event) => { // console.log("blockfile event update", event); const fileSubject = getFileSubject(event.data.zoneid, event.data.filename); if (fileSubject != null) { fileSubject.next(event.data); } }, }); waveEventSubscribeSingle({ eventType: "waveai:ratelimit", handler: (event) => { globalStore.set(atoms.waveAIRateLimitInfoAtom, event.data); }, }); setupBadgesSubscription(); } const blockCache = new Map<string, Map<string, any>>(); function useBlockCache<T>(blockId: string, name: string, makeFn: () => T): T { let blockMap = blockCache.get(blockId); if (blockMap == null) { blockMap = new Map<string, any>(); blockCache.set(blockId, blockMap); } let value = blockMap.get(name); if (value == null) { value = makeFn(); blockMap.set(name, value); } return value as T; } function getBlockMetaKeyAtom<T extends keyof MetaType>(blockId: string, key: T): Atom<MetaType[T]> { const blockCache = getSingleBlockAtomCache(blockId); const metaAtomName = "#meta-" + key; let metaAtom = blockCache.get(metaAtomName); if (metaAtom != null) { return metaAtom; } metaAtom = atom((get) => { const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); const blockData = get(blockAtom); return blockData?.meta?.[key]; }); blockCache.set(metaAtomName, metaAtom); return metaAtom; } function getOrefMetaKeyAtom<T extends keyof MetaType>(oref: string, key: T): Atom<MetaType[T]> { const orefCache = getSingleOrefAtomCache(oref); const metaAtomName = "#meta-" + key; let metaAtom = orefCache.get(metaAtomName); if (metaAtom != null) { return metaAtom; } metaAtom = atom((get) => { const objAtom = WOS.getWaveObjectAtom(oref); const objData = get(objAtom); return objData?.meta?.[key]; }); orefCache.set(metaAtomName, metaAtom); return metaAtom; } function useOrefMetaKeyAtom<T extends keyof MetaType>(oref: string, key: T): MetaType[T] { return useAtomValue(getOrefMetaKeyAtom(oref, key)); } function getConnConfigKeyAtom<T extends keyof ConnKeywords>(connName: string, key: T): Atom<ConnKeywords[T]> { if (isPreviewWindow()) return NullAtom as Atom<ConnKeywords[T]>; const connCache = getSingleConnAtomCache(connName); const keyAtomName = "#conn-" + key; let keyAtom = connCache.get(keyAtomName); if (keyAtom != null) { return keyAtom; } keyAtom = atom((get) => { const fullConfig = get(atoms.fullConfigAtom); return fullConfig.connections?.[connName]?.[key]; }); connCache.set(keyAtomName, keyAtom); return keyAtom; } const settingsAtomCache = new Map<string, Atom<any>>(); function getOverrideConfigAtom<T extends keyof SettingsType>(blockId: string, key: T): Atom<SettingsType[T]> { if (isPreviewWindow()) return NullAtom as Atom<SettingsType[T]>; const blockCache = getSingleBlockAtomCache(blockId); const overrideAtomName = "#settingsoverride-" + key; let overrideAtom = blockCache.get(overrideAtomName); if (overrideAtom != null) { return overrideAtom; } overrideAtom = atom((get) => { const blockMetaKeyAtom = getBlockMetaKeyAtom(blockId, key as any); const metaKeyVal = get(blockMetaKeyAtom); if (metaKeyVal != null) { return metaKeyVal; } const connNameAtom = getBlockMetaKeyAtom(blockId, "connection"); const connName = get(connNameAtom); const connConfigKeyAtom = getConnConfigKeyAtom(connName, key as any); const connConfigKeyVal = get(connConfigKeyAtom); if (connConfigKeyVal != null) { return connConfigKeyVal; } const settingsKeyAtom = getSettingsKeyAtom(key); const settingsVal = get(settingsKeyAtom); if (settingsVal != null) { return settingsVal; } return null; }); blockCache.set(overrideAtomName, overrideAtom); return overrideAtom; } function useOverrideConfigAtom<T extends keyof SettingsType>(blockId: string | null, key: T): SettingsType[T] { if (blockId == null) { return useAtomValue(getSettingsKeyAtom(key)); } return useAtomValue(getOverrideConfigAtom(blockId, key)); } function getSettingsKeyAtom<T extends keyof SettingsType>(key: T): Atom<SettingsType[T]> { if (isPreviewWindow()) return NullAtom as Atom<SettingsType[T]>; let settingsKeyAtom = settingsAtomCache.get(key) as Atom<SettingsType[T]>; if (settingsKeyAtom == null) { settingsKeyAtom = atom((get) => { const settings = get(atoms.settingsAtom); if (settings == null) { return null; } return settings[key]; }); settingsAtomCache.set(key, settingsKeyAtom); } return settingsKeyAtom; } function useSettingsKeyAtom<T extends keyof SettingsType>(key: T): SettingsType[T] { return useAtomValue(getSettingsKeyAtom(key)); } function getSettingsPrefixAtom(prefix: string): Atom<SettingsType> { if (isPreviewWindow()) return NullAtom as Atom<SettingsType>; let settingsPrefixAtom = settingsAtomCache.get(prefix + ":"); if (settingsPrefixAtom == null) { // create a stable, closured reference to use as the deepCompareReturnPrev key const cacheKey = {}; settingsPrefixAtom = atom((get) => { const settings = get(atoms.settingsAtom); const newValue = getPrefixedSettings(settings, prefix); return deepCompareReturnPrev(cacheKey, newValue); }); settingsAtomCache.set(prefix + ":", settingsPrefixAtom); } return settingsPrefixAtom; } function getSingleBlockAtomCache(blockId: string): Map<string, Atom<any>> { const blockORef = WOS.makeORef("block", blockId); return getSingleOrefAtomCache(blockORef); } function getSingleConnAtomCache(connName: string): Map<string, Atom<any>> { // this is not a real "oref", but it will work for the cache. const connORef = WOS.makeORef("conn", connName); return getSingleOrefAtomCache(connORef); } function getSingleOrefAtomCache(oref: string): Map<string, Atom<any>> { let orefCache = orefAtomCache.get(oref); if (orefCache == null) { orefCache = new Map<string, Atom<any>>(); orefAtomCache.set(oref, orefCache); } return orefCache; } // this function should be kept up to date with IsBlockTermDurable in pkg/jobcontroller/jobcontroller.go // Note: null/false both map to false in the Go code, but this returns a special null value // to indicate when the block is not even eligible to be durable function getBlockTermDurableAtom(blockId: string): Atom<null | boolean> { const blockCache = getSingleBlockAtomCache(blockId); const durableAtomName = "#termdurable"; let durableAtom = blockCache.get(durableAtomName); if (durableAtom != null) { return durableAtom; } durableAtom = atom((get) => { const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", blockId)); const block = get(blockAtom); if (block == null) { return null; } // Check if view is "term", and controller is "shell" if (block.meta?.view != "term" || block.meta?.controller != "shell") { return null; } // 1. Check if block has a JobId if (block.jobid != null && block.jobid != "") { return true; } // 2. Check if connection is local or WSL (not eligible for durability) const connName = block.meta?.connection ?? ""; if (isLocalConnName(connName) || isWslConnName(connName)) { return null; } // 3. Check config hierarchy: blockmeta → connection → global (default true) const durableConfigAtom = getOverrideConfigAtom(blockId, "term:durable"); const durableConfig = get(durableConfigAtom); if (durableConfig != null) { return durableConfig; } // Default to true for non-local connections return true; }); blockCache.set(durableAtomName, durableAtom); return durableAtom; } function useBlockAtom<T>(blockId: string, name: string, makeFn: () => Atom<T>): Atom<T> { const blockCache = getSingleBlockAtomCache(blockId); let atom = blockCache.get(name); if (atom == null) { atom = makeFn(); blockCache.set(name, atom); console.log("New BlockAtom", blockId, name); } return atom as Atom<T>; } /** * Safely read an atom value, returning null if the atom is null. */ function readAtom<T>(atom: Atom<T>): T { if (atom == null) { return null; } return globalStore.get(atom); } /** * Get the preload api. */ function getApi(): ElectronApi { return (window as any).api; } async function createBlockSplitHorizontally( blockDef: BlockDef, targetBlockId: string, position: "before" | "after" ): Promise<string> { const layoutModel = getLayoutModelForStaticTab(); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts); const targetNodeId = layoutModel.getNodeByBlockId(targetBlockId)?.id; if (targetNodeId == null) { throw new Error(`targetNodeId not found for blockId: ${targetBlockId}`); } const splitAction: LayoutTreeSplitHorizontalAction = { type: LayoutTreeActionType.SplitHorizontal, targetNodeId: targetNodeId, newNode: newLayoutNode(undefined, undefined, undefined, { blockId: newBlockId }), position: position, focused: true, }; layoutModel.treeReducer(splitAction); return newBlockId; } async function createBlockSplitVertically( blockDef: BlockDef, targetBlockId: string, position: "before" | "after" ): Promise<string> { const layoutModel = getLayoutModelForStaticTab(); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts); const targetNodeId = layoutModel.getNodeByBlockId(targetBlockId)?.id; if (targetNodeId == null) { throw new Error(`targetNodeId not found for blockId: ${targetBlockId}`); } const splitAction: LayoutTreeSplitVerticalAction = { type: LayoutTreeActionType.SplitVertical, targetNodeId: targetNodeId, newNode: newLayoutNode(undefined, undefined, undefined, { blockId: newBlockId }), position: position, focused: true, }; layoutModel.treeReducer(splitAction); return newBlockId; } async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = false): Promise<string> { const layoutModel = getLayoutModelForStaticTab(); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; const blockId = await ObjectService.CreateBlock(blockDef, rtOpts); if (ephemeral) { layoutModel.newEphemeralNode(blockId); return blockId; } const insertNodeAction: LayoutTreeInsertNodeAction = { type: LayoutTreeActionType.InsertNode, node: newLayoutNode(undefined, undefined, undefined, { blockId }), magnified, focused: true, }; layoutModel.treeReducer(insertNodeAction); return blockId; } async function replaceBlock(blockId: string, blockDef: BlockDef, focus: boolean): Promise<string> { const layoutModel = getLayoutModelForStaticTab(); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts); setTimeout(() => { fireAndForget(() => ObjectService.DeleteBlock(blockId)); }, 300); const targetNodeId = layoutModel.getNodeByBlockId(blockId)?.id; if (targetNodeId == null) { throw new Error(`targetNodeId not found for blockId: ${blockId}`); } const replaceNodeAction: LayoutTreeReplaceNodeAction = { type: LayoutTreeActionType.ReplaceNode, targetNodeId: targetNodeId, newNode: newLayoutNode(undefined, undefined, undefined, { blockId: newBlockId }), focused: focus, }; layoutModel.treeReducer(replaceNodeAction); return newBlockId; } // when file is not found, returns {data: null, fileInfo: null} async function fetchWaveFile( zoneId: string, fileName: string, offset?: number ): Promise<{ data: Uint8Array; fileInfo: WaveFile }> { const usp = new URLSearchParams(); usp.set("zoneid", zoneId); usp.set("name", fileName); if (offset != null) { usp.set("offset", offset.toString()); } const resp = await fetch(getWebServerEndpoint() + "/wave/file?" + usp.toString()); if (!resp.ok) { if (resp.status === 404) { return { data: null, fileInfo: null }; } throw new Error("error getting wave file: " + resp.statusText); } if (resp.status == 204) { return { data: null, fileInfo: null }; } const fileInfo64 = resp.headers.get("X-ZoneFileInfo"); if (fileInfo64 == null) { throw new Error(`missing zone file info for ${zoneId}:${fileName}`); } const fileInfo = JSON.parse(base64ToString(fileInfo64)); const data = await resp.arrayBuffer(); return { data: new Uint8Array(data), fileInfo }; } function setNodeFocus(nodeId: string) { const layoutModel = getLayoutModelForStaticTab(); layoutModel.focusNode(nodeId); } const objectIdWeakMap = new WeakMap(); let objectIdCounter = 0; function getObjectId(obj: any): number { if (!objectIdWeakMap.has(obj)) { objectIdWeakMap.set(obj, objectIdCounter++); } return objectIdWeakMap.get(obj); } let cachedIsDev: boolean = null; function isDev() { if (cachedIsDev == null) { cachedIsDev = getApi().getIsDev(); } return cachedIsDev; } let cachedUserName: string = null; function getUserName(): string { if (cachedUserName == null) { cachedUserName = getApi().getUserName(); } return cachedUserName; } let cachedHostName: string = null; function getHostName(): string { if (cachedHostName == null) { cachedHostName = getApi().getHostName(); } return cachedHostName; } const LocalHostDisplayNameAtom: Atom<string> = atom((get) => { const configValue = get(getSettingsKeyAtom("conn:localhostdisplayname")); if (configValue != null) { return configValue; } return getUserName() + "@" + getHostName(); }); function getLocalHostDisplayNameAtom(): Atom<string> { return LocalHostDisplayNameAtom; } /** * Open a link in a new window, or in a new web widget. The user can set all links to open in a new web widget using the `web:openlinksinternally` setting. * @param uri The link to open. * @param forceOpenInternally Force the link to open in a new web widget. */ async function openLink(uri: string, forceOpenInternally = false) { if (forceOpenInternally || globalStore.get(atoms.settingsAtom)?.["web:openlinksinternally"]) { const blockDef: BlockDef = { meta: { view: "web", url: uri, }, }; await createBlock(blockDef); } else { getApi().openExternal(uri); } } function registerBlockComponentModel(blockId: string, bcm: BlockComponentModel) { blockComponentModelMap.set(blockId, bcm); } function unregisterBlockComponentModel(blockId: string) { blockComponentModelMap.delete(blockId); } function getBlockComponentModel(blockId: string): BlockComponentModel { return blockComponentModelMap.get(blockId); } function getAllBlockComponentModels(): BlockComponentModel[] { return Array.from(blockComponentModelMap.values()); } function getFocusedBlockId(): string { const layoutModel = getLayoutModelForStaticTab(); if (layoutModel?.focusedNode == null) return null; const focusedLayoutNode = globalStore.get(layoutModel.focusedNode); return focusedLayoutNode?.data?.blockId; } // pass null to refocus the currently focused block function refocusNode(blockId: string) { if (blockId == null) { blockId = getFocusedBlockId(); if (blockId == null) { return; } } const layoutModel = getLayoutModelForStaticTab(); const layoutNodeId = layoutModel.getNodeByBlockId(blockId); if (layoutNodeId?.id == null) { return; } layoutModel.focusNode(layoutNodeId.id); const bcm = getBlockComponentModel(blockId); const ok = bcm?.viewModel?.giveFocus?.(); if (!ok) { const inputElem = document.getElementById(`${blockId}-dummy-focus`); inputElem?.focus(); } } async function loadConnStatus() { const connStatusArr = await ClientService.GetAllConnStatus(); if (connStatusArr == null) { return; } for (const connStatus of connStatusArr) { const curAtom = getConnStatusAtom(connStatus.connection); globalStore.set(curAtom, connStatus); } } function subscribeToConnEvents() { waveEventSubscribeSingle({ eventType: "connchange", handler: (event) => { try { const connStatus = event.data; if (connStatus == null || isBlank(connStatus.connection)) { return; } console.log("connstatus update", connStatus); const curAtom = getConnStatusAtom(connStatus.connection); globalStore.set(curAtom, connStatus); } catch (e) { console.log("connchange error", e); } }, }); } function makeDefaultConnStatus(conn: string): ConnStatus { if (isLocalConnName(conn)) { return { connection: conn, connected: true, error: null, status: "connected", hasconnected: true, activeconnnum: 0, wshenabled: false, }; } return { connection: conn, connected: false, error: null, status: "disconnected", hasconnected: false, activeconnnum: 0, wshenabled: false, }; } function getConnStatusAtom(conn: string): PrimitiveAtom<ConnStatus> { const connStatusMap = globalStore.get(ConnStatusMapAtom); let rtn = connStatusMap.get(conn); if (rtn == null) { rtn = atom(makeDefaultConnStatus(conn)); const newConnStatusMap = new Map(connStatusMap); newConnStatusMap.set(conn, rtn); globalStore.set(ConnStatusMapAtom, newConnStatusMap); } return rtn; } function createTab() { getApi().createTab(); } function setActiveTab(tabId: string) { getApi().setActiveTab(tabId); } function recordTEvent(event: string, props?: TEventProps) { if (isPreviewWindow()) return; if (props == null) { props = {}; } RpcApi.RecordTEventCommand(TabRpcClient, { event, props }, { noresponse: true }); } export { atoms, createBlock, createBlockSplitHorizontally, createBlockSplitVertically, createTab, fetchWaveFile, getAllBlockComponentModels, getApi, getBlockComponentModel, getBlockMetaKeyAtom, getConnConfigKeyAtom, getBlockTermDurableAtom, getConnStatusAtom, getFocusedBlockId, getHostName, getLocalHostDisplayNameAtom, getObjectId, getOrefMetaKeyAtom, getOverrideConfigAtom, getSettingsKeyAtom, getSettingsPrefixAtom, getUserName, globalPrimaryTabStartup, globalStore, initGlobal, initGlobalWaveEventSubs, isDev, loadConnStatus, makeDefaultConnStatus, openLink, readAtom, recordTEvent, refocusNode, registerBlockComponentModel, replaceBlock, setActiveTab, setNodeFocus, setPlatform, subscribeToConnEvents, unregisterBlockComponentModel, useBlockAtom, useBlockCache, useOrefMetaKeyAtom, useOverrideConfigAtom, useSettingsKeyAtom, WOS, }; ================================================ FILE: frontend/app/store/jotaiStore.ts ================================================ import { createStore } from "jotai"; export const globalStore = createStore(); ================================================ FILE: frontend/app/store/keymodel.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { FocusManager } from "@/app/store/focusManager"; import { atoms, createBlock, createBlockSplitHorizontally, createBlockSplitVertically, createTab, getAllBlockComponentModels, getApi, getBlockComponentModel, getFocusedBlockId, getSettingsKeyAtom, globalStore, recordTEvent, refocusNode, replaceBlock, WOS, } from "@/app/store/global"; import { getActiveTabModel } from "@/app/store/tab-model"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab, getLayoutModelForStaticTab, NavigateDirection } from "@/layout/index"; import * as keyutil from "@/util/keyutil"; import { isWindows } from "@/util/platformutil"; import { CHORD_TIMEOUT } from "@/util/sharedconst"; import { fireAndForget } from "@/util/util"; import * as jotai from "jotai"; import { modalsModel } from "./modalmodel"; import { isBuilderWindow, isTabWindow } from "./windowtype"; type KeyHandler = (event: WaveKeyboardEvent) => boolean; const simpleControlShiftAtom = jotai.atom(false); const globalKeyMap = new Map<string, (waveEvent: WaveKeyboardEvent) => boolean>(); const globalChordMap = new Map<string, Map<string, KeyHandler>>(); let globalKeybindingsDisabled = false; // track current chord state and timeout (for resetting) let activeChord: string | null = null; let chordTimeout: NodeJS.Timeout = null; function resetChord() { activeChord = null; if (chordTimeout) { clearTimeout(chordTimeout); chordTimeout = null; } } function setActiveChord(activeChordArg: string) { getApi().setKeyboardChordMode(); if (chordTimeout) { clearTimeout(chordTimeout); } activeChord = activeChordArg; chordTimeout = setTimeout(() => resetChord(), CHORD_TIMEOUT); } export function keyboardMouseDownHandler(e: MouseEvent) { if (!e.ctrlKey || !e.shiftKey) { unsetControlShift(); } } function getFocusedBlockInStaticTab(): string { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); return focusedNode.data?.blockId; } function getSimpleControlShiftAtom() { return simpleControlShiftAtom; } function setControlShift() { globalStore.set(simpleControlShiftAtom, true); const disableDisplay = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftdisplay")); if (!disableDisplay) { setTimeout(() => { const simpleState = globalStore.get(simpleControlShiftAtom); if (simpleState) { globalStore.set(atoms.controlShiftDelayAtom, true); } }, 400); } } function unsetControlShift() { globalStore.set(simpleControlShiftAtom, false); globalStore.set(atoms.controlShiftDelayAtom, false); } function disableGlobalKeybindings() { globalKeybindingsDisabled = true; } function enableGlobalKeybindings() { globalKeybindingsDisabled = false; } function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean { if (globalStore.get(atoms.modalOpen)) { return false; } const activeElem = document.activeElement; if (activeElem != null && activeElem instanceof HTMLElement) { if (activeElem.tagName == "INPUT" || activeElem.tagName == "TEXTAREA" || activeElem.contentEditable == "true") { if (activeElem.classList.contains("dummy-focus") || activeElem.classList.contains("dummy")) { return true; } if (keyutil.isInputEvent(e)) { return false; } return true; } } return true; } function getStaticTabBlockCount(): number { const tabId = globalStore.get(atoms.staticTabId); const tabORef = WOS.makeORef("tab", tabId); const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef); const tabData = globalStore.get(tabAtom); return tabData?.blockids?.length ?? 0; } function simpleCloseStaticTab() { const workspaceId = globalStore.get(atoms.workspaceId); const tabId = globalStore.get(atoms.staticTabId); const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false; getApi() .closeTab(workspaceId, tabId, confirmClose) .then((didClose) => { if (didClose) { deleteLayoutModelForTab(tabId); } }) .catch((e) => { console.log("error closing tab", e); }); } function uxCloseBlock(blockId: string) { const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); const isAIPanelOpen = workspaceLayoutModel.getAIPanelVisible(); if (isAIPanelOpen && getStaticTabBlockCount() === 1) { const aiModel = WaveAIModel.getInstance(); const shouldSwitchToAI = !globalStore.get(aiModel.isChatEmptyAtom) || aiModel.hasNonEmptyInput(); if (shouldSwitchToAI) { replaceBlock(blockId, { meta: { view: "launcher" } }, false); setTimeout(() => WaveAIModel.getInstance().focusInput(), 50); return; } } const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", blockId)); const blockData = globalStore.get(blockAtom); const isAIFileDiff = blockData?.meta?.view === "aifilediff"; // If this is the last block, closing it will close the tab — route through simpleCloseStaticTab // so the tab:confirmclose setting is respected. if (getStaticTabBlockCount() === 1) { simpleCloseStaticTab(); return; } const layoutModel = getLayoutModelForStaticTab(); const node = layoutModel.getNodeByBlockId(blockId); if (node) { fireAndForget(() => layoutModel.closeNode(node.id)); if (isAIFileDiff && isAIPanelOpen) { setTimeout(() => WaveAIModel.getInstance().focusInput(), 50); } } } function genericClose() { const focusType = FocusManager.getInstance().getFocusType(); if (focusType === "waveai") { WorkspaceLayoutModel.getInstance().setAIPanelVisible(false); return; } const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); const isAIPanelOpen = workspaceLayoutModel.getAIPanelVisible(); if (isAIPanelOpen && getStaticTabBlockCount() === 1) { const aiModel = WaveAIModel.getInstance(); const shouldSwitchToAI = !globalStore.get(aiModel.isChatEmptyAtom) || aiModel.hasNonEmptyInput(); if (shouldSwitchToAI) { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode) { replaceBlock(focusedNode.data.blockId, { meta: { view: "launcher" } }, false); setTimeout(() => WaveAIModel.getInstance().focusInput(), 50); return; } } } const blockCount = getStaticTabBlockCount(); if (blockCount === 0) { simpleCloseStaticTab(); return; } // If this is the last block, closing it will close the tab — route through simpleCloseStaticTab // so the tab:confirmclose setting is respected. if (blockCount === 1) { simpleCloseStaticTab(); return; } const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); const blockId = focusedNode?.data?.blockId; const blockAtom = blockId ? WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", blockId)) : null; const blockData = blockAtom ? globalStore.get(blockAtom) : null; const isAIFileDiff = blockData?.meta?.view === "aifilediff"; fireAndForget(layoutModel.closeFocusedNode.bind(layoutModel)); if (isAIFileDiff && isAIPanelOpen) { setTimeout(() => WaveAIModel.getInstance().focusInput(), 50); } } function switchBlockByBlockNum(index: number) { const layoutModel = getLayoutModelForStaticTab(); if (!layoutModel) { return; } layoutModel.switchNodeFocusByBlockNum(index); setTimeout(() => { globalRefocus(); }, 10); } function switchBlockInDirection(direction: NavigateDirection) { const layoutModel = getLayoutModelForStaticTab(); const focusType = FocusManager.getInstance().getFocusType(); if (direction === NavigateDirection.Left) { const numBlocks = globalStore.get(layoutModel.numLeafs); if (focusType === "waveai") { return; } if (numBlocks === 1) { FocusManager.getInstance().requestWaveAIFocus(); setTimeout(() => { FocusManager.getInstance().refocusNode(); }, 10); return; } } if (direction === NavigateDirection.Right && focusType === "waveai") { FocusManager.getInstance().requestNodeFocus(); return; } const inWaveAI = focusType === "waveai"; const navResult = layoutModel.switchNodeFocusInDirection(direction, inWaveAI); if (navResult.atLeft) { FocusManager.getInstance().requestWaveAIFocus(); setTimeout(() => { FocusManager.getInstance().refocusNode(); }, 10); return; } setTimeout(() => { globalRefocus(); }, 10); } function getAllTabs(ws: Workspace): string[] { return ws.tabids ?? []; } function switchTabAbs(index: number) { console.log("switchTabAbs", index); const ws = globalStore.get(atoms.workspace); const newTabIdx = index - 1; const tabids = getAllTabs(ws); if (newTabIdx < 0 || newTabIdx >= tabids.length) { return; } const newActiveTabId = tabids[newTabIdx]; getApi().setActiveTab(newActiveTabId); } function switchTab(offset: number) { console.log("switchTab", offset); const ws = globalStore.get(atoms.workspace); const curTabId = globalStore.get(atoms.staticTabId); let tabIdx = -1; const tabids = getAllTabs(ws); for (let i = 0; i < tabids.length; i++) { if (tabids[i] == curTabId) { tabIdx = i; break; } } if (tabIdx == -1) { return; } const newTabIdx = (tabIdx + offset + tabids.length) % tabids.length; const newActiveTabId = tabids[newTabIdx]; getApi().setActiveTab(newActiveTabId); } function handleCmdI() { globalRefocus(); } function globalRefocusWithTimeout(timeoutVal: number) { setTimeout(() => { globalRefocus(); }, timeoutVal); } function globalRefocus() { if (isBuilderWindow()) { return; } const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { // focus a node layoutModel.focusFirstNode(); return; } const blockId = focusedNode?.data?.blockId; if (blockId == null) { return; } refocusNode(blockId); } function getDefaultNewBlockDef(): BlockDef { const adnbAtom = getSettingsKeyAtom("app:defaultnewblock"); const adnb = globalStore.get(adnbAtom) ?? "term"; if (adnb == "launcher") { return { meta: { view: "launcher", }, }; } // "term", blank, anything else, fall back to terminal const termBlockDef: BlockDef = { meta: { view: "term", controller: "shell", }, }; const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode != null) { const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", focusedNode.data?.blockId)); const blockData = globalStore.get(blockAtom); if (blockData?.meta?.view == "term") { if (blockData?.meta?.["cmd:cwd"] != null) { termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"]; } } if (blockData?.meta?.connection != null) { termBlockDef.meta.connection = blockData.meta.connection; } } return termBlockDef; } async function handleCmdN() { const blockDef = getDefaultNewBlockDef(); await createBlock(blockDef); } async function handleSplitHorizontal(position: "before" | "after") { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { return; } const blockDef = getDefaultNewBlockDef(); await createBlockSplitHorizontally(blockDef, focusedNode.data.blockId, position); } async function handleSplitVertical(position: "before" | "after") { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { return; } const blockDef = getDefaultNewBlockDef(); await createBlockSplitVertically(blockDef, focusedNode.data.blockId, position); } let lastHandledEvent: KeyboardEvent | null = null; // returns [keymatch, T] function checkKeyMap<T>(waveEvent: WaveKeyboardEvent, keyMap: Map<string, T>): [string, T] { for (const key of keyMap.keys()) { if (keyutil.checkKeyPressed(waveEvent, key)) { const val = keyMap.get(key); return [key, val]; } } return [null, null]; } function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { if (globalKeybindingsDisabled) { return false; } const nativeEvent = (waveEvent as any).nativeEvent; if (lastHandledEvent != null && nativeEvent != null && lastHandledEvent === nativeEvent) { return false; } lastHandledEvent = nativeEvent; if (activeChord) { console.log("handle activeChord", activeChord); // If we're in chord mode, look for the second key. const chordBindings = globalChordMap.get(activeChord); const [, handler] = checkKeyMap(waveEvent, chordBindings); if (handler) { resetChord(); return handler(waveEvent); } else { // invalid chord; reset state and consume key resetChord(); return true; } } const [chordKeyMatch] = checkKeyMap(waveEvent, globalChordMap); if (chordKeyMatch) { setActiveChord(chordKeyMatch); return true; } const [, globalHandler] = checkKeyMap(waveEvent, globalKeyMap); if (globalHandler) { const handled = globalHandler(waveEvent); if (handled) { return true; } } if (isTabWindow()) { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); const blockId = focusedNode?.data?.blockId; if (blockId != null && shouldDispatchToBlock(waveEvent)) { const bcm = getBlockComponentModel(blockId); const viewModel = bcm?.viewModel; if (viewModel?.keyDownHandler) { const handledByBlock = viewModel.keyDownHandler(waveEvent); if (handledByBlock) { return true; } } } } return false; } function registerControlShiftStateUpdateHandler() { getApi().onControlShiftStateUpdate((state: boolean) => { if (state) { setControlShift(); } else { unsetControlShift(); } }); } function registerElectronReinjectKeyHandler() { getApi().onReinjectKey((event: WaveKeyboardEvent) => { appHandleKeyDown(event); }); } function tryReinjectKey(event: WaveKeyboardEvent): boolean { return appHandleKeyDown(event); } function countTermBlocks(): number { const allBCMs = getAllBlockComponentModels(); let count = 0; const gsGetBound = globalStore.get.bind(globalStore); for (const bcm of allBCMs) { const viewModel = bcm.viewModel; if (viewModel.viewType == "term" && viewModel.isBasicTerm?.(gsGetBound)) { count++; } } return count; } function registerGlobalKeys() { globalKeyMap.set("Cmd:]", () => { switchTab(1); return true; }); globalKeyMap.set("Shift:Cmd:]", () => { switchTab(1); return true; }); globalKeyMap.set("Cmd:[", () => { switchTab(-1); return true; }); globalKeyMap.set("Shift:Cmd:[", () => { switchTab(-1); return true; }); globalKeyMap.set("Cmd:n", () => { handleCmdN(); return true; }); globalKeyMap.set("Cmd:d", () => { handleSplitHorizontal("after"); return true; }); globalKeyMap.set("Shift:Cmd:d", () => { handleSplitVertical("after"); return true; }); globalKeyMap.set("Cmd:i", () => { handleCmdI(); return true; }); globalKeyMap.set("Cmd:t", () => { createTab(); return true; }); globalKeyMap.set("Cmd:w", () => { genericClose(); return true; }); globalKeyMap.set("Cmd:Shift:w", () => { simpleCloseStaticTab(); return true; }); globalKeyMap.set("Cmd:m", () => { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode != null) { layoutModel.magnifyNodeToggle(focusedNode.id); } return true; }); globalKeyMap.set("Ctrl:Shift:ArrowUp", () => { const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); if (disableCtrlShiftArrows) { return false; } switchBlockInDirection(NavigateDirection.Up); return true; }); globalKeyMap.set("Ctrl:Shift:ArrowDown", () => { const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); if (disableCtrlShiftArrows) { return false; } switchBlockInDirection(NavigateDirection.Down); return true; }); globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => { const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); if (disableCtrlShiftArrows) { return false; } switchBlockInDirection(NavigateDirection.Left); return true; }); globalKeyMap.set("Ctrl:Shift:ArrowRight", () => { const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); if (disableCtrlShiftArrows) { return false; } switchBlockInDirection(NavigateDirection.Right); return true; }); // Vim-style aliases for block focus navigation. globalKeyMap.set("Ctrl:Shift:h", () => { const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); if (disableCtrlShiftArrows) { return false; } switchBlockInDirection(NavigateDirection.Left); return true; }); globalKeyMap.set("Ctrl:Shift:j", () => { const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); if (disableCtrlShiftArrows) { return false; } switchBlockInDirection(NavigateDirection.Down); return true; }); globalKeyMap.set("Ctrl:Shift:k", () => { const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); if (disableCtrlShiftArrows) { return false; } switchBlockInDirection(NavigateDirection.Up); return true; }); globalKeyMap.set("Ctrl:Shift:l", () => { const disableCtrlShiftArrows = globalStore.get(getSettingsKeyAtom("app:disablectrlshiftarrows")); if (disableCtrlShiftArrows) { return false; } switchBlockInDirection(NavigateDirection.Right); return true; }); globalKeyMap.set("Ctrl:Shift:x", () => { const blockId = getFocusedBlockId(); if (blockId == null) { return true; } replaceBlock( blockId, { meta: { view: "launcher", }, }, true ); return true; }); globalKeyMap.set("Cmd:g", () => { const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); if (bcm.openSwitchConnection != null) { recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "keyboard" }); bcm.openSwitchConnection(); return true; } }); globalKeyMap.set("Ctrl:Shift:i", () => { const tabModel = getActiveTabModel(); if (tabModel == null) { return true; } const curMI = globalStore.get(tabModel.isTermMultiInput); if (!curMI && countTermBlocks() <= 1) { // don't turn on multi-input unless there are 2 or more basic term blocks return true; } globalStore.set(tabModel.isTermMultiInput, !curMI); return true; }); for (let idx = 1; idx <= 9; idx++) { globalKeyMap.set(`Cmd:${idx}`, () => { switchTabAbs(idx); return true; }); globalKeyMap.set(`Ctrl:Shift:c{Digit${idx}}`, () => { switchBlockByBlockNum(idx); return true; }); globalKeyMap.set(`Ctrl:Shift:c{Numpad${idx}}`, () => { switchBlockByBlockNum(idx); return true; }); } if (isWindows()) { globalKeyMap.set("Alt:c{Digit0}", () => { WaveAIModel.getInstance().focusInput(); return true; }); globalKeyMap.set("Alt:c{Numpad0}", () => { WaveAIModel.getInstance().focusInput(); return true; }); } else { globalKeyMap.set("Ctrl:Shift:c{Digit0}", () => { WaveAIModel.getInstance().focusInput(); return true; }); globalKeyMap.set("Ctrl:Shift:c{Numpad0}", () => { WaveAIModel.getInstance().focusInput(); return true; }); } function activateSearch(event: WaveKeyboardEvent): boolean { const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); // Ctrl+f is reserved in most shells if (event.control && bcm.viewModel.viewType == "term") { return false; } if (bcm.viewModel.searchAtoms) { if (globalStore.get(bcm.viewModel.searchAtoms.isOpen)) { // Already open — increment the focusInput counter so this block's // SearchComponent focuses its own input (avoids a global DOM query // that could target the wrong block when multiple searches are open). const cur = globalStore.get(bcm.viewModel.searchAtoms.focusInput) as number; globalStore.set(bcm.viewModel.searchAtoms.focusInput, cur + 1); } else { globalStore.set(bcm.viewModel.searchAtoms.isOpen, true); } return true; } return false; } function deactivateSearch(): boolean { const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) { globalStore.set(bcm.viewModel.searchAtoms.isOpen, false); return true; } return false; } globalKeyMap.set("Cmd:f", activateSearch); globalKeyMap.set("Escape", () => { if (modalsModel.hasOpenModals()) { modalsModel.popModal(); return true; } if (deactivateSearch()) { return true; } return false; }); globalKeyMap.set("Cmd:Shift:a", () => { const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); return true; }); const allKeys = Array.from(globalKeyMap.keys()); // special case keys, handled by web view allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o"); getApi().registerGlobalWebviewKeys(allKeys); const splitBlockKeys = new Map<string, KeyHandler>(); splitBlockKeys.set("ArrowUp", () => { handleSplitVertical("before"); return true; }); splitBlockKeys.set("ArrowDown", () => { handleSplitVertical("after"); return true; }); splitBlockKeys.set("ArrowLeft", () => { handleSplitHorizontal("before"); return true; }); splitBlockKeys.set("ArrowRight", () => { handleSplitHorizontal("after"); return true; }); globalChordMap.set("Ctrl:Shift:s", splitBlockKeys); } function registerBuilderGlobalKeys() { globalKeyMap.set("Cmd:w", () => { getApi().closeBuilderWindow(); return true; }); const allKeys = Array.from(globalKeyMap.keys()); getApi().registerGlobalWebviewKeys(allKeys); } function getAllGlobalKeyBindings(): string[] { const allKeys = Array.from(globalKeyMap.keys()); return allKeys; } export { appHandleKeyDown, disableGlobalKeybindings, enableGlobalKeybindings, getSimpleControlShiftAtom, globalRefocus, globalRefocusWithTimeout, registerBuilderGlobalKeys, registerControlShiftStateUpdateHandler, registerElectronReinjectKeyHandler, registerGlobalKeys, tryReinjectKey, unsetControlShift, uxCloseBlock, }; ================================================ FILE: frontend/app/store/modalmodel.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as jotai from "jotai"; import { globalStore } from "./jotaiStore"; class ModalsModel { modalsAtom: jotai.PrimitiveAtom<Array<{ displayName: string; props?: any }>>; newInstallOnboardingOpen: jotai.PrimitiveAtom<boolean>; upgradeOnboardingOpen: jotai.PrimitiveAtom<boolean>; constructor() { this.newInstallOnboardingOpen = jotai.atom(false); this.upgradeOnboardingOpen = jotai.atom(false); this.modalsAtom = jotai.atom([]); } pushModal = (displayName: string, props?: any) => { const modals = globalStore.get(this.modalsAtom); globalStore.set(this.modalsAtom, [...modals, { displayName, props }]); }; popModal = (callback?: () => void) => { const modals = globalStore.get(this.modalsAtom); if (modals.length > 0) { const updatedModals = modals.slice(0, -1); globalStore.set(this.modalsAtom, updatedModals); if (callback) callback(); } }; hasOpenModals(): boolean { const modals = globalStore.get(this.modalsAtom); return modals.length > 0; } isModalOpen(displayName: string): boolean { const modals = globalStore.get(this.modalsAtom); return modals.some((modal) => modal.displayName === displayName); } } const modalsModel = new ModalsModel(); export { modalsModel }; ================================================ FILE: frontend/app/store/services.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // generated by cmd/generate/main-generatets.go import * as WOS from "./wos"; import type { WaveEnv } from "@/app/waveenv/waveenv"; function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> { if (waveEnv != null) { return waveEnv.callBackendService(service, method, args, noUIContext) } return WOS.callBackendService(service, method, args, noUIContext); } // blockservice.BlockService (block) export class BlockServiceType { waveEnv: WaveEnv; constructor(waveEnv?: WaveEnv) { this.waveEnv = waveEnv; } // queue a layout action to cleanup orphaned blocks in the tab // @returns object updates CleanupOrphanedBlocks(tabId: string): Promise<void> { return callBackendService(this?.waveEnv, "block", "CleanupOrphanedBlocks", Array.from(arguments)) } GetControllerStatus(arg2: string): Promise<BlockControllerRuntimeStatus> { return callBackendService(this?.waveEnv, "block", "GetControllerStatus", Array.from(arguments)) } // save the terminal state to a blockfile SaveTerminalState(blockId: string, state: string, stateType: string, ptyOffset: number, termSize: TermSize): Promise<void> { return callBackendService(this?.waveEnv, "block", "SaveTerminalState", Array.from(arguments)) } SaveWaveAiData(arg2: string, arg3: WaveAIPromptMessageType[]): Promise<void> { return callBackendService(this?.waveEnv, "block", "SaveWaveAiData", Array.from(arguments)) } } export const BlockService = new BlockServiceType(); // clientservice.ClientService (client) export class ClientServiceType { waveEnv: WaveEnv; constructor(waveEnv?: WaveEnv) { this.waveEnv = waveEnv; } // @returns object updates AgreeTos(): Promise<void> { return callBackendService(this?.waveEnv, "client", "AgreeTos", Array.from(arguments)) } FocusWindow(arg2: string): Promise<void> { return callBackendService(this?.waveEnv, "client", "FocusWindow", Array.from(arguments)) } GetAllConnStatus(): Promise<ConnStatus[]> { return callBackendService(this?.waveEnv, "client", "GetAllConnStatus", Array.from(arguments)) } GetClientData(): Promise<Client> { return callBackendService(this?.waveEnv, "client", "GetClientData", Array.from(arguments)) } GetTab(arg1: string): Promise<Tab> { return callBackendService(this?.waveEnv, "client", "GetTab", Array.from(arguments)) } TelemetryUpdate(arg2: boolean): Promise<void> { return callBackendService(this?.waveEnv, "client", "TelemetryUpdate", Array.from(arguments)) } } export const ClientService = new ClientServiceType(); // objectservice.ObjectService (object) export class ObjectServiceType { waveEnv: WaveEnv; constructor(waveEnv?: WaveEnv) { this.waveEnv = waveEnv; } // @returns blockId (and object updates) CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise<string> { return callBackendService(this?.waveEnv, "object", "CreateBlock", Array.from(arguments)) } // @returns object updates DeleteBlock(blockId: string): Promise<void> { return callBackendService(this?.waveEnv, "object", "DeleteBlock", Array.from(arguments)) } // get wave object by oref GetObject(oref: string): Promise<WaveObj> { return callBackendService(this?.waveEnv, "object", "GetObject", Array.from(arguments)) } // @returns objects GetObjects(orefs: string[]): Promise<WaveObj[]> { return callBackendService(this?.waveEnv, "object", "GetObjects", Array.from(arguments)) } // @returns object updates UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise<void> { return callBackendService(this?.waveEnv, "object", "UpdateObject", Array.from(arguments)) } // @returns object updates UpdateObjectMeta(oref: string, meta: MetaType): Promise<void> { return callBackendService(this?.waveEnv, "object", "UpdateObjectMeta", Array.from(arguments)) } } export const ObjectService = new ObjectServiceType(); // userinputservice.UserInputService (userinput) export class UserInputServiceType { waveEnv: WaveEnv; constructor(waveEnv?: WaveEnv) { this.waveEnv = waveEnv; } SendUserInputResponse(arg1: UserInputResponse): Promise<void> { return callBackendService(this?.waveEnv, "userinput", "SendUserInputResponse", Array.from(arguments)) } } export const UserInputService = new UserInputServiceType(); // windowservice.WindowService (window) export class WindowServiceType { waveEnv: WaveEnv; constructor(waveEnv?: WaveEnv) { this.waveEnv = waveEnv; } CloseWindow(windowId: string, fromElectron: boolean): Promise<void> { return callBackendService(this?.waveEnv, "window", "CloseWindow", Array.from(arguments)) } CreateWindow(winSize: WinSize, workspaceId: string): Promise<WaveWindow> { return callBackendService(this?.waveEnv, "window", "CreateWindow", Array.from(arguments)) } GetWindow(windowId: string): Promise<WaveWindow> { return callBackendService(this?.waveEnv, "window", "GetWindow", Array.from(arguments)) } // set window position and size // @returns object updates SetWindowPosAndSize(windowId: string, pos: Point, size: WinSize): Promise<void> { return callBackendService(this?.waveEnv, "window", "SetWindowPosAndSize", Array.from(arguments)) } SwitchWorkspace(windowId: string, workspaceId: string): Promise<Workspace> { return callBackendService(this?.waveEnv, "window", "SwitchWorkspace", Array.from(arguments)) } } export const WindowService = new WindowServiceType(); // workspaceservice.WorkspaceService (workspace) export class WorkspaceServiceType { waveEnv: WaveEnv; constructor(waveEnv?: WaveEnv) { this.waveEnv = waveEnv; } // @returns CloseTabRtn (and object updates) CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise<CloseTabRtnType> { return callBackendService(this?.waveEnv, "workspace", "CloseTab", Array.from(arguments)) } // @returns tabId (and object updates) CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise<string> { return callBackendService(this?.waveEnv, "workspace", "CreateTab", Array.from(arguments)) } // @returns workspaceId CreateWorkspace(name: string, icon: string, color: string, applyDefaults: boolean): Promise<string> { return callBackendService(this?.waveEnv, "workspace", "CreateWorkspace", Array.from(arguments)) } // @returns object updates DeleteWorkspace(workspaceId: string): Promise<string> { return callBackendService(this?.waveEnv, "workspace", "DeleteWorkspace", Array.from(arguments)) } // @returns colors GetColors(): Promise<string[]> { return callBackendService(this?.waveEnv, "workspace", "GetColors", Array.from(arguments)) } // @returns icons GetIcons(): Promise<string[]> { return callBackendService(this?.waveEnv, "workspace", "GetIcons", Array.from(arguments)) } // @returns workspace GetWorkspace(workspaceId: string): Promise<Workspace> { return callBackendService(this?.waveEnv, "workspace", "GetWorkspace", Array.from(arguments)) } ListWorkspaces(): Promise<WorkspaceListEntry[]> { return callBackendService(this?.waveEnv, "workspace", "ListWorkspaces", Array.from(arguments)) } // @returns object updates SetActiveTab(workspaceId: string, tabId: string): Promise<void> { return callBackendService(this?.waveEnv, "workspace", "SetActiveTab", Array.from(arguments)) } // @returns object updates UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise<void> { return callBackendService(this?.waveEnv, "workspace", "UpdateWorkspace", Array.from(arguments)) } } export const WorkspaceService = new WorkspaceServiceType(); export const AllServiceTypes = { "block": BlockServiceType, "client": ClientServiceType, "object": ObjectServiceType, "userinput": UserInputServiceType, "window": WindowServiceType, "workspace": WorkspaceServiceType, }; export const AllServiceImpls = { "block": BlockService, "client": ClientService, "object": ObjectService, "userinput": UserInputService, "window": WindowService, "workspace": WorkspaceService, }; ================================================ FILE: frontend/app/store/tab-model.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { atom, Atom, PrimitiveAtom } from "jotai"; import { createContext, useContext } from "react"; import { globalStore } from "./jotaiStore"; import * as WOS from "./wos"; export type TabModelEnv = WaveEnvSubset<{ wos: WaveEnv["wos"]; }>; const tabModelCache = new Map<string, TabModel>(); export const activeTabIdAtom = atom<string>(null) as PrimitiveAtom<string>; export class TabModel { tabId: string; waveEnv: TabModelEnv; tabAtom: Atom<Tab>; tabNumBlocksAtom: Atom<number>; isTermMultiInput = atom(false) as PrimitiveAtom<boolean>; metaCache: Map<string, Atom<any>> = new Map(); constructor(tabId: string, waveEnv?: TabModelEnv) { this.tabId = tabId; this.waveEnv = waveEnv; this.tabAtom = atom((get) => { if (this.waveEnv != null) { return get(this.waveEnv.wos.getWaveObjectAtom<Tab>(WOS.makeORef("tab", this.tabId))); } return WOS.getObjectValue(WOS.makeORef("tab", this.tabId), get); }); this.tabNumBlocksAtom = atom((get) => { const tabData = get(this.tabAtom); return tabData?.blockids?.length ?? 0; }); } getTabMetaAtom<T extends keyof MetaType>(metaKey: T): Atom<MetaType[T]> { let metaAtom = this.metaCache.get(metaKey); if (metaAtom == null) { metaAtom = atom((get) => { const tabData = get(this.tabAtom); return tabData?.meta?.[metaKey]; }); this.metaCache.set(metaKey, metaAtom); } return metaAtom; } } export function getTabModelByTabId(tabId: string, waveEnv?: TabModelEnv): TabModel { if (!waveEnv?.isMock) { let model = tabModelCache.get(tabId); if (model == null) { model = new TabModel(tabId, waveEnv); tabModelCache.set(tabId, model); } return model; } const key = `TabModel:${tabId}`; let model = waveEnv.mockModels.get(key); if (model == null) { model = new TabModel(tabId, waveEnv); waveEnv.mockModels.set(key, model); } return model; } export function getActiveTabModel(waveEnv?: TabModelEnv): TabModel | null { const activeTabId = globalStore.get(activeTabIdAtom); if (activeTabId == null) { return null; } return getTabModelByTabId(activeTabId, waveEnv); } export const TabModelContext = createContext<TabModel | undefined>(undefined); export function useTabModel(): TabModel { const ctxModel = useContext(TabModelContext); if (ctxModel == null) { throw new Error("useTabModel must be used within a TabModelProvider"); } return ctxModel; } export function useTabModelMaybe(): TabModel { return useContext(TabModelContext); } ================================================ FILE: frontend/app/store/tabrpcclient.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { getApi, getBlockComponentModel, getConnStatusAtom, globalStore, WOS } from "@/app/store/global"; import type { TermViewModel } from "@/app/view/term/term-model"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { getLayoutModelForStaticTab } from "@/layout/index"; import { base64ToArrayBuffer } from "@/util/util"; import { RpcResponseHelper, WshClient } from "./wshclient"; import { RpcApi } from "./wshclientapi"; export class TabClient extends WshClient { constructor(routeId: string) { super(routeId); } handle_captureblockscreenshot(rh: RpcResponseHelper, data: CommandCaptureBlockScreenshotData): Promise<string> { return this.captureBlockScreenshot(data.blockid); } async captureBlockScreenshot(blockId: string): Promise<string> { const layoutModel = getLayoutModelForStaticTab(); if (!layoutModel) { throw new Error("Layout model not found"); } const node = layoutModel.getNodeByBlockId(blockId); if (!node) { throw new Error(`Block not found: ${blockId}`); } const displayContainer = layoutModel.displayContainerRef.current; if (!displayContainer) { throw new Error("Display container not found"); } const containerRect = displayContainer.getBoundingClientRect(); const additionalProps = layoutModel.getNodeAdditionalProperties(node); let electronRect: Electron.Rectangle; if (!additionalProps?.rect) { // Bug: rect is not set when there is only one block in the layout // In this case, use the full container rect electronRect = { x: Math.round(containerRect.x), y: Math.round(containerRect.y), width: Math.round(containerRect.width), height: Math.round(containerRect.height), }; } else { const blockRect = additionalProps.rect; electronRect = { x: Math.round(containerRect.x + blockRect.left), y: Math.round(containerRect.y + blockRect.top), width: Math.round(blockRect.width), height: Math.round(blockRect.height), }; } return await getApi().captureScreenshot(electronRect); } async handle_waveaiaddcontext(rh: RpcResponseHelper, data: CommandWaveAIAddContextData): Promise<void> { const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); if (!workspaceLayoutModel.getAIPanelVisible()) { workspaceLayoutModel.setAIPanelVisible(true, { nofocus: true }); } const model = WaveAIModel.getInstance(); if (data.newchat) { model.clearChat(); } if (data.files && data.files.length > 0) { for (const fileData of data.files) { const decodedData = base64ToArrayBuffer(fileData.data64); const blob = new Blob([decodedData], { type: fileData.type }); const file = new File([blob], fileData.name, { type: fileData.type }); await model.addFile(file); } } if (data.text) { model.appendText(data.text); } if (data.submit) { await model.handleSubmit(); } } async handle_setblockfocus(rh: RpcResponseHelper, blockId: string): Promise<void> { const layoutModel = getLayoutModelForStaticTab(); if (!layoutModel) { throw new Error("Layout model not found"); } const node = layoutModel.getNodeByBlockId(blockId); if (!node) { throw new Error(`Block not found in tab: ${blockId}`); } layoutModel.focusNode(node.id); } async handle_getfocusedblockdata(rh: RpcResponseHelper): Promise<FocusedBlockData> { const layoutModel = getLayoutModelForStaticTab(); if (!layoutModel) { throw new Error("Layout model not found"); } const focusedNode = globalStore.get(layoutModel.focusedNode); const blockId = focusedNode?.data?.blockId; if (!blockId) { return null; } const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", blockId)); const blockData = globalStore.get(blockAtom); if (!blockData) { return null; } const viewType = blockData.meta?.view ?? ""; const controller = blockData.meta?.controller ?? ""; const connName = blockData.meta?.connection ?? ""; const result: FocusedBlockData = { blockid: blockId, viewtype: viewType, controller: controller, connname: connName, blockmeta: blockData.meta ?? {}, }; if (viewType === "term" && controller === "shell") { const jobStatus = await RpcApi.BlockJobStatusCommand(this, blockId); if (jobStatus) { result.termjobstatus = jobStatus; } } if (connName) { const connStatusAtom = getConnStatusAtom(connName); const connStatus = globalStore.get(connStatusAtom); if (connStatus) { result.connstatus = connStatus; } } if (viewType === "term") { try { const bcm = getBlockComponentModel(blockId); if (bcm?.viewModel) { const termViewModel = bcm.viewModel as TermViewModel; if (termViewModel.termRef?.current?.shellIntegrationStatusAtom) { const shellIntegrationStatus = globalStore.get(termViewModel.termRef.current.shellIntegrationStatusAtom); result.termshellintegrationstatus = shellIntegrationStatus || ""; } if (termViewModel.termRef?.current?.lastCommandAtom) { const lastCommand = globalStore.get(termViewModel.termRef.current.lastCommandAtom); result.termlastcommand = lastCommand || ""; } } } catch (e) { console.log("error getting term-specific data", e); } } return result; } } ================================================ FILE: frontend/app/store/windowtype.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // waveWindowType is set once at startup and never changes. let waveWindowType: "tab" | "builder" | "preview" = "tab"; function getWaveWindowType(): "tab" | "builder" | "preview" { return waveWindowType; } function isBuilderWindow(): boolean { return waveWindowType === "builder"; } function isTabWindow(): boolean { return waveWindowType === "tab"; } function isPreviewWindow(): boolean { return waveWindowType === "preview"; } function setWaveWindowType(windowType: "tab" | "builder" | "preview") { waveWindowType = windowType; } export { getWaveWindowType, isBuilderWindow, isPreviewWindow, isTabWindow, setWaveWindowType }; ================================================ FILE: frontend/app/store/wos.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // WaveObjectStore import { isPreviewWindow } from "@/app/store/windowtype"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { fireAndForget } from "@/util/util"; import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai"; import { globalStore } from "./jotaiStore"; import { ObjectService } from "./services"; type WaveObjectDataItemType<T extends WaveObj> = { value: T; loading: boolean; }; type WaveObjectValue<T extends WaveObj> = { pendingPromise: Promise<T>; dataAtom: PrimitiveAtom<WaveObjectDataItemType<T>>; }; function splitORef(oref: string): [string, string] { const parts = oref.split(":"); if (parts.length != 2) { throw new Error("invalid oref"); } return [parts[0], parts[1]]; } function isBlank(str: string): boolean { return str == null || str == ""; } function isBlankNum(num: number): boolean { return num == null || isNaN(num) || num == 0; } function isValidWaveObj(val: WaveObj): boolean { if (val == null) { return false; } if (isBlank(val.otype) || isBlank(val.oid) || isBlankNum(val.version)) { return false; } return true; } function makeORef(otype: string, oid: string): string { if (isBlank(otype) || isBlank(oid)) { return null; } return `${otype}:${oid}`; } const previewMockObjects: Map<string, WaveObj> = new Map(); function mockObjectForPreview<T extends WaveObj>(oref: string, obj: T): void { if (!isPreviewWindow()) { throw new Error("mockObjectForPreview can only be called in a preview window"); } previewMockObjects.set(oref, obj); } function GetObject<T>(oref: string): Promise<T> { if (isPreviewWindow()) { return Promise.resolve((previewMockObjects.get(oref) as T) ?? null); } return callBackendService("object", "GetObject", [oref], true); } function debugLogBackendCall(methodName: string, durationStr: string, args: any[]) { durationStr = "| " + durationStr; if (methodName == "object.UpdateObject" && args.length > 0) { console.log("[service] object.UpdateObject", args[0].otype, args[0].oid, durationStr, args[0]); return; } if (methodName == "object.GetObject" && args.length > 0) { console.log("[service] object.GetObject", args[0], durationStr); return; } if (methodName == "file.StatFile" && args.length >= 2) { console.log("[service] file.StatFile", args[1], durationStr); return; } console.log("[service]", methodName, durationStr); } function wpsSubscribeToObject(oref: string): () => void { return waveEventSubscribeSingle({ eventType: "waveobj:update", scope: oref, handler: (event) => { updateWaveObject(event.data); }, }); } function callBackendService(service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> { const startTs = Date.now(); let uiContext: UIContext = null; if (!noUIContext && globalThis.window != null) { uiContext = globalStore.get(((window as any).globalAtoms as GlobalAtomsType).uiContext); } const waveCall: WebCallType = { service: service, method: method, args: args, uicontext: uiContext, }; // usp is just for debugging (easier to filter URLs) const methodName = `${service}.${method}`; const usp = new URLSearchParams(); usp.set("service", service); usp.set("method", method); const webEndpoint = getWebServerEndpoint(); if (webEndpoint == null) throw new Error(`cannot call ${methodName}: no web endpoint`); const url = webEndpoint + "/wave/service?" + usp.toString(); const fetchPromise = fetch(url, { method: "POST", body: JSON.stringify(waveCall), }); const prtn = fetchPromise .then((resp) => { if (!resp.ok) { throw new Error(`call ${methodName} failed: ${resp.status} ${resp.statusText}`); } return resp.json(); }) .then((respData: WebReturnType) => { if (respData == null) { return null; } if (respData.updates != null) { updateWaveObjects(respData.updates); } if (respData.error != null) { throw new Error(`call ${methodName} error: ${respData.error}`); } const durationStr = Date.now() - startTs + "ms"; debugLogBackendCall(methodName, durationStr, args); return respData.data; }); return prtn; } const waveObjectValueCache = new Map<string, WaveObjectValue<any>>(); function reloadWaveObject<T extends WaveObj>(oref: string): Promise<T> { let wov = waveObjectValueCache.get(oref); if (wov === undefined) { wov = getWaveObjectValue<T>(oref, true); return wov.pendingPromise; } const prtn = GetObject<T>(oref); prtn.then((val) => { globalStore.set(wov.dataAtom, { value: val, loading: false }); }); return prtn; } function createWaveValueObject<T extends WaveObj>(oref: string, shouldFetch: boolean): WaveObjectValue<T> { const wov = { pendingPromise: null, dataAtom: null }; wov.dataAtom = atom({ value: null, loading: true }); if (!shouldFetch) { return wov; } const startTs = Date.now(); const localPromise = GetObject<T>(oref); wov.pendingPromise = localPromise; localPromise.then((val) => { if (wov.pendingPromise != localPromise) { return; } const [otype, oid] = splitORef(oref); if (val != null) { if (val["otype"] != otype) { throw new Error("GetObject returned wrong type"); } if (val["oid"] != oid) { throw new Error("GetObject returned wrong id"); } } wov.pendingPromise = null; globalStore.set(wov.dataAtom, { value: val, loading: false }); console.log("WaveObj resolved", oref, Date.now() - startTs + "ms"); }); return wov; } function getWaveObjectValue<T extends WaveObj>(oref: string, createIfMissing = true): WaveObjectValue<T> { let wov = waveObjectValueCache.get(oref); if (wov === undefined && createIfMissing) { wov = createWaveValueObject(oref, true); waveObjectValueCache.set(oref, wov); } return wov; } function loadAndPinWaveObject<T extends WaveObj>(oref: string): Promise<T> { const wov = getWaveObjectValue<T>(oref); if (wov.pendingPromise == null) { const dataValue = globalStore.get(wov.dataAtom); return Promise.resolve(dataValue.value); } return wov.pendingPromise; } const waveObjectDerivedAtomCache = new Map<string, Atom<any>>(); function getWaveObjectAtom<T extends WaveObj>(oref: string): Atom<T> { const cacheKey = oref + ":value"; let cachedAtom = waveObjectDerivedAtomCache.get(cacheKey) as Atom<T>; if (cachedAtom != null) { return cachedAtom; } const wov = getWaveObjectValue<T>(oref); cachedAtom = atom((get) => get(wov.dataAtom).value); waveObjectDerivedAtomCache.set(cacheKey, cachedAtom); return cachedAtom; } function getWaveObjectLoadingAtom(oref: string): Atom<boolean> { const cacheKey = oref + ":loading"; let cachedAtom = waveObjectDerivedAtomCache.get(cacheKey) as Atom<boolean>; if (cachedAtom != null) { return cachedAtom; } const wov = getWaveObjectValue(oref); cachedAtom = atom((get) => { const dataValue = get(wov.dataAtom); return dataValue.loading; }); waveObjectDerivedAtomCache.set(cacheKey, cachedAtom); return cachedAtom; } function isWaveObjectNullAtom(oref: string): Atom<boolean> { const cacheKey = oref + ":isnull"; let cachedAtom = waveObjectDerivedAtomCache.get(cacheKey) as Atom<boolean>; if (cachedAtom != null) { return cachedAtom; } cachedAtom = atom((get) => get(getWaveObjectAtom(oref)) == null); waveObjectDerivedAtomCache.set(cacheKey, cachedAtom); return cachedAtom; } function useWaveObjectValue<T extends WaveObj>(oref: string): [T, boolean] { const wov = getWaveObjectValue<T>(oref); const atomVal = useAtomValue(wov.dataAtom); return [atomVal.value, atomVal.loading]; } function updateWaveObject(update: WaveObjUpdate) { if (update == null) { return; } const oref = makeORef(update.otype, update.oid); const wov = getWaveObjectValue(oref); if (update.updatetype == "delete") { console.log("WaveObj deleted", oref); globalStore.set(wov.dataAtom, { value: null, loading: false }); } else { if (!isValidWaveObj(update.obj)) { console.log("invalid wave object update", update); return; } const curValue: WaveObjectDataItemType<WaveObj> = globalStore.get(wov.dataAtom); if (curValue.value != null && curValue.value.version >= update.obj.version) { return; } console.log("WaveObj updated", oref); globalStore.set(wov.dataAtom, { value: update.obj, loading: false }); } return; } function updateWaveObjects(vals: WaveObjUpdate[]) { for (const val of vals) { updateWaveObject(val); } } // gets the value of a WaveObject from the cache. // should provide getFn if it is available (e.g. inside of a jotai atom) // otherwise it will use the globalStore.get function function getObjectValue<T extends WaveObj>(oref: string, getFn?: Getter): T { const wov = getWaveObjectValue<T>(oref); if (getFn == null) { getFn = globalStore.get; } const atomVal = getFn(wov.dataAtom); return atomVal.value; } // sets the value of a WaveObject in the cache. // should provide setFn if it is available (e.g. inside of a jotai atom) // otherwise it will use the globalStore.set function function setObjectValue<T extends WaveObj>(value: T, setFn?: Setter, pushToServer?: boolean) { const oref = makeORef(value.otype, value.oid); const wov = getWaveObjectValue(oref, false); if (wov === undefined) { return; } if (setFn === undefined) { setFn = globalStore.set; } setFn(wov.dataAtom, { value: value, loading: false }); if (pushToServer) { fireAndForget(() => ObjectService.UpdateObject(value, false)); } } export { callBackendService, getObjectValue, getWaveObjectAtom, getWaveObjectLoadingAtom, isWaveObjectNullAtom, loadAndPinWaveObject, makeORef, mockObjectForPreview, reloadWaveObject, setObjectValue, splitORef, updateWaveObject, updateWaveObjects, useWaveObjectValue, wpsSubscribeToObject, }; ================================================ FILE: frontend/app/store/wps.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { isPreviewWindow } from "@/app/store/windowtype"; import { isBlank } from "@/util/util"; import { Subject } from "rxjs"; let WpsRpcClient: WshClient; function setWpsRpcClient(client: WshClient) { WpsRpcClient = client; } type WaveEventSubject<T extends WaveEventName = WaveEventName> = { handler: (event: Extract<WaveEvent, { event: T }>) => void; scope?: string; }; type WaveEventSubjectContainer = { handler: (event: WaveEvent) => void; scope?: string; id: string; }; type WaveEventSubscription<T extends WaveEventName = WaveEventName> = WaveEventSubject<T> & { eventType: T; }; type WaveEventUnsubscribe = { id: string; eventType: string; }; // key is "eventType" or "eventType|oref" const fileSubjects = new Map<string, SubjectWithRef<WSFileEventData>>(); const waveEventSubjects = new Map<string, WaveEventSubjectContainer[]>(); function wpsReconnectHandler() { for (const eventType of waveEventSubjects.keys()) { updateWaveEventSub(eventType); } } function updateWaveEventSub(eventType: string) { if (isPreviewWindow()) { return; } const subjects = waveEventSubjects.get(eventType); if (subjects == null) { RpcApi.EventUnsubCommand(WpsRpcClient, eventType, { noresponse: true }); return; } const subreq: SubscriptionRequest = { event: eventType, scopes: [], allscopes: false }; for (const scont of subjects) { if (isBlank(scont.scope)) { subreq.allscopes = true; subreq.scopes = []; break; } subreq.scopes.push(scont.scope); } RpcApi.EventSubCommand(WpsRpcClient, subreq, { noresponse: true }); } function waveEventSubscribeSingle<T extends WaveEventName>(subscription: WaveEventSubscription<T>): () => void { // console.log("waveEventSubscribeSingle", subscription); if (subscription.handler == null) { return () => {}; } const id: string = crypto.randomUUID(); let subjects = waveEventSubjects.get(subscription.eventType); if (subjects == null) { subjects = []; waveEventSubjects.set(subscription.eventType, subjects); } const subcont: WaveEventSubjectContainer = { id, handler: subscription.handler as (event: WaveEvent) => void, scope: subscription.scope, }; subjects.push(subcont); updateWaveEventSub(subscription.eventType); return () => waveEventUnsubscribe({ id, eventType: subscription.eventType }); } function waveEventUnsubscribe(...unsubscribes: WaveEventUnsubscribe[]) { const eventTypeSet = new Set<string>(); for (const unsubscribe of unsubscribes) { const subjects = waveEventSubjects.get(unsubscribe.eventType); if (subjects == null) { return; } const idx = subjects.findIndex((s) => s.id === unsubscribe.id); if (idx === -1) { return; } subjects.splice(idx, 1); if (subjects.length === 0) { waveEventSubjects.delete(unsubscribe.eventType); } eventTypeSet.add(unsubscribe.eventType); } for (const eventType of eventTypeSet) { updateWaveEventSub(eventType); } } function getFileSubject(zoneId: string, fileName: string): SubjectWithRef<WSFileEventData> { const subjectKey = zoneId + "|" + fileName; let subject = fileSubjects.get(subjectKey); if (subject == null) { subject = new Subject<any>() as any; subject.refCount = 0; subject.release = () => { subject.refCount--; if (subject.refCount === 0) { subject.complete(); fileSubjects.delete(subjectKey); } }; fileSubjects.set(subjectKey, subject); } subject.refCount++; return subject; } function handleWaveEvent(event: WaveEvent) { // console.log("handleWaveEvent", event); const subjects = waveEventSubjects.get(event.event); if (subjects == null) { return; } for (const scont of subjects) { if (isBlank(scont.scope)) { scont.handler(event); continue; } if (event.scopes == null) { continue; } if (event.scopes.includes(scont.scope)) { scont.handler(event); } } } export { getFileSubject, handleWaveEvent, setWpsRpcClient, waveEventSubscribeSingle, waveEventUnsubscribe, wpsReconnectHandler, }; ================================================ FILE: frontend/app/store/ws.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { type WebSocket, newWebSocket } from "@/util/wsutil"; import debug from "debug"; import { sprintf } from "sprintf-js"; const AuthKeyHeader = "X-AuthKey"; const dlog = debug("wave:ws"); const WarnWebSocketSendSize = 1024 * 1024; // 1MB const MaxWebSocketSendSize = 5 * 1024 * 1024; // 5MB const reconnectHandlers: (() => void)[] = []; const StableConnTime = 2000; function addWSReconnectHandler(handler: () => void) { reconnectHandlers.push(handler); } function removeWSReconnectHandler(handler: () => void) { const index = reconnectHandlers.indexOf(handler); if (index > -1) { reconnectHandlers.splice(index, 1); } } type WSEventCallback = (arg0: WSEventType) => void; type ElectronOverrideOpts = { authKey: string; }; class WSControl { wsConn: WebSocket; open: boolean; opening: boolean = false; reconnectTimes: number = 0; msgQueue: any[] = []; stableId: string; messageCallback: WSEventCallback; watchSessionId: string = null; watchScreenId: string = null; wsLog: string[] = []; baseHostPort: string; lastReconnectTime: number = 0; eoOpts: ElectronOverrideOpts; noReconnect: boolean = false; onOpenTimeoutId: NodeJS.Timeout = null; constructor( baseHostPort: string, stableId: string, messageCallback: WSEventCallback, electronOverrideOpts?: ElectronOverrideOpts ) { this.baseHostPort = baseHostPort; this.messageCallback = messageCallback; this.stableId = stableId; this.open = false; this.eoOpts = electronOverrideOpts; setInterval(this.sendPing.bind(this), 5000); } shutdown() { this.noReconnect = true; this.wsConn.close(); } connectNow(desc: string) { if (this.open || this.noReconnect) { return; } this.lastReconnectTime = Date.now(); dlog("try reconnect:", desc); this.opening = true; this.wsConn = newWebSocket( this.baseHostPort + "/ws?stableid=" + encodeURIComponent(this.stableId), this.eoOpts ? { [AuthKeyHeader]: this.eoOpts.authKey, } : null ); this.wsConn.onopen = (e: Event) => { this.onopen(e); }; this.wsConn.onmessage = (e: MessageEvent) => { this.onmessage(e); }; this.wsConn.onclose = (e: CloseEvent) => { this.onclose(e); }; // turns out onerror is not necessary (onclose always follows onerror) // this.wsConn.onerror = this.onerror; } reconnect(forceClose?: boolean) { if (this.noReconnect) { return; } if (this.open) { if (forceClose) { this.wsConn.close(); // this will force a reconnect } return; } this.reconnectTimes++; if (this.reconnectTimes > 20) { dlog("cannot connect, giving up"); return; } const timeoutArr = [0, 0, 2, 5, 10, 10, 30, 60]; let timeout = 60; if (this.reconnectTimes < timeoutArr.length) { timeout = timeoutArr[this.reconnectTimes]; } if (Date.now() - this.lastReconnectTime < 500) { timeout = 1; } if (timeout > 0) { dlog(sprintf("sleeping %ds", timeout)); } setTimeout(() => { this.connectNow(String(this.reconnectTimes)); }, timeout * 1000); } onclose(event: CloseEvent) { // console.log("close", event); if (this.onOpenTimeoutId) { clearTimeout(this.onOpenTimeoutId); } if (event.wasClean) { dlog("connection closed"); } else { dlog("connection error/disconnected"); } if (this.open || this.opening) { this.open = false; this.opening = false; this.reconnect(); } } onopen(e: Event) { dlog("connection open"); this.open = true; this.opening = false; this.onOpenTimeoutId = setTimeout(() => { this.reconnectTimes = 0; dlog("clear reconnect times"); }, StableConnTime); for (let handler of reconnectHandlers) { handler(); } this.runMsgQueue(); } runMsgQueue() { if (!this.open) { return; } if (this.msgQueue.length == 0) { return; } const msg = this.msgQueue.shift(); this.sendMessage(msg); setTimeout(() => { this.runMsgQueue(); }, 100); } onmessage(event: MessageEvent) { let eventData = null; if (event.data != null) { eventData = JSON.parse(event.data); } if (eventData == null) { return; } if (eventData.type == "ping") { this.wsConn.send(JSON.stringify({ type: "pong", stime: Date.now() })); return; } if (eventData.type == "pong") { // nothing return; } if (this.messageCallback) { try { this.messageCallback(eventData); } catch (e) { console.log("[error] messageCallback", e); } } } sendPing() { if (!this.open) { return; } this.wsConn.send(JSON.stringify({ type: "ping", stime: Date.now() })); } sendMessage(data: WSCommandType) { if (!this.open) { return; } const msg = JSON.stringify(data); const byteSize = new Blob([msg]).size; if (byteSize > MaxWebSocketSendSize) { console.log("ws message too large", byteSize, data.wscommand, msg.substring(0, 100)); return; } if (byteSize > WarnWebSocketSendSize) { console.log("ws message large", byteSize, data.wscommand, msg.substring(0, 100)); } this.wsConn.send(msg); } pushMessage(data: WSCommandType) { if (!this.open) { if (data.wscommand === "rpc" && data.message) { const cmd = data.message.command; if (cmd === "routeannounce" || cmd === "routeunannounce") { return; } } this.msgQueue.push(data); return; } this.sendMessage(data); } } let globalWS: WSControl; function initGlobalWS( baseHostPort: string, stableId: string, messageCallback: WSEventCallback, electronOverrideOpts?: ElectronOverrideOpts ) { globalWS = new WSControl(baseHostPort, stableId, messageCallback, electronOverrideOpts); } function sendRawRpcMessage(msg: RpcMessage) { const wsMsg: WSRpcCommand = { wscommand: "rpc", message: msg }; sendWSCommand(wsMsg); } function sendWSCommand(cmd: WSCommandType) { globalWS?.pushMessage(cmd); } export { WSControl, addWSReconnectHandler, globalWS, initGlobalWS, removeWSReconnectHandler, sendRawRpcMessage, sendWSCommand, type ElectronOverrideOpts, }; ================================================ FILE: frontend/app/store/wshclient.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { sendRpcCommand, sendRpcResponse } from "@/app/store/wshrpcutil-base"; import * as util from "@/util/util"; const notFoundLogMap = new Map<string, boolean>(); class RpcResponseHelper { client: WshClient; cmdMsg: RpcMessage; done: boolean; constructor(client: WshClient, cmdMsg: RpcMessage) { this.client = client; this.cmdMsg = cmdMsg; // if reqid is null, no response required this.done = cmdMsg.reqid == null; } getSource(): string { return this.cmdMsg?.source; } sendResponse(msg: RpcMessage) { if (this.done || util.isBlank(this.cmdMsg.reqid)) { return; } if (msg == null) { msg = {}; } msg.resid = this.cmdMsg.reqid; msg.source = this.client.routeId; sendRpcResponse(msg); if (!msg.cont) { this.done = true; this.client.openRpcs.delete(this.cmdMsg.reqid); } } } class WshClient { routeId: string; openRpcs: Map<string, ClientRpcEntry> = new Map(); constructor(routeId: string) { this.routeId = routeId; } wshRpcCall(command: string, data: any, opts: RpcOpts): Promise<any> { const msg: RpcMessage = { command: command, data: data, source: this.routeId, }; if (!opts?.noresponse) { msg.reqid = crypto.randomUUID(); } if (opts?.timeout) { msg.timeout = opts.timeout; } if (opts?.route) { msg.route = opts.route; } const rpcGen = sendRpcCommand(this.openRpcs, msg); if (rpcGen == null) { return null; } const respMsgPromise = rpcGen.next(true); // pass true to force termination of rpc after 1 response (not streaming) return respMsgPromise.then((msg: IteratorResult<any, void>) => { return msg.value; }); } wshRpcStream(command: string, data: any, opts: RpcOpts): AsyncGenerator<any, void, boolean> { if (opts?.noresponse) { throw new Error("noresponse not supported for responsestream calls"); } const msg: RpcMessage = { command: command, data: data, reqid: crypto.randomUUID(), source: this.routeId, }; if (opts?.timeout) { msg.timeout = opts.timeout; } if (opts?.route) { msg.route = opts.route; } const rpcGen = sendRpcCommand(this.openRpcs, msg); return rpcGen; } async handleIncomingCommand(msg: RpcMessage) { // TODO implement a timeout (setTimeout + sendResponse) const helper = new RpcResponseHelper(this, msg); const handlerName = `handle_${msg.command}`; try { let result: any = null; let prtn: any = null; if (handlerName in this) { prtn = this[handlerName](helper, msg.data); } else { prtn = this.handle_default(helper, msg); } if (prtn instanceof Promise) { result = await prtn; } else { result = prtn; } if (!helper.done) { helper.sendResponse({ data: result }); } } catch (e) { if (!helper.done) { helper.sendResponse({ error: e.message }); } else { console.log(`rpc-client[${this.routeId}] command[${msg.command}] error`, e.message); } } finally { if (!helper.done) { helper.sendResponse({}); } } return; } recvRpcMessage(msg: RpcMessage) { const isRequest = msg.command != null || msg.reqid != null; if (isRequest) { this.handleIncomingCommand(msg); return; } if (msg.resid == null) { console.log("rpc response missing resid", msg); return; } const entry = this.openRpcs.get(msg.resid); if (entry == null) { if (!notFoundLogMap.has(msg.resid)) { notFoundLogMap.set(msg.resid, true); console.log("rpc response generator not found", msg); } return; } entry.msgFn(msg); } async handle_message(helper: RpcResponseHelper, data: CommandMessageData): Promise<void> { console.log(`rpc:message[${this.routeId}]`, data?.message); } async handle_default(helper: RpcResponseHelper, msg: RpcMessage): Promise<void> { throw new Error(`rpc command "${msg.command}" not supported by [${this.routeId}]`); } } export { RpcResponseHelper, WshClient }; ================================================ FILE: frontend/app/store/wshclientapi.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // generated by cmd/generate/main-generatets.go import { WshClient } from "./wshclient"; export interface MockRpcClient { mockWshRpcCall(client: WshClient, command: string, data: any, opts?: RpcOpts): Promise<any>; mockWshRpcStream(client: WshClient, command: string, data: any, opts?: RpcOpts): AsyncGenerator<any, void, boolean>; } // WshServerCommandToDeclMap export class RpcApiType { mockClient: MockRpcClient = null; setMockRpcClient(client: MockRpcClient): void { this.mockClient = client; } // command "activity" [call] ActivityCommand(client: WshClient, data: ActivityUpdate, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "activity", data, opts); return client.wshRpcCall("activity", data, opts); } // command "aisendmessage" [call] AiSendMessageCommand(client: WshClient, data: AiMessageData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "aisendmessage", data, opts); return client.wshRpcCall("aisendmessage", data, opts); } // command "authenticate" [call] AuthenticateCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<CommandAuthenticateRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticate", data, opts); return client.wshRpcCall("authenticate", data, opts); } // command "authenticatejobmanager" [call] AuthenticateJobManagerCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticatejobmanager", data, opts); return client.wshRpcCall("authenticatejobmanager", data, opts); } // command "authenticatejobmanagerverify" [call] AuthenticateJobManagerVerifyCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticatejobmanagerverify", data, opts); return client.wshRpcCall("authenticatejobmanagerverify", data, opts); } // command "authenticatetojobmanager" [call] AuthenticateToJobManagerCommand(client: WshClient, data: CommandAuthenticateToJobData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticatetojobmanager", data, opts); return client.wshRpcCall("authenticatetojobmanager", data, opts); } // command "authenticatetoken" [call] AuthenticateTokenCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise<CommandAuthenticateRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticatetoken", data, opts); return client.wshRpcCall("authenticatetoken", data, opts); } // command "authenticatetokenverify" [call] AuthenticateTokenVerifyCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise<CommandAuthenticateRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticatetokenverify", data, opts); return client.wshRpcCall("authenticatetokenverify", data, opts); } // command "badgewatchpid" [call] BadgeWatchPidCommand(client: WshClient, data: CommandBadgeWatchPidData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "badgewatchpid", data, opts); return client.wshRpcCall("badgewatchpid", data, opts); } // command "blockinfo" [call] BlockInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<BlockInfoData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "blockinfo", data, opts); return client.wshRpcCall("blockinfo", data, opts); } // command "blockjobstatus" [call] BlockJobStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<BlockJobStatusData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "blockjobstatus", data, opts); return client.wshRpcCall("blockjobstatus", data, opts); } // command "blockslist" [call] BlocksListCommand(client: WshClient, data: BlocksListRequest, opts?: RpcOpts): Promise<BlocksListEntry[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "blockslist", data, opts); return client.wshRpcCall("blockslist", data, opts); } // command "captureblockscreenshot" [call] CaptureBlockScreenshotCommand(client: WshClient, data: CommandCaptureBlockScreenshotData, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "captureblockscreenshot", data, opts); return client.wshRpcCall("captureblockscreenshot", data, opts); } // command "checkgoversion" [call] CheckGoVersionCommand(client: WshClient, opts?: RpcOpts): Promise<CommandCheckGoVersionRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "checkgoversion", null, opts); return client.wshRpcCall("checkgoversion", null, opts); } // command "connconnect" [call] ConnConnectCommand(client: WshClient, data: ConnRequest, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connconnect", data, opts); return client.wshRpcCall("connconnect", data, opts); } // command "conndisconnect" [call] ConnDisconnectCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "conndisconnect", data, opts); return client.wshRpcCall("conndisconnect", data, opts); } // command "connensure" [call] ConnEnsureCommand(client: WshClient, data: ConnExtData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connensure", data, opts); return client.wshRpcCall("connensure", data, opts); } // command "connlist" [call] ConnListCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connlist", null, opts); return client.wshRpcCall("connlist", null, opts); } // command "connreinstallwsh" [call] ConnReinstallWshCommand(client: WshClient, data: ConnExtData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connreinstallwsh", data, opts); return client.wshRpcCall("connreinstallwsh", data, opts); } // command "connserverinit" [call] ConnServerInitCommand(client: WshClient, data: CommandConnServerInitData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connserverinit", data, opts); return client.wshRpcCall("connserverinit", data, opts); } // command "connstatus" [call] ConnStatusCommand(client: WshClient, opts?: RpcOpts): Promise<ConnStatus[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connstatus", null, opts); return client.wshRpcCall("connstatus", null, opts); } // command "connupdatewsh" [call] ConnUpdateWshCommand(client: WshClient, data: RemoteInfo, opts?: RpcOpts): Promise<boolean> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "connupdatewsh", data, opts); return client.wshRpcCall("connupdatewsh", data, opts); } // command "controlgetrouteid" [call] ControlGetRouteIdCommand(client: WshClient, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "controlgetrouteid", null, opts); return client.wshRpcCall("controlgetrouteid", null, opts); } // command "controllerappendoutput" [call] ControllerAppendOutputCommand(client: WshClient, data: CommandControllerAppendOutputData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "controllerappendoutput", data, opts); return client.wshRpcCall("controllerappendoutput", data, opts); } // command "controllerdestroy" [call] ControllerDestroyCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "controllerdestroy", data, opts); return client.wshRpcCall("controllerdestroy", data, opts); } // command "controllerinput" [call] ControllerInputCommand(client: WshClient, data: CommandBlockInputData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "controllerinput", data, opts); return client.wshRpcCall("controllerinput", data, opts); } // command "controllerresync" [call] ControllerResyncCommand(client: WshClient, data: CommandControllerResyncData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "controllerresync", data, opts); return client.wshRpcCall("controllerresync", data, opts); } // command "createblock" [call] CreateBlockCommand(client: WshClient, data: CommandCreateBlockData, opts?: RpcOpts): Promise<ORef> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "createblock", data, opts); return client.wshRpcCall("createblock", data, opts); } // command "createsubblock" [call] CreateSubBlockCommand(client: WshClient, data: CommandCreateSubBlockData, opts?: RpcOpts): Promise<ORef> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "createsubblock", data, opts); return client.wshRpcCall("createsubblock", data, opts); } // command "debugterm" [call] DebugTermCommand(client: WshClient, data: CommandDebugTermData, opts?: RpcOpts): Promise<CommandDebugTermRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "debugterm", data, opts); return client.wshRpcCall("debugterm", data, opts); } // command "deleteappfile" [call] DeleteAppFileCommand(client: WshClient, data: CommandDeleteAppFileData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "deleteappfile", data, opts); return client.wshRpcCall("deleteappfile", data, opts); } // command "deleteblock" [call] DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "deleteblock", data, opts); return client.wshRpcCall("deleteblock", data, opts); } // command "deletebuilder" [call] DeleteBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "deletebuilder", data, opts); return client.wshRpcCall("deletebuilder", data, opts); } // command "deletesubblock" [call] DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "deletesubblock", data, opts); return client.wshRpcCall("deletesubblock", data, opts); } // command "dismisswshfail" [call] DismissWshFailCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "dismisswshfail", data, opts); return client.wshRpcCall("dismisswshfail", data, opts); } // command "dispose" [call] DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "dispose", data, opts); return client.wshRpcCall("dispose", data, opts); } // command "disposesuggestions" [call] DisposeSuggestionsCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "disposesuggestions", data, opts); return client.wshRpcCall("disposesuggestions", data, opts); } // command "electrondecrypt" [call] ElectronDecryptCommand(client: WshClient, data: CommandElectronDecryptData, opts?: RpcOpts): Promise<CommandElectronDecryptRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "electrondecrypt", data, opts); return client.wshRpcCall("electrondecrypt", data, opts); } // command "electronencrypt" [call] ElectronEncryptCommand(client: WshClient, data: CommandElectronEncryptData, opts?: RpcOpts): Promise<CommandElectronEncryptRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "electronencrypt", data, opts); return client.wshRpcCall("electronencrypt", data, opts); } // command "electronsystembell" [call] ElectronSystemBellCommand(client: WshClient, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "electronsystembell", null, opts); return client.wshRpcCall("electronsystembell", null, opts); } // command "eventpublish" [call] EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventpublish", data, opts); return client.wshRpcCall("eventpublish", data, opts); } // command "eventreadhistory" [call] EventReadHistoryCommand(client: WshClient, data: CommandEventReadHistoryData, opts?: RpcOpts): Promise<WaveEvent[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventreadhistory", data, opts); return client.wshRpcCall("eventreadhistory", data, opts); } // command "eventrecv" [call] EventRecvCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventrecv", data, opts); return client.wshRpcCall("eventrecv", data, opts); } // command "eventsub" [call] EventSubCommand(client: WshClient, data: SubscriptionRequest, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventsub", data, opts); return client.wshRpcCall("eventsub", data, opts); } // command "eventunsub" [call] EventUnsubCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventunsub", data, opts); return client.wshRpcCall("eventunsub", data, opts); } // command "eventunsuball" [call] EventUnsubAllCommand(client: WshClient, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "eventunsuball", null, opts); return client.wshRpcCall("eventunsuball", null, opts); } // command "fetchsuggestions" [call] FetchSuggestionsCommand(client: WshClient, data: FetchSuggestionsData, opts?: RpcOpts): Promise<FetchSuggestionsResponse> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "fetchsuggestions", data, opts); return client.wshRpcCall("fetchsuggestions", data, opts); } // command "fileappend" [call] FileAppendCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "fileappend", data, opts); return client.wshRpcCall("fileappend", data, opts); } // command "filecopy" [call] FileCopyCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filecopy", data, opts); return client.wshRpcCall("filecopy", data, opts); } // command "filecreate" [call] FileCreateCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filecreate", data, opts); return client.wshRpcCall("filecreate", data, opts); } // command "filedelete" [call] FileDeleteCommand(client: WshClient, data: CommandDeleteFileData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filedelete", data, opts); return client.wshRpcCall("filedelete", data, opts); } // command "fileinfo" [call] FileInfoCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<FileInfo> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "fileinfo", data, opts); return client.wshRpcCall("fileinfo", data, opts); } // command "filejoin" [call] FileJoinCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<FileInfo> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filejoin", data, opts); return client.wshRpcCall("filejoin", data, opts); } // command "filelist" [call] FileListCommand(client: WshClient, data: FileListData, opts?: RpcOpts): Promise<FileInfo[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filelist", data, opts); return client.wshRpcCall("filelist", data, opts); } // command "fileliststream" [responsestream] FileListStreamCommand(client: WshClient, data: FileListData, opts?: RpcOpts): AsyncGenerator<CommandRemoteListEntriesRtnData, void, boolean> { if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "fileliststream", data, opts); return client.wshRpcStream("fileliststream", data, opts); } // command "filemkdir" [call] FileMkdirCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filemkdir", data, opts); return client.wshRpcCall("filemkdir", data, opts); } // command "filemove" [call] FileMoveCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filemove", data, opts); return client.wshRpcCall("filemove", data, opts); } // command "fileread" [call] FileReadCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<FileData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "fileread", data, opts); return client.wshRpcCall("fileread", data, opts); } // command "filereadstream" [responsestream] FileReadStreamCommand(client: WshClient, data: FileData, opts?: RpcOpts): AsyncGenerator<FileData, void, boolean> { if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "filereadstream", data, opts); return client.wshRpcStream("filereadstream", data, opts); } // command "filerestorebackup" [call] FileRestoreBackupCommand(client: WshClient, data: CommandFileRestoreBackupData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filerestorebackup", data, opts); return client.wshRpcCall("filerestorebackup", data, opts); } // command "filestream" [call] FileStreamCommand(client: WshClient, data: CommandFileStreamData, opts?: RpcOpts): Promise<FileInfo> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filestream", data, opts); return client.wshRpcCall("filestream", data, opts); } // command "filewrite" [call] FileWriteCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filewrite", data, opts); return client.wshRpcCall("filewrite", data, opts); } // command "findgitbash" [call] FindGitBashCommand(client: WshClient, data: boolean, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "findgitbash", data, opts); return client.wshRpcCall("findgitbash", data, opts); } // command "focuswindow" [call] FocusWindowCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "focuswindow", data, opts); return client.wshRpcCall("focuswindow", data, opts); } // command "getallbadges" [call] GetAllBadgesCommand(client: WshClient, opts?: RpcOpts): Promise<BadgeEvent[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getallbadges", null, opts); return client.wshRpcCall("getallbadges", null, opts); } // command "getallvars" [call] GetAllVarsCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise<CommandVarResponseData[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getallvars", data, opts); return client.wshRpcCall("getallvars", data, opts); } // command "getbuilderoutput" [call] GetBuilderOutputCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<string[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getbuilderoutput", data, opts); return client.wshRpcCall("getbuilderoutput", data, opts); } // command "getbuilderstatus" [call] GetBuilderStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<BuilderStatusData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getbuilderstatus", data, opts); return client.wshRpcCall("getbuilderstatus", data, opts); } // command "getfocusedblockdata" [call] GetFocusedBlockDataCommand(client: WshClient, opts?: RpcOpts): Promise<FocusedBlockData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getfocusedblockdata", null, opts); return client.wshRpcCall("getfocusedblockdata", null, opts); } // command "getfullconfig" [call] GetFullConfigCommand(client: WshClient, opts?: RpcOpts): Promise<FullConfigType> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getfullconfig", null, opts); return client.wshRpcCall("getfullconfig", null, opts); } // command "getjwtpublickey" [call] GetJwtPublicKeyCommand(client: WshClient, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getjwtpublickey", null, opts); return client.wshRpcCall("getjwtpublickey", null, opts); } // command "getmeta" [call] GetMetaCommand(client: WshClient, data: CommandGetMetaData, opts?: RpcOpts): Promise<MetaType> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getmeta", data, opts); return client.wshRpcCall("getmeta", data, opts); } // command "getrtinfo" [call] GetRTInfoCommand(client: WshClient, data: CommandGetRTInfoData, opts?: RpcOpts): Promise<ObjRTInfo> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getrtinfo", data, opts); return client.wshRpcCall("getrtinfo", data, opts); } // command "getsecrets" [call] GetSecretsCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<{[key: string]: string}> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getsecrets", data, opts); return client.wshRpcCall("getsecrets", data, opts); } // command "getsecretslinuxstoragebackend" [call] GetSecretsLinuxStorageBackendCommand(client: WshClient, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getsecretslinuxstoragebackend", null, opts); return client.wshRpcCall("getsecretslinuxstoragebackend", null, opts); } // command "getsecretsnames" [call] GetSecretsNamesCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getsecretsnames", null, opts); return client.wshRpcCall("getsecretsnames", null, opts); } // command "gettab" [call] GetTabCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<Tab> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "gettab", data, opts); return client.wshRpcCall("gettab", data, opts); } // command "gettempdir" [call] GetTempDirCommand(client: WshClient, data: CommandGetTempDirData, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "gettempdir", data, opts); return client.wshRpcCall("gettempdir", data, opts); } // command "getupdatechannel" [call] GetUpdateChannelCommand(client: WshClient, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getupdatechannel", null, opts); return client.wshRpcCall("getupdatechannel", null, opts); } // command "getvar" [call] GetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise<CommandVarResponseData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getvar", data, opts); return client.wshRpcCall("getvar", data, opts); } // command "getwaveaichat" [call] GetWaveAIChatCommand(client: WshClient, data: CommandGetWaveAIChatData, opts?: RpcOpts): Promise<UIChat> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getwaveaichat", data, opts); return client.wshRpcCall("getwaveaichat", data, opts); } // command "getwaveaimodeconfig" [call] GetWaveAIModeConfigCommand(client: WshClient, opts?: RpcOpts): Promise<AIModeConfigUpdate> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getwaveaimodeconfig", null, opts); return client.wshRpcCall("getwaveaimodeconfig", null, opts); } // command "getwaveairatelimit" [call] GetWaveAIRateLimitCommand(client: WshClient, opts?: RpcOpts): Promise<RateLimitInfo> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getwaveairatelimit", null, opts); return client.wshRpcCall("getwaveairatelimit", null, opts); } // command "jobcmdexited" [call] JobCmdExitedCommand(client: WshClient, data: CommandJobCmdExitedData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcmdexited", data, opts); return client.wshRpcCall("jobcmdexited", data, opts); } // command "jobcontrollerattachjob" [call] JobControllerAttachJobCommand(client: WshClient, data: CommandJobControllerAttachJobData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerattachjob", data, opts); return client.wshRpcCall("jobcontrollerattachjob", data, opts); } // command "jobcontrollerconnectedjobs" [call] JobControllerConnectedJobsCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerconnectedjobs", null, opts); return client.wshRpcCall("jobcontrollerconnectedjobs", null, opts); } // command "jobcontrollerdeletejob" [call] JobControllerDeleteJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerdeletejob", data, opts); return client.wshRpcCall("jobcontrollerdeletejob", data, opts); } // command "jobcontrollerdetachjob" [call] JobControllerDetachJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerdetachjob", data, opts); return client.wshRpcCall("jobcontrollerdetachjob", data, opts); } // command "jobcontrollerdisconnectjob" [call] JobControllerDisconnectJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerdisconnectjob", data, opts); return client.wshRpcCall("jobcontrollerdisconnectjob", data, opts); } // command "jobcontrollerexitjob" [call] JobControllerExitJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerexitjob", data, opts); return client.wshRpcCall("jobcontrollerexitjob", data, opts); } // command "jobcontrollergetalljobmanagerstatus" [call] JobControllerGetAllJobManagerStatusCommand(client: WshClient, opts?: RpcOpts): Promise<JobManagerStatusUpdate[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollergetalljobmanagerstatus", null, opts); return client.wshRpcCall("jobcontrollergetalljobmanagerstatus", null, opts); } // command "jobcontrollerlist" [call] JobControllerListCommand(client: WshClient, opts?: RpcOpts): Promise<Job[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerlist", null, opts); return client.wshRpcCall("jobcontrollerlist", null, opts); } // command "jobcontrollerreconnectjob" [call] JobControllerReconnectJobCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerreconnectjob", data, opts); return client.wshRpcCall("jobcontrollerreconnectjob", data, opts); } // command "jobcontrollerreconnectjobsforconn" [call] JobControllerReconnectJobsForConnCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerreconnectjobsforconn", data, opts); return client.wshRpcCall("jobcontrollerreconnectjobsforconn", data, opts); } // command "jobcontrollerstartjob" [call] JobControllerStartJobCommand(client: WshClient, data: CommandJobControllerStartJobData, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcontrollerstartjob", data, opts); return client.wshRpcCall("jobcontrollerstartjob", data, opts); } // command "jobinput" [call] JobInputCommand(client: WshClient, data: CommandJobInputData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobinput", data, opts); return client.wshRpcCall("jobinput", data, opts); } // command "jobprepareconnect" [call] JobPrepareConnectCommand(client: WshClient, data: CommandJobPrepareConnectData, opts?: RpcOpts): Promise<CommandJobConnectRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobprepareconnect", data, opts); return client.wshRpcCall("jobprepareconnect", data, opts); } // command "jobstartstream" [call] JobStartStreamCommand(client: WshClient, data: CommandJobStartStreamData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobstartstream", data, opts); return client.wshRpcCall("jobstartstream", data, opts); } // command "listallappfiles" [call] ListAllAppFilesCommand(client: WshClient, data: CommandListAllAppFilesData, opts?: RpcOpts): Promise<CommandListAllAppFilesRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "listallappfiles", data, opts); return client.wshRpcCall("listallappfiles", data, opts); } // command "listallapps" [call] ListAllAppsCommand(client: WshClient, opts?: RpcOpts): Promise<AppInfo[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "listallapps", null, opts); return client.wshRpcCall("listallapps", null, opts); } // command "listalleditableapps" [call] ListAllEditableAppsCommand(client: WshClient, opts?: RpcOpts): Promise<AppInfo[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "listalleditableapps", null, opts); return client.wshRpcCall("listalleditableapps", null, opts); } // command "macosversion" [call] MacOSVersionCommand(client: WshClient, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "macosversion", null, opts); return client.wshRpcCall("macosversion", null, opts); } // command "makedraftfromlocal" [call] MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise<CommandMakeDraftFromLocalRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "makedraftfromlocal", data, opts); return client.wshRpcCall("makedraftfromlocal", data, opts); } // command "message" [call] MessageCommand(client: WshClient, data: CommandMessageData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "message", data, opts); return client.wshRpcCall("message", data, opts); } // command "networkonline" [call] NetworkOnlineCommand(client: WshClient, opts?: RpcOpts): Promise<boolean> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "networkonline", null, opts); return client.wshRpcCall("networkonline", null, opts); } // command "notify" [call] NotifyCommand(client: WshClient, data: WaveNotificationOptions, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "notify", data, opts); return client.wshRpcCall("notify", data, opts); } // command "notifysystemresume" [call] NotifySystemResumeCommand(client: WshClient, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "notifysystemresume", null, opts); return client.wshRpcCall("notifysystemresume", null, opts); } // command "path" [call] PathCommand(client: WshClient, data: PathCommandData, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "path", data, opts); return client.wshRpcCall("path", data, opts); } // command "publishapp" [call] PublishAppCommand(client: WshClient, data: CommandPublishAppData, opts?: RpcOpts): Promise<CommandPublishAppRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "publishapp", data, opts); return client.wshRpcCall("publishapp", data, opts); } // command "readappfile" [call] ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise<CommandReadAppFileRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "readappfile", data, opts); return client.wshRpcCall("readappfile", data, opts); } // command "recordtevent" [call] RecordTEventCommand(client: WshClient, data: TEvent, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "recordtevent", data, opts); return client.wshRpcCall("recordtevent", data, opts); } // command "remotedisconnectfromjobmanager" [call] RemoteDisconnectFromJobManagerCommand(client: WshClient, data: CommandRemoteDisconnectFromJobManagerData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotedisconnectfromjobmanager", data, opts); return client.wshRpcCall("remotedisconnectfromjobmanager", data, opts); } // command "remotefilecopy" [call] RemoteFileCopyCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise<boolean> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilecopy", data, opts); return client.wshRpcCall("remotefilecopy", data, opts); } // command "remotefiledelete" [call] RemoteFileDeleteCommand(client: WshClient, data: CommandDeleteFileData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefiledelete", data, opts); return client.wshRpcCall("remotefiledelete", data, opts); } // command "remotefileinfo" [call] RemoteFileInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<FileInfo> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefileinfo", data, opts); return client.wshRpcCall("remotefileinfo", data, opts); } // command "remotefilejoin" [call] RemoteFileJoinCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<FileInfo> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilejoin", data, opts); return client.wshRpcCall("remotefilejoin", data, opts); } // command "remotefilemove" [call] RemoteFileMoveCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilemove", data, opts); return client.wshRpcCall("remotefilemove", data, opts); } // command "remotefilemultiinfo" [call] RemoteFileMultiInfoCommand(client: WshClient, data: CommandRemoteFileMultiInfoData, opts?: RpcOpts): Promise<{[key: string]: FileInfo}> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilemultiinfo", data, opts); return client.wshRpcCall("remotefilemultiinfo", data, opts); } // command "remotefilestream" [call] RemoteFileStreamCommand(client: WshClient, data: CommandRemoteFileStreamData, opts?: RpcOpts): Promise<FileInfo> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilestream", data, opts); return client.wshRpcCall("remotefilestream", data, opts); } // command "remotefiletouch" [call] RemoteFileTouchCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefiletouch", data, opts); return client.wshRpcCall("remotefiletouch", data, opts); } // command "remotegetinfo" [call] RemoteGetInfoCommand(client: WshClient, opts?: RpcOpts): Promise<RemoteInfo> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotegetinfo", null, opts); return client.wshRpcCall("remotegetinfo", null, opts); } // command "remoteinstallrcfiles" [call] RemoteInstallRcFilesCommand(client: WshClient, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteinstallrcfiles", null, opts); return client.wshRpcCall("remoteinstallrcfiles", null, opts); } // command "remotelistentries" [responsestream] RemoteListEntriesCommand(client: WshClient, data: CommandRemoteListEntriesData, opts?: RpcOpts): AsyncGenerator<CommandRemoteListEntriesRtnData, void, boolean> { if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "remotelistentries", data, opts); return client.wshRpcStream("remotelistentries", data, opts); } // command "remotemkdir" [call] RemoteMkdirCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotemkdir", data, opts); return client.wshRpcCall("remotemkdir", data, opts); } // command "remotereconnecttojobmanager" [call] RemoteReconnectToJobManagerCommand(client: WshClient, data: CommandRemoteReconnectToJobManagerData, opts?: RpcOpts): Promise<CommandRemoteReconnectToJobManagerRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotereconnecttojobmanager", data, opts); return client.wshRpcCall("remotereconnecttojobmanager", data, opts); } // command "remotestartjob" [call] RemoteStartJobCommand(client: WshClient, data: CommandRemoteStartJobData, opts?: RpcOpts): Promise<CommandStartJobRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotestartjob", data, opts); return client.wshRpcCall("remotestartjob", data, opts); } // command "remotestreamcpudata" [responsestream] RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator<TimeSeriesData, void, boolean> { if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "remotestreamcpudata", null, opts); return client.wshRpcStream("remotestreamcpudata", null, opts); } // command "remotestreamfile" [responsestream] RemoteStreamFileCommand(client: WshClient, data: CommandRemoteStreamFileData, opts?: RpcOpts): AsyncGenerator<FileData, void, boolean> { if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "remotestreamfile", data, opts); return client.wshRpcStream("remotestreamfile", data, opts); } // command "remoteterminatejobmanager" [call] RemoteTerminateJobManagerCommand(client: WshClient, data: CommandRemoteTerminateJobManagerData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteterminatejobmanager", data, opts); return client.wshRpcCall("remoteterminatejobmanager", data, opts); } // command "remotewritefile" [call] RemoteWriteFileCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotewritefile", data, opts); return client.wshRpcCall("remotewritefile", data, opts); } // command "renameappfile" [call] RenameAppFileCommand(client: WshClient, data: CommandRenameAppFileData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "renameappfile", data, opts); return client.wshRpcCall("renameappfile", data, opts); } // command "resolveids" [call] ResolveIdsCommand(client: WshClient, data: CommandResolveIdsData, opts?: RpcOpts): Promise<CommandResolveIdsRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "resolveids", data, opts); return client.wshRpcCall("resolveids", data, opts); } // command "restartbuilderandwait" [call] RestartBuilderAndWaitCommand(client: WshClient, data: CommandRestartBuilderAndWaitData, opts?: RpcOpts): Promise<RestartBuilderAndWaitResult> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "restartbuilderandwait", data, opts); return client.wshRpcCall("restartbuilderandwait", data, opts); } // command "routeannounce" [call] RouteAnnounceCommand(client: WshClient, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "routeannounce", null, opts); return client.wshRpcCall("routeannounce", null, opts); } // command "routeunannounce" [call] RouteUnannounceCommand(client: WshClient, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "routeunannounce", null, opts); return client.wshRpcCall("routeunannounce", null, opts); } // command "sendtelemetry" [call] SendTelemetryCommand(client: WshClient, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "sendtelemetry", null, opts); return client.wshRpcCall("sendtelemetry", null, opts); } // command "setblockfocus" [call] SetBlockFocusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setblockfocus", data, opts); return client.wshRpcCall("setblockfocus", data, opts); } // command "setconfig" [call] SetConfigCommand(client: WshClient, data: SettingsType, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setconfig", data, opts); return client.wshRpcCall("setconfig", data, opts); } // command "setconnectionsconfig" [call] SetConnectionsConfigCommand(client: WshClient, data: ConnConfigRequest, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setconnectionsconfig", data, opts); return client.wshRpcCall("setconnectionsconfig", data, opts); } // command "setmeta" [call] SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setmeta", data, opts); return client.wshRpcCall("setmeta", data, opts); } // command "setpeerinfo" [call] SetPeerInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setpeerinfo", data, opts); return client.wshRpcCall("setpeerinfo", data, opts); } // command "setrtinfo" [call] SetRTInfoCommand(client: WshClient, data: CommandSetRTInfoData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setrtinfo", data, opts); return client.wshRpcCall("setrtinfo", data, opts); } // command "setsecrets" [call] SetSecretsCommand(client: WshClient, data: {[key: string]: string}, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setsecrets", data, opts); return client.wshRpcCall("setsecrets", data, opts); } // command "setvar" [call] SetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setvar", data, opts); return client.wshRpcCall("setvar", data, opts); } // command "startbuilder" [call] StartBuilderCommand(client: WshClient, data: CommandStartBuilderData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "startbuilder", data, opts); return client.wshRpcCall("startbuilder", data, opts); } // command "startjob" [call] StartJobCommand(client: WshClient, data: CommandStartJobData, opts?: RpcOpts): Promise<CommandStartJobRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "startjob", data, opts); return client.wshRpcCall("startjob", data, opts); } // command "stopbuilder" [call] StopBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "stopbuilder", data, opts); return client.wshRpcCall("stopbuilder", data, opts); } // command "streamcpudata" [responsestream] StreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator<TimeSeriesData, void, boolean> { if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "streamcpudata", data, opts); return client.wshRpcStream("streamcpudata", data, opts); } // command "streamdata" [call] StreamDataCommand(client: WshClient, data: CommandStreamData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "streamdata", data, opts); return client.wshRpcCall("streamdata", data, opts); } // command "streamdataack" [call] StreamDataAckCommand(client: WshClient, data: CommandStreamAckData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "streamdataack", data, opts); return client.wshRpcCall("streamdataack", data, opts); } // command "streamtest" [responsestream] StreamTestCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator<number, void, boolean> { if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "streamtest", null, opts); return client.wshRpcStream("streamtest", null, opts); } // command "streamwaveai" [responsestream] StreamWaveAiCommand(client: WshClient, data: WaveAIStreamRequest, opts?: RpcOpts): AsyncGenerator<WaveAIPacketType, void, boolean> { if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "streamwaveai", data, opts); return client.wshRpcStream("streamwaveai", data, opts); } // command "termgetscrollbacklines" [call] TermGetScrollbackLinesCommand(client: WshClient, data: CommandTermGetScrollbackLinesData, opts?: RpcOpts): Promise<CommandTermGetScrollbackLinesRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "termgetscrollbacklines", data, opts); return client.wshRpcCall("termgetscrollbacklines", data, opts); } // command "test" [call] TestCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "test", data, opts); return client.wshRpcCall("test", data, opts); } // command "testmultiarg" [call] TestMultiArgCommand(client: WshClient, arg1: string, arg2: number, arg3: boolean, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "testmultiarg", { args: [arg1, arg2, arg3] }, opts); return client.wshRpcCall("testmultiarg", { args: [arg1, arg2, arg3] }, opts); } // command "updatetabname" [call] UpdateTabNameCommand(client: WshClient, arg1: string, arg2: string, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updatetabname", { args: [arg1, arg2] }, opts); return client.wshRpcCall("updatetabname", { args: [arg1, arg2] }, opts); } // command "updateworkspacetabids" [call] UpdateWorkspaceTabIdsCommand(client: WshClient, arg1: string, arg2: string[], opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updateworkspacetabids", { args: [arg1, arg2] }, opts); return client.wshRpcCall("updateworkspacetabids", { args: [arg1, arg2] }, opts); } // command "vdomasyncinitiation" [call] VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "vdomasyncinitiation", data, opts); return client.wshRpcCall("vdomasyncinitiation", data, opts); } // command "vdomcreatecontext" [call] VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise<ORef> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "vdomcreatecontext", data, opts); return client.wshRpcCall("vdomcreatecontext", data, opts); } // command "vdomrender" [responsestream] VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): AsyncGenerator<VDomBackendUpdate, void, boolean> { if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "vdomrender", data, opts); return client.wshRpcStream("vdomrender", data, opts); } // command "vdomurlrequest" [responsestream] VDomUrlRequestCommand(client: WshClient, data: VDomUrlRequestData, opts?: RpcOpts): AsyncGenerator<VDomUrlRequestResponse, void, boolean> { if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "vdomurlrequest", data, opts); return client.wshRpcStream("vdomurlrequest", data, opts); } // command "waitforroute" [call] WaitForRouteCommand(client: WshClient, data: CommandWaitForRouteData, opts?: RpcOpts): Promise<boolean> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waitforroute", data, opts); return client.wshRpcCall("waitforroute", data, opts); } // command "waveaiaddcontext" [call] WaveAIAddContextCommand(client: WshClient, data: CommandWaveAIAddContextData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaiaddcontext", data, opts); return client.wshRpcCall("waveaiaddcontext", data, opts); } // command "waveaienabletelemetry" [call] WaveAIEnableTelemetryCommand(client: WshClient, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaienabletelemetry", null, opts); return client.wshRpcCall("waveaienabletelemetry", null, opts); } // command "waveaigettooldiff" [call] WaveAIGetToolDiffCommand(client: WshClient, data: CommandWaveAIGetToolDiffData, opts?: RpcOpts): Promise<CommandWaveAIGetToolDiffRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaigettooldiff", data, opts); return client.wshRpcCall("waveaigettooldiff", data, opts); } // command "waveaitoolapprove" [call] WaveAIToolApproveCommand(client: WshClient, data: CommandWaveAIToolApproveData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaitoolapprove", data, opts); return client.wshRpcCall("waveaitoolapprove", data, opts); } // command "wavefilereadstream" [call] WaveFileReadStreamCommand(client: WshClient, data: CommandWaveFileReadStreamData, opts?: RpcOpts): Promise<WaveFileInfo> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wavefilereadstream", data, opts); return client.wshRpcCall("wavefilereadstream", data, opts); } // command "waveinfo" [call] WaveInfoCommand(client: WshClient, opts?: RpcOpts): Promise<WaveInfoData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveinfo", null, opts); return client.wshRpcCall("waveinfo", null, opts); } // command "webselector" [call] WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise<string[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "webselector", data, opts); return client.wshRpcCall("webselector", data, opts); } // command "workspacelist" [call] WorkspaceListCommand(client: WshClient, opts?: RpcOpts): Promise<WorkspaceInfoData[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "workspacelist", null, opts); return client.wshRpcCall("workspacelist", null, opts); } // command "writeappfile" [call] WriteAppFileCommand(client: WshClient, data: CommandWriteAppFileData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writeappfile", data, opts); return client.wshRpcCall("writeappfile", data, opts); } // command "writeappgofile" [call] WriteAppGoFileCommand(client: WshClient, data: CommandWriteAppGoFileData, opts?: RpcOpts): Promise<CommandWriteAppGoFileRtnData> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writeappgofile", data, opts); return client.wshRpcCall("writeappgofile", data, opts); } // command "writeappsecretbindings" [call] WriteAppSecretBindingsCommand(client: WshClient, data: CommandWriteAppSecretBindingsData, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writeappsecretbindings", data, opts); return client.wshRpcCall("writeappsecretbindings", data, opts); } // command "writetempfile" [call] WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writetempfile", data, opts); return client.wshRpcCall("writetempfile", data, opts); } // command "wshactivity" [call] WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise<void> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wshactivity", data, opts); return client.wshRpcCall("wshactivity", data, opts); } // command "wsldefaultdistro" [call] WslDefaultDistroCommand(client: WshClient, opts?: RpcOpts): Promise<string> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wsldefaultdistro", null, opts); return client.wshRpcCall("wsldefaultdistro", null, opts); } // command "wsllist" [call] WslListCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wsllist", null, opts); return client.wshRpcCall("wsllist", null, opts); } // command "wslstatus" [call] WslStatusCommand(client: WshClient, opts?: RpcOpts): Promise<ConnStatus[]> { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wslstatus", null, opts); return client.wshRpcCall("wslstatus", null, opts); } } export const RpcApi = new RpcApiType(); ================================================ FILE: frontend/app/store/wshrouter.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { handleWaveEvent } from "@/app/store/wps"; import * as util from "@/util/util"; import debug from "debug"; const dlog = debug("wave:router"); const SysRouteName = "sys"; const ControlRouteName = "$control"; type RouteInfo = { rpcId: string; sourceRouteId: string; destRouteId: string; }; function makeFeBlockRouteId(feBlockId: string): string { return `feblock:${feBlockId}`; } function makeTabRouteId(tabId: string): string { return `tab:${tabId}`; } function makeBuilderRouteId(builderId: string): string { return `builder:${builderId}`; } class WshRouter { routeMap: Map<string, AbstractWshClient>; // routeid -> client upstreamClient: AbstractWshClient; rpcMap: Map<string, RouteInfo>; // rpcid -> routeinfo constructor(upstreamClient: AbstractWshClient) { this.routeMap = new Map(); this.rpcMap = new Map(); if (upstreamClient == null) { throw new Error("upstream client cannot be null"); } this.upstreamClient = upstreamClient; } reannounceRoutes() { for (const [routeId, client] of this.routeMap) { const announceMsg: RpcMessage = { command: "routeannounce", data: routeId, source: routeId, route: ControlRouteName, }; this.upstreamClient.recvRpcMessage(announceMsg); } } // returns true if the message was sent _sendRoutedMessage(msg: RpcMessage, destRouteId: string) { const client = this.routeMap.get(destRouteId); if (client) { client.recvRpcMessage(msg); return; } // there should always an upstream client if (!this.upstreamClient) { throw new Error(`no upstream client for message: ${msg}`); } this.upstreamClient?.recvRpcMessage(msg); } _registerRouteInfo(reqid: string, sourceRouteId: string, destRouteId: string) { dlog("registering route info", reqid, sourceRouteId, destRouteId); if (util.isBlank(reqid)) { return; } const routeInfo: RouteInfo = { rpcId: reqid, sourceRouteId: sourceRouteId, destRouteId: destRouteId, }; this.rpcMap.set(reqid, routeInfo); } recvRpcMessage(msg: RpcMessage) { dlog("router received message", msg); // we are a terminal node by definition, so we don't need to process with announce/unannounce messages if (msg.command == "routeannounce" || msg.command == "routeunannounce") { return; } // handle events if (msg.command == "eventrecv") { handleWaveEvent(msg.data); return; } if (!util.isBlank(msg.command)) { // send + register routeinfo if (!util.isBlank(msg.reqid)) { this._registerRouteInfo(msg.reqid, msg.source, msg.route); } this._sendRoutedMessage(msg, msg.route); return; } if (!util.isBlank(msg.reqid)) { const routeInfo = this.rpcMap.get(msg.reqid); if (!routeInfo) { // no route info, discard dlog("no route info for reqid, discarding", msg); return; } this._sendRoutedMessage(msg, routeInfo.destRouteId); return; } if (!util.isBlank(msg.resid)) { const routeInfo = this.rpcMap.get(msg.resid); if (!routeInfo) { // no route info, discard dlog("no route info for resid, discarding", msg); return; } this._sendRoutedMessage(msg, routeInfo.sourceRouteId); if (!msg.cont) { dlog("deleting route info", msg.resid); this.rpcMap.delete(msg.resid); } return; } dlog("bad rpc message recevied by router, no command, reqid, or resid (discarding)", msg); } registerRoute(routeId: string, client: AbstractWshClient) { if (routeId == SysRouteName) { throw new Error(`Cannot register route with reserved name (${routeId})`); } dlog("registering route: ", routeId); // announce const announceMsg: RpcMessage = { command: "routeannounce", data: routeId, source: routeId, route: ControlRouteName, }; this.upstreamClient.recvRpcMessage(announceMsg); this.routeMap.set(routeId, client); } unregisterRoute(routeId: string) { dlog("unregister route: ", routeId); // unannounce const unannounceMsg: RpcMessage = { command: "routeunannounce", data: routeId, source: routeId, route: ControlRouteName, }; this.upstreamClient?.recvRpcMessage(unannounceMsg); this.routeMap.delete(routeId); } } export { makeBuilderRouteId, makeFeBlockRouteId, makeTabRouteId, WshRouter }; ================================================ FILE: frontend/app/store/wshrpcutil-base.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { setWpsRpcClient, wpsReconnectHandler } from "@/app/store/wps"; import { WshClient } from "@/app/store/wshclient"; import { WshRouter } from "@/app/store/wshrouter"; import { getWSServerEndpoint } from "@/util/endpoints"; import { addWSReconnectHandler, ElectronOverrideOpts, globalWS, initGlobalWS } from "./ws"; let DefaultRouter: WshRouter; function setDefaultRouter(router: WshRouter) { DefaultRouter = router; } async function* rpcResponseGenerator( openRpcs: Map<string, ClientRpcEntry>, command: string, reqid: string, timeout: number ): AsyncGenerator<any, void, boolean> { const msgQueue: RpcMessage[] = []; let signalFn: () => void; let signalPromise = new Promise<void>((resolve) => (signalFn = resolve)); let timeoutId: NodeJS.Timeout = null; if (timeout > 0) { timeoutId = setTimeout(() => { msgQueue.push({ resid: reqid, error: "EC-TIME: timeout waiting for response" }); signalFn(); }, timeout); } const msgFn = (msg: RpcMessage) => { msgQueue.push(msg); signalFn(); // reset signal promise signalPromise = new Promise<void>((resolve) => (signalFn = resolve)); }; openRpcs.set(reqid, { reqId: reqid, startTs: Date.now(), command: command, msgFn: msgFn, }); yield null; try { while (true) { while (msgQueue.length > 0) { const msg = msgQueue.shift()!; if (msg.error != null) { throw new Error(msg.error); } if (!msg.cont && msg.data == null) { return; } const shouldTerminate = yield msg.data; if (shouldTerminate) { sendRpcCancel(reqid); return; } if (!msg.cont) { return; } } await signalPromise; } } finally { openRpcs.delete(reqid); if (timeoutId != null) { clearTimeout(timeoutId); } } } function sendRpcCancel(reqid: string) { const rpcMsg: RpcMessage = { reqid: reqid, cancel: true }; DefaultRouter.recvRpcMessage(rpcMsg); } function sendRpcResponse(msg: RpcMessage) { DefaultRouter.recvRpcMessage(msg); } function sendRpcCommand( openRpcs: Map<string, ClientRpcEntry>, msg: RpcMessage ): AsyncGenerator<RpcMessage, void, boolean> { DefaultRouter.recvRpcMessage(msg); if (msg.reqid == null) { return null; } const rtnGen = rpcResponseGenerator(openRpcs, msg.command, msg.reqid, msg.timeout); rtnGen.next(); return rtnGen; } async function consumeGenerator(gen: AsyncGenerator<any, any, any>) { let idx = 0; try { for await (const msg of gen) { console.log("gen", idx, msg); idx++; } const result = await gen.return(undefined); console.log("gen done", result.value); } catch (e) { console.log("gen error", e); } } if (globalThis.window != null) { globalThis["consumeGenerator"] = consumeGenerator; } function initElectronWshrpc(electronClient: WshClient, eoOpts: ElectronOverrideOpts) { setDefaultRouter(new WshRouter(new UpstreamWshRpcProxy())); const handleFn = (event: WSEventType) => { DefaultRouter.recvRpcMessage(event.data); }; initGlobalWS(getWSServerEndpoint(), "electron", handleFn, eoOpts); globalWS.connectNow("connectWshrpc"); setWpsRpcClient(electronClient); DefaultRouter.registerRoute(electronClient.routeId, electronClient); addWSReconnectHandler(() => { DefaultRouter.reannounceRoutes(); }); addWSReconnectHandler(wpsReconnectHandler); } function shutdownWshrpc() { globalWS?.shutdown(); } class UpstreamWshRpcProxy implements AbstractWshClient { recvRpcMessage(msg: RpcMessage): void { const wsMsg: WSRpcCommand = { wscommand: "rpc", message: msg }; globalWS?.pushMessage(wsMsg); } } export { DefaultRouter, initElectronWshrpc, sendRpcCommand, sendRpcResponse, setDefaultRouter, shutdownWshrpc }; ================================================ FILE: frontend/app/store/wshrpcutil.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { setWpsRpcClient, wpsReconnectHandler } from "@/app/store/wps"; import { TabClient } from "@/app/store/tabrpcclient"; import { WshRouter } from "@/app/store/wshrouter"; import { getWSServerEndpoint } from "@/util/endpoints"; import { addWSReconnectHandler, globalWS, initGlobalWS, WSControl } from "./ws"; import { DefaultRouter, setDefaultRouter } from "./wshrpcutil-base"; let TabRpcClient: TabClient; function initWshrpc(routeId: string): WSControl { const router = new WshRouter(new UpstreamWshRpcProxy()); setDefaultRouter(router); const handleFn = (event: WSEventType) => { DefaultRouter.recvRpcMessage(event.data); }; initGlobalWS(getWSServerEndpoint(), routeId, handleFn); globalWS.connectNow("connectWshrpc"); TabRpcClient = new TabClient(routeId); setWpsRpcClient(TabRpcClient); DefaultRouter.registerRoute(TabRpcClient.routeId, TabRpcClient); addWSReconnectHandler(() => { DefaultRouter.reannounceRoutes(); }); addWSReconnectHandler(wpsReconnectHandler); return globalWS; } class UpstreamWshRpcProxy implements AbstractWshClient { recvRpcMessage(msg: RpcMessage): void { const wsMsg: WSRpcCommand = { wscommand: "rpc", message: msg }; globalWS?.pushMessage(wsMsg); } } export { DefaultRouter, initWshrpc, TabRpcClient }; export { initElectronWshrpc, sendRpcCommand, sendRpcResponse, shutdownWshrpc } from "./wshrpcutil-base"; ================================================ FILE: frontend/app/suggestion/suggestion.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { atoms } from "@/app/store/global"; import { isBlank, makeIconClass } from "@/util/util"; import { offset, useFloating } from "@floating-ui/react"; import clsx from "clsx"; import { Atom, useAtomValue } from "jotai"; import React, { ReactNode, useEffect, useId, useRef, useState } from "react"; interface SuggestionControlProps { anchorRef: React.RefObject<HTMLElement>; isOpen: boolean; onClose: () => void; onSelect: (item: SuggestionType, queryStr: string) => boolean; onTab?: (item: SuggestionType, queryStr: string) => string; fetchSuggestions: SuggestionsFnType; className?: string; placeholderText?: string; children?: React.ReactNode; } type BlockHeaderSuggestionControlProps = Omit<SuggestionControlProps, "anchorRef" | "isOpen"> & { blockRef: React.RefObject<HTMLElement>; openAtom: Atom<boolean>; }; function SuggestionControl({ anchorRef, isOpen, onClose, onSelect, onTab, fetchSuggestions, className, children, }: SuggestionControlProps) { if (!isOpen || !anchorRef.current || !fetchSuggestions) return null; return ( <SuggestionControlInner {...{ anchorRef, onClose, onSelect, onTab, fetchSuggestions, className, children }} /> ); } function highlightPositions(target: string, positions: number[]): ReactNode[] { if (target == null) { return []; } if (positions == null) { return [target]; } const result: ReactNode[] = []; let targetIndex = 0; let posIndex = 0; while (targetIndex < target.length) { if (posIndex < positions.length && targetIndex === positions[posIndex]) { result.push( <span key={`h-${targetIndex}`} className="text-blue-500 font-bold"> {target[targetIndex]} </span> ); posIndex++; } else { result.push(target[targetIndex]); } targetIndex++; } return result; } function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): [string, string] { if (mimeType == null) { return [null, null]; } while (mimeType.length > 0) { const icon = fullConfig.mimetypes?.[mimeType]?.icon ?? null; const iconColor = fullConfig.mimetypes?.[mimeType]?.color ?? null; if (icon != null) { return [icon, iconColor]; } mimeType = mimeType.slice(0, -1); } return [null, null]; } function SuggestionIcon({ suggestion }: { suggestion: SuggestionType }) { if (suggestion.iconsrc) { return <img src={suggestion.iconsrc} alt="favicon" className="w-4 h-4 object-contain" />; } if (suggestion.icon) { const iconClass = makeIconClass(suggestion.icon, true); const iconColor = suggestion.iconcolor; return <i className={iconClass} style={{ color: iconColor }} />; } if (suggestion.type === "url") { const iconClass = makeIconClass("globe", true); const iconColor = suggestion.iconcolor; return <i className={iconClass} style={{ color: iconColor }} />; } else if (suggestion.type === "file") { // For file suggestions, use the existing logic. const fullConfig = useAtomValue(atoms.fullConfigAtom); let icon: string = null; let iconColor: string = null; if (icon == null && suggestion["file:mimetype"] != null) { [icon, iconColor] = getMimeTypeIconAndColor(fullConfig, suggestion["file:mimetype"]); } const iconClass = makeIconClass(icon, true, { defaultIcon: "file" }); return <i className={iconClass} style={{ color: iconColor }} />; } const iconClass = makeIconClass("file", true); return <i className={iconClass} />; } function SuggestionContent({ suggestion }: { suggestion: SuggestionType }) { if (!isBlank(suggestion.subtext)) { return ( <div className="flex flex-col"> {/* Title on the first line, with highlighting */} <div className="truncate text-white">{highlightPositions(suggestion.display, suggestion.matchpos)}</div> {/* Subtext on the second line in a smaller, grey style */} <div className="truncate text-sm text-secondary"> {highlightPositions(suggestion.subtext, suggestion.submatchpos)} </div> </div> ); } return <span className="truncate">{highlightPositions(suggestion.display, suggestion.matchpos)}</span>; } function BlockHeaderSuggestionControl(props: BlockHeaderSuggestionControlProps) { const [headerElem, setHeaderElem] = useState<HTMLElement>(null); const isOpen = useAtomValue(props.openAtom); useEffect(() => { if (props.blockRef.current == null) { setHeaderElem(null); return; } const headerElem = props.blockRef.current.querySelector("[data-role='block-header']"); setHeaderElem(headerElem as HTMLElement); }, [props.blockRef.current]); const newClass = clsx(props.className, "rounded-t-none"); return <SuggestionControl {...props} anchorRef={{ current: headerElem }} isOpen={isOpen} className={newClass} />; } /** * The empty state component that can be used as a child of SuggestionControl. * If no children are provided to SuggestionControl, this default empty state will be used. */ function SuggestionControlNoResults({ children }: { children?: React.ReactNode }) { return ( <div className="flex items-center justify-center min-h-[120px] p-4"> {children ?? <span className="text-gray-500">No Suggestions</span>} </div> ); } function SuggestionControlNoData({ children }: { children?: React.ReactNode }) { return ( <div className="flex items-center justify-center min-h-[120px] p-4"> {children ?? <span className="text-gray-500">No Suggestions</span>} </div> ); } type SuggestionControlInnerProps = Omit<SuggestionControlProps, "isOpen">; function SuggestionControlInner({ anchorRef, onClose, onSelect, onTab, fetchSuggestions, className, placeholderText, children, }: SuggestionControlInnerProps) { const widgetId = useId(); const [query, setQuery] = useState(""); const reqNumRef = useRef(0); let [suggestions, setSuggestions] = useState<SuggestionType[]>([]); const [selectedIndex, setSelectedIndex] = useState(0); const [fetched, setFetched] = useState(false); const inputRef = useRef<HTMLInputElement>(null); const dropdownRef = useRef<HTMLDivElement>(null); const { refs, floatingStyles, middlewareData } = useFloating({ placement: "bottom", strategy: "absolute", middleware: [offset(-1)], }); const emptyStateChild = React.Children.toArray(children).find( (child) => React.isValidElement(child) && child.type === SuggestionControlNoResults ); const noDataChild = React.Children.toArray(children).find( (child) => React.isValidElement(child) && child.type === SuggestionControlNoData ); useEffect(() => { refs.setReference(anchorRef.current); }, [anchorRef.current]); useEffect(() => { reqNumRef.current++; fetchSuggestions(query, { widgetid: widgetId, reqnum: reqNumRef.current }).then((results) => { if (results.reqnum !== reqNumRef.current) { return; } setSuggestions(results.suggestions ?? []); setFetched(true); }); }, [query, fetchSuggestions]); useEffect(() => { return () => { reqNumRef.current++; fetchSuggestions("", { widgetid: widgetId, reqnum: reqNumRef.current, dispose: true }); }; }, []); useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { onClose(); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [onClose, anchorRef]); useEffect(() => { if (dropdownRef.current) { const children = dropdownRef.current.children; if (children[selectedIndex]) { (children[selectedIndex] as HTMLElement).scrollIntoView({ behavior: "auto", block: "nearest", }); } } }, [selectedIndex]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); e.stopPropagation(); setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); setSelectedIndex((prev) => Math.max(prev - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); let suggestion: SuggestionType = null; if (selectedIndex >= 0 && selectedIndex < suggestions.length) { suggestion = suggestions[selectedIndex]; } if (onSelect(suggestion, query)) { onClose(); } } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose(); } else if (e.key === "Tab") { e.preventDefault(); e.stopPropagation(); const suggestion = suggestions[selectedIndex]; if (suggestion != null) { const tabResult = onTab?.(suggestion, query); if (tabResult != null) { setQuery(tabResult); } } } else if (e.key === "PageDown") { e.preventDefault(); e.stopPropagation(); setSelectedIndex((prev) => Math.min(prev + 10, suggestions.length - 1)); } else if (e.key === "PageUp") { e.preventDefault(); e.stopPropagation(); setSelectedIndex((prev) => Math.max(prev - 10, 0)); } }; return ( <div className={clsx( "w-96 rounded-lg bg-modalbg shadow-lg border border-gray-700 z-[var(--zindex-typeahead-modal)] absolute", middlewareData?.offset == null ? "opacity-0" : null, className )} ref={refs.setFloating} style={floatingStyles} > <div className="p-2"> <input ref={inputRef} type="text" value={query} onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }} onKeyDown={handleKeyDown} className="w-full bg-zinc-900 text-gray-100 px-4 py-2 rounded-md border border-gray-700 focus:outline-none focus:border-accent placeholder-secondary" placeholder={placeholderText} /> </div> {fetched && (suggestions.length > 0 ? ( <div ref={dropdownRef} className="max-h-96 overflow-y-auto divide-y divide-gray-700"> {suggestions.map((suggestion, index) => ( <div key={suggestion.suggestionid} className={clsx( "flex items-center gap-3 px-4 py-2 cursor-pointer", index === selectedIndex ? "bg-accentbg" : "hover:bg-hoverbg", "text-gray-100" )} onClick={() => { onSelect(suggestion, query); onClose(); }} > <SuggestionIcon suggestion={suggestion} /> <SuggestionContent suggestion={suggestion} /> </div> ))} </div> ) : ( // Render the empty state (either a provided child or the default) <div key="empty" className="flex items-center justify-center min-h-[120px] p-4"> {query === "" ? (noDataChild ?? <SuggestionControlNoData />) : (emptyStateChild ?? <SuggestionControlNoResults />)} </div> ))} </div> ); } export { BlockHeaderSuggestionControl, SuggestionControl, SuggestionControlNoData, SuggestionControlNoResults }; ================================================ FILE: frontend/app/tab/tab.scss ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .tab { position: absolute; width: 130px; height: calc(100% - 3px); padding: 0 0 0 0; box-sizing: border-box; font-weight: bold; color: var(--secondary-text-color); opacity: 0; display: flex; align-items: center; justify-content: center; .tab-divider { position: absolute; left: 0; width: 1px; height: 14px; background: rgb(from var(--main-text-color) r g b / 0.2); } .tab-inner { position: relative; width: calc(100% - 6px); height: 100%; white-space: nowrap; border-radius: 6px; } &.animate { transition: transform 0.3s ease, background-color 0.3s ease-in-out; } &.active { .tab-inner { border-color: transparent; border-radius: 6px; background: rgb(from var(--main-text-color) r g b / 0.1); } .name { color: rgba(255, 255, 255, 1); font-weight: 600; } } .name { position: absolute; top: 50%; left: 50%; transform: translate3d(-50%, -50%, 0); user-select: none; z-index: var(--zindex-tab-name); font-size: 11px; font-weight: 500; text-shadow: 0px 0px 4px rgb(from var(--main-bg-color) r g b / 0.25); overflow: hidden; width: calc(100% - 10px); text-overflow: ellipsis; text-align: center; &.focused { outline: none; border: 1px solid rgb(from var(--main-text-color) r g b / 0.179); padding: 2px 6px; border-radius: 2px; } } .wave-button { position: absolute; top: 50%; right: 4px; transform: translate3d(0, -50%, 0); width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: var(--zindex-tab-name); padding: 1px 2px; transition: none !important; } .close { visibility: hidden; } } // Only apply hover effects when not in nohover mode. This prevents the previously-hovered tab from remaining hovered while a tab view is not mounted. body:not(.nohover) .tab:hover + .tab, body:not(.nohover) .tab.dragging + .tab { .tab-divider { display: none; } } body:not(.nohover) .tab:hover, body:not(.nohover) .tab.dragging { .tab-divider { display: none; } .tab-inner { border-color: transparent; background: rgb(from var(--main-text-color) r g b / 0.1); } .close { visibility: visible; &:hover { color: var(--main-text-color); } } } // When in nohover mode, always show the close button on the active tab. This prevents the close button of the active tab from flickering when nohover is toggled. body.nohover .tab.active .close { visibility: visible; } @keyframes expandWidthAndFadeIn { from { width: var(--initial-tab-width); opacity: 0; } to { width: var(--final-tab-width); opacity: 1; } } .tab.new-tab { animation: expandWidthAndFadeIn 0.1s forwards; } ================================================ FILE: frontend/app/tab/tab.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { getTabBadgeAtom } from "@/app/store/badge"; import { refocusNode } from "@/app/store/global"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv"; import { Button } from "@/element/button"; import { validateCssColor } from "@/util/color-validator"; import { fireAndForget } from "@/util/util"; import clsx from "clsx"; import { useAtomValue } from "jotai"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { makeORef } from "../store/wos"; import { TabBadges } from "./tabbadges"; import "./tab.scss"; import { buildTabContextMenu } from "./tabcontextmenu"; export type TabEnv = WaveEnvSubset<{ rpc: { ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"]; }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; }; wos: WaveEnv["wos"]; getSettingsKeyAtom: WaveEnv["getSettingsKeyAtom"]; showContextMenu: WaveEnv["showContextMenu"]; }>; interface TabVProps { tabId: string; tabName: string; active: boolean; showDivider: boolean; isDragging: boolean; tabWidth: number; isNew: boolean; badges?: Badge[] | null; flagColor?: string | null; onClick: () => void; onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void; onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => void; onRename: (newName: string) => void; /** Optional ref that TabV populates with a startRename() function for external callers */ renameRef?: React.RefObject<(() => void) | null>; } const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => { const { tabId, tabName, active, showDivider, isDragging, tabWidth, isNew, badges, flagColor, onClick, onClose, onDragStart, onContextMenu, onRename, renameRef, } = props; const MaxTabNameLength = 14; const truncateTabName = (name: string) => [...(name ?? "")].slice(0, MaxTabNameLength).join(""); const displayName = truncateTabName(tabName); const [originalName, setOriginalName] = useState(displayName); const [isEditable, setIsEditable] = useState(false); const editableRef = useRef<HTMLDivElement>(null); const editableTimeoutRef = useRef<NodeJS.Timeout>(null); const tabRef = useRef<HTMLDivElement>(null); useImperativeHandle(ref, () => tabRef.current as HTMLDivElement); useEffect(() => { setOriginalName(truncateTabName(tabName)); }, [tabName]); useEffect(() => { return () => { if (editableTimeoutRef.current) { clearTimeout(editableTimeoutRef.current); } }; }, []); const selectEditableText = useCallback(() => { if (!editableRef.current) { return; } editableRef.current.focus(); const range = document.createRange(); const selection = window.getSelection(); if (!selection) { return; } range.selectNodeContents(editableRef.current); selection.removeAllRanges(); selection.addRange(range); }, []); const startRename = useCallback(() => { setIsEditable(true); editableTimeoutRef.current = setTimeout(() => { selectEditableText(); }, 50); }, [selectEditableText]); const handleRenameTab: React.MouseEventHandler<HTMLDivElement> = useCallback( (event) => { event?.stopPropagation(); startRename(); }, [startRename] ); // Expose startRename to external callers (e.g. context menu in TabInner) if (renameRef != null) { renameRef.current = startRename; } const handleBlur = () => { if (!editableRef.current) return; let newText = editableRef.current.innerText.trim(); newText = newText || originalName; editableRef.current.innerText = newText; setIsEditable(false); onRename(newText); }; const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => { if ((event.metaKey || event.ctrlKey) && event.key === "a") { event.preventDefault(); selectEditableText(); return; } if (!editableRef.current) return; const curLen = Array.from(editableRef.current.innerText).length; if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); if (editableRef.current.innerText.trim() === "") { editableRef.current.innerText = originalName; } editableRef.current.blur(); } else if (event.key === "Escape") { editableRef.current.innerText = originalName; editableRef.current.blur(); event.preventDefault(); event.stopPropagation(); } else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) { const selection = window.getSelection(); if (!selection || selection.isCollapsed) { event.preventDefault(); event.stopPropagation(); } } }; useEffect(() => { if (tabRef.current && isNew) { const initialWidth = `${(tabWidth / 3) * 2}px`; tabRef.current.style.setProperty("--initial-tab-width", initialWidth); tabRef.current.style.setProperty("--final-tab-width", `${tabWidth}px`); } }, [isNew, tabWidth]); const handleMouseDownOnClose = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { event.stopPropagation(); }; return ( <div ref={tabRef} className={clsx("tab", { active, dragging: isDragging, "new-tab": isNew, })} onMouseDown={onDragStart} onClick={onClick} onContextMenu={onContextMenu} data-tab-id={tabId} > {showDivider && <div className="tab-divider" />} <div className="tab-inner"> <div ref={editableRef} className={clsx("name", { focused: isEditable })} contentEditable={isEditable} onDoubleClick={handleRenameTab} onBlur={handleBlur} onKeyDown={handleKeyDown} suppressContentEditableWarning={true} > {displayName} </div> <TabBadges badges={badges} flagColor={flagColor} /> <Button className="ghost grey close" onClick={onClose} onMouseDown={handleMouseDownOnClose} title="Close Tab" > <i className="fa fa-solid fa-xmark" /> </Button> </div> </div> ); }); TabV.displayName = "TabV"; interface TabProps { id: string; active: boolean; showDivider: boolean; isDragging: boolean; tabWidth: number; isNew: boolean; onSelect: () => void; onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void; onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onLoaded: () => void; } const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => { const { id, active, showDivider, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props; const env = useWaveEnv<TabEnv>(); const [tabData, _] = env.wos.useWaveObjectValue<Tab>(makeORef("tab", id)); const badges = useAtomValue(getTabBadgeAtom(id, env)); const rawFlagColor = tabData?.meta?.["tab:flagcolor"]; let flagColor: string | null = null; if (rawFlagColor) { try { validateCssColor(rawFlagColor); flagColor = rawFlagColor; } catch { flagColor = null; } } const loadedRef = useRef(false); const renameRef = useRef<(() => void) | null>(null); useEffect(() => { if (!loadedRef.current) { onLoaded(); loadedRef.current = true; } }, [onLoaded]); const handleTabClick = () => { onSelect(); }; const handleContextMenu = useCallback( (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => { e.preventDefault(); const menu = buildTabContextMenu(id, renameRef, onClose, env); env.showContextMenu(menu, e); }, [id, onClose, env] ); const handleRename = useCallback( (newName: string) => { fireAndForget(() => env.rpc.UpdateTabNameCommand(TabRpcClient, id, newName)); setTimeout(() => refocusNode(null), 10); }, [id, env] ); return ( <TabV ref={ref} tabId={id} tabName={tabData?.name ?? ""} active={active} showDivider={showDivider} isDragging={isDragging} tabWidth={tabWidth} isNew={isNew} badges={badges} flagColor={flagColor} onClick={handleTabClick} onClose={onClose} onDragStart={onDragStart} onContextMenu={handleContextMenu} onRename={handleRename} renameRef={renameRef} /> ); }); const Tab = memo(TabInner); Tab.displayName = "Tab"; export { Tab, TabV }; ================================================ FILE: frontend/app/tab/tabbadges.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { sortBadgesForTab } from "@/app/store/badge"; import { cn, makeIconClass } from "@/util/util"; import { useMemo } from "react"; import { v7 as uuidv7 } from "uuid"; export interface TabBadgesProps { badges?: Badge[] | null; flagColor?: string | null; className?: string; } const DefaultClassName = "pointer-events-none absolute left-[4px] top-1/2 z-[3] flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center px-[2px] py-[1px]"; export function TabBadges({ badges, flagColor, className }: TabBadgesProps) { const flagBadgeId = useMemo(() => uuidv7(), []); const allBadges = useMemo(() => { const base = badges ?? []; if (!flagColor) { return base; } const flagBadge: Badge = { icon: "flag", color: flagColor, priority: 0, badgeid: flagBadgeId }; return sortBadgesForTab([...base, flagBadge]); }, [badges, flagColor, flagBadgeId]); if (!allBadges[0]) { return null; } const firstBadge = allBadges[0]; const extraBadges = allBadges.slice(1, 3); return ( <div className={cn(DefaultClassName, className)}> <i className={makeIconClass(firstBadge.icon, true, { defaultIcon: "circle-small" }) + " text-[12px]"} style={{ color: firstBadge.color || "#fbbf24" }} /> {extraBadges.length > 0 && ( <div className="ml-[2px] flex flex-col items-center justify-center gap-[2px]"> {extraBadges.map((badge, idx) => ( <div key={idx} className="h-[4px] w-[4px] rounded-full" style={{ backgroundColor: badge.color || "#fbbf24" }} /> ))} </div> )} </div> ); } ================================================ FILE: frontend/app/tab/tabbar-model.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export class TabBarModel { private static instance: TabBarModel | null = null; private constructor() {} static getInstance(): TabBarModel { if (!TabBarModel.instance) { TabBarModel.instance = new TabBarModel(); } return TabBarModel.instance; } } ================================================ FILE: frontend/app/tab/tabbar.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .tab-bar-wrapper { padding-top: 3px; position: relative; user-select: none; display: flex; flex-direction: row; align-items: end; width: 100vw; -webkit-app-region: drag; height: max(33px, calc(33px * var(--zoomfactor-inv))); backdrop-filter: blur(20px); background: rgba(0, 0, 0, 0.35); flex-shrink: 0; button { -webkit-app-region: no-drag; } .tabs-wrapper { transition: var(--tabs-wrapper-transition); height: 26px; } .tab-bar { position: relative; // Needed for absolute positioning of child tabs display: flex; flex-direction: row; height: 27px; -webkit-app-region: no-drag; margin-bottom: 1px; } .pinned-tab-spacer { display: block; height: 100%; margin: 2px; border: 1px solid var(--border-color); } .add-tab { padding: 0 10px; height: 27px; margin-bottom: 2px; } // Customize scrollbar styles .os-theme-dark, .os-theme-light { box-sizing: border-box; --os-size: 2px; --os-padding-perpendicular: 0px; --os-padding-axis: 0px; --os-track-border-radius: 2px; --os-handle-interactive-area-offset: 0px; --os-handle-border-radius: 2px; } } ================================================ FILE: frontend/app/tab/tabbar.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab } from "@/layout/index"; import { isMacOSTahoeOrLater } from "@/util/platformutil"; import { fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; import { OverlayScrollbars } from "overlayscrollbars"; import { createRef, memo, useCallback, useEffect, useRef, useState } from "react"; import { debounce } from "throttle-debounce"; import { Tab } from "./tab"; import "./tabbar.scss"; import { TabBarEnv } from "./tabbarenv"; import { UpdateStatusBanner } from "./updatebanner"; import { WorkspaceSwitcher } from "./workspaceswitcher"; const TabDefaultWidth = 130; const TabMinWidth = 100; const MacOSTrafficLightsWidth = 74; const MacOSTahoeTrafficLightsWidth = 80; const OSOptions = { overflow: { x: "scroll", y: "hidden", }, scrollbars: { theme: "os-theme-dark", visibility: "auto", autoHide: "leave", autoHideDelay: 1300, autoHideSuspend: false, dragScroll: true, clickScroll: false, pointers: ["mouse", "touch", "pen"], }, }; interface TabBarProps { workspace: Workspace; noTabs?: boolean; } const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject<HTMLDivElement> }) => { const env = useWaveEnv<TabBarEnv>(); const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton")); const onClick = () => { const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); }; if (hideAiButton) { return null; } return ( <Tooltip content="Toggle Wave AI Panel" placement="bottom" hideOnClick divClassName={`flex h-[22px] px-3.5 justify-end mb-1 items-center rounded-md mr-1 box-border cursor-pointer bg-hover hover:bg-hoverbg transition-colors text-[12px] ${aiPanelOpen ? "text-accent" : "text-secondary"}`} divStyle={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} divOnClick={onClick} divRef={divRef} > <i className="fa fa-sparkles" /> </Tooltip> ); }); WaveAIButton.displayName = "WaveAIButton"; function strArrayIsEqual(a: string[], b: string[]) { // null check if (a == null && b == null) { return true; } if (a == null || b == null) { return false; } if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; } } return true; } const TabBar = memo(({ workspace, noTabs }: TabBarProps) => { const env = useWaveEnv<TabBarEnv>(); const [tabIds, setTabIds] = useState<string[]>([]); const [dragStartPositions, setDragStartPositions] = useState<number[]>([]); const [draggingTab, setDraggingTab] = useState<string>(); const [tabsLoaded, setTabsLoaded] = useState({}); const [newTabId, setNewTabId] = useState<string | null>(null); const tabbarWrapperRef = useRef<HTMLDivElement>(null); const tabBarRef = useRef<HTMLDivElement>(null); const tabsWrapperRef = useRef<HTMLDivElement>(null); const tabRefs = useRef<React.RefObject<HTMLDivElement>[]>([]); const addBtnRef = useRef<HTMLButtonElement>(null); const draggingRemovedRef = useRef(false); const draggingTabDataRef = useRef({ tabId: "", ref: { current: null }, tabStartX: 0, tabStartIndex: 0, tabIndex: 0, initialOffsetX: null, totalScrollOffset: null, dragged: false, }); const osInstanceRef = useRef<OverlayScrollbars>(null); const draggerLeftRef = useRef<HTMLDivElement>(null); const rightContainerRef = useRef<HTMLDivElement>(null); const workspaceSwitcherRef = useRef<HTMLDivElement>(null); const waveAIButtonRef = useRef<HTMLDivElement>(null); const appMenuButtonRef = useRef<HTMLDivElement>(null); const tabWidthRef = useRef<number>(TabDefaultWidth); const scrollableRef = useRef<boolean>(false); const prevAllLoadedRef = useRef<boolean>(false); const activeTabId = useAtomValue(env.atoms.staticTabId); const isFullScreen = useAtomValue(env.atoms.isFullScreen); const zoomFactor = useAtomValue(env.atoms.zoomFactorAtom); const showMenuBar = useAtomValue(env.getSettingsKeyAtom("window:showmenubar")); const confirmClose = useAtomValue(env.getSettingsKeyAtom("tab:confirmclose")) ?? false; const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton")); const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom); let prevDelta: number; let prevDragDirection: string; // Update refs when tabIds change useEffect(() => { tabRefs.current = tabIds.map((_, index) => tabRefs.current[index] || createRef()); }, [tabIds]); useEffect(() => { if (!workspace) { return; } const newTabIdsArr = workspace.tabids ?? []; const areEqual = strArrayIsEqual(tabIds, newTabIdsArr); if (!areEqual) { setTabIds(newTabIdsArr); } }, [workspace, tabIds]); const saveTabsPosition = useCallback(() => { const tabs = tabRefs.current; if (tabs === null) return; const newStartPositions: number[] = []; let cumulativeLeft = 0; // Start from the left edge tabRefs.current.forEach((ref) => { if (ref.current) { newStartPositions.push(cumulativeLeft); cumulativeLeft += ref.current.getBoundingClientRect().width; // Add each tab's actual width to the cumulative position } }); setDragStartPositions(newStartPositions); }, []); const setSizeAndPosition = (animate?: boolean) => { const tabBar = tabBarRef.current; if (tabBar === null) return; const getOuterWidth = (el: HTMLElement): number => { const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return rect.width + parseFloat(style.marginLeft) + parseFloat(style.marginRight); }; const tabbarWrapperWidth = tabbarWrapperRef.current.getBoundingClientRect().width; const windowDragLeftWidth = draggerLeftRef.current.getBoundingClientRect().width; const rightContainerWidth = rightContainerRef.current?.getBoundingClientRect().width ?? 0; const addBtnWidth = getOuterWidth(addBtnRef.current); const appMenuButtonWidth = appMenuButtonRef.current?.getBoundingClientRect().width ?? 0; const workspaceSwitcherWidth = workspaceSwitcherRef.current?.getBoundingClientRect().width ?? 0; const waveAIButtonWidth = waveAIButtonRef.current != null ? getOuterWidth(waveAIButtonRef.current) : 0; const nonTabElementsWidth = windowDragLeftWidth + rightContainerWidth + addBtnWidth + appMenuButtonWidth + workspaceSwitcherWidth + waveAIButtonWidth; const spaceForTabs = tabbarWrapperWidth - nonTabElementsWidth; const numberOfTabs = tabIds.length; // Compute the ideal width per tab by dividing the available space by the number of tabs let idealTabWidth = spaceForTabs / numberOfTabs; // Apply min/max constraints idealTabWidth = Math.max(TabMinWidth, Math.min(idealTabWidth, TabDefaultWidth)); // Determine if the tab bar needs to be scrollable const newScrollable = idealTabWidth * numberOfTabs > spaceForTabs; // Apply the calculated width and position to all tabs tabRefs.current.forEach((ref, index) => { if (ref.current) { if (animate) { ref.current.classList.add("animate"); } else { ref.current.classList.remove("animate"); } ref.current.style.width = `${idealTabWidth}px`; ref.current.style.transform = `translate3d(${index * idealTabWidth}px,0,0)`; ref.current.style.opacity = "1"; } }); // Update the state with the new tab width if it has changed if (idealTabWidth !== tabWidthRef.current) { tabWidthRef.current = idealTabWidth; } // Update the state with the new scrollable state if it has changed if (newScrollable !== scrollableRef.current) { scrollableRef.current = newScrollable; } // Initialize/destroy overlay scrollbars if (newScrollable) { osInstanceRef.current = OverlayScrollbars(tabBarRef.current, { ...(OSOptions as any) }); } else { if (osInstanceRef.current) { osInstanceRef.current.destroy(); } } }; const saveTabsPositionDebounced = useCallback( debounce(100, () => saveTabsPosition()), [saveTabsPosition] ); const handleResizeTabs = useCallback(() => { setSizeAndPosition(); saveTabsPositionDebounced(); }, [tabIds, newTabId, isFullScreen]); // update layout on reinit version const reinitVersion = useAtomValue(env.atoms.reinitVersion); useEffect(() => { if (reinitVersion > 0) { setSizeAndPosition(); } }, [reinitVersion]); // update layout on resize useEffect(() => { window.addEventListener("resize", handleResizeTabs); return () => { window.removeEventListener("resize", handleResizeTabs); }; }, [handleResizeTabs]); // update layout on changed tabIds, tabsLoaded, newTabId, hideAiButton, appUpdateStatus, or zoomFactor useEffect(() => { // Check if all tabs are loaded const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]); if (allLoaded) { setSizeAndPosition(newTabId === null && prevAllLoadedRef.current); saveTabsPosition(); if (!prevAllLoadedRef.current) { prevAllLoadedRef.current = true; } } }, [ tabIds, tabsLoaded, newTabId, saveTabsPosition, hideAiButton, appUpdateStatus, zoomFactor, showMenuBar, ]); const getDragDirection = (currentX: number) => { let dragDirection: string; if (currentX - prevDelta > 0) { dragDirection = "+"; } else if (currentX - prevDelta === 0) { dragDirection = prevDragDirection; } else { dragDirection = "-"; } prevDelta = currentX; prevDragDirection = dragDirection; return dragDirection; }; const getNewTabIndex = (currentX: number, tabIndex: number, dragDirection: string) => { let newTabIndex = tabIndex; const tabWidth = tabWidthRef.current; if (dragDirection === "+") { // Dragging to the right for (let i = tabIndex + 1; i < tabIds.length; i++) { const otherTabStart = dragStartPositions[i]; if (currentX + tabWidth > otherTabStart + tabWidth / 2) { newTabIndex = i; } } } else { // Dragging to the left for (let i = tabIndex - 1; i >= 0; i--) { const otherTabEnd = dragStartPositions[i] + tabWidth; if (currentX < otherTabEnd - tabWidth / 2) { newTabIndex = i; } } } return newTabIndex; }; const handleMouseMove = (event: MouseEvent) => { const { tabId, ref, tabStartX } = draggingTabDataRef.current; let initialOffsetX = draggingTabDataRef.current.initialOffsetX; let totalScrollOffset = draggingTabDataRef.current.totalScrollOffset; if (initialOffsetX === null) { initialOffsetX = event.clientX - tabStartX; draggingTabDataRef.current.initialOffsetX = initialOffsetX; } let currentX = event.clientX - initialOffsetX - totalScrollOffset; let tabBarRectWidth = tabBarRef.current.getBoundingClientRect().width; // for macos, it's offset to make space for the window buttons const tabBarRectLeftOffset = tabBarRef.current.getBoundingClientRect().left; const incrementDecrement = tabBarRectLeftOffset * 0.05; const dragDirection = getDragDirection(currentX); const scrollable = scrollableRef.current; const tabWidth = tabWidthRef.current; // Scroll the tab bar if the dragged tab overflows the container bounds if (scrollable) { const { viewport } = osInstanceRef.current.elements(); const currentScrollLeft = viewport.scrollLeft; if (event.clientX <= tabBarRectLeftOffset) { viewport.scrollLeft = Math.max(0, currentScrollLeft - incrementDecrement); // Scroll left if (viewport.scrollLeft !== currentScrollLeft) { // Only adjust if the scroll actually changed draggingTabDataRef.current.totalScrollOffset += currentScrollLeft - viewport.scrollLeft; } } else if (event.clientX >= tabBarRectWidth + tabBarRectLeftOffset) { viewport.scrollLeft = Math.min(viewport.scrollWidth, currentScrollLeft + incrementDecrement); // Scroll right if (viewport.scrollLeft !== currentScrollLeft) { // Only adjust if the scroll actually changed draggingTabDataRef.current.totalScrollOffset -= viewport.scrollLeft - currentScrollLeft; } } } // Re-calculate currentX after potential scroll adjustment initialOffsetX = draggingTabDataRef.current.initialOffsetX; totalScrollOffset = draggingTabDataRef.current.totalScrollOffset; currentX = event.clientX - initialOffsetX - totalScrollOffset; setDraggingTab((prev) => (prev !== tabId ? tabId : prev)); // Check if the tab has moved 5 pixels if (Math.abs(currentX - tabStartX) >= 50) { draggingTabDataRef.current.dragged = true; } // Constrain movement within the container bounds if (tabBarRef.current) { const numberOfTabs = tabIds.length; const totalDefaultTabWidth = numberOfTabs * TabDefaultWidth; if (totalDefaultTabWidth < tabBarRectWidth) { // Set to the total default tab width if there's vacant space tabBarRectWidth = totalDefaultTabWidth; } else if (scrollable) { // Set to the scrollable width if the tab bar is scrollable tabBarRectWidth = tabsWrapperRef.current.scrollWidth; } const minLeft = 0; const maxRight = tabBarRectWidth - tabWidth; // Adjust currentX to stay within bounds currentX = Math.min(Math.max(currentX, minLeft), maxRight); } ref.current!.style.transform = `translate3d(${currentX}px,0,0)`; ref.current!.style.zIndex = "100"; const tabIndex = draggingTabDataRef.current.tabIndex; const newTabIndex = getNewTabIndex(currentX, tabIndex, dragDirection); if (newTabIndex !== tabIndex) { // Remove the dragged tab if not already done if (!draggingRemovedRef.current) { tabIds.splice(tabIndex, 1); draggingRemovedRef.current = true; } // Find current index of the dragged tab in tempTabs const currentIndexOfDraggingTab = tabIds.indexOf(tabId); // Move the dragged tab to its new position if (currentIndexOfDraggingTab !== -1) { tabIds.splice(currentIndexOfDraggingTab, 1); } tabIds.splice(newTabIndex, 0, tabId); // Update visual positions of the tabs tabIds.forEach((localTabId, index) => { const ref = tabRefs.current.find((ref) => ref.current.dataset.tabId === localTabId); if (ref.current && localTabId !== tabId) { ref.current.style.transform = `translate3d(${index * tabWidth}px,0,0)`; ref.current.classList.add("animate"); } }); draggingTabDataRef.current.tabIndex = newTabIndex; } }; const setUpdatedTabsDebounced = useCallback( debounce(300, (tabIds: string[]) => { // Reset styles tabRefs.current.forEach((ref) => { ref.current.style.zIndex = "0"; ref.current.classList.remove("animate"); }); // Reset dragging state setDraggingTab(null); // Update workspace tab ids fireAndForget(() => env.rpc.UpdateWorkspaceTabIdsCommand(TabRpcClient, workspace.oid, tabIds)); }), [] ); const handleMouseUp = (_event: MouseEvent) => { const { tabIndex, dragged } = draggingTabDataRef.current; // Update the final position of the dragged tab const draggingTab = tabIds[tabIndex]; const tabWidth = tabWidthRef.current; const finalLeftPosition = tabIndex * tabWidth; const ref = tabRefs.current.find((ref) => ref.current.dataset.tabId === draggingTab); if (ref.current) { ref.current.classList.add("animate"); ref.current.style.transform = `translate3d(${finalLeftPosition}px,0,0)`; } if (dragged) { setUpdatedTabsDebounced(tabIds); } else { // Reset styles tabRefs.current.forEach((ref) => { ref.current.style.zIndex = "0"; ref.current.classList.remove("animate"); }); // Reset dragging state setDraggingTab(null); } document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mousemove", handleMouseMove); draggingRemovedRef.current = false; }; const handleDragStart = useCallback( (event: React.MouseEvent<HTMLDivElement, MouseEvent>, tabId: string, ref: React.RefObject<HTMLDivElement>) => { if (event.button !== 0) return; const tabIndex = tabIds.indexOf(tabId); const tabStartX = dragStartPositions[tabIndex]; // Starting X position of the tab console.log("handleDragStart", tabId, tabIndex, tabStartX); if (ref.current) { draggingTabDataRef.current = { tabId: ref.current.dataset.tabId, ref, tabStartX, tabIndex, tabStartIndex: tabIndex, initialOffsetX: null, totalScrollOffset: 0, dragged: false, }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); } }, [tabIds, dragStartPositions] ); const handleSelectTab = (tabId: string) => { if (!draggingTabDataRef.current.dragged) { env.electron.setActiveTab(tabId); } }; const updateScrollDebounced = useCallback( debounce(30, () => { if (scrollableRef.current) { const { viewport } = osInstanceRef.current.elements(); viewport.scrollLeft = tabIds.length * tabWidthRef.current; } }), [tabIds] ); const setNewTabIdDebounced = useCallback( debounce(100, (tabId: string) => { setNewTabId(tabId); }), [] ); const handleAddTab = () => { env.electron.createTab(); tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease"); updateScrollDebounced(); setNewTabIdDebounced(null); }; const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => { event?.stopPropagation(); env.electron .closeTab(workspace.oid, tabId, confirmClose) .then((didClose) => { if (didClose) { tabsWrapperRef.current?.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease"); deleteLayoutModelForTab(tabId); } }) .catch((e) => { console.log("error closing tab", e); }); }; const handleTabLoaded = useCallback((tabId: string) => { setTabsLoaded((prev) => { if (!prev[tabId]) { // Only update if the tab isn't already marked as loaded return { ...prev, [tabId]: true }; } return prev; }); }, []); const activeTabIndex = tabIds.indexOf(activeTabId); function onEllipsisClick() { env.electron.showWorkspaceAppMenu(workspace.oid); } const tabsWrapperWidth = tabIds.length * tabWidthRef.current; const showAppMenuButton = env.isWindows() || (!env.isMacOS() && !showMenuBar); // Calculate window drag left width based on platform and state let windowDragLeftWidth = 10; if (env.isMacOS() && !isFullScreen) { const trafficLightsWidth = isMacOSTahoeOrLater() ? MacOSTahoeTrafficLightsWidth : MacOSTrafficLightsWidth; if (zoomFactor > 0) { windowDragLeftWidth = trafficLightsWidth / zoomFactor; } else { windowDragLeftWidth = trafficLightsWidth; } } // Calculate window drag right width let windowDragRightWidth = 12; if (env.isWindows()) { if (zoomFactor > 0) { windowDragRightWidth = 139 / zoomFactor; } else { windowDragRightWidth = 139; } } return ( <div ref={tabbarWrapperRef} className="tab-bar-wrapper"> <div ref={draggerLeftRef} className="h-full shrink-0 z-window-drag" style={{ width: windowDragLeftWidth, WebkitAppRegion: "drag" } as any} /> {showAppMenuButton && ( <div ref={appMenuButtonRef} className="flex items-center justify-center pr-1.5 text-[26px] select-none cursor-pointer text-secondary hover:text-primary" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} onClick={onEllipsisClick} > <i className="fa fa-ellipsis" /> </div> )} <WaveAIButton divRef={waveAIButtonRef} /> <Tooltip content="Workspace Switcher" placement="bottom" hideOnClick divRef={workspaceSwitcherRef} divClassName="flex items-center" > <WorkspaceSwitcher /> </Tooltip> <div className="tab-bar" ref={tabBarRef} data-overlayscrollbars-initialize> <div className="tabs-wrapper" ref={tabsWrapperRef} style={{ width: noTabs ? 0 : tabsWrapperWidth, ...(noTabs ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : {}), }} > {!noTabs && tabIds.map((tabId, index) => { const isActive = activeTabId === tabId; const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1; return ( <Tab key={tabId} ref={tabRefs.current[index]} id={tabId} showDivider={showDivider} onSelect={() => handleSelectTab(tabId)} active={isActive} onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} onClose={(event) => handleCloseTab(event, tabId)} onLoaded={() => handleTabLoaded(tabId)} isDragging={draggingTab === tabId} tabWidth={tabWidthRef.current} isNew={tabId === newTabId} /> ); })} </div> </div> <button ref={addBtnRef} title="Add Tab" className={`flex h-[22px] px-2 mb-1 mx-1 items-center rounded-md box-border cursor-pointer hover:bg-hoverbg transition-colors text-[12px] text-secondary hover:text-primary${noTabs ? " invisible" : ""}`} style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} onClick={handleAddTab} > <i className="fa fa-solid fa-plus" /> </button> <div className="flex-1" /> <div ref={rightContainerRef} className="flex flex-row gap-1 items-end"> <UpdateStatusBanner /> <div className="h-full shrink-0 z-window-drag" style={{ width: windowDragRightWidth, WebkitAppRegion: "drag" } as any} /> </div> </div> ); }); export { TabBar, WaveAIButton }; ================================================ FILE: frontend/app/tab/tabbarenv.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; export type TabBarEnv = WaveEnvSubset<{ electron: { createTab: WaveEnv["electron"]["createTab"]; closeTab: WaveEnv["electron"]["closeTab"]; setActiveTab: WaveEnv["electron"]["setActiveTab"]; showWorkspaceAppMenu: WaveEnv["electron"]["showWorkspaceAppMenu"]; installAppUpdate: WaveEnv["electron"]["installAppUpdate"]; }; rpc: { ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"]; UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"]; }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; hasConfigErrors: WaveEnv["atoms"]["hasConfigErrors"]; staticTabId: WaveEnv["atoms"]["staticTabId"]; isFullScreen: WaveEnv["atoms"]["isFullScreen"]; zoomFactorAtom: WaveEnv["atoms"]["zoomFactorAtom"]; reinitVersion: WaveEnv["atoms"]["reinitVersion"]; updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"]; }; wos: WaveEnv["wos"]; getSettingsKeyAtom: SettingsKeyAtomFnType<"app:hideaibutton" | "app:tabbar" | "tab:confirmclose" | "window:showmenubar">; showContextMenu: WaveEnv["showContextMenu"]; mockSetWaveObj: WaveEnv["mockSetWaveObj"]; isWindows: WaveEnv["isWindows"]; isMacOS: WaveEnv["isMacOS"]; }>; ================================================ FILE: frontend/app/tab/tabcontent.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Block } from "@/app/block/block"; import { CenteredDiv } from "@/element/quickelems"; import { ContentRenderer, NodeModel, PreviewRenderer, TileLayout } from "@/layout/index"; import { TileLayoutContents } from "@/layout/lib/types"; import { atoms, getApi } from "@/store/global"; import * as services from "@/store/services"; import * as WOS from "@/store/wos"; import { atom, useAtomValue } from "jotai"; import * as React from "react"; import { useMemo } from "react"; const tileGapSizeAtom = atom((get) => { const settings = get(atoms.settingsAtom); return settings["window:tilegapsize"]; }); const TabContent = React.memo(({ tabId, noTopPadding }: { tabId: string; noTopPadding?: boolean }) => { const oref = useMemo(() => WOS.makeORef("tab", tabId), [tabId]); const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom(oref), [oref]); const tabLoading = useAtomValue(loadingAtom); const tabAtom = useMemo(() => WOS.getWaveObjectAtom<Tab>(oref), [oref]); const tabData = useAtomValue(tabAtom); const tileGapSize = useAtomValue(tileGapSizeAtom); const tileLayoutContents = useMemo(() => { const renderContent: ContentRenderer = (nodeModel: NodeModel) => { return <Block key={nodeModel.blockId} nodeModel={nodeModel} preview={false} />; }; const renderPreview: PreviewRenderer = (nodeModel: NodeModel) => { return <Block key={nodeModel.blockId} nodeModel={nodeModel} preview={true} />; }; function onNodeDelete(data: TabLayoutData) { return services.ObjectService.DeleteBlock(data.blockId); } return { renderContent, renderPreview, tabId, onNodeDelete, gapSizePx: tileGapSize, } as TileLayoutContents; }, [tabId, tileGapSize]); let innerContent; if (tabLoading) { innerContent = <CenteredDiv>Tab Loading</CenteredDiv>; } else if (!tabData) { innerContent = <CenteredDiv>Tab Not Found</CenteredDiv>; } else if (tabData?.blockids?.length == 0) { innerContent = null; } else { innerContent = ( <TileLayout key={tabId} contents={tileLayoutContents} tabAtom={tabAtom} getCursorPoint={getApi().getCursorPoint} /> ); } return ( <div className={`flex flex-row flex-grow min-h-0 w-full items-center justify-center overflow-hidden relative ${noTopPadding ? "" : "pt-[3px]"} pr-[3px]`}> {innerContent} </div> ); }); export { TabContent }; ================================================ FILE: frontend/app/tab/tabcontextmenu.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { getOrefMetaKeyAtom, globalStore, recordTEvent } from "@/app/store/global"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { fireAndForget } from "@/util/util"; import { makeORef } from "../store/wos"; import type { TabEnv } from "./tab"; const FlagColors: { label: string; value: string }[] = [ { label: "Green", value: "#58C142" }, { label: "Teal", value: "#00FFDB" }, { label: "Blue", value: "#429DFF" }, { label: "Purple", value: "#BF55EC" }, { label: "Red", value: "#FF453A" }, { label: "Orange", value: "#FF9500" }, { label: "Yellow", value: "#FFE900" }, ]; export function buildTabBarContextMenu(env: TabEnv): ContextMenuItem[] { const currentTabBar = globalStore.get(env.getSettingsKeyAtom("app:tabbar")) ?? "top"; const tabBarSubmenu: ContextMenuItem[] = [ { label: "Top", type: "checkbox", checked: currentTabBar === "top", click: () => fireAndForget(() => env.rpc.SetConfigCommand(TabRpcClient, { "app:tabbar": "top" })), }, { label: "Left", type: "checkbox", checked: currentTabBar === "left", click: () => fireAndForget(() => env.rpc.SetConfigCommand(TabRpcClient, { "app:tabbar": "left" })), }, ]; return [{ label: "Tab Bar Position", type: "submenu", submenu: tabBarSubmenu }]; } export function buildTabContextMenu( id: string, renameRef: React.RefObject<(() => void) | null>, onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void, env: TabEnv ): ContextMenuItem[] { const menu: ContextMenuItem[] = []; menu.push( { label: "Rename Tab", click: () => renameRef.current?.() }, { label: "Copy TabId", click: () => fireAndForget(() => navigator.clipboard.writeText(id)), }, { type: "separator" } ); const tabORef = makeORef("tab", id); const currentFlagColor = globalStore.get(getOrefMetaKeyAtom(tabORef, "tab:flagcolor")) ?? null; const flagSubmenu: ContextMenuItem[] = [ { label: "None", type: "checkbox", checked: currentFlagColor == null, click: () => fireAndForget(() => env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": null } }) ), }, ...FlagColors.map((fc) => ({ label: fc.label, type: "checkbox" as const, checked: currentFlagColor === fc.value, click: () => fireAndForget(() => env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": fc.value } }) ), })), ]; menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }, { type: "separator" }); const fullConfig = globalStore.get(env.atoms.fullConfigAtom); const bgPresets: string[] = []; for (const key in fullConfig?.presets ?? {}) { if (key.startsWith("bg@") && fullConfig.presets[key] != null) { bgPresets.push(key); } } bgPresets.sort((a, b) => { const aOrder = fullConfig.presets[a]["display:order"] ?? 0; const bOrder = fullConfig.presets[b]["display:order"] ?? 0; return aOrder - bOrder; }); if (bgPresets.length > 0) { const submenu: ContextMenuItem[] = []; const oref = makeORef("tab", id); for (const presetName of bgPresets) { // preset cannot be null (filtered above) const preset = fullConfig.presets[presetName]; submenu.push({ label: preset["display:name"] ?? presetName, click: () => fireAndForget(async () => { await env.rpc.SetMetaCommand(TabRpcClient, { oref, meta: preset }); env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); recordTEvent("action:settabtheme"); }), }); } menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" }); } menu.push(...buildTabBarContextMenu(env), { type: "separator" }); menu.push({ label: "Close Tab", click: () => onClose(null) }); return menu; } ================================================ FILE: frontend/app/tab/updatebanner.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/element/tooltip"; import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv"; import { useAtomValue } from "jotai"; import { memo, useCallback } from "react"; type UpdateBannerEnv = WaveEnvSubset<{ electron: { installAppUpdate: WaveEnv["electron"]["installAppUpdate"]; }; atoms: { updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"]; }; }>; function getUpdateStatusMessage(status: string): string { switch (status) { case "ready": return "Update"; case "downloading": return "Downloading"; case "installing": return "Installing"; default: return null; } } const UpdateStatusBannerComponent = () => { const env = useWaveEnv<UpdateBannerEnv>(); const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom); const updateStatusMessage = getUpdateStatusMessage(appUpdateStatus); const onClick = useCallback(() => { env.electron.installAppUpdate(); }, [env]); if (!updateStatusMessage) { return null; } const isReady = appUpdateStatus === "ready"; const tooltipContent = isReady ? "Click to Install Update" : updateStatusMessage; return ( <Tooltip content={tooltipContent} placement="bottom" divOnClick={isReady ? onClick : undefined} divClassName={`flex items-center gap-1 px-2 mb-1 h-[22px] text-xs font-medium text-black bg-accent rounded-sm transition-all ${isReady ? "cursor-pointer hover:bg-[var(--button-green-border-color)]" : ""}`} divStyle={{ WebkitAppRegion: "no-drag" } as any} > <i className="fa fa-download" /> {updateStatusMessage} </Tooltip> ); }; UpdateStatusBannerComponent.displayName = "UpdateStatusBannerComponent"; export const UpdateStatusBanner = memo(UpdateStatusBannerComponent); ================================================ FILE: frontend/app/tab/vtab.test.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { renderToStaticMarkup } from "react-dom/server"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { VTab, VTabItem } from "./vtab"; const OriginalCss = globalThis.CSS; const HexColorRegex = /^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i; function renderVTab(tab: VTabItem): string { return renderToStaticMarkup( <VTab tab={tab} active={false} isDragging={false} isReordering={false} onSelect={() => null} onDragStart={() => null} onDragOver={() => null} onDrop={() => null} onDragEnd={() => null} /> ); } describe("VTab badges", () => { beforeAll(() => { globalThis.CSS = { supports: (_property: string, value: string) => HexColorRegex.test(value), } as typeof CSS; }); afterAll(() => { globalThis.CSS = OriginalCss; }); it("renders shared badges and a validated flag badge", () => { const markup = renderVTab({ id: "tab-1", name: "Build Logs", badges: [{ badgeid: "badge-1", icon: "bell", color: "#f59e0b", priority: 2 }], flagColor: "#429DFF", }); expect(markup).toContain("#429DFF"); expect(markup).toContain("#f59e0b"); expect(markup).toContain("rounded-full"); }); it("ignores invalid flag colors", () => { const markup = renderVTab({ id: "tab-2", name: "Deploy", badges: [{ badgeid: "badge-2", icon: "bell", color: "#4ade80", priority: 2 }], flagColor: "definitely-not-a-color", }); expect(markup).not.toContain("definitely-not-a-color"); expect(markup).not.toContain("fa-flag"); expect(markup).toContain("#4ade80"); }); }); ================================================ FILE: frontend/app/tab/vtab.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { validateCssColor } from "@/util/color-validator"; import { cn } from "@/util/util"; import { useCallback, useEffect, useRef, useState } from "react"; import { TabBadges } from "./tabbadges"; const RenameFocusDelayMs = 50; export interface VTabItem { id: string; name: string; badge?: Badge | null; badges?: Badge[] | null; flagColor?: string | null; } interface VTabProps { tab: VTabItem; active: boolean; showDivider?: boolean; isDragging: boolean; isReordering: boolean; onSelect: () => void; onClose?: () => void; onRename?: (newName: string) => void; onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void; onDragStart: (event: React.DragEvent<HTMLDivElement>) => void; onDragOver: (event: React.DragEvent<HTMLDivElement>) => void; onDrop: (event: React.DragEvent<HTMLDivElement>) => void; onDragEnd: () => void; onHoverChanged?: (isHovered: boolean) => void; renameRef?: React.RefObject<(() => void) | null>; } export function VTab({ tab, active, showDivider = true, isDragging, isReordering, onSelect, onClose, onRename, onContextMenu, onDragStart, onDragOver, onDrop, onDragEnd, onHoverChanged, renameRef, }: VTabProps) { const [originalName, setOriginalName] = useState(tab.name); const [isEditable, setIsEditable] = useState(false); const editableRef = useRef<HTMLDivElement>(null); const editableTimeoutRef = useRef<NodeJS.Timeout | null>(null); const badges = tab.badges ?? (tab.badge ? [tab.badge] : null); const rawFlagColor = tab.flagColor; let flagColor: string | null = null; if (rawFlagColor) { try { validateCssColor(rawFlagColor); flagColor = rawFlagColor; } catch { flagColor = null; } } useEffect(() => { setOriginalName(tab.name); }, [tab.name]); useEffect(() => { return () => { if (editableTimeoutRef.current) { clearTimeout(editableTimeoutRef.current); } }; }, []); const selectEditableText = useCallback(() => { if (!editableRef.current) { return; } editableRef.current.focus(); const range = document.createRange(); const selection = window.getSelection(); if (!selection) { return; } range.selectNodeContents(editableRef.current); selection.removeAllRanges(); selection.addRange(range); }, []); const startRename = useCallback(() => { if (onRename == null || isReordering) { return; } if (editableTimeoutRef.current) { clearTimeout(editableTimeoutRef.current); } setIsEditable(true); editableTimeoutRef.current = setTimeout(() => { selectEditableText(); }, RenameFocusDelayMs); }, [isReordering, onRename, selectEditableText]); if (renameRef != null) { renameRef.current = startRename; } const handleBlur = () => { if (!editableRef.current) { return; } const newText = editableRef.current.textContent?.trim() || originalName; editableRef.current.textContent = newText; setIsEditable(false); if (newText !== originalName) { onRename?.(newText); } }; const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => { if (!editableRef.current) { return; } if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); editableRef.current.blur(); return; } if (event.key !== "Escape") { return; } editableRef.current.textContent = originalName; editableRef.current.blur(); event.preventDefault(); event.stopPropagation(); }; return ( <div draggable data-tabid={tab.id} onClick={onSelect} onDoubleClick={(event) => { event.stopPropagation(); startRename(); }} onContextMenu={onContextMenu} onDragStart={onDragStart} onDragOver={onDragOver} onDrop={onDrop} onDragEnd={onDragEnd} onMouseEnter={() => onHoverChanged?.(true)} onMouseLeave={() => onHoverChanged?.(false)} className={cn( "group relative flex h-9 w-full shrink-0 cursor-pointer items-center pl-3 text-xs transition-colors select-none", "whitespace-nowrap", active ? "text-primary" : isReordering ? "text-secondary" : "text-secondary hover:text-primary", isDragging && "opacity-50" )} > {active && ( <div className="pointer-events-none absolute inset-x-1 inset-y-[4px] rounded-sm bg-foreground/10" /> )} {!active && !isReordering && ( <div className="pointer-events-none absolute inset-x-1 inset-y-[4px] rounded-sm bg-transparent transition-colors group-hover:bg-foreground/10" /> )} <div className={cn( "pointer-events-none absolute bottom-0 left-[5%] right-[5%] h-px bg-border/70", !showDivider && "opacity-0" )} /> <TabBadges badges={badges} flagColor={flagColor} className="mr-1 min-w-[16px] shrink-0 static top-auto left-auto z-auto h-[16px] w-auto translate-y-0 justify-start px-[2px] py-[1px] [&_i]:text-[10px]" /> <div ref={editableRef} className={cn( "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-[padding-right] pr-3", onClose && !isReordering && "group-hover:pr-6", isEditable && "rounded-[2px] bg-white/15 outline-none" )} contentEditable={isEditable} role="textbox" aria-label="Tab name" aria-readonly={!isEditable} onBlur={handleBlur} onKeyDown={handleKeyDown} suppressContentEditableWarning={true} > {tab.name} </div> {onClose && ( <button type="button" className={cn( "absolute top-1/2 right-0 shrink-0 -translate-y-1/2 cursor-pointer py-1 pl-1 pr-3 text-secondary transition", isReordering ? "opacity-0" : "opacity-0 group-hover:opacity-100 hover:text-primary" )} onClick={(event) => { event.stopPropagation(); onClose(); }} aria-label="Close tab" > <i className="fa fa-solid fa-xmark" /> </button> )} </div> ); } ================================================ FILE: frontend/app/tab/vtabbar.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; import { getTabBadgeAtom } from "@/app/store/badge"; import { makeORef } from "@/app/store/wos"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { validateCssColor } from "@/util/color-validator"; import { cn, fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { buildTabBarContextMenu, buildTabContextMenu } from "./tabcontextmenu"; import { UpdateStatusBanner } from "./updatebanner"; import { VTab, VTabItem } from "./vtab"; import { VTabBarEnv } from "./vtabbarenv"; import { WorkspaceSwitcher } from "./workspaceswitcher"; export type { VTabItem } from "./vtab"; const VTabBarAIButton = memo(() => { const env = useWaveEnv<VTabBarEnv>(); const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton")); const onClick = () => { const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); }; if (hideAiButton) { return null; } return ( <Tooltip content="Toggle Wave AI Panel" placement="bottom" hideOnClick divClassName={`flex h-[22px] px-3.5 justify-end mb-1 items-center rounded-md mr-1 box-border cursor-pointer bg-hover hover:bg-hoverbg transition-colors text-[12px] ${aiPanelOpen ? "text-accent" : "text-secondary"}`} divStyle={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} divOnClick={onClick} > <i className="fa fa-sparkles" /> </Tooltip> ); }); VTabBarAIButton.displayName = "VTabBarAIButton"; const MacOSHeader = memo(() => { const env = useWaveEnv<VTabBarEnv>(); const isFullScreen = useAtomValue(env.atoms.isFullScreen); return ( <> {!isFullScreen && ( <div className="w-full shrink-0" style={ { height: "calc(25px * var(--zoomfactor-inv))", WebkitAppRegion: "drag", } as React.CSSProperties } /> )} <div className="flex shrink-0 flex-row flex-wrap items-end px-1 pb-1 pl-2" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > <VTabBarAIButton /> <Tooltip content="Workspace Switcher" placement="bottom" hideOnClick divClassName="flex items-center"> <WorkspaceSwitcher /> </Tooltip> <UpdateStatusBanner /> </div> </> ); }); MacOSHeader.displayName = "MacOSHeader"; interface VTabBarProps { workspace: Workspace; className?: string; } interface VTabWrapperProps { tabId: string; active: boolean; showDivider: boolean; isDragging: boolean; isReordering: boolean; hoverResetVersion: number; index: number; onSelect: () => void; onClose: () => void; onRename: (newName: string) => void; onDragStart: (event: React.DragEvent<HTMLDivElement>) => void; onDragOver: (event: React.DragEvent<HTMLDivElement>) => void; onDrop: (event: React.DragEvent<HTMLDivElement>) => void; onDragEnd: () => void; onHoverChanged: (isHovered: boolean) => void; } function VTabWrapper({ tabId, active, showDivider, isDragging, isReordering, hoverResetVersion, onSelect, onClose, onRename, onDragStart, onDragOver, onDrop, onDragEnd, onHoverChanged, }: VTabWrapperProps) { const env = useWaveEnv<VTabBarEnv>(); const [tabData] = env.wos.useWaveObjectValue<Tab>(makeORef("tab", tabId)); const badges = useAtomValue(getTabBadgeAtom(tabId, env)); const renameRef = useRef<(() => void) | null>(null); const rawFlagColor = tabData?.meta?.["tab:flagcolor"]; let flagColor: string | null = null; if (rawFlagColor) { try { validateCssColor(rawFlagColor); flagColor = rawFlagColor; } catch { flagColor = null; } } const tab: VTabItem = { id: tabId, name: tabData?.name ?? "", badges, flagColor, }; const handleContextMenu = useCallback( (e: React.MouseEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); const menu = buildTabContextMenu(tabId, renameRef, () => onClose(), env); env.showContextMenu(menu, e); }, [tabId, onClose, env] ); return ( <VTab key={`${tabId}:${hoverResetVersion}`} tab={tab} active={active} showDivider={showDivider} isDragging={isDragging} isReordering={isReordering} onSelect={onSelect} onClose={onClose} onRename={onRename} onContextMenu={handleContextMenu} onDragStart={onDragStart} onDragOver={onDragOver} onDrop={onDrop} onDragEnd={onDragEnd} onHoverChanged={onHoverChanged} renameRef={renameRef} /> ); } export function VTabBar({ workspace, className }: VTabBarProps) { const env = useWaveEnv<VTabBarEnv>(); const activeTabId = useAtomValue(env.atoms.staticTabId); const reinitVersion = useAtomValue(env.atoms.reinitVersion); const documentHasFocus = useAtomValue(env.atoms.documentHasFocus); const tabIds = workspace?.tabids ?? []; const [orderedTabIds, setOrderedTabIds] = useState<string[]>(tabIds); const [dragTabId, setDragTabId] = useState<string | null>(null); const [dropIndex, setDropIndex] = useState<number | null>(null); const [dropLineTop, setDropLineTop] = useState<number | null>(null); const [hoverResetVersion, setHoverResetVersion] = useState(0); const [hoveredTabId, setHoveredTabId] = useState<string | null>(null); const [isNewTabHovered, setIsNewTabHovered] = useState(false); const dragSourceRef = useRef<string | null>(null); const didResetHoverForDragRef = useRef(false); const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollAnimFrameRef = useRef<number | null>(null); const scrollDirectionRef = useRef<number>(0); const scrollSpeedRef = useRef<number>(0); useEffect(() => { setOrderedTabIds(tabIds); }, [workspace?.tabids]); useEffect(() => { if (reinitVersion > 0) { setOrderedTabIds(workspace?.tabids ?? []); } }, [reinitVersion]); useEffect(() => { if (activeTabId == null || scrollContainerRef.current == null) { return; } const el = scrollContainerRef.current.querySelector(`[data-tabid="${activeTabId}"]`); el?.scrollIntoView({ block: "nearest" }); }, [activeTabId]); useEffect(() => { if (!documentHasFocus || activeTabId == null || scrollContainerRef.current == null) { return; } const el = scrollContainerRef.current.querySelector(`[data-tabid="${activeTabId}"]`); el?.scrollIntoView({ block: "nearest" }); }, [documentHasFocus]); const stopScrollLoop = useCallback(() => { if (scrollAnimFrameRef.current != null) { cancelAnimationFrame(scrollAnimFrameRef.current); scrollAnimFrameRef.current = null; } scrollDirectionRef.current = 0; }, []); const startScrollLoop = useCallback(() => { if (scrollAnimFrameRef.current != null) { return; } const loop = () => { const container = scrollContainerRef.current; if (container == null || scrollDirectionRef.current === 0) { scrollAnimFrameRef.current = null; return; } container.scrollTop += scrollDirectionRef.current * scrollSpeedRef.current; scrollAnimFrameRef.current = requestAnimationFrame(loop); }; scrollAnimFrameRef.current = requestAnimationFrame(loop); }, []); const updateScrollFromDragY = useCallback( (clientY: number) => { const container = scrollContainerRef.current; if (container == null) { return; } const EdgeZone = 60; const MaxScrollSpeed = 12; const rect = container.getBoundingClientRect(); const relY = clientY - rect.top; const height = rect.height; if (relY < EdgeZone) { scrollDirectionRef.current = -1; scrollSpeedRef.current = MaxScrollSpeed * (1 - relY / EdgeZone); startScrollLoop(); } else if (relY > height - EdgeZone) { scrollDirectionRef.current = 1; scrollSpeedRef.current = MaxScrollSpeed * (1 - (height - relY) / EdgeZone); startScrollLoop(); } else { scrollDirectionRef.current = 0; stopScrollLoop(); } }, [startScrollLoop, stopScrollLoop] ); const clearDragState = () => { stopScrollLoop(); if (dragSourceRef.current != null && !didResetHoverForDragRef.current) { didResetHoverForDragRef.current = true; setHoverResetVersion((version) => version + 1); } dragSourceRef.current = null; setDragTabId(null); setDropIndex(null); setDropLineTop(null); }; const reorder = (targetIndex: number) => { const sourceTabId = dragSourceRef.current; if (sourceTabId == null) { return; } const sourceIndex = orderedTabIds.findIndex((id) => id === sourceTabId); if (sourceIndex === -1) { return; } const boundedTargetIndex = Math.max(0, Math.min(targetIndex, orderedTabIds.length)); const adjustedTargetIndex = sourceIndex < boundedTargetIndex ? boundedTargetIndex - 1 : boundedTargetIndex; if (sourceIndex === adjustedTargetIndex) { return; } const nextTabIds = [...orderedTabIds]; const [movedId] = nextTabIds.splice(sourceIndex, 1); nextTabIds.splice(adjustedTargetIndex, 0, movedId); setOrderedTabIds(nextTabIds); fireAndForget(() => env.rpc.UpdateWorkspaceTabIdsCommand(TabRpcClient, workspace.oid, nextTabIds)); }; const handleTabBarContextMenu = useCallback( (e: React.MouseEvent<HTMLDivElement>) => { e.preventDefault(); const menu = buildTabBarContextMenu(env); env.showContextMenu(menu, e); }, [env] ); return ( <div className={cn("flex h-full flex-col overflow-hidden", className)} style={{ backdropFilter: "blur(20px)", background: "rgba(0, 0, 0, 0.35)" }} onContextMenu={handleTabBarContextMenu} > {env.isMacOS() && <MacOSHeader />} <div ref={scrollContainerRef} className="relative flex min-h-0 flex-col overflow-y-auto" onDragOver={(event) => { event.preventDefault(); updateScrollFromDragY(event.clientY); if (event.target === event.currentTarget) { setDropIndex(orderedTabIds.length); setDropLineTop(event.currentTarget.scrollHeight); } }} onDrop={(event) => { event.preventDefault(); if (dropIndex != null) { reorder(dropIndex); } clearDragState(); }} > {orderedTabIds.map((tabId, index) => { const isActive = tabId === activeTabId; const isHovered = tabId === hoveredTabId; const isLast = index === orderedTabIds.length - 1; const nextTabId = orderedTabIds[index + 1]; const isNextActive = nextTabId === activeTabId; const isNextHovered = nextTabId === hoveredTabId; return ( <VTabWrapper key={`${tabId}:${hoverResetVersion}`} tabId={tabId} active={isActive} showDivider={ !isActive && !isNextActive && !isHovered && !isNextHovered && !(isLast && isNewTabHovered) } isDragging={dragTabId === tabId} isReordering={dragTabId != null} hoverResetVersion={hoverResetVersion} index={index} onSelect={() => env.electron.setActiveTab(tabId)} onClose={() => fireAndForget(() => env.electron.closeTab(workspace.oid, tabId, false))} onRename={(newName) => fireAndForget(() => env.rpc.UpdateTabNameCommand(TabRpcClient, tabId, newName)) } onDragStart={(event) => { didResetHoverForDragRef.current = false; dragSourceRef.current = tabId; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", tabId); setDragTabId(tabId); setDropIndex(index); setDropLineTop(event.currentTarget.offsetTop); }} onDragOver={(event) => { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); const relativeY = event.clientY - rect.top; const midpoint = event.currentTarget.offsetHeight / 2; const insertBefore = relativeY < midpoint; setDropIndex(insertBefore ? index : index + 1); setDropLineTop( insertBefore ? event.currentTarget.offsetTop : event.currentTarget.offsetTop + event.currentTarget.offsetHeight ); }} onDrop={(event) => { event.preventDefault(); if (dropIndex != null) { reorder(dropIndex); } clearDragState(); }} onDragEnd={clearDragState} onHoverChanged={(isHovered) => setHoveredTabId(isHovered ? tabId : null)} /> ); })} {dragTabId != null && dropIndex != null && dropLineTop != null && ( <div className="pointer-events-none absolute left-0 right-0 border-t-2 border-accent/80" style={{ top: dropLineTop, transform: "translateY(-1px)" }} /> )} </div> <button type="button" className="group relative flex h-9 w-full shrink-0 cursor-pointer items-center gap-1.5 pl-3 pr-3 text-xs text-secondary/60 transition-colors hover:text-primary select-none whitespace-nowrap" onClick={() => env.electron.createTab()} onMouseEnter={() => setIsNewTabHovered(true)} onMouseLeave={() => setIsNewTabHovered(false)} aria-label="New Tab" > <div className="pointer-events-none absolute inset-x-1 inset-y-[4px] rounded-sm bg-transparent transition-colors group-hover:bg-hover" /> <i className="fa fa-solid fa-plus" style={{ fontSize: "10px" }} /> <span>New Tab</span> </button> </div> ); } ================================================ FILE: frontend/app/tab/vtabbarenv.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; export type VTabBarEnv = WaveEnvSubset<{ electron: { createTab: WaveEnv["electron"]["createTab"]; closeTab: WaveEnv["electron"]["closeTab"]; setActiveTab: WaveEnv["electron"]["setActiveTab"]; deleteWorkspace: WaveEnv["electron"]["deleteWorkspace"]; createWorkspace: WaveEnv["electron"]["createWorkspace"]; switchWorkspace: WaveEnv["electron"]["switchWorkspace"]; installAppUpdate: WaveEnv["electron"]["installAppUpdate"]; }; rpc: { UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"]; UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"]; ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; }; atoms: { staticTabId: WaveEnv["atoms"]["staticTabId"]; fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; reinitVersion: WaveEnv["atoms"]["reinitVersion"]; documentHasFocus: WaveEnv["atoms"]["documentHasFocus"]; workspace: WaveEnv["atoms"]["workspace"]; updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"]; isFullScreen: WaveEnv["atoms"]["isFullScreen"]; }; services: { workspace: WaveEnv["services"]["workspace"]; }; wos: WaveEnv["wos"]; showContextMenu: WaveEnv["showContextMenu"]; getSettingsKeyAtom: SettingsKeyAtomFnType<"tab:confirmclose" | "app:tabbar" | "app:hideaibutton">; mockSetWaveObj: WaveEnv["mockSetWaveObj"]; isWindows: WaveEnv["isWindows"]; isMacOS: WaveEnv["isMacOS"]; }>; ================================================ FILE: frontend/app/tab/workspaceeditor.scss ================================================ .workspace-editor { width: 100%; .input { margin: 5px 0 10px; } .color-selector { display: grid; grid-template-columns: repeat(auto-fit, minmax(15px, 15px)); // Ensures each color circle has a fixed 14px size grid-gap: 18.5px; // Space between items justify-content: center; align-items: center; margin-top: 5px; padding-bottom: 15px; border-bottom: 1px solid var(--modal-border-color); .color-circle { width: 15px; height: 15px; border-radius: 50%; cursor: pointer; position: relative; // Border offset outward &:before { content: ""; position: absolute; top: -3px; left: -3px; right: -3px; bottom: -3px; border-radius: 50%; border: 1px solid transparent; } &.selected:before { border-color: var(--main-text-color); // Highlight for the selected circle } } } .icon-selector { display: grid; grid-template-columns: repeat(auto-fit, minmax(16px, 16px)); // Ensures each color circle has a fixed 14px size grid-column-gap: 17.5px; // Space between items grid-row-gap: 13px; // Space between items justify-content: center; align-items: center; margin-top: 15px; .icon-item { font-size: 15px; color: oklch(from var(--modal-bg-color) calc(l * 1.5) c h); cursor: pointer; transition: color 0.3s ease; &.selected, &:hover { color: var(--main-text-color); } } } .delete-ws-btn-wrapper { display: flex; align-items: center; justify-content: center; margin-top: 10px; } } ================================================ FILE: frontend/app/tab/workspaceeditor.tsx ================================================ import { fireAndForget, makeIconClass } from "@/util/util"; import clsx from "clsx"; import { memo, useEffect, useRef, useState } from "react"; import { Button } from "../element/button"; import { Input } from "../element/input"; import { WorkspaceService } from "../store/services"; import "./workspaceeditor.scss"; interface ColorSelectorProps { colors: string[]; selectedColor?: string; onSelect: (color: string) => void; className?: string; } const ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => { const handleColorClick = (color: string) => { onSelect(color); }; return ( <div className={clsx("color-selector", className)}> {colors.map((color) => ( <div key={color} className={clsx("color-circle", { selected: selectedColor === color })} style={{ backgroundColor: color }} onClick={() => handleColorClick(color)} /> ))} </div> ); }); interface IconSelectorProps { icons: string[]; selectedIcon?: string; onSelect: (icon: string) => void; className?: string; } const IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSelectorProps) => { const handleIconClick = (icon: string) => { onSelect(icon); }; return ( <div className={clsx("icon-selector", className)}> {icons.map((icon) => { const iconClass = makeIconClass(icon, true); return ( <i key={icon} className={clsx(iconClass, "icon-item", { selected: selectedIcon === icon })} onClick={() => handleIconClick(icon)} /> ); })} </div> ); }); interface WorkspaceEditorProps { title: string; icon: string; color: string; focusInput: boolean; onTitleChange: (newTitle: string) => void; onColorChange: (newColor: string) => void; onIconChange: (newIcon: string) => void; onDeleteWorkspace: () => void; } const WorkspaceEditorComponent = ({ title, icon, color, focusInput, onTitleChange, onColorChange, onIconChange, onDeleteWorkspace, }: WorkspaceEditorProps) => { const inputRef = useRef<HTMLInputElement>(null); const [colors, setColors] = useState<string[]>([]); const [icons, setIcons] = useState<string[]>([]); useEffect(() => { fireAndForget(async () => { const colors = await WorkspaceService.GetColors(); const icons = await WorkspaceService.GetIcons(); setColors(colors); setIcons(icons); }); }, []); useEffect(() => { if (focusInput && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [focusInput]); return ( <div className="workspace-editor"> <Input ref={inputRef} className={clsx("py-[3px]", { error: title === "" })} onChange={onTitleChange} value={title} autoFocus autoSelect /> <ColorSelector selectedColor={color} colors={colors} onSelect={onColorChange} /> <IconSelector selectedIcon={icon} icons={icons} onSelect={onIconChange} /> <div className="delete-ws-btn-wrapper"> <Button className="ghost red text-[12px] bold" onClick={onDeleteWorkspace}> Delete workspace </Button> </div> </div> ); }; export const WorkspaceEditor = memo(WorkspaceEditorComponent) as typeof WorkspaceEditorComponent; ================================================ FILE: frontend/app/tab/workspaceswitcher.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .workspace-switcher-button { display: flex; height: 22px; padding: 0px 12px; justify-content: flex-end; align-items: center; gap: 12px; border-radius: 6px; margin-right: 6px; margin-bottom: 4px; box-sizing: border-box; background-color: rgb(from var(--main-text-color) r g b / 0.1) !important; &:hover { background-color: rgb(from var(--main-text-color) r g b / 0.14) !important; } .workspace-icon { width: 15px; height: 15px; display: flex; align-items: center; justify-content: center; } } .workspace-switcher-content { min-height: auto; display: flex; width: 256px; padding: 0; flex-direction: column; align-items: center; border-radius: 8px; box-shadow: 0px 8px 24px 0px var(--modal-shadow-color); .icon-left, .icon-right { display: flex; align-items: center; justify-content: center; font-size: 20px; } .divider { width: 1px; height: 20px; background: rgba(255, 255, 255, 0.08); } .scrollable { max-height: 400px; width: 100%; } .title { font-size: 12px; line-height: 19px; font-weight: 600; margin-bottom: 5px; width: 100%; padding: 6px 8px 0px; } .expandable-menu { gap: 5px; } .expandable-menu-item { margin: 3px 8px; } .expandable-menu-item-group { margin: 0 8px; border: 1px solid transparent; border-radius: 4px; --workspace-color: var(--main-bg-color); &:last-child { margin-bottom: 4px; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; } .expandable-menu-item { margin: 0; } .menu-group-title-wrapper { display: flex; width: 100%; padding: 5px 8px; border-radius: 4px; .icons { display: flex; flex-direction: row; gap: 5px; } .wave-iconbutton.edit { visibility: hidden; } .wave-iconbutton.window { cursor: default; opacity: 1 !important; } } &:hover .wave-iconbutton.edit { visibility: visible; } &.open { background-color: var(--modal-bg-color); border: 1px solid var(--modal-border-color); } &.is-current .menu-group-title-wrapper { background-color: rgb(from var(--workspace-color) r g b / 0.1); } } .expandable-menu-item, .expandable-menu-item-group-title { font-size: 12px; line-height: 19px; padding: 5px 8px; .content { width: 100%; } &:hover { background-color: transparent; } } .expandable-menu-item-group-title { height: 29px; padding: 0; } .actions { width: 100%; padding: 3px 0; border-top: 1px solid var(--modal-border-color); } } ================================================ FILE: frontend/app/tab/workspaceswitcher.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { ExpandableMenu, ExpandableMenuItem, ExpandableMenuItemGroup, ExpandableMenuItemGroupTitle, ExpandableMenuItemLeftElement, ExpandableMenuItemRightElement, } from "@/element/expandablemenu"; import { Popover, PopoverButton, PopoverContent } from "@/element/popover"; import { fireAndForget, makeIconClass, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { splitAtom } from "jotai/utils"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { CSSProperties, forwardRef, useCallback, useEffect } from "react"; import WorkspaceSVG from "../asset/workspace.svg"; import { IconButton } from "../element/iconbutton"; import { globalStore } from "@/app/store/jotaiStore"; import { makeORef } from "../store/wos"; import { waveEventSubscribeSingle } from "../store/wps"; import { WorkspaceEditor } from "./workspaceeditor"; import "./workspaceswitcher.scss"; export type WorkspaceSwitcherEnv = WaveEnvSubset<{ electron: { deleteWorkspace: WaveEnv["electron"]["deleteWorkspace"]; createWorkspace: WaveEnv["electron"]["createWorkspace"]; switchWorkspace: WaveEnv["electron"]["switchWorkspace"]; }; atoms: { workspace: WaveEnv["atoms"]["workspace"]; }; services: { workspace: WaveEnv["services"]["workspace"]; }; wos: WaveEnv["wos"]; }>; type WorkspaceListEntry = { windowId: string; workspace: Workspace; }; type WorkspaceList = WorkspaceListEntry[]; const workspaceMapAtom = atom<WorkspaceList>([]); const workspaceSplitAtom = splitAtom(workspaceMapAtom); const editingWorkspaceAtom = atom<string>(); const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => { const env = useWaveEnv<WorkspaceSwitcherEnv>(); const setWorkspaceList = useSetAtom(workspaceMapAtom); const activeWorkspace = useAtomValueSafe(env.atoms.workspace); const workspaceList = useAtomValue(workspaceSplitAtom); const setEditingWorkspace = useSetAtom(editingWorkspaceAtom); const updateWorkspaceList = useCallback(async () => { const workspaceList = await env.services.workspace.ListWorkspaces(); if (!workspaceList) { return; } const newList: WorkspaceList = []; for (const entry of workspaceList) { // This just ensures that the atom exists for easier setting of the object globalStore.get(env.wos.getWaveObjectAtom(makeORef("workspace", entry.workspaceid))); newList.push({ windowId: entry.windowid, workspace: await env.services.workspace.GetWorkspace(entry.workspaceid), }); } setWorkspaceList(newList); }, []); useEffect( () => waveEventSubscribeSingle({ eventType: "workspace:update", handler: () => fireAndForget(updateWorkspaceList), }), [] ); useEffect(() => { fireAndForget(updateWorkspaceList); }, []); const onDeleteWorkspace = useCallback((workspaceId: string) => { env.electron.deleteWorkspace(workspaceId); }, []); const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon); const workspaceIcon = isActiveWorkspaceSaved ? ( <i className={makeIconClass(activeWorkspace.icon, false)} style={{ color: activeWorkspace.color }}></i> ) : ( <WorkspaceSVG /> ); const saveWorkspace = () => { fireAndForget(async () => { await env.services.workspace.UpdateWorkspace(activeWorkspace.oid, "", "", "", true); await updateWorkspaceList(); setEditingWorkspace(activeWorkspace.oid); }); }; return ( <Popover className="workspace-switcher-popover" placement="bottom-start" onDismiss={() => setEditingWorkspace(null)} ref={ref} > <PopoverButton className="workspace-switcher-button grey" as="div" onClick={() => { fireAndForget(updateWorkspaceList); }} > <span className="workspace-icon">{workspaceIcon}</span> </PopoverButton> <PopoverContent className="workspace-switcher-content"> <div className="title">{isActiveWorkspaceSaved ? "Switch workspace" : "Open workspace"}</div> <OverlayScrollbarsComponent className={"scrollable"} options={{ scrollbars: { autoHide: "leave" } }}> <ExpandableMenu noIndent singleOpen> {workspaceList.map((entry, i) => ( <WorkspaceSwitcherItem key={i} entryAtom={entry} onDeleteWorkspace={onDeleteWorkspace} /> ))} </ExpandableMenu> </OverlayScrollbarsComponent> <div className="actions"> {isActiveWorkspaceSaved ? ( <ExpandableMenuItem onClick={() => env.electron.createWorkspace()}> <ExpandableMenuItemLeftElement> <i className="fa-sharp fa-solid fa-plus"></i> </ExpandableMenuItemLeftElement> <div className="content">Create new workspace</div> </ExpandableMenuItem> ) : ( <ExpandableMenuItem onClick={() => saveWorkspace()}> <ExpandableMenuItemLeftElement> <i className="fa-sharp fa-solid fa-floppy-disk"></i> </ExpandableMenuItemLeftElement> <div className="content">Save workspace</div> </ExpandableMenuItem> )} </div> </PopoverContent> </Popover> ); }); const WorkspaceSwitcherItem = ({ entryAtom, onDeleteWorkspace, }: { entryAtom: PrimitiveAtom<WorkspaceListEntry>; onDeleteWorkspace: (workspaceId: string) => void; }) => { const env = useWaveEnv<WorkspaceSwitcherEnv>(); const activeWorkspace = useAtomValueSafe(env.atoms.workspace); const [workspaceEntry, setWorkspaceEntry] = useAtom(entryAtom); const [editingWorkspace, setEditingWorkspace] = useAtom(editingWorkspaceAtom); const workspace = workspaceEntry.workspace; const isCurrentWorkspace = activeWorkspace.oid === workspace.oid; const setWorkspace = useCallback((newWorkspace: Workspace) => { setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace }); if (newWorkspace.name != "") { fireAndForget(() => env.services.workspace.UpdateWorkspace( workspace.oid, newWorkspace.name, newWorkspace.icon, newWorkspace.color, false ) ); } }, []); const isActive = !!workspaceEntry.windowId; const editIconDecl: IconButtonDecl = { elemtype: "iconbutton", className: "edit", icon: "pencil", title: "Edit workspace", click: (e) => { e.stopPropagation(); if (editingWorkspace === workspace.oid) { setEditingWorkspace(null); } else { setEditingWorkspace(workspace.oid); } }, }; const windowIconDecl: IconButtonDecl = { elemtype: "iconbutton", className: "window", noAction: true, icon: isCurrentWorkspace ? "check" : "window", title: isCurrentWorkspace ? "This is your current workspace" : "This workspace is open", }; const isEditing = editingWorkspace === workspace.oid; return ( <ExpandableMenuItemGroup key={workspace.oid} isOpen={isEditing} className={clsx({ "is-current": isCurrentWorkspace })} > <ExpandableMenuItemGroupTitle onClick={() => { env.electron.switchWorkspace(workspace.oid); // Create a fake escape key event to close the popover document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); }} > <div className="menu-group-title-wrapper" style={ { "--workspace-color": workspace.color, } as CSSProperties } > <ExpandableMenuItemLeftElement> <i className={clsx("left-icon", makeIconClass(workspace.icon, true))} style={{ color: workspace.color }} /> </ExpandableMenuItemLeftElement> <div className="label">{workspace.name}</div> <ExpandableMenuItemRightElement> <div className="icons"> <IconButton decl={editIconDecl} /> {isActive && <IconButton decl={windowIconDecl} />} </div> </ExpandableMenuItemRightElement> </div> </ExpandableMenuItemGroupTitle> <ExpandableMenuItem> <WorkspaceEditor title={workspace.name} icon={workspace.icon} color={workspace.color} focusInput={isEditing} onTitleChange={(title) => setWorkspace({ ...workspace, name: title })} onColorChange={(color) => setWorkspace({ ...workspace, color })} onIconChange={(icon) => setWorkspace({ ...workspace, icon })} onDeleteWorkspace={() => onDeleteWorkspace(workspace.oid)} /> </ExpandableMenuItem> </ExpandableMenuItemGroup> ); }; export { WorkspaceSwitcher }; ================================================ FILE: frontend/app/theme.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 :root { --main-text-color: #f7f7f7; --title-font-size: 18px; --window-opacity: 1; --secondary-text-color: rgb(195, 200, 194); --grey-text-color: #666; --main-bg-color: rgb(34, 34, 34); --border-color: rgba(255, 255, 255, 0.16); --base-font: normal 14px / normal "Inter", sans-serif; --fixed-font: normal 12px / normal "Hack", monospace; --accent-color: rgb(88, 193, 66); --panel-bg-color: rgba(31, 33, 31, 0.5); --highlight-bg-color: rgba(255, 255, 255, 0.2); --markdown-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; --markdown-font-size: 14px; --markdown-fixed-font-size: 12px; --error-color: rgb(229, 77, 46); --warning-color: rgb(224, 185, 86); --success-color: rgb(78, 154, 6); --hover-bg-color: rgba(255, 255, 255, 0.1); --block-bg-color: rgba(0, 0, 0, 0.5); --block-bg-solid-color: rgb(0, 0, 0); --block-border-radius: 8px; --keybinding-color: #e0e0e0; --keybinding-bg-color: #333; --keybinding-border-color: #444; /* scrollbar colors */ --scrollbar-background-color: transparent; --scrollbar-thumb-color: rgba(255, 255, 255, 0.15); --scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5); --scrollbar-thumb-active-color: rgba(255, 255, 255, 0.6); --header-font: 700 11px / normal "Inter", sans-serif; --header-icon-size: 14px; --header-icon-width: 16px; --header-height: 30px; --tab-green: rgb(88, 193, 66); /* z-index values */ --zindex-header-hover: 100; --zindex-termstickers: 20; --zindex-modal: 2; --zindex-modal-wrapper: 500; --zindex-modal-backdrop: 1; --zindex-typeahead-modal: 100; --zindex-typeahead-modal-backdrop: 90; --zindex-elem-modal: 100; --zindex-window-drag: 100; --zindex-tab-name: 3; --zindex-layout-display-container: 0; --zindex-layout-last-magnified-node: 1; --zindex-layout-last-ephemeral-node: 2; --zindex-layout-resize-handle: 3; --zindex-layout-placeholder-container: 4; --zindex-layout-overlay-container: 5; --zindex-layout-magnified-node-backdrop: 6; --zindex-layout-magnified-node: 7; --zindex-layout-ephemeral-node-backdrop: 8; --zindex-layout-ephemeral-node: 9; --zindex-block-mask-inner: 10; --zindex-app-background: -1; // z-indexes in xterm.css // xterm-helpers: 5 // xterm-helper-textarea: -5 // composition-view: 1 // xterm-message: 10 // xterm-decoration: 6 // xterm-decoration-top-layer: 7 // xterm-decoration-overview-ruler: 8 // xterm-decoration-top: 2 // modal colors --modal-bg-color: #232323; --modal-header-bottom-border-color: rgba(241, 246, 243, 0.15); --modal-border-color: rgba(255, 255, 255, 0.12); /* toggle colors */ --modal-border-radius: 6px; --toggle-bg-color: var(--border-color); --modal-shadow-color: rgba(0, 0, 0, 0.8); --modal-box-shadow: box-shadow: 0px 8px 24px 0px var(--modal-shadow-color); --toggle-thumb-color: var(--main-text-color); --toggle-checked-bg-color: var(--accent-color); // link color --link-color: #58c142; // form colors --form-element-border-color: rgba(241, 246, 243, 0.15); --form-element-bg-color: var(--main-bg-color); --form-element-text-color: var(--main-text-color); --form-element-primary-text-color: var(--main-text-color); --form-element-primary-color: var(--accent-color); --form-element-secondary-color: rgba(255, 255, 255, 0.2); --form-element-error-color: var(--error-color); --conn-icon-color: #53b4ea; --conn-icon-color-1: #53b4ea; --conn-icon-color-2: #aa67ff; --conn-icon-color-3: #fda7fd; --conn-icon-color-4: #ef476f; --conn-icon-color-5: #497bf8; --conn-icon-color-6: #ffa24e; --conn-icon-color-7: #dbde52; --conn-icon-color-8: #58c142; --conn-status-overlay-bg-color: rgba(230, 186, 30, 0.2); --sysinfo-cpu-color: #58c142; --sysinfo-mem-color: #53b4ea; --bulb-color: rgb(255, 221, 51); // term colors (16 + 6) form the base terminal theme // for consistency these colors should be used by plugins/applications --term-black: #000000; --term-red: #cc0000; --term-green: #4e9a06; --term-yellow: #c4a000; --term-blue: #3465a4; --term-magenta: #bc3fbc; --term-cyan: #06989a; --term-white: #d0d0d0; --term-bright-black: #555753; --term-bright-red: #ef2929; --term-bright-green: #58c142; --term-bright-yellow: #fce94f; --term-bright-blue: #32afff; --term-bright-magenta: #ad7fa8; --term-bright-cyan: #34e2e2; --term-bright-white: #e7e7e7; --term-gray: #8b918a; // not an official terminal color --term-cmdtext: #ffffff; --term-foreground: #d3d7cf; --term-background: #000000; --term-selection-background: #ffffff60; --term-cursor-accent: #000000; // button colors --button-text-color: #000000; --button-green-bg: var(--term-green); --button-green-border-color: #29f200; --button-grey-bg: rgba(255, 255, 255, 0.04); --button-grey-hover-bg: rgba(255, 255, 255, 0.09); --button-grey-border-color: rgba(255, 255, 255, 0.1); --button-grey-outlined-color: rgba(255, 255, 255, 0.6); --button-red-bg: #cc0000; --button-red-hover-bg: #f93939; --button-red-border-color: #fc3131; --button-red-outlined-color: #ff3c3c; --button-yellow-bg: #c4a000; --button-yellow-hover-bg: #fce94f; } ================================================ FILE: frontend/app/treeview/treeview.test.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { buildVisibleRows, TreeNodeData } from "@/app/treeview/treeview"; import { describe, expect, it } from "vitest"; function makeNodes(entries: TreeNodeData[]): Map<string, TreeNodeData> { return new Map(entries.map((entry) => [entry.id, entry])); } describe("treeview visible rows", () => { it("sorts directories before files and alphabetically", () => { const nodes = makeNodes([ { id: "root", isDirectory: true, childrenStatus: "loaded", childrenIds: ["c", "a", "b"], }, { id: "a", parentId: "root", isDirectory: false, label: "z-last.txt" }, { id: "b", parentId: "root", isDirectory: true, label: "docs", childrenStatus: "loaded", childrenIds: [] }, { id: "c", parentId: "root", isDirectory: false, label: "a-first.txt" }, ]); const rows = buildVisibleRows(nodes, ["root"], new Set(["root"])); expect(rows.map((row) => row.id)).toEqual(["root", "b", "c", "a"]); }); it("renders loading and capped synthetic rows", () => { const nodes = makeNodes([ { id: "root", isDirectory: true, childrenStatus: "loading" }, { id: "dir", isDirectory: true, childrenStatus: "capped", childrenIds: ["f1"], capInfo: { max: 1 }, }, { id: "f1", parentId: "dir", isDirectory: false, label: "one.txt" }, ]); const loadingRows = buildVisibleRows(nodes, ["root"], new Set(["root"])); expect(loadingRows.map((row) => row.kind)).toEqual(["node", "loading"]); const cappedRows = buildVisibleRows(nodes, ["dir"], new Set(["dir"])); expect(cappedRows.map((row) => row.kind)).toEqual(["node", "node", "capped"]); }); }); ================================================ FILE: frontend/app/treeview/treeview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { makeIconClass } from "@/util/util"; import { useVirtualizer } from "@tanstack/react-virtual"; import clsx from "clsx"; import React, { CSSProperties, KeyboardEvent, MouseEvent, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react"; type TreeNodeChildrenStatus = "unloaded" | "loading" | "loaded" | "error" | "capped"; export interface TreeNodeData { id: string; parentId?: string; label?: string; path?: string; isDirectory: boolean; mimeType?: string; icon?: string; isReadonly?: boolean; notfound?: boolean; staterror?: string; childrenStatus?: TreeNodeChildrenStatus; childrenIds?: string[]; capInfo?: { max: number; totalKnown?: number }; } interface FetchDirResult { nodes: TreeNodeData[]; capped?: boolean; totalKnown?: number; } export interface TreeViewVisibleRow { id: string; parentId?: string; depth: number; kind: "node" | "loading" | "error" | "capped"; label: string; isDirectory?: boolean; isExpanded?: boolean; hasChildren?: boolean; icon?: string; node?: TreeNodeData; } export interface TreeViewProps { rootIds: string[]; initialNodes: Record<string, TreeNodeData>; fetchDir?: (id: string, limit: number) => Promise<FetchDirResult>; maxDirEntries?: number; rowHeight?: number; indentWidth?: number; overscan?: number; minWidth?: number; maxWidth?: number; width?: number | string; height?: number | string; className?: string; onOpenFile?: (id: string, node: TreeNodeData) => void; onSelectionChange?: (id: string, node: TreeNodeData) => void; } export interface TreeViewRef { scrollToId: (id: string) => void; } const DefaultRowHeight = 24; const DefaultIndentWidth = 16; const DefaultOverscan = 10; const ChevronWidth = 16; function normalizeLabel(node: TreeNodeData): string { if (node.label?.trim()) { return node.label; } const path = node.path ?? node.id; const chunks = path.split("/").filter(Boolean); return chunks[chunks.length - 1] ?? path; } function sortIdsByNode(nodesById: Map<string, TreeNodeData>, ids: string[]): string[] { return [...ids].sort((leftId, rightId) => { const left = nodesById.get(leftId); const right = nodesById.get(rightId); const leftDir = left?.isDirectory ? 0 : 1; const rightDir = right?.isDirectory ? 0 : 1; if (leftDir !== rightDir) { return leftDir - rightDir; } const leftLabel = normalizeLabel(left ?? { id: leftId, isDirectory: false }).toLocaleLowerCase(); const rightLabel = normalizeLabel(right ?? { id: rightId, isDirectory: false }).toLocaleLowerCase(); if (leftLabel !== rightLabel) { return leftLabel.localeCompare(rightLabel); } return leftId.localeCompare(rightId); }); } export function buildVisibleRows( nodesById: Map<string, TreeNodeData>, rootIds: string[], expandedIds: Set<string> ): TreeViewVisibleRow[] { const rows: TreeViewVisibleRow[] = []; const appendNode = (id: string, depth: number) => { const node = nodesById.get(id); if (node == null) { return; } const childIds = node.childrenIds ?? []; const hasChildren = node.isDirectory && (childIds.length > 0 || node.childrenStatus !== "loaded"); const isExpanded = expandedIds.has(id); rows.push({ id, parentId: node.parentId, depth, kind: "node", label: normalizeLabel(node), isDirectory: node.isDirectory, isExpanded, hasChildren, icon: node.icon, node, }); if (!isExpanded || !node.isDirectory) { return; } const status = node.childrenStatus ?? "unloaded"; if (status === "loading") { rows.push({ id: `${id}::__loading`, parentId: id, depth: depth + 1, kind: "loading", label: "Loading…", }); return; } if (status === "error") { rows.push({ id: `${id}::__error`, parentId: id, depth: depth + 1, kind: "error", label: node.staterror ? `Error: ${node.staterror}` : "Unable to load directory", }); return; } const sortedChildren = sortIdsByNode(nodesById, childIds); sortedChildren.forEach((childId) => appendNode(childId, depth + 1)); if (status === "capped") { const capMax = node.capInfo?.max ?? childIds.length; rows.push({ id: `${id}::__capped`, parentId: id, depth: depth + 1, kind: "capped", label: `Showing first ${capMax} entries`, }); } }; sortIdsByNode(nodesById, rootIds).forEach((id) => appendNode(id, 0)); return rows; } function getNodeIcon(node: TreeNodeData, isExpanded: boolean): string { if (node.notfound || node.staterror) { return "triangle-exclamation"; } if (node.icon) { return node.icon; } if (node.isDirectory) { return isExpanded ? "folder-open" : "folder"; } const mime = node.mimeType ?? ""; if (mime.startsWith("image/")) { return "image"; } if (mime === "application/pdf") { return "file-pdf"; } const extension = normalizeLabel(node).split(".").pop()?.toLocaleLowerCase(); if (["js", "jsx", "ts", "tsx", "go", "py", "java", "c", "cpp", "h", "hpp", "json", "yaml", "yml"].includes(extension)) { return "file-code"; } if (["md", "txt", "log"].includes(extension)) { return "file-lines"; } return "file"; } export const TreeView = forwardRef<TreeViewRef, TreeViewProps>((props, ref) => { const { rootIds, initialNodes, fetchDir, maxDirEntries = 500, rowHeight = DefaultRowHeight, indentWidth = DefaultIndentWidth, overscan = DefaultOverscan, minWidth = 100, maxWidth = 400, width = "100%", height = 360, className, onOpenFile, onSelectionChange, } = props; const [nodesById, setNodesById] = useState<Map<string, TreeNodeData>>( () => new Map( Object.entries(initialNodes).map(([id, node]) => [id, { ...node, childrenStatus: node.childrenStatus ?? "unloaded" }]) ) ); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [selectedId, setSelectedId] = useState<string>(rootIds[0]); const scrollRef = useRef<HTMLDivElement>(null); useEffect(() => { setNodesById( new Map( Object.entries(initialNodes).map(([id, node]) => [ id, { ...node, childrenStatus: node.childrenStatus ?? "unloaded", }, ]) ) ); }, [initialNodes]); const visibleRows = useMemo(() => buildVisibleRows(nodesById, rootIds, expandedIds), [nodesById, rootIds, expandedIds]); const idToIndex = useMemo( () => new Map(visibleRows.map((row, index) => [row.id, index])), [visibleRows] ); const virtualizer = useVirtualizer({ count: visibleRows.length, getScrollElement: () => scrollRef.current, estimateSize: () => rowHeight, overscan, }); const commitSelection = (id: string) => { const node = nodesById.get(id); if (node == null) { return; } setSelectedId(id); onSelectionChange?.(id, node); }; const scrollToId = (id: string) => { const index = idToIndex.get(id); if (index == null) { return; } virtualizer.scrollToIndex(index, { align: "auto" }); }; useImperativeHandle( ref, () => ({ scrollToId, }), [idToIndex, virtualizer] ); const loadChildren = async (id: string) => { const currentNode = nodesById.get(id); if (currentNode == null || !currentNode.isDirectory || currentNode.notfound || currentNode.staterror || fetchDir == null) { return; } const status = currentNode.childrenStatus ?? "unloaded"; if (status !== "unloaded") { return; } setNodesById((prev) => { const next = new Map(prev); next.set(id, { ...currentNode, childrenStatus: "loading" }); return next; }); try { const result = await fetchDir(id, maxDirEntries); setNodesById((prev) => { const next = new Map(prev); result.nodes.forEach((node) => { const merged: TreeNodeData = { ...node, parentId: node.parentId ?? id, childrenStatus: node.childrenStatus ?? (node.isDirectory ? "unloaded" : "loaded"), }; next.set(merged.id, merged); }); const childrenIds = sortIdsByNode( next, result.nodes.map((entry) => entry.id) ); const source = next.get(id) ?? currentNode; next.set(id, { ...source, childrenIds, childrenStatus: result.capped ? "capped" : "loaded", capInfo: result.capped ? { max: maxDirEntries, totalKnown: result.totalKnown } : undefined, }); return next; }); } catch (error) { setNodesById((prev) => { const next = new Map(prev); const source = next.get(id) ?? currentNode; next.set(id, { ...source, childrenStatus: "error", staterror: error instanceof Error ? error.message : "Unknown error", }); return next; }); } }; const toggleExpand = (id: string) => { const node = nodesById.get(id); if (node == null || !node.isDirectory || node.notfound || node.staterror) { return; } const expanded = expandedIds.has(id); if (!expanded) { loadChildren(id); } setExpandedIds((prev) => { const next = new Set(prev); if (expanded) { next.delete(id); } else { next.add(id); } return next; }); scrollToId(id); }; const selectVisibleNodeAt = (index: number) => { if (index < 0 || index >= visibleRows.length) { return; } const row = visibleRows[index]; if (row.kind !== "node") { return; } commitSelection(row.id); scrollToId(row.id); }; const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { const selectedIndex = selectedId != null ? idToIndex.get(selectedId) : undefined; if (event.key === "ArrowDown") { event.preventDefault(); const nextIndex = (selectedIndex ?? -1) + 1; for (let idx = nextIndex; idx < visibleRows.length; idx++) { if (visibleRows[idx].kind === "node") { selectVisibleNodeAt(idx); break; } } return; } if (event.key === "ArrowUp") { event.preventDefault(); const previousIndex = (selectedIndex ?? visibleRows.length) - 1; for (let idx = previousIndex; idx >= 0; idx--) { if (visibleRows[idx].kind === "node") { selectVisibleNodeAt(idx); break; } } return; } const node = selectedId ? nodesById.get(selectedId) : null; if (node == null) { return; } if (event.key === "ArrowLeft") { event.preventDefault(); if (node.isDirectory && expandedIds.has(node.id)) { toggleExpand(node.id); return; } if (node.parentId != null) { commitSelection(node.parentId); scrollToId(node.parentId); } return; } if (event.key === "ArrowRight") { event.preventDefault(); if (node.isDirectory && !expandedIds.has(node.id)) { toggleExpand(node.id); return; } if (node.isDirectory && expandedIds.has(node.id) && node.childrenIds?.[0]) { commitSelection(node.childrenIds[0]); scrollToId(node.childrenIds[0]); } } }; const containerStyle: CSSProperties = { width, minWidth, maxWidth, height, }; return ( <div className={clsx("rounded-md border border-border bg-panel", className)} style={containerStyle} tabIndex={0} onKeyDown={onKeyDown} > <div ref={scrollRef} className="h-full overflow-auto"> <div className="relative w-max min-w-full" style={{ height: virtualizer.getTotalSize() }}> {virtualizer.getVirtualItems().map((virtualRow) => { const row = visibleRows[virtualRow.index]; if (row.kind === "node" && row.node == null) { return null; } const selected = row.id === selectedId; return ( <div key={row.id} className={clsx( "absolute left-0 right-0 flex items-center whitespace-nowrap text-sm", row.kind === "node" ? "cursor-pointer" : "text-muted", selected ? "bg-accent/25 text-foreground" : "text-foreground hover:bg-muted/50" )} style={{ top: 0, height: rowHeight, transform: `translateY(${virtualRow.start}px)`, }} onClick={() => row.kind === "node" && commitSelection(row.id)} onDoubleClick={() => { if (row.kind !== "node") { return; } if (row.isDirectory) { toggleExpand(row.id); return; } if (row.node != null) { onOpenFile?.(row.id, row.node); } }} > <div className="flex items-center" style={{ paddingLeft: row.depth * indentWidth, width: ChevronWidth + row.depth * indentWidth }} > {row.kind === "node" && row.isDirectory && row.hasChildren ? ( <button className="h-4 w-4 rounded text-muted hover:text-foreground cursor-pointer" onClick={(event: MouseEvent<HTMLButtonElement>) => { event.stopPropagation(); toggleExpand(row.id); }} > <i className={clsx( "fa-sharp fa-solid text-[11px]", row.isExpanded ? "fa-chevron-down" : "fa-chevron-right" )} /> </button> ) : ( <span className="inline-block h-4 w-4" /> )} </div> {row.kind === "node" ? ( <> <i className={makeIconClass(getNodeIcon(row.node, row.isExpanded), true)} style={{ color: row.node.notfound || row.node.staterror ? "var(--color-error)" : "inherit", }} /> <span className={clsx("ml-2 pr-3", row.node.isReadonly && "text-muted")} title={row.label} > {row.label} </span> </> ) : ( <span className="ml-6 pr-3 text-xs">{row.label}</span> )} </div> ); })} </div> </div> </div> ); }); TreeView.displayName = "TreeView"; ================================================ FILE: frontend/app/view/aifilediff/aifilediff.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; import type { TabModel } from "@/app/store/tab-model"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { DiffViewer } from "@/app/view/codeeditor/diffviewer"; import type { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { globalStore } from "@/store/jotaiStore"; import { base64ToString } from "@/util/util"; import * as jotai from "jotai"; import { useEffect } from "react"; type DiffData = { original: string; modified: string; fileName: string; }; export type AiFileDiffEnv = WaveEnvSubset<{ rpc: { WaveAIGetToolDiffCommand: WaveEnv["rpc"]["WaveAIGetToolDiffCommand"]; }; wos: WaveEnv["wos"]; }>; export class AiFileDiffViewModel implements ViewModel { blockId: string; nodeModel: BlockNodeModel; tabModel: TabModel; env: AiFileDiffEnv; viewType = "aifilediff"; blockAtom: jotai.Atom<Block>; diffDataAtom: jotai.PrimitiveAtom<DiffData | null>; errorAtom: jotai.PrimitiveAtom<string | null>; loadingAtom: jotai.PrimitiveAtom<boolean>; viewIcon: jotai.Atom<string>; viewName: jotai.Atom<string>; viewText: jotai.Atom<string>; constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; this.env = waveEnv as AiFileDiffEnv; this.blockAtom = this.env.wos.getWaveObjectAtom<Block>(`block:${blockId}`); this.diffDataAtom = jotai.atom(null) as jotai.PrimitiveAtom<DiffData | null>; this.errorAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>; this.loadingAtom = jotai.atom<boolean>(true); this.viewIcon = jotai.atom("file-lines"); this.viewName = jotai.atom("AI Diff Viewer"); this.viewText = jotai.atom((get) => { const diffData = get(this.diffDataAtom); return diffData?.fileName ?? ""; }); } get viewComponent(): ViewComponent { return AiFileDiffView; } } function AiFileDiffView({ blockId, model }: ViewComponentProps<AiFileDiffViewModel>) { const blockData = jotai.useAtomValue(model.blockAtom); const diffData = jotai.useAtomValue(model.diffDataAtom); const error = jotai.useAtomValue(model.errorAtom); const loading = jotai.useAtomValue(model.loadingAtom); useEffect(() => { async function loadDiffData() { const chatId = blockData?.meta?.["aifilediff:chatid"]; const toolCallId = blockData?.meta?.["aifilediff:toolcallid"]; const fileName = blockData?.meta?.file; if (!chatId || !toolCallId) { globalStore.set(model.errorAtom, "Missing chatId or toolCallId in block metadata"); globalStore.set(model.loadingAtom, false); return; } if (!fileName) { globalStore.set(model.errorAtom, "Missing file name in block metadata"); globalStore.set(model.loadingAtom, false); return; } try { const result = await model.env.rpc.WaveAIGetToolDiffCommand(TabRpcClient, { chatid: chatId, toolcallid: toolCallId, }); if (!result) { globalStore.set(model.errorAtom, "No diff data returned from server"); globalStore.set(model.loadingAtom, false); return; } const originalContent = base64ToString(result.originalcontents64); const modifiedContent = base64ToString(result.modifiedcontents64); globalStore.set(model.diffDataAtom, { original: originalContent, modified: modifiedContent, fileName: fileName, }); globalStore.set(model.loadingAtom, false); } catch (e) { console.error("Error loading diff data:", e); globalStore.set(model.errorAtom, `Error loading diff data: ${e.message}`); globalStore.set(model.loadingAtom, false); } } loadDiffData(); }, [blockData?.meta?.["aifilediff:chatid"], blockData?.meta?.["aifilediff:toolcallid"], blockData?.meta?.file]); if (loading) { return ( <div className="flex items-center justify-center w-full h-full"> <div className="text-secondary">Loading diff...</div> </div> ); } if (error) { return ( <div className="flex items-center justify-center w-full h-full"> <div className="text-red-500">{error}</div> </div> ); } if (!diffData) { return ( <div className="flex items-center justify-center w-full h-full"> <div className="text-secondary">No diff data available</div> </div> ); } return ( <DiffViewer blockId={blockId} original={diffData.original} modified={diffData.modified} fileName={diffData.fileName} /> ); } export default AiFileDiffView; ================================================ FILE: frontend/app/view/codeeditor/codeeditor.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { MonacoCodeEditor } from "@/app/monaco/monaco-react"; import { useOverrideConfigAtom } from "@/app/store/global"; import { boundNumber } from "@/util/util"; import type * as MonacoTypes from "monaco-editor"; import * as MonacoModule from "monaco-editor"; import React, { useMemo, useRef } from "react"; function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions { const opts: MonacoTypes.editor.IEditorOptions = { scrollBeyondLastLine: false, fontSize: 12, fontFamily: "Hack", smoothScrolling: true, scrollbar: { useShadows: false, verticalScrollbarSize: 5, horizontalScrollbarSize: 5, }, minimap: { enabled: true, }, stickyScroll: { enabled: false, }, }; return opts; } interface CodeEditorProps { blockId: string; text: string; readonly: boolean; language?: string; fileName?: string; onChange?: (text: string) => void; onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: typeof MonacoModule) => () => void; } export function CodeEditor({ blockId, text, language, fileName, readonly, onChange, onMount }: CodeEditorProps) { const divRef = useRef<HTMLDivElement>(null); const unmountRef = useRef<() => void>(null); const minimapEnabled = useOverrideConfigAtom(blockId, "editor:minimapenabled") ?? false; const stickyScrollEnabled = useOverrideConfigAtom(blockId, "editor:stickyscrollenabled") ?? false; const wordWrap = useOverrideConfigAtom(blockId, "editor:wordwrap") ?? false; const fontSize = boundNumber(useOverrideConfigAtom(blockId, "editor:fontsize"), 6, 64); const uuidRef = useRef(crypto.randomUUID()).current; let editorPath: string; if (fileName) { const separator = fileName.startsWith("/") ? "" : "/"; editorPath = blockId + separator + fileName; } else { editorPath = uuidRef; } React.useEffect(() => { return () => { // unmount function if (unmountRef.current) { unmountRef.current(); } }; }, []); function handleEditorChange(text: string) { if (onChange) { onChange(text); } } function handleEditorOnMount( editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: typeof MonacoModule ): () => void { if (onMount) { const cleanup = onMount(editor, monaco); unmountRef.current = cleanup; return cleanup; } return undefined; } const editorOpts = useMemo(() => { const opts = defaultEditorOptions(); opts.minimap.enabled = minimapEnabled; opts.stickyScroll.enabled = stickyScrollEnabled; opts.wordWrap = wordWrap ? "on" : "off"; opts.fontSize = fontSize; opts.copyWithSyntaxHighlighting = false; return opts; }, [minimapEnabled, stickyScrollEnabled, wordWrap, fontSize, readonly]); return ( <div className="flex flex-col w-full h-full overflow-hidden items-center justify-center"> <div className="flex flex-col h-full w-full" ref={divRef}> <MonacoCodeEditor readonly={readonly} text={text} options={editorOpts} onChange={handleEditorChange} onMount={handleEditorOnMount} path={editorPath} language={language} /> </div> </div> ); } ================================================ FILE: frontend/app/view/codeeditor/diffviewer.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { MonacoDiffViewer } from "@/app/monaco/monaco-react"; import { useOverrideConfigAtom } from "@/app/store/global"; import { boundNumber } from "@/util/util"; import type * as MonacoTypes from "monaco-editor"; import { useMemo, useRef } from "react"; interface DiffViewerProps { blockId: string; original: string; modified: string; language?: string; fileName: string; } function defaultDiffEditorOptions(): MonacoTypes.editor.IDiffEditorOptions { const opts: MonacoTypes.editor.IDiffEditorOptions = { scrollBeyondLastLine: false, fontSize: 12, fontFamily: "Hack", smoothScrolling: true, scrollbar: { useShadows: false, verticalScrollbarSize: 5, horizontalScrollbarSize: 5, }, minimap: { enabled: true, }, readOnly: true, renderSideBySide: true, originalEditable: false, }; return opts; } export function DiffViewer({ blockId, original, modified, language, fileName }: DiffViewerProps) { const minimapEnabled = useOverrideConfigAtom(blockId, "editor:minimapenabled") ?? false; const fontSize = boundNumber(useOverrideConfigAtom(blockId, "editor:fontsize"), 6, 64); const inlineDiff = useOverrideConfigAtom(blockId, "editor:inlinediff"); const uuidRef = useRef(crypto.randomUUID()).current; let editorPath: string; if (fileName) { const separator = fileName.startsWith("/") ? "" : "/"; editorPath = blockId + separator + fileName; } else { editorPath = uuidRef; } const editorOpts = useMemo(() => { const opts = defaultDiffEditorOptions(); opts.minimap.enabled = minimapEnabled; opts.fontSize = fontSize; if (inlineDiff != null) { opts.renderSideBySide = !inlineDiff; } return opts; }, [minimapEnabled, fontSize, inlineDiff]); return ( <div className="flex flex-col w-full h-full overflow-hidden items-center justify-center"> <div className="flex flex-col h-full w-full"> <MonacoDiffViewer path={editorPath} original={original} modified={modified} options={editorOpts} language={language} /> </div> </div> ); } ================================================ FILE: frontend/app/view/helpview/helpview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore, WOS } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WebView, WebViewModel } from "@/app/view/webview/webview"; import { atom } from "jotai"; const docsiteUrl = "https://docs.waveterm.dev/?ref=app"; class HelpViewModel extends WebViewModel { get viewComponent(): ViewComponent { return HelpView; } constructor(initOpts: ViewModelInitType) { super(initOpts); this.viewText = atom((get) => { // force a dependency on meta.url so we re-render the buttons when the url changes void (get(this.blockAtom)?.meta?.url || get(this.homepageUrl)); return [ { elemtype: "iconbutton", icon: "chevron-left", click: this.handleBack.bind(this), disabled: this.shouldDisableBackButton(), }, { elemtype: "iconbutton", icon: "chevron-right", click: this.handleForward.bind(this), disabled: this.shouldDisableForwardButton(), }, { elemtype: "iconbutton", icon: "house", click: this.handleHome.bind(this), disabled: this.shouldDisableHomeButton(), }, ]; }); this.homepageUrl = atom(docsiteUrl); this.viewType = "help"; this.viewIcon = atom("circle-question"); this.viewName = atom("Help"); } setZoomFactor(factor: number | null) { // null is ok (will reset to default) if (factor != null && factor < 0.1) { factor = 0.1; } if (factor != null && factor > 5) { factor = 5; } const domReady = globalStore.get(this.domReady); if (!domReady) { return; } this.webviewRef.current?.setZoomFactor(factor || 1); RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "web:zoom": factor }, // allow null so we can remove the zoom factor here }); } getSettingsMenuItems(): ContextMenuItem[] { const zoomSubMenu: ContextMenuItem[] = []; let curZoom = 1; if (globalStore.get(this.domReady)) { curZoom = this.webviewRef.current?.getZoomFactor() || 1; } // eslint-disable-next-line @typescript-eslint/no-this-alias const model = this; // for the closure to work (this is getting unset) function makeZoomFactorMenuItem(label: string, factor: number): ContextMenuItem { return { label: label, type: "checkbox", click: () => { model.setZoomFactor(factor); }, checked: curZoom == factor, }; } zoomSubMenu.push({ label: "Reset", click: () => { model.setZoomFactor(null); }, }); zoomSubMenu.push(makeZoomFactorMenuItem("25%", 0.25)); zoomSubMenu.push(makeZoomFactorMenuItem("50%", 0.5)); zoomSubMenu.push(makeZoomFactorMenuItem("70%", 0.7)); zoomSubMenu.push(makeZoomFactorMenuItem("80%", 0.8)); zoomSubMenu.push(makeZoomFactorMenuItem("90%", 0.9)); zoomSubMenu.push(makeZoomFactorMenuItem("100%", 1)); zoomSubMenu.push(makeZoomFactorMenuItem("110%", 1.1)); zoomSubMenu.push(makeZoomFactorMenuItem("120%", 1.2)); zoomSubMenu.push(makeZoomFactorMenuItem("130%", 1.3)); zoomSubMenu.push(makeZoomFactorMenuItem("150%", 1.5)); zoomSubMenu.push(makeZoomFactorMenuItem("175%", 1.75)); zoomSubMenu.push(makeZoomFactorMenuItem("200%", 2)); return [ { label: this.webviewRef.current?.isDevToolsOpened() ? "Close DevTools" : "Open DevTools", click: async () => { if (this.webviewRef.current) { if (this.webviewRef.current.isDevToolsOpened()) { this.webviewRef.current.closeDevTools(); } else { this.webviewRef.current.openDevTools(); } } }, }, { label: "Set Zoom Factor", submenu: zoomSubMenu, }, ]; } } function HelpView(props: ViewComponentProps<HelpViewModel>) { return ( <div className="w-full h-full"> <WebView {...props} /> </div> ); } export { HelpViewModel }; ================================================ FILE: frontend/app/view/launcher/launcher.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import logoUrl from "@/app/asset/logo.svg?url"; import type { BlockNodeModel } from "@/app/block/blocktypes"; import { atoms, globalStore, replaceBlock } from "@/app/store/global"; import type { TabModel } from "@/app/store/tab-model"; import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { isBlank, makeIconClass } from "@/util/util"; import clsx from "clsx"; import { atom, useAtom, useAtomValue } from "jotai"; import React, { useEffect, useLayoutEffect, useRef } from "react"; function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | undefined): WidgetConfigType[] { if (!wmap) return []; const wlist = Object.values(wmap); wlist.sort((a, b) => (a["display:order"] ?? 0) - (b["display:order"] ?? 0)); return wlist; } type GridLayoutType = { columns: number; tileWidth: number; tileHeight: number; showLabel: boolean }; export class LauncherViewModel implements ViewModel { blockId: string; nodeModel: BlockNodeModel; tabModel: TabModel; viewType = "launcher"; viewIcon = atom("shapes"); viewName = atom("Widget Launcher"); viewComponent = LauncherView; noHeader = atom(true); inputRef = { current: null } as React.RefObject<HTMLInputElement>; searchTerm = atom(""); selectedIndex = atom(0); containerSize = atom({ width: 0, height: 0 }); gridLayout: GridLayoutType = null; constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; } filteredWidgetsAtom = atom((get) => { const searchTerm = get(this.searchTerm); const widgets = sortByDisplayOrder(get(atoms.fullConfigAtom)?.widgets || {}); return widgets.filter( (widget) => !widget["display:hidden"] && (!searchTerm || widget.label?.toLowerCase().includes(searchTerm.toLowerCase())) ); }); giveFocus(): boolean { if (this.inputRef.current) { this.inputRef.current.focus(); return true; } return false; } keyDownHandler(e: WaveKeyboardEvent): boolean { if (this.gridLayout == null) { return; } const gridLayout = this.gridLayout; const filteredWidgets = globalStore.get(this.filteredWidgetsAtom); const selectedIndex = globalStore.get(this.selectedIndex); const rows = Math.ceil(filteredWidgets.length / gridLayout.columns); const currentRow = Math.floor(selectedIndex / gridLayout.columns); const currentCol = selectedIndex % gridLayout.columns; if (checkKeyPressed(e, "ArrowUp")) { if (filteredWidgets.length == 0) { return true; } if (currentRow > 0) { const newIndex = selectedIndex - gridLayout.columns; if (newIndex >= 0) { globalStore.set(this.selectedIndex, newIndex); } } return true; } if (checkKeyPressed(e, "ArrowDown")) { if (filteredWidgets.length == 0) { return true; } if (currentRow < rows - 1) { const newIndex = selectedIndex + gridLayout.columns; if (newIndex < filteredWidgets.length) { globalStore.set(this.selectedIndex, newIndex); } } return true; } if (checkKeyPressed(e, "ArrowLeft")) { if (filteredWidgets.length == 0) { return true; } if (currentCol > 0) { globalStore.set(this.selectedIndex, selectedIndex - 1); } return true; } if (checkKeyPressed(e, "ArrowRight")) { if (filteredWidgets.length == 0) { return true; } if (currentCol < gridLayout.columns - 1 && selectedIndex + 1 < filteredWidgets.length) { globalStore.set(this.selectedIndex, selectedIndex + 1); } return true; } if (checkKeyPressed(e, "Enter")) { if (filteredWidgets.length == 0) { return true; } if (filteredWidgets[selectedIndex]) { this.handleWidgetSelect(filteredWidgets[selectedIndex]); } return true; } if (checkKeyPressed(e, "Escape")) { globalStore.set(this.searchTerm, ""); globalStore.set(this.selectedIndex, 0); return true; } return false; } async handleWidgetSelect(widget: WidgetConfigType) { try { await replaceBlock(this.blockId, widget.blockdef, true); } catch (error) { console.error("Error replacing block:", error); } } } function LauncherView({ blockId, model }: ViewComponentProps<LauncherViewModel>) { // Search and selection state const [searchTerm, setSearchTerm] = useAtom(model.searchTerm); const [selectedIndex, setSelectedIndex] = useAtom(model.selectedIndex); const filteredWidgets = useAtomValue(model.filteredWidgetsAtom); // Container measurement const containerRef = useRef<HTMLDivElement>(null); const [containerSize, setContainerSize] = useAtom(model.containerSize); useLayoutEffect(() => { if (!containerRef.current) return; const resizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { setContainerSize({ width: entry.contentRect.width, height: entry.contentRect.height, }); } }); resizeObserver.observe(containerRef.current); return () => { resizeObserver.disconnect(); }; }, []); // Layout constants const GAP = 16; const LABEL_THRESHOLD = 60; const MARGIN_BOTTOM = 24; const MAX_TILE_SIZE = 120; const calculatedLogoWidth = containerSize.width * 0.3; const logoWidth = containerSize.width >= 100 ? Math.min(Math.max(calculatedLogoWidth, 100), 300) : 0; const showLogo = logoWidth >= 100; const availableHeight = containerSize.height - (showLogo ? logoWidth + MARGIN_BOTTOM : 0); // Determine optimal grid layout const gridLayout: GridLayoutType = React.useMemo(() => { if (containerSize.width === 0 || availableHeight <= 0 || filteredWidgets.length === 0) { return { columns: 1, tileWidth: 90, tileHeight: 90, showLabel: true }; } let bestColumns = 1; let bestTileSize = 0; let bestTileWidth = 90; let bestTileHeight = 90; let showLabel = true; for (let cols = 1; cols <= filteredWidgets.length; cols++) { const rows = Math.ceil(filteredWidgets.length / cols); const tileWidth = (containerSize.width - (cols - 1) * GAP) / cols; const tileHeight = (availableHeight - (rows - 1) * GAP) / rows; const currentTileSize = Math.min(tileWidth, tileHeight); if (currentTileSize > bestTileSize) { bestTileSize = currentTileSize; bestColumns = cols; bestTileWidth = tileWidth; bestTileHeight = tileHeight; showLabel = tileHeight >= LABEL_THRESHOLD; } } return { columns: bestColumns, tileWidth: bestTileWidth, tileHeight: bestTileHeight, showLabel }; }, [containerSize, availableHeight, filteredWidgets.length]); model.gridLayout = gridLayout; const finalTileWidth = Math.min(gridLayout.tileWidth, MAX_TILE_SIZE); const finalTileHeight = gridLayout.showLabel ? Math.min(gridLayout.tileHeight, MAX_TILE_SIZE) : finalTileWidth; // Reset selection when search term changes useEffect(() => { setSelectedIndex(0); }, [searchTerm]); return ( <div ref={containerRef} className="w-full h-full p-4 box-border flex flex-col items-center justify-center"> {/* Hidden input for search */} <input ref={model.inputRef} type="text" value={searchTerm} onKeyDown={keydownWrapper(model.keyDownHandler.bind(model))} onChange={(e) => setSearchTerm(e.target.value)} className="sr-only dummy" aria-label="Search widgets" /> {/* Logo */} {showLogo && ( <div className="mb-6" style={{ width: logoWidth, maxWidth: 300 }}> <img src={logoUrl} className="w-full h-auto filter grayscale brightness-70 opacity-70" alt="Logo" /> </div> )} {/* Grid of widgets */} <div className="grid gap-4 justify-center" style={{ gridTemplateColumns: `repeat(${gridLayout.columns}, ${finalTileWidth}px)`, }} > {filteredWidgets.map((widget, index) => ( <div key={index} onClick={() => model.handleWidgetSelect(widget)} title={widget.description || widget.label} className={clsx( "flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center", "transition-colors duration-150", index === selectedIndex ? "bg-white/20 text-white" : "bg-white/5 hover:bg-white/10 text-secondary hover:text-white" )} style={{ width: finalTileWidth, height: finalTileHeight, }} > <div style={{ color: widget.color }}> <i className={makeIconClass(widget.icon, true, { defaultIcon: "browser", })} /> </div> {gridLayout.showLabel && !isBlank(widget.label) && ( <div className="mt-1 w-full text-[11px] leading-4 overflow-hidden text-ellipsis whitespace-nowrap"> {widget.label} </div> )} </div> ))} </div> {/* Search instructions */} <div className="mt-4 text-secondary text-xs"> {filteredWidgets.length === 0 ? ( <span>No widgets found. Press Escape to clear search.</span> ) : ( <span> {searchTerm == "" ? "Type to Filter" : "Searching " + '"' + searchTerm + '"'}, Enter to Launch, {searchTerm == "" ? "Arrow Keys to Navigate" : null} </span> )} </div> </div> ); } export default LauncherView; ================================================ FILE: frontend/app/view/preview/csvview.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .csv-view { opacity: 0; /* Start with an opacity of 0, meaning it's invisible */ overflow-x: auto; overflow-y: hidden; .cursor-pointer { cursor: pointer; } .select-none { user-select: none; } table.probe { position: absolute; visibility: hidden; } table { border-collapse: collapse; overflow-x: auto; border: 1px solid var(--scrollbar-thumb-hover-color); thead { position: relative; display: block; width: 100%; overflow-y: scroll; tr { border-bottom: 1px solid var(--scrollbar-thumb-hover-color); th { color: var(--main-text-color); border-right: 1px solid var(--scrollbar-thumb-hover-color); border-bottom: none; padding: 2px 10px; flex-basis: 100%; flex-grow: 2; display: block; text-align: left; position: relative; .inner { text-align: left; padding-right: 15px; position: relative; .sort-icon { position: absolute; right: 0px; top: 2px; width: 9px; } } } } } tbody { display: block; position: relative; overflow-y: scroll; overscroll-behavior: contain; } tr { width: 100%; display: flex; td { border-right: 1px solid var(--scrollbar-thumb-hover-color); border-left: 1px solid var(--scrollbar-thumb-hover-color); padding: 3px 10px; flex-basis: 100%; flex-grow: 2; display: block; text-align: left; } } } } .csv-view.show { opacity: 1; } ================================================ FILE: frontend/app/view/preview/csvview.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useTableNav } from "@table-nav/react"; import { createColumnHelper, flexRender, getCoreRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; import clsx from "clsx"; import Papa from "papaparse"; import { useEffect, useMemo, useRef, useState } from "react"; import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; import "./csvview.scss"; const MAX_DATA_SIZE = 10 * 1024 * 1024; // 10MB in bytes type CSVRow = { [key: string]: string | number; }; interface CSVViewProps { parentRef: React.RefObject<HTMLDivElement>; content: string; filename: string; readonly: boolean; } interface State { content: string | null; showReadonly: boolean; tbodyHeight: number; } const columnHelper = createColumnHelper<any>(); // TODO remove parentRef dependency -- use own height const CSVView = ({ parentRef, filename, content }: CSVViewProps) => { const csvCacheRef = useRef(new Map<string, string>()); const rowRef = useRef<(HTMLTableRowElement | null)[]>([]); const headerRef = useRef<HTMLTableRowElement | null>(null); const probeRef = useRef<HTMLTableRowElement | null>(null); const tbodyRef = useRef<HTMLTableSectionElement | null>(null); const [state, setState] = useState<State>({ content, showReadonly: true, tbodyHeight: 0, }); const [tableLoaded, setTableLoaded] = useState(false); const { listeners } = useTableNav(); const domRect = useDimensionsWithExistingRef(parentRef, 30); const parentHeight = domRect?.height ?? 0; const cacheKey = `${filename}`; csvCacheRef.current.set(cacheKey, content); // Parse the CSV data const parsedData = useMemo<CSVRow[]>(() => { if (!state.content) return []; // Trim the content and then check for headers based on the first row's content. const trimmedContent = state.content.trim(); const firstRow = trimmedContent.split("\n")[0]; // This checks if the first row starts with a letter or a quote const hasHeaders = !!firstRow.match(/^[a-zA-Z"]/); const results = Papa.parse(trimmedContent, { header: hasHeaders }); // Check for non-header CSVs if (!hasHeaders && Array.isArray(results.data) && Array.isArray(results.data[0])) { const dataArray = results.data as string[][]; // Asserting the type const headers = Array.from({ length: dataArray[0].length }, (_, i) => `Column ${i + 1}`); results.data = dataArray.map((row) => { const newRow: CSVRow = {}; row.forEach((value, index) => { newRow[headers[index]] = value; }); return newRow; }); } return results.data.map((row) => { return Object.fromEntries( Object.entries(row as CSVRow).map(([key, value]) => { if (typeof value === "string") { const numberValue = parseFloat(value); if (!isNaN(numberValue) && String(numberValue) === value) { return [key, numberValue]; } } return [key, value]; }) ) as CSVRow; }); }, [state.content]); // Column Definitions const columns = useMemo(() => { if (parsedData.length === 0) { return []; } const headers = Object.keys(parsedData[0]); return headers.map((header) => columnHelper.accessor(header, { header: () => header, cell: (info) => info.renderValue(), }) ); }, [parsedData]); useEffect(() => { if (probeRef.current && headerRef.current && parsedData.length && parentRef.current) { const rowHeight = probeRef.current.offsetHeight; const fullTBodyHeight = rowHeight * parsedData.length; const headerHeight = headerRef.current.offsetHeight; const maxHeightLessHeader = parentHeight - headerHeight; const tbodyHeight = Math.min(maxHeightLessHeader, fullTBodyHeight) - 3; // 3 for the borders setState((prevState) => ({ ...prevState, tbodyHeight })); } }, [parentHeight, parsedData]); // Makes sure rows are rendered before setting the renderer as loaded useEffect(() => { let tid: NodeJS.Timeout; if (rowRef.current.length === parsedData.length) { tid = setTimeout(() => { setTableLoaded(true); }, 50); // Delay a bit to make sure the rows are rendered } return () => clearTimeout(tid); }, [rowRef, parsedData]); const table = useReactTable({ manualPagination: true, data: parsedData, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), }); return ( <div className={clsx("csv-view ellipsis", { show: tableLoaded })} style={{ height: "auto" }}> <table className="probe"> <tbody> <tr ref={probeRef}> <td>dummy data</td> </tr> </tbody> </table> <table {...listeners}> <thead> {table.getHeaderGroups().map((headerGroup, index) => ( <tr key={headerGroup.id} ref={headerRef} id={headerGroup.id} tabIndex={index}> {headerGroup.headers.map((header, index) => ( <th key={header.id} colSpan={header.colSpan} id={header.id} tabIndex={index} style={{ width: header.getSize() }} > {header.isPlaceholder ? null : ( <div {...{ className: header.column.getCanSort() ? "inner cursor-pointer select-none ellipsis" : "", onClick: header.column.getToggleSortingHandler(), }} > {flexRender(header.column.columnDef.header, header.getContext())} {header.column.getIsSorted() === "asc" ? ( <i className="sort-icon fa-sharp fa-solid fa-sort-up"></i> ) : header.column.getIsSorted() === "desc" ? ( <i className="sort-icon fa-sharp fa-solid fa-sort-down"></i> ) : null} </div> )} </th> ))} </tr> ))} </thead> <tbody style={{ height: `${state.tbodyHeight}px` }} ref={tbodyRef}> {table.getRowModel().rows.map((row, index) => ( <tr key={row.id} ref={(el) => { rowRef.current[index] = el; }} id={row.id} tabIndex={index} > {row.getVisibleCells().map((cell) => ( <td className="ellipsis" key={cell.id} id={cell.id} tabIndex={index}> {flexRender(cell.column.columnDef.cell, cell.getContext())} </td> ))} </tr> ))} </tbody> </table> </div> ); }; export { CSVView }; ================================================ FILE: frontend/app/view/preview/directorypreview.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .dir-table-container { display: flex; flex-direction: column; height: 100%; --min-row-width: 35rem; .dir-table { height: 100%; width: 100%; --col-size-size: 0.2rem; display: flex; flex-direction: column; &:not([data-scroll-height="0"]) .dir-table-head::after { background: oklch(from var(--block-bg-color) calc(l + 0.5) c h); backdrop-filter: blur(2px); content: ""; z-index: -1; position: absolute; top: 0; bottom: 0; left: 0; right: 0; } .dir-table-head { position: sticky; top: 0; z-index: 10; width: 100%; min-width: fit-content; border-bottom: 1px solid var(--border-color); .dir-table-head-row { display: flex; min-width: var(--min-row-width); padding: 4px 6px; font-size: 0.75rem; .dir-table-head-cell { flex: 0 0 auto; user-select: none; } .dir-table-head-cell:not(:first-child) { position: relative; display: flex; white-space: nowrap; overflow: hidden; .dir-table-head-cell-content { padding: 2px 4px; display: flex; gap: 0.3rem; flex: 1 1 auto; overflow-x: hidden; letter-spacing: -0.12px; .dir-table-head-direction { margin-right: 0.2rem; margin-top: 0.2rem; } .dir-table-head-size { align-self: flex-end; } } .dir-table-head-resize-box { width: 12px; display: flex; justify-content: center; flex: 0 0 auto; position: relative; &::before { content: ""; position: absolute; left: 50%; top: 10%; height: 80%; width: 1px; background-color: var(--border-color); pointer-events: none; } .dir-table-head-resize { cursor: col-resize; user-select: none; -webkit-user-select: none; touch-action: none; width: 4px; } } } } } .dir-table-body { display: flex; flex-direction: column; padding: 0 5px 5px 5px; .dir-table-body-scroll-box { position: relative; .dummy { position: absolute; visibility: hidden; } .dir-table-body-row { display: flex; align-items: center; border-radius: 5px; padding: 0 6px; min-width: var(--min-row-width); &.focused { background-color: rgb(from var(--accent-color) r g b / 0.5); color: var(--main-text-color); .dir-table-body-cell { .dir-table-lastmod, .dir-table-modestr, .dir-table-size, .dir-table-type { color: var(--main-text-color); } } } &:focus { background-color: rgb(from var(--accent-color) r g b / 0.5); color: var(--main-text-color); .dir-table-body-cell { .dir-table-lastmod, .dir-table-modestr, .dir-table-size, .dir-table-type { color: var(--main-text-color); } } } &:nth-child(odd):not(.focused):not(:focus) { background-color: rgba(255, 255, 255, 0.06); } &:hover:not(:focus):not(.focused) { background-color: var(--highlight-bg-color); } .dir-table-body-cell { overflow: hidden; white-space: nowrap; padding: 0.25rem; cursor: default; font-size: 12px; flex: 0 0 auto; &.col-size { text-align: right; } .dir-table-lastmod, .dir-table-modestr, .dir-table-type { color: var(--secondary-text-color); margin-right: 12px; } .dir-table-modestr, .dir-table-size, .dir-table-lastmod { color: var(--secondary-text-color); font-family: Hack; font-size: 11px; } .dir-table-name { font-weight: 500; } } } } } } } .entry-manager-overlay { display: flex; flex-direction: column; max-width: 90%; max-height: fit-content; padding: 10px; gap: 10px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.15); background: #212121; box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3); .entry-manager-buttons { display: flex; flex-direction: row; gap: 10px; } } ================================================ FILE: frontend/app/view/preview/entry-manager.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; import { Input } from "@/app/element/input"; import React, { memo, useState } from "react"; export enum EntryManagerType { NewFile = "New File", NewDirectory = "New Folder", EditName = "Rename", } export type EntryManagerOverlayProps = { forwardRef?: React.Ref<HTMLDivElement>; entryManagerType: EntryManagerType; startingValue?: string; onSave: (newValue: string) => void; onCancel?: () => void; style?: React.CSSProperties; getReferenceProps?: () => any; }; export const EntryManagerOverlay = memo( ({ entryManagerType, startingValue, onSave, onCancel, forwardRef, style, getReferenceProps, }: EntryManagerOverlayProps) => { const [value, setValue] = useState(startingValue); return ( <div className="entry-manager-overlay" ref={forwardRef} style={style} {...(getReferenceProps?.() ?? {})}> <div className="entry-manager-type">{entryManagerType}</div> <div className="entry-manager-input"> <Input value={value} onChange={setValue} autoFocus={true} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); onSave(value); } }} /> </div> <div className="entry-manager-buttons"> <Button className="py-[4px]" onClick={() => onSave(value)}> Save </Button> <Button className="py-[4px] red outlined" onClick={onCancel}> Cancel </Button> </div> </div> ); } ); EntryManagerOverlay.displayName = "EntryManagerOverlay"; ================================================ FILE: frontend/app/view/preview/preview-directory-utils.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { fireAndForget, isBlank } from "@/util/util"; import dayjs from "dayjs"; import React from "react"; import { type PreviewModel } from "./preview-model"; export const recursiveError = "recursive flag must be set for directory operations"; export const overwriteError = "set overwrite flag to delete the existing file"; export const mergeError = "set overwrite flag to delete the existing contents or set merge flag to merge the contents"; export const displaySuffixes = { B: "b", kB: "k", MB: "m", GB: "g", TB: "t", KiB: "k", MiB: "m", GiB: "g", TiB: "t", }; export function getBestUnit(bytes: number, si = false, sigfig = 3): string { if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "-"; if (bytes === 0) return "0B"; const units = si ? ["kB", "MB", "GB", "TB"] : ["KiB", "MiB", "GiB", "TiB"]; const divisor = si ? 1000 : 1024; const idx = Math.min(Math.floor(Math.log(bytes) / Math.log(divisor)), units.length); const unit = idx === 0 ? "B" : units[idx - 1]; const value = bytes / Math.pow(divisor, idx); return `${parseFloat(value.toPrecision(sigfig))}${displaySuffixes[unit] ?? unit}`; } function padDay(day: number) { return String(day).padStart(2, " "); } export function getLastModifiedTime(unixMillis: number): string { const file = dayjs(unixMillis); const now = dayjs(); const day = padDay(file.date()); const time = file.format("HH:mm"); if (now.isSame(file, "year")) { return `${file.format("MMM")} ${day} ${time}`; } return `${file.format("YYYY-MM-DD")}`; } const iconRegex = /^[a-z0-9- ]+$/; export function isIconValid(icon: string): boolean { if (isBlank(icon)) { return false; } return icon.match(iconRegex) != null; } export function getSortIcon(sortType: string | boolean): React.ReactNode { switch (sortType) { case "asc": return <i className="fa-solid fa-chevron-up dir-table-head-direction"></i>; case "desc": return <i className="fa-solid fa-chevron-down dir-table-head-direction"></i>; default: return null; } } export function cleanMimetype(input: string): string { const truncated = input.split(";")[0]; return truncated.trim(); } export function handleRename( model: PreviewModel, path: string, newPath: string, isDir: boolean, setErrorMsg: (msg: ErrorMsg) => void ) { fireAndForget(async () => { try { let srcuri = await model.formatRemoteUri(path, globalStore.get); if (isDir) { srcuri += "/"; } await model.env.rpc.FileMoveCommand(TabRpcClient, { srcuri, desturi: await model.formatRemoteUri(newPath, globalStore.get), }); } catch (e) { const errorText = `${e}`; console.warn(`Rename failed: ${errorText}`); const errorMsg: ErrorMsg = { status: "Rename Failed", text: `${e}`, }; setErrorMsg(errorMsg); } model.refreshCallback(); }); } export function handleFileDelete( model: PreviewModel, path: string, recursive: boolean, setErrorMsg: (msg: ErrorMsg) => void ) { fireAndForget(async () => { const formattedPath = await model.formatRemoteUri(path, globalStore.get); try { await model.env.rpc.FileDeleteCommand(TabRpcClient, { path: formattedPath, recursive, }); } catch (e) { const errorText = `${e}`; console.warn(`Delete failed: ${errorText}`); let errorMsg: ErrorMsg; if (errorText.includes(recursiveError) && !recursive) { errorMsg = { status: "Confirm Delete Directory", text: "Deleting a directory requires the recursive flag. Proceed?", level: "warning", buttons: [ { text: "Delete Recursively", onClick: () => handleFileDelete(model, path, true, setErrorMsg), }, ], }; } else { errorMsg = { status: "Delete Failed", text: `${e}`, }; } setErrorMsg(errorMsg); } model.refreshCallback(); }); } export function makeDirectoryDefaultMenuItems(model: PreviewModel): ContextMenuItem[] { const defaultSort = globalStore.get(model.env.getSettingsKeyAtom("preview:defaultsort")) ?? "name"; const showHiddenFiles = globalStore.get(model.showHiddenFiles) ?? true; return [ { label: "Directory Sort Order", submenu: [ { label: "Name", type: "checkbox", checked: defaultSort === "name", click: () => fireAndForget(() => model.env.rpc.SetConfigCommand(TabRpcClient, { "preview:defaultsort": "name" }) ), }, { label: "Last Modified", type: "checkbox", checked: defaultSort === "modtime", click: () => fireAndForget(() => model.env.rpc.SetConfigCommand(TabRpcClient, { "preview:defaultsort": "modtime" }) ), }, ], }, { label: "Show Hidden Files", submenu: [ { label: "On", type: "checkbox", checked: showHiddenFiles, click: () => { globalStore.set(model.showHiddenFiles, true); fireAndForget(() => model.env.rpc.SetConfigCommand(TabRpcClient, { "preview:showhiddenfiles": true }) ); }, }, { label: "Off", type: "checkbox", checked: !showHiddenFiles, click: () => { globalStore.set(model.showHiddenFiles, false); fireAndForget(() => model.env.rpc.SetConfigCommand(TabRpcClient, { "preview:showhiddenfiles": false }) ); }, }, ], }, ]; } ================================================ FILE: frontend/app/view/preview/preview-directory.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ContextMenuModel } from "@/app/store/contextmenu"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { globalStore } from "@/app/store/jotaiStore"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { addOpenMenuItems } from "@/util/previewutil"; import { fireAndForget } from "@/util/util"; import { formatRemoteUri } from "@/util/waveutil"; import { offset, useDismiss, useFloating, useInteractions } from "@floating-ui/react"; import { Header, Row, RowData, Table, createColumnHelper, flexRender, getCoreRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; import clsx from "clsx"; import { PrimitiveAtom, atom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { quote as shellQuote } from "shell-quote"; import { debounce } from "throttle-debounce"; import "./directorypreview.scss"; import { EntryManagerOverlay, EntryManagerOverlayProps, EntryManagerType } from "./entry-manager"; import { cleanMimetype, getBestUnit, getLastModifiedTime, getSortIcon, handleFileDelete, handleRename, isIconValid, makeDirectoryDefaultMenuItems, mergeError, overwriteError, } from "./preview-directory-utils"; import { type PreviewModel } from "./preview-model"; import type { PreviewEnv } from "./previewenv"; const PageJumpSize = 20; interface DirectoryTableHeaderCellProps { header: Header<FileInfo, unknown>; } function DirectoryTableHeaderCell({ header }: DirectoryTableHeaderCellProps) { return ( <div className="dir-table-head-cell" key={header.id} style={{ width: `calc(var(--header-${header.id}-size) * 1px)` }} > <div className="dir-table-head-cell-content" onClick={() => header.column.toggleSorting()}> {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {getSortIcon(header.column.getIsSorted())} </div> <div className="dir-table-head-resize-box"> <div className="dir-table-head-resize" onMouseDown={header.getResizeHandler()} onTouchStart={header.getResizeHandler()} /> </div> </div> ); } declare module "@tanstack/react-table" { interface TableMeta<TData extends RowData> { updateName: (path: string, isDir: boolean) => void; newFile: () => void; newDirectory: () => void; } } interface DirectoryTableProps { model: PreviewModel; data: FileInfo[]; search: string; focusIndex: number; setFocusIndex: (_: number) => void; setSearch: (_: string) => void; setSelectedPath: (_: string) => void; setRefreshVersion: React.Dispatch<React.SetStateAction<number>>; entryManagerOverlayPropsAtom: PrimitiveAtom<EntryManagerOverlayProps>; newFile: () => void; newDirectory: () => void; } const columnHelper = createColumnHelper<FileInfo>(); function DirectoryTable({ model, data, search, focusIndex, setFocusIndex, setSearch, setSelectedPath, setRefreshVersion, entryManagerOverlayPropsAtom, newFile, newDirectory, }: DirectoryTableProps) { const env = useWaveEnv<PreviewEnv>(); const searchActive = useAtomValue(model.directorySearchActive); const fullConfig = useAtomValue(env.atoms.fullConfigAtom); const defaultSort = useAtomValue(env.getSettingsKeyAtom("preview:defaultsort")) ?? "name"; const setErrorMsg = useSetAtom(model.errorMsgAtom); const getIconFromMimeType = useCallback( (mimeType: string): string => { while (mimeType.length > 0) { const icon = fullConfig.mimetypes?.[mimeType]?.icon ?? null; if (isIconValid(icon)) { return `fa fa-solid fa-${icon} fa-fw`; } mimeType = mimeType.slice(0, -1); } return "fa fa-solid fa-file fa-fw"; }, [fullConfig.mimetypes] ); const getIconColor = useCallback( (mimeType: string): string => fullConfig.mimetypes?.[mimeType]?.color ?? "inherit", [fullConfig.mimetypes] ); const columns = useMemo( () => [ columnHelper.accessor("mimetype", { cell: (info) => ( <i className={getIconFromMimeType(info.getValue() ?? "")} style={{ color: getIconColor(info.getValue() ?? "") }} ></i> ), header: () => <span></span>, id: "logo", size: 25, enableSorting: false, }), columnHelper.accessor("name", { cell: (info) => <span className="dir-table-name ellipsis">{info.getValue()}</span>, header: () => <span className="dir-table-head-name">Name</span>, sortingFn: "alphanumeric", size: 200, minSize: 90, }), columnHelper.accessor("modestr", { cell: (info) => <span className="dir-table-modestr">{info.getValue()}</span>, header: () => <span>Perm</span>, size: 91, minSize: 90, sortingFn: "alphanumeric", }), columnHelper.accessor("modtime", { cell: (info) => <span className="dir-table-lastmod">{getLastModifiedTime(info.getValue())}</span>, header: () => <span>Last Modified</span>, size: 91, minSize: 65, sortingFn: "datetime", }), columnHelper.accessor("size", { cell: (info) => <span className="dir-table-size">{getBestUnit(info.getValue())}</span>, header: () => <span className="dir-table-head-size">Size</span>, size: 55, minSize: 50, sortingFn: "auto", }), columnHelper.accessor("mimetype", { cell: (info) => <span className="dir-table-type ellipsis">{cleanMimetype(info.getValue() ?? "")}</span>, header: () => <span className="dir-table-head-type">Type</span>, size: 97, minSize: 97, sortingFn: "alphanumeric", }), columnHelper.accessor("path", {}), ], [fullConfig] ); const setEntryManagerProps = useSetAtom(entryManagerOverlayPropsAtom); const updateName = useCallback( (path: string, isDir: boolean) => { const fileName = path.split("/").at(-1); setEntryManagerProps({ entryManagerType: EntryManagerType.EditName, startingValue: fileName, onSave: (newName: string) => { let newPath: string; if (newName !== fileName) { const lastInstance = path.lastIndexOf(fileName); newPath = path.substring(0, lastInstance) + newName; console.log(`replacing ${fileName} with ${newName}: ${path}`); handleRename(model, path, newPath, isDir, setErrorMsg); } setEntryManagerProps(undefined); }, }); }, [model, setErrorMsg] ); const initialSorting = defaultSort === "modtime" ? [{ id: "modtime", desc: true }] : [{ id: "name", desc: false }]; const table = useReactTable({ data, columns, columnResizeMode: "onChange", getSortedRowModel: getSortedRowModel(), getCoreRowModel: getCoreRowModel(), initialState: { sorting: initialSorting, columnVisibility: { path: false, }, }, enableMultiSort: false, enableSortingRemoval: false, meta: { updateName, newFile, newDirectory, }, }); const sortingState = table.getState().sorting; useEffect(() => { const allRows = table.getRowModel()?.flatRows || []; setSelectedPath((allRows[focusIndex]?.getValue("path") as string) ?? null); }, [focusIndex, data, setSelectedPath, sortingState]); const columnSizeVars = useMemo(() => { const headers = table.getFlatHeaders(); const colSizes: { [key: string]: number } = {}; for (let i = 0; i < headers.length; i++) { const header = headers[i]!; colSizes[`--header-${header.id}-size`] = header.getSize(); colSizes[`--col-${header.column.id}-size`] = header.column.getSize(); } return colSizes; }, [table.getState().columnSizingInfo]); const osRef = useRef<OverlayScrollbarsComponentRef>(null); const bodyRef = useRef<HTMLDivElement>(null); const [scrollHeight, setScrollHeight] = useState(0); const onScroll = useCallback( debounce(2, () => { setScrollHeight(osRef.current.osInstance().elements().viewport.scrollTop); }), [] ); const TableComponent = table.getState().columnSizingInfo.isResizingColumn ? MemoizedTableBody : TableBody; return ( <OverlayScrollbarsComponent options={{ scrollbars: { autoHide: "leave" } }} events={{ scroll: onScroll }} className="dir-table" style={{ ...columnSizeVars }} ref={osRef} data-scroll-height={scrollHeight} > <div className="dir-table-head"> {table.getHeaderGroups().map((headerGroup) => ( <div className="dir-table-head-row" key={headerGroup.id}> {headerGroup.headers.map((header) => ( <DirectoryTableHeaderCell key={header.id} header={header} /> ))} </div> ))} </div> <TableComponent bodyRef={bodyRef} model={model} data={data} table={table} search={search} focusIndex={focusIndex} setFocusIndex={setFocusIndex} setSearch={setSearch} setSelectedPath={setSelectedPath} setRefreshVersion={setRefreshVersion} osRef={osRef.current} /> </OverlayScrollbarsComponent> ); } interface TableBodyProps { bodyRef: React.RefObject<HTMLDivElement>; model: PreviewModel; data: Array<FileInfo>; table: Table<FileInfo>; search: string; focusIndex: number; setFocusIndex: (_: number) => void; setSearch: (_: string) => void; setSelectedPath: (_: string) => void; setRefreshVersion: React.Dispatch<React.SetStateAction<number>>; osRef: OverlayScrollbarsComponentRef; } function TableBody({ bodyRef, model, table, search, focusIndex, setFocusIndex, setSearch, setRefreshVersion, osRef, }: TableBodyProps) { const searchActive = useAtomValue(model.directorySearchActive); const dummyLineRef = useRef<HTMLDivElement>(null); const warningBoxRef = useRef<HTMLDivElement>(null); const conn = useAtomValue(model.connection); const setErrorMsg = useSetAtom(model.errorMsgAtom); useEffect(() => { if (focusIndex === null || !bodyRef.current || !osRef) { return; } const rowElement = bodyRef.current.querySelector(`[data-rowindex="${focusIndex}"]`) as HTMLDivElement; if (!rowElement) { return; } const viewport = osRef.osInstance().elements().viewport; const viewportHeight = viewport.offsetHeight; const rowRect = rowElement.getBoundingClientRect(); const parentRect = viewport.getBoundingClientRect(); const viewportScrollTop = viewport.scrollTop; const rowTopRelativeToViewport = rowRect.top - parentRect.top + viewport.scrollTop; const rowBottomRelativeToViewport = rowRect.bottom - parentRect.top + viewport.scrollTop; if (rowTopRelativeToViewport - 30 < viewportScrollTop) { // Row is above the visible area let topVal = rowTopRelativeToViewport - 30; if (topVal < 0) { topVal = 0; } viewport.scrollTo({ top: topVal }); } else if (rowBottomRelativeToViewport + 5 > viewportScrollTop + viewportHeight) { // Row is below the visible area const topVal = rowBottomRelativeToViewport - viewportHeight + 5; viewport.scrollTo({ top: topVal }); } }, [focusIndex]); const handleFileContextMenu = useCallback( async (e: any, finfo: FileInfo) => { e.preventDefault(); e.stopPropagation(); if (finfo == null) { return; } const fileName = finfo.path.split("/").pop(); const menu: ContextMenuItem[] = [ { label: "New File", click: () => { table.options.meta.newFile(); }, }, { label: "New Folder", click: () => { table.options.meta.newDirectory(); }, }, { label: "Rename", click: () => { table.options.meta.updateName(finfo.path, finfo.isdir); }, }, { type: "separator", }, { label: "Copy File Name", click: () => fireAndForget(() => navigator.clipboard.writeText(fileName)), }, { label: "Copy Full File Name", click: () => fireAndForget(() => navigator.clipboard.writeText(finfo.path)), }, { label: "Copy File Name (Shell Quoted)", click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([fileName]))), }, { label: "Copy Full File Name (Shell Quoted)", click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([finfo.path]))), }, ]; addOpenMenuItems(menu, conn, finfo); menu.push( { type: "separator", }, { label: "Default Settings", submenu: makeDirectoryDefaultMenuItems(model), }, { type: "separator", }, { label: "Delete", click: () => handleFileDelete(model, finfo.path, false, setErrorMsg), } ); ContextMenuModel.getInstance().showContextMenu(menu, e); }, [setRefreshVersion, conn] ); const allRows = table.getRowModel().flatRows; const dotdotRow = allRows.find((row) => row.getValue("name") === ".."); const otherRows = allRows.filter((row) => row.getValue("name") !== ".."); return ( <div className="dir-table-body" ref={bodyRef}> {(searchActive || search !== "") && ( <div className="flex rounded-[3px] py-1 px-2 bg-warning text-black" ref={warningBoxRef}> <span>{search === "" ? "Type to search (Esc to cancel)" : `Searching for "${search}"`}</span> <div className="ml-auto bg-transparent flex justify-center items-center flex-col p-0.5 rounded-md hover:bg-hoverbg focus:bg-hoverbg focus-within:bg-hoverbg cursor-pointer" onClick={() => { setSearch(""); globalStore.set(model.directorySearchActive, false); }} > <i className="fa-solid fa-xmark" /> <input type="text" value={search} onChange={() => {}} className="w-0 h-0 opacity-0 p-0 border-none pointer-events-none" /> </div> </div> )} <div className="dir-table-body-scroll-box"> <div className="dummy dir-table-body-row" ref={dummyLineRef}> <div className="dir-table-body-cell">dummy-data</div> </div> {dotdotRow && ( <TableRow model={model} row={dotdotRow} focusIndex={focusIndex} setFocusIndex={setFocusIndex} setSearch={setSearch} idx={0} handleFileContextMenu={handleFileContextMenu} key="dotdot" /> )} {otherRows.map((row, idx) => ( <TableRow model={model} row={row} focusIndex={focusIndex} setFocusIndex={setFocusIndex} setSearch={setSearch} idx={dotdotRow ? idx + 1 : idx} handleFileContextMenu={handleFileContextMenu} key={idx} /> ))} </div> </div> ); } type TableRowProps = { model: PreviewModel; row: Row<FileInfo>; focusIndex: number; setFocusIndex: (_: number) => void; setSearch: (_: string) => void; idx: number; handleFileContextMenu: (e: any, finfo: FileInfo) => Promise<void>; }; function TableRow({ model, row, focusIndex, setFocusIndex, setSearch, idx, handleFileContextMenu }: TableRowProps) { const dirPath = useAtomValue(model.statFilePath); const connection = useAtomValue(model.connection); const dragItem: DraggedFile = { relName: row.getValue("name") as string, absParent: dirPath, uri: formatRemoteUri(row.getValue("path") as string, connection), isDir: row.original.isdir, }; const [_, drag] = useDrag( () => ({ type: "FILE_ITEM", canDrag: true, item: () => dragItem, }), [dragItem] ); const dragRef = useCallback( (node: HTMLDivElement | null) => { drag(node); }, [drag] ); return ( <div className={clsx("dir-table-body-row", { focused: focusIndex === idx })} data-rowindex={idx} onDoubleClick={() => { const newFileName = row.getValue("path") as string; model.goHistory(newFileName); setSearch(""); globalStore.set(model.directorySearchActive, false); }} onClick={() => setFocusIndex(idx)} onContextMenu={(e) => handleFileContextMenu(e, row.original)} ref={dragRef} > {row.getVisibleCells().map((cell) => ( <div className={clsx("dir-table-body-cell", "col-" + cell.column.id)} key={cell.id} style={{ width: `calc(var(--col-${cell.column.id}-size) * 1px)` }} > {flexRender(cell.column.columnDef.cell, cell.getContext())} </div> ))} </div> ); } const MemoizedTableBody = React.memo( TableBody, (prev, next) => prev.table.options.data == next.table.options.data ) as typeof TableBody; interface DirectoryPreviewProps { model: PreviewModel; } function DirectoryPreview({ model }: DirectoryPreviewProps) { const env = useWaveEnv<PreviewEnv>(); const [searchText, setSearchText] = useState(""); const [focusIndex, setFocusIndex] = useState(0); const [unfilteredData, setUnfilteredData] = useState<FileInfo[]>([]); const showHiddenFiles = useAtomValue(model.showHiddenFiles); const [selectedPath, setSelectedPath] = useState(""); const [refreshVersion, setRefreshVersion] = useAtom(model.refreshVersion); const conn = useAtomValue(model.connection); const blockData = useAtomValue(model.blockAtom); const finfo = useAtomValue(model.statFile); const dirPath = finfo?.path; const setErrorMsg = useSetAtom(model.errorMsgAtom); useEffect(() => { model.refreshCallback = () => { setRefreshVersion((refreshVersion) => refreshVersion + 1); }; return () => { model.refreshCallback = null; }; }, [setRefreshVersion]); useEffect( () => fireAndForget(async () => { let entries: FileInfo[]; try { const file = await env.rpc.FileReadCommand( TabRpcClient, { info: { path: await model.formatRemoteUri(dirPath, globalStore.get), }, }, null ); entries = file.entries ?? []; if (file?.info && file.info.dir && file.info?.path !== file.info?.dir) { entries.unshift({ name: "..", path: file?.info?.dir, isdir: true, modtime: new Date().getTime(), mimetype: "directory", }); } } catch (e) { setErrorMsg({ status: "Cannot Read Directory", text: `${e}`, }); } setUnfilteredData(entries); }), [conn, dirPath, refreshVersion] ); const filteredData = useMemo( () => unfilteredData?.filter((fileInfo) => { if (fileInfo.name == null) { console.log("fileInfo.name is null", fileInfo); return false; } if (!showHiddenFiles && fileInfo.name.startsWith(".") && fileInfo.name != "..") { return false; } return fileInfo.name.toLowerCase().includes(searchText); }) ?? [], [unfilteredData, showHiddenFiles, searchText] ); useEffect(() => { model.directoryKeyDownHandler = (waveEvent: WaveKeyboardEvent): boolean => { if (checkKeyPressed(waveEvent, "Cmd:f")) { globalStore.set(model.directorySearchActive, true); return true; } if (checkKeyPressed(waveEvent, "Escape")) { setSearchText(""); globalStore.set(model.directorySearchActive, false); return; } if (checkKeyPressed(waveEvent, "ArrowUp")) { setFocusIndex((idx) => Math.max(idx - 1, 0)); return true; } if (checkKeyPressed(waveEvent, "ArrowDown")) { setFocusIndex((idx) => Math.min(idx + 1, filteredData.length - 1)); return true; } if (checkKeyPressed(waveEvent, "PageUp")) { setFocusIndex((idx) => Math.max(idx - PageJumpSize, 0)); return true; } if (checkKeyPressed(waveEvent, "PageDown")) { setFocusIndex((idx) => Math.min(idx + PageJumpSize, filteredData.length - 1)); return true; } if (checkKeyPressed(waveEvent, "Enter")) { if (filteredData.length == 0) { return; } model.goHistory(selectedPath); setSearchText(""); globalStore.set(model.directorySearchActive, false); return true; } if (checkKeyPressed(waveEvent, "Backspace")) { if (searchText.length == 0) { return true; } setSearchText((current) => current.slice(0, -1)); return true; } if ( checkKeyPressed(waveEvent, "Space") && searchText == "" && PLATFORM == PlatformMacOS && !blockData?.meta?.connection ) { env.electron.onQuicklook(selectedPath); return true; } if (isCharacterKeyEvent(waveEvent)) { setSearchText((current) => current + waveEvent.key); return true; } return false; }; return () => { model.directoryKeyDownHandler = null; }; }, [filteredData, selectedPath, searchText]); useEffect(() => { if (filteredData.length != 0 && focusIndex > filteredData.length - 1) { setFocusIndex(filteredData.length - 1); } }, [filteredData]); const entryManagerPropsAtom = useState( atom<EntryManagerOverlayProps>(null) as PrimitiveAtom<EntryManagerOverlayProps> )[0]; const [entryManagerProps, setEntryManagerProps] = useAtom(entryManagerPropsAtom); const { refs, floatingStyles, context } = useFloating({ open: !!entryManagerProps, onOpenChange: () => setEntryManagerProps(undefined), middleware: [offset(({ rects }) => -rects.reference.height / 2 - rects.floating.height / 2)], }); const handleDropCopy = useCallback( async (data: CommandFileCopyData, isDir: boolean) => { try { await env.rpc.FileCopyCommand(TabRpcClient, data, { timeout: data.opts.timeout }); } catch (e) { console.warn("Copy failed:", e); const copyError = `${e}`; const allowRetry = copyError.includes(overwriteError) || copyError.includes(mergeError); let errorMsg: ErrorMsg; if (allowRetry) { errorMsg = { status: "Confirm Overwrite File(s)", text: "This copy operation will overwrite an existing file. Would you like to continue?", level: "warning", buttons: [ { text: "Delete Then Copy", onClick: async () => { data.opts.overwrite = true; await handleDropCopy(data, isDir); }, }, { text: "Sync", onClick: async () => { data.opts.merge = true; await handleDropCopy(data, isDir); }, }, ], }; } else { errorMsg = { status: "Copy Failed", text: copyError, level: "error", }; } setErrorMsg(errorMsg); } model.refreshCallback(); }, [model.refreshCallback] ); const [, drop] = useDrop( () => ({ accept: "FILE_ITEM", //a name of file drop type canDrop: (_, monitor) => { const dragItem = monitor.getItem<DraggedFile>(); // drop if not current dir is the parent directory of the dragged item // requires absolute path if (monitor.isOver({ shallow: false }) && dragItem.absParent !== dirPath) { return true; } return false; }, drop: async (draggedFile: DraggedFile, monitor) => { if (!monitor.didDrop()) { const timeoutYear = 31536000000; // one year const opts: FileCopyOpts = { timeout: timeoutYear, }; const desturi = await model.formatRemoteUri(dirPath, globalStore.get); const data: CommandFileCopyData = { srcuri: draggedFile.uri, desturi, opts, }; await handleDropCopy(data, draggedFile.isDir); } }, // TODO: mabe add a hover option? }), [dirPath, model.formatRemoteUri, model.refreshCallback] ); useEffect(() => { drop(refs.reference); }, [refs.reference]); const dismiss = useDismiss(context); const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); const newFile = useCallback(() => { setEntryManagerProps({ entryManagerType: EntryManagerType.NewFile, onSave: (newName: string) => { console.log(`newFile: ${newName}`); fireAndForget(async () => { await env.rpc.FileCreateCommand( TabRpcClient, { info: { path: await model.formatRemoteUri(`${dirPath}/${newName}`, globalStore.get), }, }, null ); model.refreshCallback(); }); setEntryManagerProps(undefined); }, }); }, [dirPath]); const newDirectory = useCallback(() => { setEntryManagerProps({ entryManagerType: EntryManagerType.NewDirectory, onSave: (newName: string) => { console.log(`newDirectory: ${newName}`); fireAndForget(async () => { await env.rpc.FileMkdirCommand(TabRpcClient, { info: { path: await model.formatRemoteUri(`${dirPath}/${newName}`, globalStore.get), }, }); model.refreshCallback(); }); setEntryManagerProps(undefined); }, }); }, [dirPath]); const handleFileContextMenu = useCallback( (e: any) => { e.preventDefault(); e.stopPropagation(); const menu: ContextMenuItem[] = [ { label: "New File", click: () => { newFile(); }, }, { label: "New Folder", click: () => { newDirectory(); }, }, { type: "separator", }, ]; addOpenMenuItems(menu, conn, finfo); ContextMenuModel.getInstance().showContextMenu(menu, e); }, [setRefreshVersion, conn, newFile, newDirectory, dirPath] ); return ( <Fragment> <div ref={refs.setReference} className="dir-table-container" onChangeCapture={(e) => { const event = e as React.ChangeEvent<HTMLInputElement>; if (!entryManagerProps) { setSearchText(event.target.value.toLowerCase()); } }} {...getReferenceProps()} onContextMenu={(e) => handleFileContextMenu(e)} onClick={() => setEntryManagerProps(undefined)} > <DirectoryTable model={model} data={filteredData} search={searchText} focusIndex={focusIndex} setFocusIndex={setFocusIndex} setSearch={setSearchText} setSelectedPath={setSelectedPath} setRefreshVersion={setRefreshVersion} entryManagerOverlayPropsAtom={entryManagerPropsAtom} newFile={newFile} newDirectory={newDirectory} /> </div> {entryManagerProps && ( <EntryManagerOverlay {...entryManagerProps} forwardRef={refs.setFloating} style={floatingStyles} getReferenceProps={getFloatingProps} onCancel={() => setEntryManagerProps(undefined)} /> )} </Fragment> ); } export { DirectoryPreview }; ================================================ FILE: frontend/app/view/preview/preview-edit.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import { tryReinjectKey } from "@/app/store/keymodel"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; import { useAtomValue, useSetAtom } from "jotai"; import type * as MonacoTypes from "monaco-editor"; import * as monaco from "monaco-editor"; import { useEffect } from "react"; import type { SpecializedViewProps } from "./preview"; export const shellFileMap: Record<string, string> = { ".bashrc": "shell", ".bash_profile": "shell", ".bash_login": "shell", ".bash_logout": "shell", ".profile": "shell", ".zshrc": "shell", ".zprofile": "shell", ".zshenv": "shell", ".zlogin": "shell", ".zlogout": "shell", ".kshrc": "shell", ".cshrc": "shell", ".tcshrc": "shell", ".xonshrc": "python", ".shrc": "shell", ".aliases": "shell", ".functions": "shell", ".exports": "shell", ".direnvrc": "shell", ".vimrc": "shell", ".gvimrc": "shell", }; function CodeEditPreview({ model }: SpecializedViewProps) { const fileContent = useAtomValue(model.fileContent); const setNewFileContent = useSetAtom(model.newFileContent); const fileInfo = useAtomValue(model.statFile); const fileName = fileInfo?.path || fileInfo?.name; const baseName = fileName ? fileName.split("/").pop() : null; const language = baseName && shellFileMap[baseName] ? shellFileMap[baseName] : undefined; function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean { if (checkKeyPressed(e, "Cmd:e")) { fireAndForget(() => model.setEditMode(false)); return true; } if (checkKeyPressed(e, "Cmd:s") || checkKeyPressed(e, "Ctrl:s")) { fireAndForget(model.handleFileSave.bind(model)); return true; } if (checkKeyPressed(e, "Cmd:r")) { fireAndForget(model.handleFileRevert.bind(model)); return true; } return false; } useEffect(() => { model.codeEditKeyDownHandler = codeEditKeyDownHandler; model.refreshCallback = () => { globalStore.set(model.refreshVersion, (v) => v + 1); }; return () => { model.codeEditKeyDownHandler = null; model.monacoRef.current = null; model.refreshCallback = null; }; }, []); function onMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monacoApi: typeof monaco): () => void { model.monacoRef.current = editor; const keyDownDisposer = editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => { const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent); const handled = tryReinjectKey(waveEvent); if (handled) { e.stopPropagation(); e.preventDefault(); } }); const isFocused = globalStore.get(model.nodeModel.isFocused); if (isFocused) { editor.focus(); } return () => { keyDownDisposer.dispose(); }; } return ( <CodeEditor blockId={model.blockId} text={fileContent} fileName={fileName} language={language} readonly={fileInfo.readonly} onChange={(text) => setNewFileContent(text)} onMount={onMount} /> ); } export { CodeEditPreview }; ================================================ FILE: frontend/app/view/preview/preview-error-overlay.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; import { CopyButton } from "@/app/element/copybutton"; import clsx from "clsx"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { memo, useCallback } from "react"; export const ErrorOverlay = memo(({ errorMsg, resetOverlay }: { errorMsg: ErrorMsg; resetOverlay: () => void }) => { const showDismiss = errorMsg.showDismiss ?? true; const buttonClassName = "outlined grey text-[11px] py-[3px] px-[7px]"; let iconClass = "fa-solid fa-circle-exclamation text-error text-base"; if (errorMsg.level == "warning") { iconClass = "fa-solid fa-triangle-exclamation text-warning text-base"; } const handleCopyToClipboard = useCallback(async () => { await navigator.clipboard.writeText(errorMsg.text); }, [errorMsg.text]); return ( <div className="absolute top-[0] left-1.5 right-1.5 z-[var(--zindex-block-mask-inner)] overflow-hidden bg-[var(--conn-status-overlay-bg-color)] backdrop-blur-[50px] rounded-md shadow-lg"> <div className="flex flex-row justify-between p-2.5 pl-3 font-normal text-sm leading-normal font-sans text-secondary"> <div className={clsx("flex flex-row items-center gap-3 grow min-w-0 shrink", { "items-start": true, })} > <i className={iconClass}></i> <div className="flex flex-col items-start gap-1 grow w-full shrink min-w-0"> <div className="max-w-full text-xs font-semibold leading-4 tracking-[0.11px] text-white overflow-hidden"> {errorMsg.status} </div> <OverlayScrollbarsComponent className="group text-xs font-normal leading-[15px] tracking-[0.11px] text-wrap max-h-20 rounded-lg py-1.5 pl-0 relative w-full" options={{ scrollbars: { autoHide: "leave" } }} > <CopyButton className="invisible group-hover:visible flex absolute top-0 right-1 rounded backdrop-blur-lg p-1 items-center justify-end gap-1" onClick={handleCopyToClipboard} title="Copy" /> <div>{errorMsg.text}</div> </OverlayScrollbarsComponent> {!!errorMsg.buttons && ( <div className="flex flex-row gap-2"> {errorMsg.buttons?.map((buttonDef) => ( <Button className={buttonClassName} onClick={() => { buttonDef.onClick(); resetOverlay(); }} key={crypto.randomUUID()} > {buttonDef.text} </Button> ))} </div> )} </div> {showDismiss && ( <div className="flex items-start"> <Button className={clsx(buttonClassName, "fa-xmark fa-solid")} onClick={() => { if (errorMsg.closeAction) { errorMsg.closeAction(); } resetOverlay(); }} /> </div> )} </div> </div> </div> ); }); ================================================ FILE: frontend/app/view/preview/preview-markdown.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import { Markdown } from "@/element/markdown"; import { getOverrideConfigAtom } from "@/store/global"; import { useAtomValue } from "jotai"; import { useEffect, useMemo } from "react"; import type { SpecializedViewProps } from "./preview"; function MarkdownPreview({ model }: SpecializedViewProps) { useEffect(() => { model.refreshCallback = () => { globalStore.set(model.refreshVersion, (v) => v + 1); }; return () => { model.refreshCallback = null; }; }, []); const connName = useAtomValue(model.connection); const fileInfo = useAtomValue(model.statFile); const fontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fontsize")); const fixedFontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fixedfontsize")); const resolveOpts: MarkdownResolveOpts = useMemo<MarkdownResolveOpts>(() => { return { connName: connName, baseDir: fileInfo.dir, }; }, [connName, fileInfo.dir]); return ( <div className="flex flex-row h-full overflow-auto items-start justify-start"> <Markdown textAtom={model.fileContent} showTocAtom={model.markdownShowToc} resolveOpts={resolveOpts} fontSizeOverride={fontSizeOverride} fixedFontSizeOverride={fixedFontSizeOverride} contentClassName="pt-[5px] pr-[15px] pb-[10px] pl-[15px]" /> </div> ); } export { MarkdownPreview }; ================================================ FILE: frontend/app/view/preview/preview-model.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { globalStore } from "@/app/store/jotaiStore"; import type { TabModel } from "@/app/store/tab-model"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getOverrideConfigAtom, refocusNode } from "@/store/global"; import * as WOS from "@/store/wos"; import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil"; import { checkKeyPressed } from "@/util/keyutil"; import { addOpenMenuItems } from "@/util/previewutil"; import { base64ToString, fireAndForget, isBlank, jotaiLoadableValue, stringToBase64 } from "@/util/util"; import { formatRemoteUri } from "@/util/waveutil"; import clsx from "clsx"; import { Atom, atom, Getter, PrimitiveAtom, WritableAtom } from "jotai"; import { loadable } from "jotai/utils"; import type * as MonacoTypes from "monaco-editor"; import { createRef } from "react"; import { PreviewView } from "./preview"; import { makeDirectoryDefaultMenuItems } from "./preview-directory-utils"; import type { PreviewEnv } from "./previewenv"; // TODO drive this using config const BOOKMARKS: { label: string; path: string }[] = [ { label: "Home", path: "~" }, { label: "Desktop", path: "~/Desktop" }, { label: "Downloads", path: "~/Downloads" }, { label: "Documents", path: "~/Documents" }, { label: "Root", path: "/" }, ]; const MaxFileSize = 1024 * 1024 * 10; // 10MB const MaxCSVSize = 1024 * 1024 * 1; // 1MB const textApplicationMimetypes = [ "application/sql", "application/x-php", "application/x-pem-file", "application/x-httpd-php", "application/liquid", "application/graphql", "application/javascript", "application/typescript", "application/x-javascript", "application/x-typescript", "application/dart", "application/vnd.dart", "application/x-ruby", "application/sql", "application/wasm", "application/x-latex", "application/x-sh", "application/x-python", "application/x-awk", ]; function isTextFile(mimeType: string): boolean { if (mimeType == null) { return false; } return ( mimeType.startsWith("text/") || textApplicationMimetypes.includes(mimeType) || (mimeType.startsWith("application/") && (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml"))) || mimeType.includes("xml") ); } function isStreamingType(mimeType: string): boolean { if (mimeType == null) { return false; } return ( mimeType.startsWith("application/pdf") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType.startsWith("image/") ); } function isMarkdownLike(mimeType: string): boolean { if (mimeType == null) { return false; } return mimeType.startsWith("text/markdown") || mimeType.startsWith("text/mdx"); } function iconForFile(mimeType: string): string { if (mimeType == null) { mimeType = "unknown"; } if (mimeType == "application/pdf") { return "file-pdf"; } else if (mimeType.startsWith("image/")) { return "image"; } else if (mimeType.startsWith("video/")) { return "film"; } else if (mimeType.startsWith("audio/")) { return "headphones"; } else if (isMarkdownLike(mimeType)) { return "file-lines"; } else if (mimeType == "text/csv") { return "file-csv"; } else if ( mimeType.startsWith("text/") || mimeType == "application/sql" || (mimeType.startsWith("application/") && (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml"))) ) { return "file-code"; } else { return "file"; } } export class PreviewModel implements ViewModel { viewType: string; blockId: string; nodeModel: BlockNodeModel; tabModel: TabModel; noPadding?: Atom<boolean>; blockAtom: Atom<Block>; viewIcon: Atom<string | IconButtonDecl>; viewName: Atom<string>; viewText: Atom<HeaderElem[]>; preIconButton: Atom<IconButtonDecl>; endIconButtons: Atom<IconButtonDecl[]>; hideViewName: Atom<boolean>; previewTextRef: React.RefObject<HTMLDivElement>; editMode: Atom<boolean>; canPreview: PrimitiveAtom<boolean>; specializedView: Atom<Promise<{ specializedView?: string; errorStr?: string }>>; loadableSpecializedView: Atom<Loadable<{ specializedView?: string; errorStr?: string }>>; manageConnection: Atom<boolean>; connStatus: Atom<ConnStatus>; filterOutNowsh?: Atom<boolean>; metaFilePath: Atom<string>; statFilePath: Atom<Promise<string>>; loadableFileInfo: Atom<Loadable<FileInfo>>; connection: Atom<Promise<string>>; connectionImmediate: Atom<string>; statFile: Atom<Promise<FileInfo>>; fullFile: Atom<Promise<FileData>>; fileMimeType: Atom<Promise<string>>; fileMimeTypeLoadable: Atom<Loadable<string>>; fileContentSaved: PrimitiveAtom<string | null>; fileContent: WritableAtom<Promise<string>, [string], void>; newFileContent: PrimitiveAtom<string | null>; connectionError: PrimitiveAtom<string>; errorMsgAtom: PrimitiveAtom<ErrorMsg>; openFileModal: PrimitiveAtom<boolean>; openFileModalDelay: PrimitiveAtom<boolean>; openFileError: PrimitiveAtom<string>; openFileModalGiveFocusRef: React.RefObject<() => boolean>; markdownShowToc: PrimitiveAtom<boolean>; monacoRef: React.RefObject<MonacoTypes.editor.IStandaloneCodeEditor>; showHiddenFiles: PrimitiveAtom<boolean>; refreshVersion: PrimitiveAtom<number>; directorySearchActive: PrimitiveAtom<boolean>; refreshCallback: () => void; directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; env: PreviewEnv; constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) { this.viewType = "preview"; this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; this.env = waveEnv; let showHiddenFiles = globalStore.get(this.env.getSettingsKeyAtom("preview:showhiddenfiles")) ?? true; this.showHiddenFiles = atom<boolean>(showHiddenFiles); this.refreshVersion = atom(0); this.directorySearchActive = atom(false); this.previewTextRef = createRef(); this.openFileModal = atom(false); this.openFileModalDelay = atom(false); this.openFileError = atom(null) as PrimitiveAtom<string>; this.openFileModalGiveFocusRef = createRef(); this.manageConnection = atom(true); this.blockAtom = this.env.wos.getWaveObjectAtom<Block>(`block:${blockId}`); this.markdownShowToc = atom(false); this.filterOutNowsh = atom(true); this.monacoRef = createRef(); this.connectionError = atom(""); this.errorMsgAtom = atom(null) as PrimitiveAtom<ErrorMsg | null>; this.viewIcon = atom((get) => { const blockData = get(this.blockAtom); if (blockData?.meta?.icon) { return blockData.meta.icon; } const connStatus = get(this.connStatus); if (connStatus?.status != "connected") { return null; } const mimeTypeLoadable = get(this.fileMimeTypeLoadable); const mimeType = jotaiLoadableValue(mimeTypeLoadable, ""); if (mimeType == "directory") { return { elemtype: "iconbutton", icon: "folder-open", longClick: (e: React.MouseEvent<any>) => { const menuItems: ContextMenuItem[] = BOOKMARKS.map((bookmark) => ({ label: `Go to ${bookmark.label} (${bookmark.path})`, click: () => this.goHistory(bookmark.path), })); ContextMenuModel.getInstance().showContextMenu(menuItems, e); }, }; } return iconForFile(mimeType); }); this.editMode = atom((get) => { const blockData = get(this.blockAtom); return blockData?.meta?.edit ?? false; }); this.viewName = atom("Preview"); this.hideViewName = atom(true); this.viewText = atom((get) => { let headerPath = get(this.metaFilePath); const connStatus = get(this.connStatus); if (connStatus?.status != "connected") { return [ { elemtype: "text", text: headerPath, className: "preview-filename", }, ]; } const loadableSV = get(this.loadableSpecializedView); const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit"; const loadableFileInfo = get(this.loadableFileInfo); if (loadableFileInfo.state == "hasData") { headerPath = loadableFileInfo.data?.path; if (headerPath == "~") { headerPath = `~ (${loadableFileInfo.data?.dir + "/" + loadableFileInfo.data?.name})`; } } if (!isBlank(headerPath) && headerPath != "/" && headerPath.endsWith("/")) { headerPath = headerPath.slice(0, -1); } const viewTextChildren: HeaderElem[] = [ { elemtype: "text", text: headerPath, ref: this.previewTextRef, className: "preview-filename", onClick: () => this.toggleOpenFileModal(), }, ]; let saveClassName = "grey"; if (get(this.newFileContent) !== null) { saveClassName = "green"; } if (isCeView) { const fileInfo = globalStore.get(this.loadableFileInfo); if (fileInfo.state != "hasData") { viewTextChildren.push({ elemtype: "textbutton", text: "Loading ...", className: clsx(`grey rounded-[4px] !py-[2px] !px-[10px] text-[11px] font-[500]`), onClick: () => {}, }); } else if (fileInfo.data.readonly) { viewTextChildren.push({ elemtype: "textbutton", text: "Read Only", className: clsx(`yellow rounded-[4px] !py-[2px] !px-[10px] text-[11px] font-[500]`), onClick: () => {}, }); } else { viewTextChildren.push({ elemtype: "textbutton", text: "Save", className: clsx(`${saveClassName} rounded-[4px] !py-[2px] !px-[10px] text-[11px] font-[500]`), onClick: () => fireAndForget(this.handleFileSave.bind(this)), }); } if (get(this.canPreview)) { viewTextChildren.push({ elemtype: "textbutton", text: "Preview", className: "grey rounded-[4px] !py-[2px] !px-[10px] text-[11px] font-[500]", onClick: () => fireAndForget(() => this.setEditMode(false)), }); } } else if (get(this.canPreview)) { viewTextChildren.push({ elemtype: "textbutton", text: "Edit", className: "grey rounded-[4px] !py-[2px] !px-[10px] text-[11px] font-[500]", onClick: () => fireAndForget(() => this.setEditMode(true)), }); } return [ { elemtype: "div", children: viewTextChildren, }, ] as HeaderElem[]; }); this.preIconButton = atom((get) => { const connStatus = get(this.connStatus); if (connStatus?.status != "connected") { return null; } const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); const metaPath = get(this.metaFilePath); if (mimeType == "directory" && metaPath == "/") { return null; } return { elemtype: "iconbutton", icon: "chevron-left", click: this.goParentDirectory.bind(this), }; }); this.endIconButtons = atom((get) => { const connStatus = get(this.connStatus); if (connStatus?.status != "connected") { return null; } const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); const loadableSV = get(this.loadableSpecializedView); const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit"; if (mimeType == "directory") { const showHiddenFiles = get(this.showHiddenFiles); return [ { elemtype: "iconbutton", icon: showHiddenFiles ? "eye" : "eye-slash", title: showHiddenFiles ? "Hide Hidden Files" : "Show Hidden Files", click: () => { globalStore.set(this.showHiddenFiles, (prev) => !prev); }, }, { elemtype: "iconbutton", icon: "arrows-rotate", click: () => this.refreshCallback?.(), }, ] as IconButtonDecl[]; } else if (!isCeView && isMarkdownLike(mimeType)) { return [ { elemtype: "iconbutton", icon: "book", title: "Table of Contents", click: () => this.markdownShowTocToggle(), }, { elemtype: "iconbutton", icon: "arrows-rotate", title: "Refresh", click: () => this.refreshCallback?.(), }, ] as IconButtonDecl[]; } else if (!isCeView && mimeType) { // For all other file types (text, code, etc.), add refresh button return [ { elemtype: "iconbutton", icon: "arrows-rotate", title: "Refresh", click: () => this.refreshCallback?.(), }, ] as IconButtonDecl[]; } return null; }); this.metaFilePath = atom<string>((get) => { const file = get(this.blockAtom)?.meta?.file; if (isBlank(file)) { return "~"; } return file; }); this.statFilePath = atom<Promise<string>>(async (get) => { const fileInfo = await get(this.statFile); return fileInfo?.path; }); this.connection = atom<Promise<string>>(async (get) => { const connName = get(this.blockAtom)?.meta?.connection; try { await this.env.rpc.ConnEnsureCommand(TabRpcClient, { connname: connName }, { timeout: 60000 }); globalStore.set(this.connectionError, ""); } catch (e) { globalStore.set(this.connectionError, e as string); } return connName; }); this.connectionImmediate = atom<string>((get) => { return get(this.blockAtom)?.meta?.connection; }); this.statFile = atom<Promise<FileInfo>>(async (get) => { const fileName = get(this.metaFilePath); const path = await this.formatRemoteUri(fileName, get); if (fileName == null) { return null; } try { const statFile = await this.env.rpc.FileInfoCommand(TabRpcClient, { info: { path, }, }); return statFile; } catch (e) { const errorStatus: ErrorMsg = { status: "File Read Failed", text: `${e}`, }; globalStore.set(this.errorMsgAtom, errorStatus); } }); this.fileMimeType = atom<Promise<string>>(async (get) => { const fileInfo = await get(this.statFile); return fileInfo?.mimetype; }); this.fileMimeTypeLoadable = loadable(this.fileMimeType); this.newFileContent = atom(null) as PrimitiveAtom<string | null>; this.goParentDirectory = this.goParentDirectory.bind(this); const fullFileAtom = atom<Promise<FileData>>(async (get) => { get(this.refreshVersion); // Subscribe to refreshVersion to trigger re-fetch const fileName = get(this.metaFilePath); const path = await this.formatRemoteUri(fileName, get); if (fileName == null) { return null; } try { const file = await this.env.rpc.FileReadCommand(TabRpcClient, { info: { path, }, }); return file; } catch (e) { const errorStatus: ErrorMsg = { status: "File Read Failed", text: `${e}`, }; globalStore.set(this.errorMsgAtom, errorStatus); } }); this.fileContentSaved = atom(null) as PrimitiveAtom<string | null>; const fileContentAtom = atom( async (get) => { const newContent = get(this.newFileContent); if (newContent != null) { return newContent; } const savedContent = get(this.fileContentSaved); if (savedContent != null) { return savedContent; } const fullFile = await get(fullFileAtom); return base64ToString(fullFile?.data64); }, (_, set, update: string) => { set(this.fileContentSaved, update); } ); this.fullFile = fullFileAtom; this.fileContent = fileContentAtom; this.specializedView = atom<Promise<{ specializedView?: string; errorStr?: string }>>(async (get) => { return this.getSpecializedView(get); }); this.loadableSpecializedView = loadable(this.specializedView); this.canPreview = atom(false); this.loadableFileInfo = loadable(this.statFile); this.connStatus = atom((get) => { const blockData = get(this.blockAtom); const connName = blockData?.meta?.connection; const connAtom = this.env.getConnStatusAtom(connName); return get(connAtom); }); this.noPadding = atom(true); } markdownShowTocToggle() { globalStore.set(this.markdownShowToc, !globalStore.get(this.markdownShowToc)); } get viewComponent(): ViewComponent { return PreviewView; } async getSpecializedView(getFn: Getter): Promise<{ specializedView?: string; errorStr?: string }> { const mimeType = await getFn(this.fileMimeType); const fileInfo = await getFn(this.statFile); const fileName = fileInfo?.name; const connErr = getFn(this.connectionError); const editMode = getFn(this.editMode); const genErr = getFn(this.errorMsgAtom); if (!fileInfo) { return { errorStr: `Load Error: ${genErr?.text}` }; } if (connErr != "") { return { errorStr: `Connection Error: ${connErr}` }; } if (fileInfo?.notfound) { return { specializedView: "codeedit" }; } if (mimeType == null) { return { errorStr: `Unable to determine mimetype for: ${fileInfo.path}` }; } if (isStreamingType(mimeType)) { return { specializedView: "streaming" }; } if (!fileInfo) { const fileNameStr = fileName ? " " + JSON.stringify(fileName) : ""; return { errorStr: "File Not Found" + fileNameStr }; } if (fileInfo.size > MaxFileSize) { return { errorStr: "File Too Large to Preview (10 MB Max)" }; } if (mimeType == "text/csv" && fileInfo.size > MaxCSVSize) { return { errorStr: "CSV File Too Large to Preview (1 MB Max)" }; } if (mimeType == "directory") { return { specializedView: "directory" }; } if (mimeType == "text/csv") { if (editMode) { return { specializedView: "codeedit" }; } return { specializedView: "csv" }; } if (isMarkdownLike(mimeType)) { if (editMode) { return { specializedView: "codeedit" }; } return { specializedView: "markdown" }; } if (isTextFile(mimeType) || fileInfo.size == 0) { return { specializedView: "codeedit" }; } return { errorStr: `Preview (${mimeType})` }; } updateOpenFileModalAndError(isOpen, errorMsg = null) { globalStore.set(this.openFileModal, isOpen); globalStore.set(this.openFileError, errorMsg); if (isOpen) { globalStore.set(this.openFileModalDelay, true); } else { const delayVal = globalStore.get(this.openFileModalDelay); if (delayVal) { setTimeout(() => { globalStore.set(this.openFileModalDelay, false); }, 200); } } } toggleOpenFileModal() { const modalOpen = globalStore.get(this.openFileModal); const delayVal = globalStore.get(this.openFileModalDelay); if (!modalOpen && delayVal) { return; } this.updateOpenFileModalAndError(!modalOpen); } async goHistory(newPath: string) { let fileName = globalStore.get(this.metaFilePath); if (fileName == null) { fileName = ""; } const blockMeta = globalStore.get(this.blockAtom)?.meta; const updateMeta = goHistory("file", fileName, newPath, blockMeta); if (updateMeta == null) { return; } const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); // Clear the saved file buffers globalStore.set(this.fileContentSaved, null); globalStore.set(this.newFileContent, null); } async goParentDirectory({ fileInfo = null }: { fileInfo?: FileInfo | null }) { // optional parameter needed for recursive case const defaultFileInfo = await globalStore.get(this.statFile); if (fileInfo === null) { fileInfo = defaultFileInfo; } if (fileInfo == null) { this.updateOpenFileModalAndError(false); return true; } try { this.updateOpenFileModalAndError(false); await this.goHistory(fileInfo.dir); refocusNode(this.blockId); } catch (e) { globalStore.set(this.openFileError, e.message); console.error("Error opening file", fileInfo.dir, e); } } async goHistoryBack() { const blockMeta = globalStore.get(this.blockAtom)?.meta; const curPath = globalStore.get(this.metaFilePath); const updateMeta = goHistoryBack("file", curPath, blockMeta, true); if (updateMeta == null) { return; } updateMeta.edit = false; const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); } async goHistoryForward() { const blockMeta = globalStore.get(this.blockAtom)?.meta; const curPath = globalStore.get(this.metaFilePath); const updateMeta = goHistoryForward("file", curPath, blockMeta); if (updateMeta == null) { return; } updateMeta.edit = false; const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, updateMeta); } async setEditMode(edit: boolean) { const blockMeta = globalStore.get(this.blockAtom)?.meta; const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, { ...blockMeta, edit }); } async handleFileSave() { const filePath = await globalStore.get(this.statFilePath); if (filePath == null) { return; } const newFileContent = globalStore.get(this.newFileContent); if (newFileContent == null) { console.log("not saving file, newFileContent is null"); return; } try { await this.env.rpc.FileWriteCommand(TabRpcClient, { info: { path: await this.formatRemoteUri(filePath, globalStore.get), }, data64: stringToBase64(newFileContent), }); globalStore.set(this.fileContent, newFileContent); globalStore.set(this.newFileContent, null); console.log("saved file", filePath); } catch (e) { const errorStatus: ErrorMsg = { status: "Save Failed", text: `${e}`, }; globalStore.set(this.errorMsgAtom, errorStatus); } } async handleFileRevert() { const fileContent = await globalStore.get(this.fileContent); this.monacoRef.current?.setValue(fileContent); globalStore.set(this.newFileContent, null); } async handleOpenFile(filePath: string) { const fileInfo = await globalStore.get(this.statFile); this.updateOpenFileModalAndError(false); if (fileInfo == null) { return true; } try { this.goHistory(filePath); refocusNode(this.blockId); } catch (e) { globalStore.set(this.openFileError, e.message); console.error("Error opening file", filePath, e); } } isSpecializedView(sv: string): boolean { const loadableSV = globalStore.get(this.loadableSpecializedView); return loadableSV.state == "hasData" && loadableSV.data.specializedView == sv; } getSettingsMenuItems(): ContextMenuItem[] { const defaultFontSize = globalStore.get(this.env.getSettingsKeyAtom("editor:fontsize")) ?? 12; const blockData = globalStore.get(this.blockAtom); const overrideFontSize = blockData?.meta?.["editor:fontsize"]; const menuItems: ContextMenuItem[] = []; menuItems.push({ label: "Copy Full Path", click: () => fireAndForget(async () => { const filePath = await globalStore.get(this.statFilePath); if (filePath == null) { return; } const conn = await globalStore.get(this.connection); if (conn) { // remote path await navigator.clipboard.writeText(formatRemoteUri(filePath, conn)); } else { // local path await navigator.clipboard.writeText(filePath); } }), }); menuItems.push({ label: "Copy File Name", click: () => fireAndForget(async () => { const fileInfo = await globalStore.get(this.statFile); if (fileInfo == null || fileInfo.name == null) { return; } await navigator.clipboard.writeText(fileInfo.name); }), }); menuItems.push({ type: "separator" }); const finfo = jotaiLoadableValue(globalStore.get(this.loadableFileInfo), null); addOpenMenuItems(menuItems, globalStore.get(this.connectionImmediate), finfo); const loadableSV = globalStore.get(this.loadableSpecializedView); const wordWrapAtom = getOverrideConfigAtom(this.blockId, "editor:wordwrap"); const wordWrap = globalStore.get(wordWrapAtom) ?? false; menuItems.push({ type: "separator" }); if (loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit") { const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map( (fontSize: number) => { return { label: fontSize.toString() + "px", type: "checkbox", checked: overrideFontSize == fontSize, click: () => { this.env.rpc.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "editor:fontsize": fontSize }, }); }, }; } ); fontSizeSubMenu.unshift({ label: "Default (" + defaultFontSize + "px)", type: "checkbox", checked: overrideFontSize == null, click: () => { this.env.rpc.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "editor:fontsize": null }, }); }, }); menuItems.push({ label: "Editor Font Size", submenu: fontSizeSubMenu, }); if (globalStore.get(this.newFileContent) != null) { menuItems.push({ type: "separator" }); menuItems.push({ label: "Save File", click: () => fireAndForget(this.handleFileSave.bind(this)), }); menuItems.push({ label: "Revert File", click: () => fireAndForget(this.handleFileRevert.bind(this)), }); } menuItems.push({ type: "separator" }); menuItems.push({ label: "Word Wrap", type: "checkbox", checked: wordWrap, click: () => fireAndForget(async () => { const blockOref = WOS.makeORef("block", this.blockId); await this.env.services.object.UpdateObjectMeta(blockOref, { "editor:wordwrap": !wordWrap, }); }), }); } if (loadableSV.state == "hasData" && loadableSV.data.specializedView == "directory") { menuItems.push({ type: "separator" }); menuItems.push({ label: "Default Settings", enabled: false }); menuItems.push(...makeDirectoryDefaultMenuItems(this)); } return menuItems; } giveFocus(): boolean { const openModalOpen = globalStore.get(this.openFileModal); if (openModalOpen) { this.openFileModalGiveFocusRef.current?.(); return true; } if (this.monacoRef.current) { this.monacoRef.current.focus(); return true; } return false; } keyDownHandler(e: WaveKeyboardEvent): boolean { if (checkKeyPressed(e, "Cmd:ArrowLeft")) { fireAndForget(this.goHistoryBack.bind(this)); return true; } if (checkKeyPressed(e, "Cmd:ArrowRight")) { fireAndForget(this.goHistoryForward.bind(this)); return true; } if (checkKeyPressed(e, "Cmd:ArrowUp")) { // handle up directory fireAndForget(() => this.goParentDirectory({})); return true; } if (checkKeyPressed(e, "Cmd:o")) { this.toggleOpenFileModal(); return true; } const canPreview = globalStore.get(this.canPreview); if (canPreview) { if (checkKeyPressed(e, "Cmd:e")) { const editMode = globalStore.get(this.editMode); fireAndForget(() => this.setEditMode(!editMode)); return true; } } if (this.directoryKeyDownHandler) { const handled = this.directoryKeyDownHandler(e); if (handled) { return true; } } if (this.codeEditKeyDownHandler) { const handled = this.codeEditKeyDownHandler(e); if (handled) { return true; } } return false; } async formatRemoteUri(path: string, get: Getter): Promise<string> { return formatRemoteUri(path, await get(this.connection)); } } ================================================ FILE: frontend/app/view/preview/preview-streaming.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; import { CenteredDiv } from "@/app/element/quickelems"; import { globalStore } from "@/app/store/jotaiStore"; import { getWebServerEndpoint } from "@/util/endpoints"; import { formatRemoteUri } from "@/util/waveutil"; import { useAtomValue } from "jotai"; import { useEffect } from "react"; import { TransformComponent, TransformWrapper, useControls } from "react-zoom-pan-pinch"; import type { SpecializedViewProps } from "./preview"; function ImageZoomControls() { const { zoomIn, zoomOut, resetTransform } = useControls(); return ( <div className="absolute flex flex-row z-[2] top-0 right-0 p-[5px] gap-1"> <Button onClick={() => zoomIn()} title="Zoom In" className="py-1 px-[5px]"> <i className="fa-sharp fa-plus" /> </Button> <Button onClick={() => zoomOut()} title="Zoom Out" className="py-1 px-[5px]"> <i className="fa-sharp fa-minus" /> </Button> <Button onClick={() => resetTransform()} title="Reset Zoom" className="py-1 px-[5px]"> <i className="fa-sharp fa-rotate-left" /> </Button> </div> ); } function StreamingImagePreview({ url }: { url: string }) { return ( <div className="flex flex-row h-full overflow-hidden items-center justify-center relative"> <TransformWrapper initialScale={1} centerOnInit pinch={{ step: 10 }}> {({ zoomIn, zoomOut, resetTransform, ...rest }) => ( <> <ImageZoomControls /> <TransformComponent wrapperClass="!h-full !w-full"> <img src={url} className="z-[1]" /> </TransformComponent> </> )} </TransformWrapper> </div> ); } function StreamingPreview({ model }: SpecializedViewProps) { useEffect(() => { model.refreshCallback = () => { globalStore.set(model.refreshVersion, (v) => v + 1); }; return () => { model.refreshCallback = null; }; }, []); const conn = useAtomValue(model.connection); const fileInfo = useAtomValue(model.statFile); const filePath = fileInfo.path; const remotePath = formatRemoteUri(filePath, conn); const usp = new URLSearchParams(); usp.set("path", remotePath); const streamingUrl = `${getWebServerEndpoint()}/wave/stream-file?${usp.toString()}`; if (fileInfo.mimetype === "application/pdf") { return ( <div className="flex flex-row h-full overflow-hidden items-center justify-center p-[5px]"> <iframe src={streamingUrl} width="100%" height="100%" name="pdfview" /> </div> ); } if (fileInfo.mimetype.startsWith("video/")) { return ( <div className="flex flex-row h-full overflow-hidden items-center justify-center"> <video controls src={streamingUrl} className="w-full h-full p-[10px] object-contain" /> </div> ); } if (fileInfo.mimetype.startsWith("audio/")) { return ( <div className="flex flex-row h-full overflow-hidden items-center justify-center"> <audio controls src={streamingUrl} className="w-full h-full p-[10px] object-contain" /> </div> ); } if (fileInfo.mimetype.startsWith("image/")) { return <StreamingImagePreview url={streamingUrl} />; } return <CenteredDiv>Preview Not Supported</CenteredDiv>; } export { StreamingPreview }; ================================================ FILE: frontend/app/view/preview/preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { CenteredDiv } from "@/app/element/quickelems"; import { globalStore } from "@/app/store/jotaiStore"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { isBlank, makeConnRoute } from "@/util/util"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { memo, useEffect } from "react"; import { CSVView } from "./csvview"; import { DirectoryPreview } from "./preview-directory"; import { CodeEditPreview } from "./preview-edit"; import { ErrorOverlay } from "./preview-error-overlay"; import { MarkdownPreview } from "./preview-markdown"; import type { PreviewModel } from "./preview-model"; import { StreamingPreview } from "./preview-streaming"; import type { PreviewEnv } from "./previewenv"; export type SpecializedViewProps = { model: PreviewModel; parentRef: React.RefObject<HTMLDivElement>; }; const SpecializedViewMap: { [view: string]: ({ model }: SpecializedViewProps) => React.JSX.Element } = { streaming: StreamingPreview, markdown: MarkdownPreview, codeedit: CodeEditPreview, csv: CSVViewPreview, directory: DirectoryPreview, }; function canPreview(mimeType: string): boolean { if (mimeType == null) { return false; } return mimeType.startsWith("text/markdown") || mimeType.startsWith("text/csv"); } function CSVViewPreview({ model, parentRef }: SpecializedViewProps) { const fileContent = useAtomValue(model.fileContent); const fileName = useAtomValue(model.statFilePath); return <CSVView parentRef={parentRef} readonly={true} content={fileContent} filename={fileName} />; } const SpecializedView = memo(({ parentRef, model }: SpecializedViewProps) => { const specializedView = useAtomValue(model.specializedView); const mimeType = useAtomValue(model.fileMimeType); const setCanPreview = useSetAtom(model.canPreview); const path = useAtomValue(model.statFilePath); useEffect(() => { setCanPreview(canPreview(mimeType)); }, [mimeType, setCanPreview]); if (specializedView.errorStr != null) { return <CenteredDiv>{specializedView.errorStr}</CenteredDiv>; } const SpecializedViewComponent = SpecializedViewMap[specializedView.specializedView]; if (!SpecializedViewComponent) { return <CenteredDiv>Invalid Specialized View Component ({specializedView.specializedView})</CenteredDiv>; } return <SpecializedViewComponent key={path} model={model} parentRef={parentRef} />; }); const fetchSuggestions = async ( env: PreviewEnv, model: PreviewModel, query: string, reqContext: SuggestionRequestContext ): Promise<FetchSuggestionsResponse> => { const conn = await globalStore.get(model.connection); let route = makeConnRoute(conn); if (isBlank(conn)) { route = null; } if (reqContext?.dispose) { env.rpc.DisposeSuggestionsCommand(TabRpcClient, reqContext.widgetid, { noresponse: true, route: route }); return null; } const fileInfo = await globalStore.get(model.statFile); if (fileInfo == null) { return null; } const sdata = { suggestiontype: "file", "file:cwd": fileInfo.path, query: query, widgetid: reqContext.widgetid, reqnum: reqContext.reqnum, "file:connection": conn, }; return await env.rpc.FetchSuggestionsCommand(TabRpcClient, sdata, { route: route, }); }; function PreviewView({ blockRef, contentRef, model, }: { blockId: string; blockRef: React.RefObject<HTMLDivElement>; contentRef: React.RefObject<HTMLDivElement>; model: PreviewModel; }) { const env = useWaveEnv<PreviewEnv>(); const connStatus = useAtomValue(model.connStatus); const [errorMsg, setErrorMsg] = useAtom(model.errorMsgAtom); const connection = useAtomValue(model.connectionImmediate); const fileInfo = useAtomValue(model.statFile); useEffect(() => { console.log("fileInfo or connection changed", fileInfo, connection); if (!fileInfo) { return; } setErrorMsg(null); }, [connection, fileInfo]); if (connStatus?.status != "connected") { return null; } const handleSelect = (s: SuggestionType, queryStr: string): boolean => { if (s == null) { if (isBlank(queryStr)) { globalStore.set(model.openFileModal, false); return true; } model.handleOpenFile(queryStr); return true; } model.handleOpenFile(s["file:path"]); return true; }; const handleTab = (s: SuggestionType, query: string): string => { if (s["file:mimetype"] == "directory") { return s["file:name"] + "/"; } else { return s["file:name"]; } }; const fetchSuggestionsFn = async (query, ctx) => { return await fetchSuggestions(env, model, query, ctx); }; return ( <> <div key="fullpreview" className="flex flex-col w-full overflow-hidden scrollbar-hide-until-hover"> {errorMsg && <ErrorOverlay errorMsg={errorMsg} resetOverlay={() => setErrorMsg(null)} />} <div ref={contentRef} className="flex-grow overflow-hidden"> <SpecializedView parentRef={contentRef} model={model} /> </div> </div> <BlockHeaderSuggestionControl blockRef={blockRef} openAtom={model.openFileModal} onClose={() => model.updateOpenFileModalAndError(false)} onSelect={handleSelect} onTab={handleTab} fetchSuggestions={fetchSuggestionsFn} placeholderText="Open File..." /> </> ); } export { PreviewView }; ================================================ FILE: frontend/app/view/preview/previewenv.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; export type PreviewEnv = WaveEnvSubset<{ electron: { onQuicklook: WaveEnv["electron"]["onQuicklook"]; }; rpc: { ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; FileInfoCommand: WaveEnv["rpc"]["FileInfoCommand"]; FileReadCommand: WaveEnv["rpc"]["FileReadCommand"]; FileWriteCommand: WaveEnv["rpc"]["FileWriteCommand"]; FileMoveCommand: WaveEnv["rpc"]["FileMoveCommand"]; FileDeleteCommand: WaveEnv["rpc"]["FileDeleteCommand"]; SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; FetchSuggestionsCommand: WaveEnv["rpc"]["FetchSuggestionsCommand"]; DisposeSuggestionsCommand: WaveEnv["rpc"]["DisposeSuggestionsCommand"]; FileCopyCommand: WaveEnv["rpc"]["FileCopyCommand"]; FileCreateCommand: WaveEnv["rpc"]["FileCreateCommand"]; FileMkdirCommand: WaveEnv["rpc"]["FileMkdirCommand"]; }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; }; services: { object: WaveEnv["services"]["object"]; }; wos: WaveEnv["wos"]; getSettingsKeyAtom: SettingsKeyAtomFnType<"preview:showhiddenfiles" | "editor:fontsize" | "preview:defaultsort">; getConnStatusAtom: WaveEnv["getConnStatusAtom"]; }>; ================================================ FILE: frontend/app/view/quicktipsview/quicktipsview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; import { QuickTips } from "@/app/element/quicktips"; import { globalStore } from "@/app/store/global"; import type { TabModel } from "@/app/store/tab-model"; import { Atom, atom, PrimitiveAtom } from "jotai"; class QuickTipsViewModel implements ViewModel { viewType: string; blockId: string; nodeModel: BlockNodeModel; tabModel: TabModel; showTocAtom: PrimitiveAtom<boolean>; endIconButtons: Atom<IconButtonDecl[]>; constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; this.viewType = "tips"; this.showTocAtom = atom(false); } get viewComponent(): ViewComponent { return QuickTipsView; } showTocToggle() { globalStore.set(this.showTocAtom, !globalStore.get(this.showTocAtom)); } } function QuickTipsView({ model }: { model: QuickTipsViewModel }) { return ( <div className="px-[5px] py-[10px] overflow-auto w-full"> <QuickTips /> </div> ); } export { QuickTipsViewModel }; ================================================ FILE: frontend/app/view/sysinfo/sysinfo.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import { makeORef } from "@/app/store/wos"; import * as util from "@/util/util"; import * as Plot from "@observablehq/plot"; import clsx from "clsx"; import dayjs from "dayjs"; import * as htl from "htl"; import * as jotai from "jotai"; import * as React from "react"; import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import type { BlockMetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; export type SysinfoEnv = WaveEnvSubset<{ rpc: { EventReadHistoryCommand: WaveEnv["rpc"]["EventReadHistoryCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; }; getConnStatusAtom: WaveEnv["getConnStatusAtom"]; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"graph:numpoints" | "sysinfo:type" | "connection" | "count">; }>; const DefaultNumPoints = 120; type DataItem = { ts: number; [k: string]: number; }; function defaultCpuMeta(name: string): TimeSeriesMeta { return { name: name, label: "%", miny: 0, maxy: 100, color: "var(--sysinfo-cpu-color)", decimalPlaces: 0, }; } function defaultMemMeta(name: string, maxY: string): TimeSeriesMeta { return { name: name, label: "GB", miny: 0, maxy: maxY, color: "var(--sysinfo-mem-color)", decimalPlaces: 1, }; } const PlotTypes: object = { CPU: function (_dataItem: DataItem): Array<string> { return ["cpu"]; }, Mem: function (_dataItem: DataItem): Array<string> { return ["mem:used"]; }, "CPU + Mem": function (_dataItem: DataItem): Array<string> { return ["cpu", "mem:used"]; }, "All CPU": function (dataItem: DataItem): Array<string> { return Object.keys(dataItem) .filter((item) => item.startsWith("cpu") && item != "cpu") .sort((a, b) => { const valA = parseInt(a.replace("cpu:", "")); const valB = parseInt(b.replace("cpu:", "")); return valA - valB; }); }, }; const DefaultPlotMeta = { cpu: defaultCpuMeta("CPU %"), "mem:total": defaultMemMeta("Memory Total", "mem:total"), "mem:used": defaultMemMeta("Memory Used", "mem:total"), "mem:free": defaultMemMeta("Memory Free", "mem:total"), "mem:available": defaultMemMeta("Memory Available", "mem:total"), }; for (let i = 0; i < 32; i++) { DefaultPlotMeta[`cpu:${i}`] = defaultCpuMeta(`Core ${i}`); } function convertWaveEventToDataItem(event: Extract<WaveEvent, { event: "sysinfo" }>): DataItem { const eventData = event.data; if (eventData == null || eventData.ts == null || eventData.values == null) { return null; } const dataItem = { ts: eventData.ts }; for (const key in eventData.values) { dataItem[key] = eventData.values[key]; } return dataItem; } class SysinfoViewModel implements ViewModel { viewType: string; termMode: jotai.Atom<string>; htmlElemFocusRef: React.RefObject<HTMLInputElement>; blockId: string; viewIcon: jotai.Atom<string>; viewText: jotai.Atom<string>; viewName: jotai.Atom<string>; dataAtom: jotai.PrimitiveAtom<Array<DataItem>>; addInitialDataAtom: jotai.WritableAtom<unknown, [DataItem[]], void>; addContinuousDataAtom: jotai.WritableAtom<unknown, [DataItem], void>; incrementCount: jotai.WritableAtom<unknown, [], Promise<void>>; loadingAtom: jotai.PrimitiveAtom<boolean>; numPoints: jotai.Atom<number>; metrics: jotai.Atom<string[]>; connection: jotai.Atom<string>; manageConnection: jotai.Atom<boolean>; filterOutNowsh: jotai.Atom<boolean>; connStatus: jotai.Atom<ConnStatus>; plotMetaAtom: jotai.PrimitiveAtom<Map<string, TimeSeriesMeta>>; endIconButtons: jotai.Atom<IconButtonDecl[]>; plotTypeSelectedAtom: jotai.Atom<string>; env: SysinfoEnv; constructor({ blockId, waveEnv }: ViewModelInitType) { this.viewType = "sysinfo"; this.blockId = blockId; this.env = waveEnv; this.addInitialDataAtom = jotai.atom(null, (get, set, points) => { const targetLen = get(this.numPoints) + 1; try { const newDataRaw = [...points]; if (newDataRaw.length == 0) { return; } const latestItemTs = newDataRaw[newDataRaw.length - 1]?.ts ?? 0; const cutoffTs = latestItemTs - 1000 * targetLen; const blankItemTemplate = { ...newDataRaw[newDataRaw.length - 1] }; for (const key in blankItemTemplate) { blankItemTemplate[key] = NaN; } const newDataFiltered = newDataRaw.filter((dataItem) => dataItem.ts >= cutoffTs); if (newDataFiltered.length == 0) { return; } const newDataWithGaps: Array<DataItem> = []; if (newDataFiltered[0].ts > cutoffTs) { const blankItemStart = { ...blankItemTemplate, ts: cutoffTs }; const blankItemEnd = { ...blankItemTemplate, ts: newDataFiltered[0].ts - 1 }; newDataWithGaps.push(blankItemStart); newDataWithGaps.push(blankItemEnd); } newDataWithGaps.push(newDataFiltered[0]); for (let i = 1; i < newDataFiltered.length; i++) { const prevIdxItem = newDataFiltered[i - 1]; const curIdxItem = newDataFiltered[i]; const timeDiff = curIdxItem.ts - prevIdxItem.ts; if (timeDiff > 2000) { const blankItemStart = { ...blankItemTemplate, ts: prevIdxItem.ts + 1, blank: 1 }; const blankItemEnd = { ...blankItemTemplate, ts: curIdxItem.ts - 1, blank: 1 }; newDataWithGaps.push(blankItemStart); newDataWithGaps.push(blankItemEnd); } newDataWithGaps.push(curIdxItem); } set(this.dataAtom, newDataWithGaps); } catch (e) { console.log("Error adding data to sysinfo", e); } }); this.addContinuousDataAtom = jotai.atom(null, (get, set, newPoint) => { const targetLen = get(this.numPoints) + 1; const data = get(this.dataAtom); try { const latestItemTs = newPoint?.ts ?? 0; const cutoffTs = latestItemTs - 1000 * targetLen; data.push(newPoint); const newData = data.filter((dataItem) => dataItem.ts >= cutoffTs); set(this.dataAtom, newData); } catch (e) { console.log("Error adding data to sysinfo", e); } }); this.plotMetaAtom = jotai.atom(new Map(Object.entries(DefaultPlotMeta))); this.manageConnection = jotai.atom(true); this.filterOutNowsh = jotai.atom(true); this.loadingAtom = jotai.atom(true); this.numPoints = jotai.atom((get) => { const metaNumPoints = get(this.env.getBlockMetaKeyAtom(blockId, "graph:numpoints")); if (metaNumPoints == null || metaNumPoints <= 0) { return DefaultNumPoints; } return metaNumPoints; }); this.metrics = jotai.atom((get) => { const plotType = get(this.plotTypeSelectedAtom); const plotData = get(this.dataAtom); try { const metrics = PlotTypes[plotType](plotData[plotData.length - 1]); if (metrics == null || !Array.isArray(metrics)) { return ["cpu"]; } return metrics; } catch (e) { return ["cpu"]; } }); this.plotTypeSelectedAtom = jotai.atom((get) => { const plotType = get(this.env.getBlockMetaKeyAtom(blockId, "sysinfo:type")); if (plotType == null || typeof plotType != "string") { return "CPU"; } return plotType; }); this.viewIcon = jotai.atom((get) => { return "chart-line"; // should not be hardcoded }); this.viewName = jotai.atom((get) => { return get(this.plotTypeSelectedAtom); }); this.incrementCount = jotai.atom(null, async (get, _set) => { const count = get(this.env.getBlockMetaKeyAtom(blockId, "count")) ?? 0; await this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { count: count + 1 }, }); }); this.connection = jotai.atom((get) => { const connValue = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); if (util.isBlank(connValue)) { return "local"; } return connValue; }); this.dataAtom = jotai.atom([]); this.loadInitialData(); this.connStatus = jotai.atom((get) => { const connName = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); const connAtom = this.env.getConnStatusAtom(connName); return get(connAtom); }); } get viewComponent(): ViewComponent { return SysinfoView; } async loadInitialData() { globalStore.set(this.loadingAtom, true); try { const numPoints = globalStore.get(this.numPoints); const connName = globalStore.get(this.connection); const initialData = await this.env.rpc.EventReadHistoryCommand(TabRpcClient, { event: "sysinfo", scope: connName, maxitems: numPoints, }); if (initialData == null) { return; } this.getDefaultData(); const initialDataItems: DataItem[] = initialData.map(convertWaveEventToDataItem); // splice the initial data into the default data (replacing the newest points) //newData.splice(newData.length - initialDataItems.length, initialDataItems.length, ...initialDataItems); globalStore.set(this.addInitialDataAtom, initialDataItems); } catch (e) { console.log("Error loading initial data for sysinfo", e); } finally { globalStore.set(this.loadingAtom, false); } } getSettingsMenuItems(): ContextMenuItem[] { const fullConfig = globalStore.get(this.env.atoms.fullConfigAtom); const termThemes = fullConfig?.termthemes ?? {}; const termThemeKeys = Object.keys(termThemes); const plotData = globalStore.get(this.dataAtom); termThemeKeys.sort((a, b) => { return (termThemes[a]["display:order"] ?? 0) - (termThemes[b]["display:order"] ?? 0); }); const fullMenu: ContextMenuItem[] = []; let submenu: ContextMenuItem[]; if (plotData.length == 0) { submenu = []; } else { submenu = Object.keys(PlotTypes).map((plotType) => { const dataTypes = PlotTypes[plotType](plotData[plotData.length - 1]); const currentlySelected = globalStore.get(this.plotTypeSelectedAtom); const menuItem: ContextMenuItem = { label: plotType, type: "radio", checked: currentlySelected == plotType, click: async () => { await this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { "graph:metrics": dataTypes, "sysinfo:type": plotType }, }); }, }; return menuItem; }); } fullMenu.push({ label: "Plot Type", submenu: submenu, }); fullMenu.push({ type: "separator" }); return fullMenu; } getDefaultData(): DataItem[] { // set it back one to avoid backwards line being possible const numPoints = globalStore.get(this.numPoints); const currentTime = Date.now() - 1000; const points: DataItem[] = []; for (let i = numPoints; i > -1; i--) { points.push({ ts: currentTime - i * 1000 }); } return points; } } const _plotColors = ["#58C142", "#FFC107", "#FF5722", "#2196F3", "#9C27B0", "#00BCD4", "#FFEB3B", "#795548"]; type SysinfoViewProps = { blockId: string; model: SysinfoViewModel; }; function resolveDomainBound(value: number | string, dataItem: DataItem): number | undefined { if (typeof value == "number") { return value; } else if (typeof value == "string") { return dataItem?.[value]; } else { return undefined; } } function SysinfoView({ model, blockId }: SysinfoViewProps) { const connName = jotai.useAtomValue(model.connection); const lastConnName = React.useRef(connName); const connStatus = jotai.useAtomValue(model.connStatus); const addContinuousData = jotai.useSetAtom(model.addContinuousDataAtom); const loading = jotai.useAtomValue(model.loadingAtom); React.useEffect(() => { if (connStatus?.status != "connected") { return; } if (lastConnName.current !== connName) { lastConnName.current = connName; model.loadInitialData(); } }, [connStatus.status, connName]); React.useEffect(() => { const unsubFn = waveEventSubscribeSingle({ eventType: "sysinfo", scope: connName, handler: (event) => { const loading = globalStore.get(model.loadingAtom); if (loading) { return; } const dataItem = convertWaveEventToDataItem(event); const prevData = globalStore.get(model.dataAtom); const prevLastTs = prevData[prevData.length - 1]?.ts ?? 0; if (dataItem.ts - prevLastTs > 2000) { model.loadInitialData(); } else { addContinuousData(dataItem); } }, }); console.log("subscribe to sysinfo", connName); return () => { unsubFn(); }; }, [connName, addContinuousData]); if (connStatus?.status != "connected") { return null; } if (loading) { return null; } return <SysinfoViewInner key={connStatus?.connection ?? "local"} blockId={blockId} model={model} />; } type SingleLinePlotProps = { plotData: Array<DataItem>; yval: string; yvalMeta: TimeSeriesMeta; blockId: string; defaultColor: string; title?: boolean; sparkline?: boolean; targetLen: number; }; function SingleLinePlot({ plotData, yval, yvalMeta, blockId, defaultColor, title = false, sparkline = false, targetLen, }: SingleLinePlotProps) { const containerRef = React.useRef<HTMLInputElement>(null); const domRect = useDimensionsWithExistingRef(containerRef, 300); const plotHeight = domRect?.height ?? 0; const plotWidth = domRect?.width ?? 0; const marks: Plot.Markish[] = []; const decimalPlaces = yvalMeta?.decimalPlaces ?? 0; let color = yvalMeta?.color; if (!color) { color = defaultColor; } marks.push( () => htl.svg`<defs> <linearGradient id="gradient-${blockId}-${yval}" gradientTransform="rotate(90)"> <stop offset="0%" stop-color="${color}" stop-opacity="0.7" /> <stop offset="100%" stop-color="${color}" stop-opacity="0" /> </linearGradient> </defs>` ); marks.push( Plot.lineY(plotData, { stroke: color, strokeWidth: 2, x: "ts", y: yval, }) ); // only add the gradient for single items marks.push( Plot.areaY(plotData, { fill: `url(#gradient-${blockId}-${yval})`, x: "ts", y: yval, }) ); if (title) { marks.push( Plot.text([yvalMeta?.name], { frameAnchor: "top-left", dx: 4, fill: "var(--grey-text-color)", }) ); } const labelY = yvalMeta?.label ?? "?"; marks.push( Plot.ruleX( plotData, Plot.pointerX({ x: "ts", py: yval, stroke: "var(--grey-text-color)", strokeWidth: 1, strokeDasharray: 2 }) ) ); marks.push( Plot.ruleY( plotData, Plot.pointerX({ px: "ts", y: yval, stroke: "var(--grey-text-color)", strokeWidth: 1, strokeDasharray: 2 }) ) ); marks.push( Plot.tip( plotData, Plot.pointerX({ x: "ts", y: yval, fill: "var(--main-bg-color)", anchor: "middle", dy: -30, title: (d) => `${dayjs.unix(d.ts / 1000).format("HH:mm:ss")} ${Number(d[yval]).toFixed(decimalPlaces)}${labelY}`, textPadding: 3, }) ) ); marks.push( Plot.dot( plotData, Plot.pointerX({ x: "ts", y: yval, fill: color, r: 3, stroke: "var(--main-text-color)", strokeWidth: 1 }) ) ); const maxY = resolveDomainBound(yvalMeta?.maxy, plotData[plotData.length - 1]) ?? 100; const minY = resolveDomainBound(yvalMeta?.miny, plotData[plotData.length - 1]) ?? 0; const maxX = plotData[plotData.length - 1].ts; const minX = maxX - targetLen * 1000; const plot = Plot.plot({ axis: !sparkline, x: { grid: true, label: "time", tickFormat: (d) => `${dayjs.unix(d / 1000).format("HH:mm:ss")}`, domain: [minX, maxX], }, y: { label: labelY, domain: [minY, maxY] }, width: plotWidth, height: plotHeight, marks: marks, }); React.useEffect(() => { containerRef.current.append(plot); return () => { plot.remove(); }; }, [plot, plotWidth, plotHeight]); return <div ref={containerRef} className="min-h-[100px]" />; } const SysinfoViewInner = React.memo(({ model }: SysinfoViewProps) => { const plotData = jotai.useAtomValue(model.dataAtom); const yvals = jotai.useAtomValue(model.metrics); const plotMeta = jotai.useAtomValue(model.plotMetaAtom); const osRef = React.useRef<OverlayScrollbarsComponentRef>(null); const targetLen = jotai.useAtomValue(model.numPoints) + 1; let title = false; let cols2 = false; if (yvals.length > 1) { title = true; } if (yvals.length > 2) { cols2 = true; } return ( <OverlayScrollbarsComponent ref={osRef} className="flex flex-col flex-grow mb-0 overflow-y-auto" options={{ scrollbars: { autoHide: "leave" } }} > <div className={clsx("w-full h-full grid grid-rows-[repeat(auto-fit,minmax(100px,1fr))] gap-[10px]", { "grid-cols-2": cols2, })} > {plotData && plotData.length > 0 && yvals.map((yval, _idx) => { return ( <SingleLinePlot key={`plot-${model.blockId}-${yval}`} plotData={plotData} yval={yval} yvalMeta={plotMeta.get(yval)} blockId={model.blockId} defaultColor={"var(--accent-color)"} title={title} targetLen={targetLen} /> ); })} </div> </OverlayScrollbarsComponent> ); }); export { SysinfoViewModel }; ================================================ FILE: frontend/app/view/term/fitaddon.ts ================================================ /** * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ // This file is a copy of the original xterm.js file, with the following changes: // - removed the allowance for the scrollbar import type { FitAddon as IFitApi } from "@xterm/addon-fit"; import type { ITerminalAddon, Terminal } from "@xterm/xterm"; import { IRenderDimensions } from "@xterm/xterm/src/browser/renderer/shared/Types"; interface ITerminalDimensions { /** * The number of rows in the terminal. */ rows: number; /** * The number of columns in the terminal. */ cols: number; } const MINIMUM_COLS = 2; const MINIMUM_ROWS = 1; export class FitAddon implements ITerminalAddon, IFitApi { private _terminal: Terminal | undefined; public scrollbarWidth: number | null = null; public activate(terminal: Terminal): void { this._terminal = terminal; } public dispose(): void {} public fit(): void { const dims = this.proposeDimensions(); if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) { return; } // TODO: Remove reliance on private API const core = (this._terminal as any)._core; // Force a full render if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) { core._renderService.clear(); this._terminal.resize(dims.cols, dims.rows); } } public proposeDimensions(): ITerminalDimensions | undefined { if (!this._terminal) { return undefined; } if (!this._terminal.element || !this._terminal.element.parentElement) { return undefined; } // TODO: Remove reliance on private API const core = (this._terminal as any)._core; const dims: IRenderDimensions = core._renderService.dimensions; if (dims.css.cell.width === 0 || dims.css.cell.height === 0) { return undefined; } // UPDATED CODE (removed reliance on FALLBACK_SCROLL_BAR_WIDTH in viewport, allow just setting the scrollbar width when known) let scrollbarWidth: number; if (this.scrollbarWidth != null) { scrollbarWidth = this.scrollbarWidth; } else { scrollbarWidth = core.viewport._viewportElement.offsetWidth - core.viewport._scrollArea.offsetWidth; } // END UPDATED CODE const parentElementStyle = window.getComputedStyle(this._terminal.element.parentElement); const parentElementHeight = parseInt(parentElementStyle.getPropertyValue("height")); const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue("width"))); const elementStyle = window.getComputedStyle(this._terminal.element); const elementPadding = { top: parseInt(elementStyle.getPropertyValue("padding-top")), bottom: parseInt(elementStyle.getPropertyValue("padding-bottom")), right: parseInt(elementStyle.getPropertyValue("padding-right")), left: parseInt(elementStyle.getPropertyValue("padding-left")), }; const elementPaddingVer = elementPadding.top + elementPadding.bottom; const elementPaddingHor = elementPadding.right + elementPadding.left; const availableHeight = parentElementHeight - elementPaddingVer; const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth; const geometry = { cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)), rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height)), }; return geometry; } } ================================================ FILE: frontend/app/view/term/ijson.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as React from "react"; import Frame from "react-frame-component"; type IJsonNode = { tag: string; props?: Record<string, any>; children?: (IJsonNode | string)[]; }; const TagMap: Record<string, React.ComponentType<{ node: IJsonNode }>> = {}; function convertNodeToTag(node: IJsonNode | string, idx?: number): React.ReactNode { if (node == null) { return null; } if (idx == null) { idx = 0; } if (typeof node === "string") { return node; } let key = node.props?.key ?? "child-" + idx; let TagComp = TagMap[node.tag]; if (!TagComp) { return <div key={key}>Unknown tag:{node.tag}</div>; } return <TagComp key={key} node={node} />; } function IJsonHtmlTag({ node }: { node: IJsonNode }) { let { tag, props, children } = node; let divProps = {}; if (props != null) { for (let [key, val] of Object.entries(props)) { if (key.startsWith("on")) { divProps[key] = (e: any) => { console.log("handler", key, val); }; } else { divProps[key] = val; } } } let childrenComps: React.ReactNode[] = []; if (children != null) { for (let idx = 0; idx < children.length; idx++) { let comp = convertNodeToTag(children[idx], idx); if (comp != null) { childrenComps.push(comp); } } } return React.createElement(tag, divProps, childrenComps); } TagMap["div"] = IJsonHtmlTag; TagMap["b"] = IJsonHtmlTag; TagMap["i"] = IJsonHtmlTag; TagMap["p"] = IJsonHtmlTag; TagMap["s"] = IJsonHtmlTag; TagMap["span"] = IJsonHtmlTag; TagMap["a"] = IJsonHtmlTag; TagMap["img"] = IJsonHtmlTag; TagMap["h1"] = IJsonHtmlTag; TagMap["h2"] = IJsonHtmlTag; TagMap["h3"] = IJsonHtmlTag; TagMap["h4"] = IJsonHtmlTag; TagMap["h5"] = IJsonHtmlTag; TagMap["h6"] = IJsonHtmlTag; TagMap["ul"] = IJsonHtmlTag; TagMap["ol"] = IJsonHtmlTag; TagMap["li"] = IJsonHtmlTag; TagMap["input"] = IJsonHtmlTag; TagMap["button"] = IJsonHtmlTag; TagMap["textarea"] = IJsonHtmlTag; TagMap["select"] = IJsonHtmlTag; TagMap["option"] = IJsonHtmlTag; TagMap["form"] = IJsonHtmlTag; function IJsonView({ rootNode }: { rootNode: IJsonNode }) { // TODO fix this huge inline style return ( <div className="ijson"> <Frame> <style> {` *::before, *::after { box-sizing: border-box; } * { margin: 0; } body { line-height: 1.2; -webkit-font-smoothing: antialiased; } img, picture, video, canvas, sgv { display: block; } input, button, textarea, select { font: inherit; } body { display: flex; flex-direction: column; width: 100vw; height: 100vh; background-color: #000; color: #fff; font: normal 15px / normal "Lato", sans-serif; } .fixed-font { normal 12px / normal "Hack", monospace; } `} </style> {convertNodeToTag(rootNode)} </Frame> </div> ); } export { IJsonView }; ================================================ FILE: frontend/app/view/term/osc-handlers.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getApi, getBlockMetaKeyAtom, getBlockTermDurableAtom, getOverrideConfigAtom, globalStore, recordTEvent, WOS, } from "@/store/global"; import { base64ToString, fireAndForget, isSshConnName, isWslConnName } from "@/util/util"; import debug from "debug"; import type { TermWrap } from "./termwrap"; const dlog = debug("wave:termwrap"); const Osc52MaxDecodedSize = 75 * 1024; // max clipboard size for OSC 52 (matches common terminal implementations) const Osc52MaxRawLength = 128 * 1024; // includes selector + base64 + whitespace (rough check) // OSC 16162 - Shell Integration Commands // See aiprompts/wave-osc-16162.md for full documentation export type ShellIntegrationStatus = "ready" | "running-command"; type Osc16162Command = | { command: "A"; data: Record<string, never> } | { command: "C"; data: { cmd64?: string } } | { command: "M"; data: { shell?: string; shellversion?: string; uname?: string; integration?: boolean; omz?: boolean; comp?: string; }; } | { command: "D"; data: { exitcode?: number } } | { command: "I"; data: { inputempty?: boolean } } | { command: "R"; data: Record<string, never> }; function checkCommandForTelemetry(decodedCmd: string) { if (!decodedCmd) { return; } if (decodedCmd.startsWith("ssh ")) { recordTEvent("conn:connect", { "conn:conntype": "ssh-manual" }); return; } const editorsRegex = /^(vim|vi|nano|nvim)\b/; if (editorsRegex.test(decodedCmd)) { recordTEvent("action:term", { "action:type": "cli-edit" }); return; } const tailFollowRegex = /(^|\|\s*)tail\s+-[fF]\b/; if (tailFollowRegex.test(decodedCmd)) { recordTEvent("action:term", { "action:type": "cli-tailf" }); return; } const claudeRegex = /^claude\b/; if (claudeRegex.test(decodedCmd)) { recordTEvent("action:term", { "action:type": "claude" }); return; } const opencodeRegex = /^opencode\b/; if (opencodeRegex.test(decodedCmd)) { recordTEvent("action:term", { "action:type": "opencode" }); return; } } function handleShellIntegrationCommandStart( termWrap: TermWrap, blockId: string, cmd: { command: "C"; data: { cmd64?: string } }, rtInfo: ObjRTInfo // this is passed by reference and modified inside of this function ): void { rtInfo["shell:state"] = "running-command"; globalStore.set(termWrap.shellIntegrationStatusAtom, "running-command"); const connName = globalStore.get(getBlockMetaKeyAtom(blockId, "connection")) ?? ""; const isRemote = isSshConnName(connName); const isWsl = isWslConnName(connName); const isDurable = globalStore.get(getBlockTermDurableAtom(blockId)) ?? false; getApi().incrementTermCommands({ isRemote, isWsl, isDurable }); if (cmd.data.cmd64) { const decodedLen = Math.ceil(cmd.data.cmd64.length * 0.75); if (decodedLen > 8192) { rtInfo["shell:lastcmd"] = `# command too large (${decodedLen} bytes)`; globalStore.set(termWrap.lastCommandAtom, rtInfo["shell:lastcmd"]); } else { try { const decodedCmd = base64ToString(cmd.data.cmd64); rtInfo["shell:lastcmd"] = decodedCmd; globalStore.set(termWrap.lastCommandAtom, decodedCmd); checkCommandForTelemetry(decodedCmd); } catch (e) { console.error("Error decoding cmd64:", e); rtInfo["shell:lastcmd"] = null; globalStore.set(termWrap.lastCommandAtom, null); } } } else { rtInfo["shell:lastcmd"] = null; globalStore.set(termWrap.lastCommandAtom, null); } rtInfo["shell:lastcmdexitcode"] = null; } // for xterm OSC handlers, we return true always because we "own" the OSC number. // even if data is invalid we don't want to propagate to other handlers. export function handleOsc52Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean { if (!loaded) { return true; } const osc52Mode = globalStore.get(getOverrideConfigAtom(blockId, "term:osc52")) ?? "always"; if (osc52Mode === "focus") { const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false; if (!document.hasFocus() || !isBlockFocused) { console.log("OSC 52: rejected, window or block not focused"); return true; } } if (!data || data.length === 0) { console.log("OSC 52: empty data received"); return true; } if (data.length > Osc52MaxRawLength) { console.log("OSC 52: raw data too large", data.length); return true; } const semicolonIndex = data.indexOf(";"); if (semicolonIndex === -1) { console.log("OSC 52: invalid format (no semicolon)", data.substring(0, 50)); return true; } const clipboardSelection = data.substring(0, semicolonIndex); const base64Data = data.substring(semicolonIndex + 1); // clipboard query ("?") is not supported for security (prevents clipboard theft) if (base64Data === "?") { console.log("OSC 52: clipboard query not supported"); return true; } if (base64Data.length === 0) { return true; } if (clipboardSelection.length > 10) { console.log("OSC 52: clipboard selection too long", clipboardSelection); return true; } const estimatedDecodedSize = Math.ceil(base64Data.length * 0.75); if (estimatedDecodedSize > Osc52MaxDecodedSize) { console.log("OSC 52: data too large", estimatedDecodedSize, "bytes"); return true; } try { // strip whitespace from base64 data (some terminals chunk with newlines per RFC 4648) const cleanBase64Data = base64Data.replace(/\s+/g, ""); const decodedText = base64ToString(cleanBase64Data); // validate actual decoded size (base64 estimate can be off for multi-byte UTF-8) const actualByteSize = new TextEncoder().encode(decodedText).length; if (actualByteSize > Osc52MaxDecodedSize) { console.log("OSC 52: decoded text too large", actualByteSize, "bytes"); return true; } fireAndForget(async () => { try { await navigator.clipboard.writeText(decodedText); dlog("OSC 52: copied", decodedText.length, "characters to clipboard"); } catch (err) { console.error("OSC 52: clipboard write failed:", err); } }); } catch (e) { console.error("OSC 52: base64 decode error:", e); } return true; } // for xterm handlers, we return true always because we "own" OSC 7. // even if it is invalid we dont want to propagate to other handlers export function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean { if (!loaded) { return true; } if (data == null || data.length == 0) { console.log("Invalid OSC 7 command received (empty)"); return true; } if (data.length > 1024) { console.log("Invalid OSC 7, data length too long", data.length); return true; } let pathPart: string; try { const url = new URL(data); if (url.protocol !== "file:") { console.log("Invalid OSC 7 command received (non-file protocol)", data); return true; } pathPart = decodeURIComponent(url.pathname); // Normalize double slashes at the beginning to single slash if (pathPart.startsWith("//")) { pathPart = pathPart.substring(1); } // Handle Windows paths (e.g., /C:/... or /D:\...) if (/^\/[a-zA-Z]:[\\/]/.test(pathPart)) { // Strip leading slash and normalize to forward slashes pathPart = pathPart.substring(1).replace(/\\/g, "/"); } // Handle UNC paths (e.g., /\\server\share) if (pathPart.startsWith("/\\\\")) { // Strip leading slash but keep backslashes for UNC pathPart = pathPart.substring(1); } } catch (e) { console.log("Invalid OSC 7 command received (parse error)", data, e); return true; } setTimeout(() => { fireAndForget(async () => { await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { "cmd:cwd": pathPart }, }); const rtInfo = { "shell:hascurcwd": true }; const rtInfoData: CommandSetRTInfoData = { oref: WOS.makeORef("block", blockId), data: rtInfo, }; await RpcApi.SetRTInfoCommand(TabRpcClient, rtInfoData).catch((e) => console.log("error setting RT info", e) ); }); }, 0); return true; } export function handleOsc16162Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean { const terminal = termWrap.terminal; if (!loaded) { return true; } if (!data || data.length === 0) { return true; } const parts = data.split(";"); const commandStr = parts[0]; const jsonDataStr = parts.length > 1 ? parts.slice(1).join(";") : null; let parsedData: Record<string, any> = {}; if (jsonDataStr) { try { parsedData = JSON.parse(jsonDataStr); } catch (e) { console.error("Error parsing OSC 16162 JSON data:", e); } } const cmd: Osc16162Command = { command: commandStr, data: parsedData } as Osc16162Command; const rtInfo: ObjRTInfo = {}; switch (cmd.command) { case "A": { rtInfo["shell:state"] = "ready"; globalStore.set(termWrap.shellIntegrationStatusAtom, "ready"); const marker = terminal.registerMarker(0); if (marker) { termWrap.promptMarkers.push(marker); // addTestMarkerDecoration(terminal, marker, termWrap); marker.onDispose(() => { const idx = termWrap.promptMarkers.indexOf(marker); if (idx !== -1) { termWrap.promptMarkers.splice(idx, 1); } }); } break; } case "C": handleShellIntegrationCommandStart(termWrap, blockId, cmd, rtInfo); break; case "M": if (cmd.data.shell) { rtInfo["shell:type"] = cmd.data.shell; } if (cmd.data.shellversion) { rtInfo["shell:version"] = cmd.data.shellversion; } if (cmd.data.uname) { rtInfo["shell:uname"] = cmd.data.uname; } if (cmd.data.integration != null) { rtInfo["shell:integration"] = cmd.data.integration; } if (cmd.data.omz != null) { rtInfo["shell:omz"] = cmd.data.omz; } if (cmd.data.comp != null) { rtInfo["shell:comp"] = cmd.data.comp; } break; case "D": if (cmd.data.exitcode != null) { rtInfo["shell:lastcmdexitcode"] = cmd.data.exitcode; } else { rtInfo["shell:lastcmdexitcode"] = null; } break; case "I": if (cmd.data.inputempty != null) { rtInfo["shell:inputempty"] = cmd.data.inputempty; } break; case "R": globalStore.set(termWrap.shellIntegrationStatusAtom, null); if (terminal.buffer.active.type === "alternate") { terminal.write("\x1b[?1049l"); } break; } if (Object.keys(rtInfo).length > 0) { setTimeout(() => { fireAndForget(async () => { const rtInfoData: CommandSetRTInfoData = { oref: WOS.makeORef("block", blockId), data: rtInfo, }; await RpcApi.SetRTInfoCommand(TabRpcClient, rtInfoData).catch((e) => console.log("error setting RT info (OSC 16162)", e) ); }); }, 0); } return true; } ================================================ FILE: frontend/app/view/term/shellblocking.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // Always block (TUIs / pagers / multiplexers / known interactive UIs) const ALWAYS_BLOCK = [ // multiplexers "tmux", "screen", "byobu", "dtach", "abduco", "tmate", // editors/pagers "vim", "nvim", "emacs", "nano", "less", "more", "man", "most", "view", // TUIs / tools "htop", "top", "btop", "fzf", "ranger", "mc", "nnn", "k9s", "nmtui", "alsamixer", "tig", "gdb", "lldb", // mail/irc "mutt", "neomutt", "alpine", "weechat", "irssi", // dialog UIs "dialog", "whiptail", // DB shells "psql", "mysql", "sqlite3", "mongo", "redis-cli", ]; // Bare REPLs only block when no args const BARE_REPLS = [ "python", "python3", "python2", "node", "ruby", "perl", "php", "lua", "ipython", "bpython", "irb", ]; // Shells: block only if interactive/new shell const SHELLS = [ "bash", "sh", "zsh", "fish", "ksh", "mksh", "dash", "ash", "tcsh", "csh", "xonsh", "elvish", "nu", "nushell", "pwsh", "powershell", "cmd", ]; // Wrappers to skip const WRAPPERS = [ "sudo", "doas", "pkexec", "rlwrap", "env", "time", "nice", "nohup", "chrt", "stdbuf", "script", "scriptreplay", "sshpass", ]; function looksInteractiveShellArgs(args: string[]): boolean { return ( args.length === 0 || args.includes("-i") || args.includes("--login") || args.includes("-l") || args.includes("-s") ); } function isNonInteractiveShellExec(args: string[]): boolean { return ( args.includes("-c") || args.some((a) => a === "-Command" || a.startsWith("-Command")) || args.some((a) => a.endsWith(".sh") || a.includes("/")) ); } function isAttachLike(cmd: string, args: string[]): boolean { if (cmd === "docker" || cmd === "podman") { if (args[0] === "attach") return true; if (args[0] === "exec") return args.some((a) => a === "-it" || a === "-i" || a === "-t"); } if (cmd === "kubectl" || cmd === "k3s" || cmd === "oc") { if (args[0] === "attach") return true; if (args[0] === "exec") return args.some((a) => a === "-it" || a === "-i" || a === "-t"); } if (cmd === "lxc" && args[0] === "exec") return args.some((a) => a === "-t" || a === "-T"); return false; } function isSshInteractive(args: string[]): boolean { const hasForcedTty = args.includes("-t") || args.includes("-tt"); const hasRemoteCmd = args.some((a) => !a.startsWith("-") && a.includes(" ")); return hasForcedTty || !hasRemoteCmd; } export function getBlockingCommand(lastCommand: string | null, inAltBuffer: boolean): string | null { if (!lastCommand) return null; let words = lastCommand.trim().split(/\s+/); if (words.length === 0) return null; while (words.length && WRAPPERS.includes(words[0])) { words.shift(); } if (!words.length) return null; const first = words[0].split("/").pop()!; const args = words.slice(1); if (inAltBuffer) return first; if (ALWAYS_BLOCK.includes(first)) return first; if (isAttachLike(first, args)) return first; if (first === "ssh" || first === "mosh" || first === "telnet" || first === "rlogin") { if (isSshInteractive(args)) return first; return null; } if (first === "su" || first === "machinectl" || first === "chroot" || first === "nsenter" || first === "lxc") { if (!args.length || SHELLS.includes(args[args.length - 1]?.split("/").pop() || "")) return first; return null; } if (SHELLS.includes(first)) { if (looksInteractiveShellArgs(args)) return first; if (isNonInteractiveShellExec(args)) return null; return null; } if (BARE_REPLS.includes(first)) { if (args.length === 0) return first; return null; } return null; } ================================================ FILE: frontend/app/view/term/term-model.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { BlockNodeModel } from "@/app/block/blocktypes"; import { appHandleKeyDown } from "@/app/store/keymodel"; import { modalsModel } from "@/app/store/modalmodel"; import type { TabModel } from "@/app/store/tab-model"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { TerminalView } from "@/app/view/term/term"; import { TermWshClient } from "@/app/view/term/term-wsh"; import { VDomModel } from "@/app/view/vdom/vdom-model"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { atoms, createBlock, createBlockSplitHorizontally, createBlockSplitVertically, getAllBlockComponentModels, getApi, getBlockComponentModel, getBlockMetaKeyAtom, getBlockTermDurableAtom, getConnStatusAtom, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, readAtom, recordTEvent, useBlockAtom, WOS, } from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; import { isMacOS, isWindows } from "@/util/platformutil"; import { boundNumber, fireAndForget, stringToBase64 } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; import { getBlockingCommand } from "./shellblocking"; import { computeTheme, DefaultTermTheme } from "./termutil"; import { TermWrap, WebGLSupported } from "./termwrap"; export class TermViewModel implements ViewModel { viewType: string; nodeModel: BlockNodeModel; tabModel: TabModel; connected: boolean; termRef: React.RefObject<TermWrap> = { current: null }; blockAtom: jotai.Atom<Block>; termMode: jotai.Atom<string>; blockId: string; viewIcon: jotai.Atom<IconButtonDecl>; viewName: jotai.Atom<string>; viewText: jotai.Atom<HeaderElem[]>; blockBg: jotai.Atom<MetaType>; manageConnection: jotai.Atom<boolean>; filterOutNowsh?: jotai.Atom<boolean>; connStatus: jotai.Atom<ConnStatus>; useTermHeader: jotai.Atom<boolean>; termWshClient: TermWshClient; vdomBlockId: jotai.Atom<string>; vdomToolbarBlockId: jotai.Atom<string>; vdomToolbarTarget: jotai.PrimitiveAtom<VDomTargetToolbar>; fontSizeAtom: jotai.Atom<number>; termThemeNameAtom: jotai.Atom<string>; termTransparencyAtom: jotai.Atom<number>; termBPMAtom: jotai.Atom<boolean>; noPadding: jotai.PrimitiveAtom<boolean>; endIconButtons: jotai.Atom<IconButtonDecl[]>; shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>; shellProcStatus: jotai.Atom<string>; shellProcStatusUnsubFn: () => void; blockJobStatusAtom: jotai.PrimitiveAtom<BlockJobStatusData>; blockJobStatusVersionTs: number; blockJobStatusUnsubFn: () => void; termBPMUnsubFn: () => void; termCursorUnsubFn: () => void; termCursorBlinkUnsubFn: () => void; isCmdController: jotai.Atom<boolean>; isRestarting: jotai.PrimitiveAtom<boolean>; termDurableStatus: jotai.Atom<BlockJobStatusData | null>; termConfigedDurable: jotai.Atom<null | boolean>; searchAtoms?: SearchAtoms; constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.viewType = "term"; this.blockId = blockId; this.tabModel = tabModel; this.termWshClient = new TermWshClient(blockId, this); DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.termWshClient); this.nodeModel = nodeModel; this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`); this.vdomBlockId = jotai.atom((get) => { const blockData = get(this.blockAtom); return blockData?.meta?.["term:vdomblockid"]; }); this.vdomToolbarBlockId = jotai.atom((get) => { const blockData = get(this.blockAtom); return blockData?.meta?.["term:vdomtoolbarblockid"]; }); this.vdomToolbarTarget = jotai.atom<VDomTargetToolbar>(null) as jotai.PrimitiveAtom<VDomTargetToolbar>; this.termMode = jotai.atom((get) => { const blockData = get(this.blockAtom); return blockData?.meta?.["term:mode"] ?? "term"; }); this.isRestarting = jotai.atom(false); this.viewIcon = jotai.atom((get) => { const termMode = get(this.termMode); if (termMode == "vdom") { return { elemtype: "iconbutton", icon: "bolt" }; } return { elemtype: "iconbutton", icon: "terminal" }; }); this.viewName = jotai.atom((get) => { const blockData = get(this.blockAtom); const termMode = get(this.termMode); if (termMode == "vdom") { return "Wave App"; } if (blockData?.meta?.controller == "cmd") { return ""; } return ""; }); this.viewText = jotai.atom((get) => { const termMode = get(this.termMode); if (termMode == "vdom") { return [ { elemtype: "iconbutton", icon: "square-terminal", title: "Switch back to Terminal", click: () => { this.setTermMode("term"); }, }, ]; } const vdomBlockId = get(this.vdomBlockId); const rtn: HeaderElem[] = []; if (vdomBlockId) { rtn.push({ elemtype: "iconbutton", icon: "bolt", title: "Switch to Wave App", click: () => { this.setTermMode("vdom"); }, }); } const isCmd = get(this.isCmdController); if (isCmd) { const blockMeta = get(this.blockAtom)?.meta; let cmdText = blockMeta?.["cmd"]; const cmdArgs = blockMeta?.["cmd:args"]; if (cmdArgs != null && Array.isArray(cmdArgs) && cmdArgs.length > 0) { cmdText += " " + cmdArgs.join(" "); } rtn.push({ elemtype: "text", text: cmdText, noGrow: true, }); const isRestarting = get(this.isRestarting); if (isRestarting) { rtn.push({ elemtype: "iconbutton", icon: "refresh", iconColor: "var(--success-color)", iconSpin: true, title: "Restarting Command", noAction: true, }); } else { const fullShellProcStatus = get(this.shellProcFullStatus); if (fullShellProcStatus?.shellprocstatus == "done") { if (fullShellProcStatus?.shellprocexitcode == 0) { rtn.push({ elemtype: "iconbutton", icon: "check", iconColor: "var(--success-color)", title: "Command Exited Successfully", noAction: true, }); } else { rtn.push({ elemtype: "iconbutton", icon: "xmark-large", iconColor: "var(--error-color)", title: "Exit Code: " + fullShellProcStatus?.shellprocexitcode, noAction: true, }); } } } } const isMI = get(this.tabModel.isTermMultiInput); if (isMI && this.isBasicTerm(get)) { rtn.push({ elemtype: "textbutton", text: "Multi Input ON", className: "yellow !py-[2px] !px-[10px] text-[11px] font-[500]", title: "Input will be sent to all connected terminals (click to disable)", onClick: () => { globalStore.set(this.tabModel.isTermMultiInput, false); }, }); } return rtn; }); this.manageConnection = jotai.atom((get) => { const termMode = get(this.termMode); if (termMode == "vdom") { return false; } const isCmd = get(this.isCmdController); if (isCmd) { return false; } return true; }); this.useTermHeader = jotai.atom((get) => { const termMode = get(this.termMode); if (termMode == "vdom") { return false; } const isCmd = get(this.isCmdController); if (isCmd) { return false; } return true; }); this.filterOutNowsh = jotai.atom(false); this.termBPMAtom = getOverrideConfigAtom(blockId, "term:allowbracketedpaste"); this.termThemeNameAtom = useBlockAtom(blockId, "termthemeatom", () => { return jotai.atom<string>((get) => { return get(getOverrideConfigAtom(this.blockId, "term:theme")) ?? DefaultTermTheme; }); }); this.termTransparencyAtom = useBlockAtom(blockId, "termtransparencyatom", () => { return jotai.atom<number>((get) => { const value = get(getOverrideConfigAtom(this.blockId, "term:transparency")) ?? 0.5; return boundNumber(value, 0, 1); }); }); this.blockBg = jotai.atom((get) => { const fullConfig = get(atoms.fullConfigAtom); const themeName = get(this.termThemeNameAtom); const termTransparency = get(this.termTransparencyAtom); const [_, bgcolor] = computeTheme(fullConfig, themeName, termTransparency); if (bgcolor != null) { return { bg: bgcolor }; } return null; }); this.connStatus = jotai.atom((get) => { const blockData = get(this.blockAtom); const connName = blockData?.meta?.connection; const connAtom = getConnStatusAtom(connName); return get(connAtom); }); this.fontSizeAtom = useBlockAtom(blockId, "fontsizeatom", () => { return jotai.atom<number>((get) => { const blockData = get(this.blockAtom); const fsSettingsAtom = getSettingsKeyAtom("term:fontsize"); const settingsFontSize = get(fsSettingsAtom); const connName = blockData?.meta?.connection; const fullConfig = get(atoms.fullConfigAtom); const connFontSize = fullConfig?.connections?.[connName]?.["term:fontsize"]; const rtnFontSize = blockData?.meta?.["term:fontsize"] ?? connFontSize ?? settingsFontSize ?? 12; if (typeof rtnFontSize != "number" || isNaN(rtnFontSize) || rtnFontSize < 4 || rtnFontSize > 64) { return 12; } return rtnFontSize; }); }); this.noPadding = jotai.atom(true); this.endIconButtons = jotai.atom((get) => { const blockData = get(this.blockAtom); const shellProcStatus = get(this.shellProcStatus); const connStatus = get(this.connStatus); const isCmd = get(this.isCmdController); const rtn: IconButtonDecl[] = []; const isAIPanelOpen = get(WorkspaceLayoutModel.getInstance().panelVisibleAtom); if (isAIPanelOpen) { const shellIntegrationButton = this.getShellIntegrationIconButton(get); if (shellIntegrationButton) { rtn.push(shellIntegrationButton); } } if (get(getSettingsKeyAtom("debug:webglstatus"))) { const webglButton = this.getWebGlIconButton(get); if (webglButton) { rtn.push(webglButton); } } if (blockData?.meta?.["controller"] != "cmd" && shellProcStatus != "done") { return rtn; } if (connStatus?.status != "connected") { return rtn; } let iconName: string = null; let title: string = null; const noun = isCmd ? "Command" : "Shell"; if (shellProcStatus == "init") { iconName = "play"; title = "Click to Start " + noun; } else if (shellProcStatus == "running") { iconName = "refresh"; title = noun + " Running. Click to Restart"; } else if (shellProcStatus == "done") { iconName = "refresh"; title = noun + " Exited. Click to Restart"; } if (iconName != null) { const buttonDecl: IconButtonDecl = { elemtype: "iconbutton", icon: iconName, click: () => fireAndForget(() => this.forceRestartController()), title: title, }; rtn.push(buttonDecl); } return rtn; }); this.isCmdController = jotai.atom((get) => { const controllerMetaAtom = getBlockMetaKeyAtom(this.blockId, "controller"); return get(controllerMetaAtom) == "cmd"; }); this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom<BlockControllerRuntimeStatus>; const initialShellProcStatus = services.BlockService.GetControllerStatus(blockId); initialShellProcStatus.then((rts) => { this.updateShellProcStatus(rts); }); this.shellProcStatusUnsubFn = waveEventSubscribeSingle({ eventType: "controllerstatus", scope: WOS.makeORef("block", blockId), handler: (event) => { this.updateShellProcStatus(event.data); }, }); this.shellProcStatus = jotai.atom((get) => { const fullStatus = get(this.shellProcFullStatus); return fullStatus?.shellprocstatus ?? "init"; }); this.termDurableStatus = jotai.atom((get) => { const isDurable = get(getBlockTermDurableAtom(this.blockId)); if (!isDurable) { return null; } const blockJobStatus = get(this.blockJobStatusAtom); if (blockJobStatus?.jobid == null || blockJobStatus?.status == null) { return null; } return blockJobStatus; }); this.termConfigedDurable = getBlockTermDurableAtom(this.blockId); this.blockJobStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<BlockJobStatusData>; this.blockJobStatusVersionTs = 0; const initialBlockJobStatus = RpcApi.BlockJobStatusCommand(TabRpcClient, blockId); initialBlockJobStatus .then((status) => { this.handleBlockJobStatusUpdate(status); }) .catch((error) => { console.log("error getting initial block job status", error); }); this.blockJobStatusUnsubFn = waveEventSubscribeSingle({ eventType: "block:jobstatus", scope: `block:${blockId}`, handler: (event) => { this.handleBlockJobStatusUpdate(event.data); }, }); this.termBPMUnsubFn = globalStore.sub(this.termBPMAtom, () => { if (this.termRef.current?.terminal) { const allowBPM = globalStore.get(this.termBPMAtom) ?? true; this.termRef.current.terminal.options.ignoreBracketedPasteMode = !allowBPM; } }); const termCursorAtom = getOverrideConfigAtom(blockId, "term:cursor"); this.termCursorUnsubFn = globalStore.sub(termCursorAtom, () => { if (this.termRef.current?.terminal) { this.termRef.current.setCursorStyle(globalStore.get(termCursorAtom)); } }); const termCursorBlinkAtom = getOverrideConfigAtom(blockId, "term:cursorblink"); this.termCursorBlinkUnsubFn = globalStore.sub(termCursorBlinkAtom, () => { if (this.termRef.current?.terminal) { this.termRef.current.setCursorBlink(globalStore.get(termCursorBlinkAtom) ?? false); } }); } getShellIntegrationIconButton(get: jotai.Getter): IconButtonDecl | null { if (!this.termRef.current?.shellIntegrationStatusAtom) { return null; } const shellIntegrationStatus = get(this.termRef.current.shellIntegrationStatusAtom); if (shellIntegrationStatus == null) { return { elemtype: "iconbutton", icon: "sparkles", className: "text-muted", title: "No shell integration — Wave AI unable to run commands.", noAction: true, }; } if (shellIntegrationStatus === "ready") { return { elemtype: "iconbutton", icon: "sparkles", className: "text-accent", title: "Shell ready — Wave AI can run commands in this terminal.", noAction: true, }; } if (shellIntegrationStatus === "running-command") { let title = "Shell busy — Wave AI unable to run commands while another command is running."; if (this.termRef.current) { const inAltBuffer = this.termRef.current.terminal?.buffer?.active?.type === "alternate"; const lastCommand = get(this.termRef.current.lastCommandAtom); const blockingCmd = getBlockingCommand(lastCommand, inAltBuffer); if (blockingCmd) { title = `Wave AI integration disabled while you're inside ${blockingCmd}.`; } } return { elemtype: "iconbutton", icon: "sparkles", className: "text-warning", title: title, noAction: true, }; } return null; } getWebGlIconButton(get: jotai.Getter): IconButtonDecl | null { if (!WebGLSupported) { return { elemtype: "iconbutton", icon: "microchip", iconColor: "var(--error-color)", title: "WebGL not supported", noAction: true, }; } if (!this.termRef.current?.webglEnabledAtom) { return null; } const webglEnabled = get(this.termRef.current.webglEnabledAtom); if (webglEnabled) { return { elemtype: "iconbutton", icon: "microchip", iconColor: "var(--success-color)", title: "WebGL enabled (click to disable)", click: () => this.toggleWebGl(), }; } return { elemtype: "iconbutton", icon: "microchip", iconColor: "var(--secondary-text-color)", title: "WebGL disabled (click to enable)", click: () => this.toggleWebGl(), }; } get viewComponent(): ViewComponent { return TerminalView as ViewComponent; } isBasicTerm(getFn: jotai.Getter): boolean { const termMode = getFn(this.termMode); if (termMode == "vdom") { return false; } const blockData = getFn(this.blockAtom); if (blockData?.meta?.controller == "cmd") { return false; } return true; } multiInputHandler(data: string) { const tvms = getAllBasicTermModels(); for (const tvm of tvms) { if (tvm != this) { tvm.sendDataToController(data); } } } sendDataToController(data: string) { const b64data = stringToBase64(data); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } setTermMode(mode: "term" | "vdom") { if (mode == "term") { mode = null; } RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:mode": mode }, }); } getTermRenderer(): "webgl" | "canvas" { return this.termRef.current?.getTermRenderer() ?? "canvas"; } isWebGlEnabled(): boolean { return this.termRef.current?.isWebGlEnabled() ?? false; } toggleWebGl() { if (!this.termRef.current) { return; } const renderer = this.termRef.current.getTermRenderer() === "webgl" ? "canvas" : "webgl"; this.termRef.current.setTermRenderer(renderer); } triggerRestartAtom() { globalStore.set(this.isRestarting, true); setTimeout(() => { globalStore.set(this.isRestarting, false); }, 300); } handleBlockJobStatusUpdate(status: BlockJobStatusData) { if (status?.versionts == null) { return; } if (status.versionts <= this.blockJobStatusVersionTs) { return; } this.blockJobStatusVersionTs = status.versionts; globalStore.set(this.blockJobStatusAtom, status); } updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { if (fullStatus == null) { return; } const curStatus = globalStore.get(this.shellProcFullStatus); if (curStatus == null || curStatus.version < fullStatus.version) { globalStore.set(this.shellProcFullStatus, fullStatus); } } getVDomModel(): VDomModel { const vdomBlockId = globalStore.get(this.vdomBlockId); if (!vdomBlockId) { return null; } const bcm = getBlockComponentModel(vdomBlockId); if (!bcm) { return null; } return bcm.viewModel as VDomModel; } getVDomToolbarModel(): VDomModel { const vdomToolbarBlockId = globalStore.get(this.vdomToolbarBlockId); if (!vdomToolbarBlockId) { return null; } const bcm = getBlockComponentModel(vdomToolbarBlockId); if (!bcm) { return null; } return bcm.viewModel as VDomModel; } dispose() { DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); this.shellProcStatusUnsubFn?.(); this.blockJobStatusUnsubFn?.(); this.termBPMUnsubFn?.(); this.termCursorUnsubFn?.(); this.termCursorBlinkUnsubFn?.(); } giveFocus(): boolean { if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) { console.log("search is open, not giving focus"); return true; } const termMode = globalStore.get(this.termMode); if (termMode == "term") { if (this.termRef?.current?.terminal) { this.termRef.current.terminal.focus(); return true; } } return false; } keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { if (keyutil.checkKeyPressed(waveEvent, "Ctrl:r")) { const shellIntegrationStatus = readAtom(this.termRef?.current?.shellIntegrationStatusAtom); if (shellIntegrationStatus === "ready") { recordTEvent("action:term", { "action:type": "term:ctrlr" }); } // just for telemetry, we allow this keybinding through, back to the terminal return false; } if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${this.blockId}`); const blockData = globalStore.get(blockAtom); const newTermMode = blockData?.meta?.["term:mode"] == "vdom" ? null : "vdom"; const vdomBlockId = globalStore.get(this.vdomBlockId); if (newTermMode == "vdom" && !vdomBlockId) { return; } this.setTermMode(newTermMode); return true; } if (keyutil.checkKeyPressed(waveEvent, "Shift:End")) { if (this.termRef?.current?.terminal) { this.termRef.current.terminal.scrollToBottom(); } return true; } if (keyutil.checkKeyPressed(waveEvent, "Shift:Home")) { if (this.termRef?.current?.terminal) { this.termRef.current.terminal.scrollToLine(0); } return true; } if (isMacOS() && keyutil.checkKeyPressed(waveEvent, "Cmd:End")) { if (this.termRef?.current?.terminal) { this.termRef.current.terminal.scrollToBottom(); } return true; } if (isMacOS() && keyutil.checkKeyPressed(waveEvent, "Cmd:Home")) { if (this.termRef?.current?.terminal) { this.termRef.current.terminal.scrollToLine(0); } return true; } if (keyutil.checkKeyPressed(waveEvent, "Shift:PageDown")) { if (this.termRef?.current?.terminal) { this.termRef.current.terminal.scrollPages(1); } return true; } if (keyutil.checkKeyPressed(waveEvent, "Shift:PageUp")) { if (this.termRef?.current?.terminal) { this.termRef.current.terminal.scrollPages(-1); } return true; } const blockData = globalStore.get(this.blockAtom); if (blockData.meta?.["term:mode"] == "vdom") { const vdomModel = this.getVDomModel(); return vdomModel?.keyDownHandler(waveEvent); } return false; } shouldHandleCtrlVPaste(): boolean { // macOS never uses Ctrl-V for paste (uses Cmd-V) if (isMacOS()) { return false; } // Get the app:ctrlvpaste setting const ctrlVPasteAtom = getSettingsKeyAtom("app:ctrlvpaste"); const ctrlVPasteSetting = globalStore.get(ctrlVPasteAtom); // If setting is explicitly set, use it if (ctrlVPasteSetting != null) { return ctrlVPasteSetting; } // Default behavior: Windows=true, Linux/other=false return isWindows(); } handleTerminalKeydown(event: KeyboardEvent): boolean { const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); if (waveEvent.type != "keydown") { return true; } // Handle Escape key during IME composition if (keyutil.checkKeyPressed(waveEvent, "Escape")) { if (this.termRef.current?.isComposing) { // Reset composition state when Escape is pressed during composition this.termRef.current.resetCompositionState(); } } if (this.keyDownHandler(waveEvent)) { event.preventDefault(); event.stopPropagation(); return false; } if (isMacOS()) { if (keyutil.checkKeyPressed(waveEvent, "Cmd:ArrowLeft")) { this.sendDataToController("\x01"); // Ctrl-A (beginning of line) event.preventDefault(); event.stopPropagation(); return false; } if (keyutil.checkKeyPressed(waveEvent, "Cmd:ArrowRight")) { this.sendDataToController("\x05"); // Ctrl-E (end of line) event.preventDefault(); event.stopPropagation(); return false; } } if (keyutil.checkKeyPressed(waveEvent, "Shift:Enter")) { const shiftEnterNewlineAtom = getOverrideConfigAtom(this.blockId, "term:shiftenternewline"); const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? true; if (shiftEnterNewlineEnabled) { this.sendDataToController("\n"); event.preventDefault(); event.stopPropagation(); return false; } } // Check for Ctrl-V paste (platform-dependent) if (this.shouldHandleCtrlVPaste() && keyutil.checkKeyPressed(waveEvent, "Ctrl:v")) { event.preventDefault(); event.stopPropagation(); getApi().nativePaste(); return false; } if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { event.preventDefault(); event.stopPropagation(); getApi().nativePaste(); // this.termRef.current?.pasteHandler(); return false; } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { event.preventDefault(); event.stopPropagation(); const sel = this.termRef.current?.terminal.getSelection(); if (!sel) { return false; } navigator.clipboard.writeText(sel); return false; } else if (keyutil.checkKeyPressed(waveEvent, "Cmd:k")) { event.preventDefault(); event.stopPropagation(); this.termRef.current?.terminal?.clear(); return false; } const shellProcStatus = globalStore.get(this.shellProcStatus); if ((shellProcStatus == "done" || shellProcStatus == "init") && keyutil.checkKeyPressed(waveEvent, "Enter")) { fireAndForget(() => this.forceRestartController()); return false; } const appHandled = appHandleKeyDown(waveEvent); if (appHandled) { event.preventDefault(); event.stopPropagation(); return false; } return true; } setTerminalTheme(themeName: string) { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:theme": themeName }, }); } async forceRestartController() { if (globalStore.get(this.isRestarting)) { return; } this.triggerRestartAtom(); await RpcApi.ControllerDestroyCommand(TabRpcClient, this.blockId); const termsize = { rows: this.termRef.current?.terminal?.rows, cols: this.termRef.current?.terminal?.cols, }; await RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: globalStore.get(atoms.staticTabId), blockid: this.blockId, forcerestart: true, rtopts: { termsize: termsize }, }); } async restartSessionWithDurability(isDurable: boolean) { await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:durable": isDurable }, }); await RpcApi.ControllerDestroyCommand(TabRpcClient, this.blockId); const termsize = { rows: this.termRef.current?.terminal?.rows, cols: this.termRef.current?.terminal?.cols, }; await RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: globalStore.get(atoms.staticTabId), blockid: this.blockId, forcerestart: true, rtopts: { termsize: termsize }, }); } getContextMenuItems(): ContextMenuItem[] { const menu: ContextMenuItem[] = []; const hasSelection = this.termRef.current?.terminal?.hasSelection(); const selection = hasSelection ? this.termRef.current?.terminal.getSelection() : null; if (hasSelection) { menu.push({ label: "Copy", click: () => { if (selection) { navigator.clipboard.writeText(selection); } }, }); menu.push({ type: "separator" }); menu.push({ label: "Send to Wave AI", click: () => { if (selection) { const aiModel = WaveAIModel.getInstance(); aiModel.appendText(selection, true, { scrollToBottom: true }); const layoutModel = WorkspaceLayoutModel.getInstance(); if (!layoutModel.getAIPanelVisible()) { layoutModel.setAIPanelVisible(true); } aiModel.focusInput(); } }, }); menu.push({ type: "separator" }); } const hoveredLinkUri = this.termRef.current?.hoveredLinkUri; if (hoveredLinkUri) { let hoveredURL: URL = null; try { hoveredURL = new URL(hoveredLinkUri); } catch (e) { // not a valid URL } if (hoveredURL) { menu.push({ label: hoveredURL.hostname ? "Open URL (" + hoveredURL.hostname + ")" : "Open URL", click: () => { createBlock({ meta: { view: "web", url: hoveredURL.toString(), }, }); }, }); menu.push({ label: "Open URL in External Browser", click: () => { getApi().openExternal(hoveredURL.toString()); }, }); menu.push({ type: "separator" }); } } menu.push({ label: "Paste", click: () => { getApi().nativePaste(); }, }); menu.push({ type: "separator" }); const magnified = globalStore.get(this.nodeModel.isMagnified); menu.push({ label: magnified ? "Un-Magnify Block" : "Magnify Block", click: () => { this.nodeModel.toggleMagnify(); }, }); menu.push({ type: "separator" }); const settingsItems = this.getSettingsMenuItems(); menu.push(...settingsItems); return menu; } getSettingsMenuItems(): ContextMenuItem[] { const fullConfig = globalStore.get(atoms.fullConfigAtom); const termThemes = fullConfig?.termthemes ?? {}; const termThemeKeys = Object.keys(termThemes); const curThemeName = globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:theme")); const defaultFontSize = globalStore.get(getSettingsKeyAtom("term:fontsize")) ?? 12; const defaultAllowBracketedPaste = globalStore.get(getSettingsKeyAtom("term:allowbracketedpaste")) ?? true; const transparencyMeta = globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:transparency")); const blockData = globalStore.get(this.blockAtom); const overrideFontSize = blockData?.meta?.["term:fontsize"]; termThemeKeys.sort((a, b) => { return (termThemes[a]["display:order"] ?? 0) - (termThemes[b]["display:order"] ?? 0); }); const defaultTermBlockDef: BlockDef = { meta: { view: "term", controller: "shell", }, }; const fullMenu: ContextMenuItem[] = []; fullMenu.push({ label: "Split Horizontally", click: () => { const blockData = globalStore.get(this.blockAtom); const blockDef: BlockDef = { meta: blockData?.meta || defaultTermBlockDef.meta, }; createBlockSplitHorizontally(blockDef, this.blockId, "after"); }, }); fullMenu.push({ label: "Split Vertically", click: () => { const blockData = globalStore.get(this.blockAtom); const blockDef: BlockDef = { meta: blockData?.meta || defaultTermBlockDef.meta, }; createBlockSplitVertically(blockDef, this.blockId, "after"); }, }); fullMenu.push({ type: "separator" }); const shellIntegrationStatus = globalStore.get(this.termRef?.current?.shellIntegrationStatusAtom); const cwd = blockData?.meta?.["cmd:cwd"]; const canShowFileBrowser = shellIntegrationStatus === "ready" && cwd != null; if (canShowFileBrowser) { fullMenu.push({ label: "File Browser", click: () => { const blockData = globalStore.get(this.blockAtom); const connection = blockData?.meta?.connection; const cwd = blockData?.meta?.["cmd:cwd"]; const meta: Record<string, any> = { view: "preview", file: cwd, }; if (connection) { meta.connection = connection; } const blockDef: BlockDef = { meta }; createBlock(blockDef); }, }); fullMenu.push({ type: "separator" }); } fullMenu.push({ label: "Save Session As...", click: () => { if (this.termRef.current) { const content = this.termRef.current.getScrollbackContent(); if (content) { fireAndForget(async () => { try { const success = await getApi().saveTextFile("session.log", content); if (!success) { console.log("Save scrollback cancelled by user"); } } catch (error) { console.error("Failed to save scrollback:", error); const errorMessage = error?.message || "An unknown error occurred"; modalsModel.pushModal("MessageModal", { children: `Failed to save session scrollback: ${errorMessage}`, }); } }); } else { modalsModel.pushModal("MessageModal", { children: "No scrollback content to save.", }); } } }, }); fullMenu.push({ type: "separator" }); const submenu: ContextMenuItem[] = termThemeKeys.map((themeName) => { return { label: termThemes[themeName]["display:name"] ?? themeName, type: "checkbox", checked: curThemeName == themeName, click: () => this.setTerminalTheme(themeName), }; }); submenu.unshift({ label: "Default", type: "checkbox", checked: curThemeName == null, click: () => this.setTerminalTheme(null), }); const transparencySubMenu: ContextMenuItem[] = []; transparencySubMenu.push({ label: "Default", type: "checkbox", checked: transparencyMeta == null, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:transparency": null }, }); }, }); transparencySubMenu.push({ label: "Transparent Background", type: "checkbox", checked: transparencyMeta == 0.5, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:transparency": 0.5 }, }); }, }); transparencySubMenu.push({ label: "No Transparency", type: "checkbox", checked: transparencyMeta == 0, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:transparency": 0 }, }); }, }); const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map( (fontSize: number) => { return { label: fontSize.toString() + "px", type: "checkbox", checked: overrideFontSize == fontSize, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:fontsize": fontSize }, }); }, }; } ); fontSizeSubMenu.unshift({ label: "Default (" + defaultFontSize + "px)", type: "checkbox", checked: overrideFontSize == null, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:fontsize": null }, }); }, }); const overrideCursor = blockData?.meta?.["term:cursor"] as string | null | undefined; const overrideCursorBlink = blockData?.meta?.["term:cursorblink"] as boolean | null | undefined; const isCursorDefault = overrideCursor == null && overrideCursorBlink == null; // normalize for comparison: null/undefined/"block" all mean "block" const effectiveCursor = overrideCursor === "underline" || overrideCursor === "bar" ? overrideCursor : "block"; const effectiveCursorBlink = overrideCursorBlink === true; const cursorSubMenu: ContextMenuItem[] = [ { label: "Default", type: "checkbox", checked: isCursorDefault, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:cursor": null, "term:cursorblink": null }, }); }, }, { label: "Block", type: "checkbox", checked: !isCursorDefault && effectiveCursor === "block" && !effectiveCursorBlink, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:cursor": "block", "term:cursorblink": false }, }); }, }, { label: "Block (Blinking)", type: "checkbox", checked: !isCursorDefault && effectiveCursor === "block" && effectiveCursorBlink, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:cursor": "block", "term:cursorblink": true }, }); }, }, { label: "Bar", type: "checkbox", checked: !isCursorDefault && effectiveCursor === "bar" && !effectiveCursorBlink, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:cursor": "bar", "term:cursorblink": false }, }); }, }, { label: "Bar (Blinking)", type: "checkbox", checked: !isCursorDefault && effectiveCursor === "bar" && effectiveCursorBlink, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:cursor": "bar", "term:cursorblink": true }, }); }, }, { label: "Underline", type: "checkbox", checked: !isCursorDefault && effectiveCursor === "underline" && !effectiveCursorBlink, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:cursor": "underline", "term:cursorblink": false }, }); }, }, { label: "Underline (Blinking)", type: "checkbox", checked: !isCursorDefault && effectiveCursor === "underline" && effectiveCursorBlink, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:cursor": "underline", "term:cursorblink": true }, }); }, }, ]; fullMenu.push({ label: "Themes", submenu: submenu, }); fullMenu.push({ label: "Font Size", submenu: fontSizeSubMenu, }); fullMenu.push({ label: "Cursor", submenu: cursorSubMenu, }); fullMenu.push({ label: "Transparency", submenu: transparencySubMenu, }); fullMenu.push({ type: "separator" }); const advancedSubmenu: ContextMenuItem[] = []; const allowBracketedPaste = blockData?.meta?.["term:allowbracketedpaste"]; advancedSubmenu.push({ label: "Allow Bracketed Paste Mode", submenu: [ { label: "Default (" + (defaultAllowBracketedPaste ? "On" : "Off") + ")", type: "checkbox", checked: allowBracketedPaste == null, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:allowbracketedpaste": null }, }); }, }, { label: "On", type: "checkbox", checked: allowBracketedPaste === true, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:allowbracketedpaste": true }, }); }, }, { label: "Off", type: "checkbox", checked: allowBracketedPaste === false, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:allowbracketedpaste": false }, }); }, }, ], }); advancedSubmenu.push({ label: "Force Restart Controller", click: () => fireAndForget(() => this.forceRestartController()), }); const isClearOnStart = blockData?.meta?.["cmd:clearonstart"]; advancedSubmenu.push({ label: "Clear Output On Restart", submenu: [ { label: "On", type: "checkbox", checked: isClearOnStart, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "cmd:clearonstart": true }, }); }, }, { label: "Off", type: "checkbox", checked: !isClearOnStart, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "cmd:clearonstart": false }, }); }, }, ], }); const runOnStart = blockData?.meta?.["cmd:runonstart"]; advancedSubmenu.push({ label: "Run On Startup", submenu: [ { label: "On", type: "checkbox", checked: runOnStart, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "cmd:runonstart": true }, }); }, }, { label: "Off", type: "checkbox", checked: !runOnStart, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "cmd:runonstart": false }, }); }, }, ], }); const debugConn = blockData?.meta?.["term:conndebug"]; advancedSubmenu.push({ label: "Debug Connection", submenu: [ { label: "Off", type: "checkbox", checked: !debugConn, click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:conndebug": null }, }); }, }, { label: "Info", type: "checkbox", checked: debugConn == "info", click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:conndebug": "info" }, }); }, }, { label: "Verbose", type: "checkbox", checked: debugConn == "debug", click: () => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:conndebug": "debug" }, }); }, }, ], }); const isDurable = globalStore.get(getBlockTermDurableAtom(this.blockId)); if (isDurable) { advancedSubmenu.push({ label: "Session Durability", submenu: [ { label: "Restart Session in Standard Mode", click: () => fireAndForget(() => this.restartSessionWithDurability(false)), }, ], }); } else if (isDurable === false) { advancedSubmenu.push({ label: "Session Durability", submenu: [ { label: "Restart Session in Durable Mode", click: () => fireAndForget(() => this.restartSessionWithDurability(true)), }, ], }); } fullMenu.push({ label: "Advanced", submenu: advancedSubmenu, }); if (blockData?.meta?.["term:vdomtoolbarblockid"]) { fullMenu.push({ type: "separator" }); fullMenu.push({ label: "Close Toolbar", click: () => { RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: blockData.meta["term:vdomtoolbarblockid"] }); }, }); } return fullMenu; } } export function getAllBasicTermModels(): TermViewModel[] { const termModels: TermViewModel[] = []; const bcms = getAllBlockComponentModels(); for (const bcm of bcms) { if (bcm?.viewModel?.viewType == "term") { const tvm = bcm.viewModel as TermViewModel; if (tvm.isBasicTerm((atom) => globalStore.get(atom))) { termModels.push(tvm); } } } return termModels; } ================================================ FILE: frontend/app/view/term/term-tooltip.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { FloatingPortal, VirtualElement, flip, offset, shift, useFloating } from "@floating-ui/react"; import * as React from "react"; import type { TermWrap } from "./termwrap"; // ── low-level primitive ────────────────────────────────────────────────────── interface TermTooltipProps { /** Screen-space mouse position (clientX/clientY). null means hidden. */ mousePos: { x: number; y: number } | null; content: React.ReactNode; } /** * A floating tooltip anchored to the current mouse position. * Uses a floating-ui virtual element (via refs.setPositionReference) so no * real DOM reference is required. Renders into a FloatingPortal. */ export const TermTooltip = React.memo(function TermTooltip({ mousePos, content }: TermTooltipProps) { const isOpen = mousePos != null; // Keep latest mousePos in a ref so the virtual element always reflects it. const mousePosRef = React.useRef(mousePos); mousePosRef.current = mousePos; const { refs, floatingStyles } = useFloating({ open: isOpen, placement: "top-start", middleware: [offset({ mainAxis: 12, crossAxis: -20 }), flip(), shift({ padding: 0 })], }); // Update the position reference whenever mousePos changes. React.useLayoutEffect(() => { if (!isOpen) { return; } const virtualEl: VirtualElement = { getBoundingClientRect() { const pos = mousePosRef.current ?? { x: 0, y: 0 }; return new DOMRect(pos.x, pos.y, 0, 0); }, }; refs.setPositionReference(virtualEl); }, [isOpen, mousePos?.x, mousePos?.y]); if (!isOpen) { return null; } return ( <FloatingPortal> <div ref={refs.setFloating} style={floatingStyles} className="bg-zinc-800/70 rounded-md px-2 py-1 text-xs text-secondary shadow-xl z-50 pointer-events-none select-none" > {content} </div> </FloatingPortal> ); }); // ── wired-up sub-component ─────────────────────────────────────────────────── function clearTimeoutRef(ref: React.RefObject<number | null>) { if (ref.current == null) { return; } window.clearTimeout(ref.current); ref.current = null; } const HoverDelayMs = 600; const MaxHoverTimeMs = 2200; const modKey = PLATFORM === PlatformMacOS ? "Cmd" : "Ctrl"; interface TermLinkTooltipProps { /** * The live TermWrap instance. Pass the instance directly (not a ref) so * React re-runs the effect when it changes (e.g. on terminal recreate). */ termWrap: TermWrap | null; } /** * Self-contained sub-component that subscribes to the termWrap link-hover * callback and renders a tooltip after a short delay. Keeping state here * prevents unnecessary re-renders of the parent TerminalView. */ export const TermLinkTooltip = React.memo(function TermLinkTooltip({ termWrap }: TermLinkTooltipProps) { const [mousePos, setMousePos] = React.useState<{ x: number; y: number } | null>(null); const timeoutRef = React.useRef<number | null>(null); const maxTimeoutRef = React.useRef<number | null>(null); React.useEffect(() => { if (termWrap == null) { return; } termWrap.onLinkHover = (uri: string | null, mouseX: number, mouseY: number) => { clearTimeoutRef(timeoutRef); if (uri == null) { clearTimeoutRef(maxTimeoutRef); setMousePos(null); return; } // Show after a short delay so fast mouse movements don't flicker. timeoutRef.current = window.setTimeout(() => { timeoutRef.current = null; setMousePos({ x: mouseX, y: mouseY }); // Auto-dismiss after MaxHoverTimeMs so the tooltip doesn't linger forever. clearTimeoutRef(maxTimeoutRef); maxTimeoutRef.current = window.setTimeout(() => { maxTimeoutRef.current = null; setMousePos(null); }, MaxHoverTimeMs); }, HoverDelayMs); }; return () => { termWrap.onLinkHover = null; clearTimeoutRef(timeoutRef); clearTimeoutRef(maxTimeoutRef); setMousePos(null); }; }, [termWrap]); return <TermTooltip mousePos={mousePos} content={<span>{modKey}-click to open link</span>} />; }); ================================================ FILE: frontend/app/view/term/term-wsh.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { atoms, globalStore } from "@/app/store/global"; import { makeORef, splitORef } from "@/app/store/wos"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { TermViewModel } from "@/app/view/term/term-model"; import { bufferLinesToText } from "@/app/view/term/termutil"; import { isBlank } from "@/util/util"; import debug from "debug"; const dlog = debug("wave:vdom"); export class TermWshClient extends WshClient { blockId: string; model: TermViewModel; constructor(blockId: string, model: TermViewModel) { super(makeFeBlockRouteId(blockId)); this.blockId = blockId; this.model = model; } async handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) { const source = rh.getSource(); if (isBlank(source)) { throw new Error("source cannot be blank"); } console.log("vdom-create", source, data); const tabId = globalStore.get(atoms.staticTabId); if (data.target?.newblock) { const oref = await RpcApi.CreateBlockCommand(this, { tabid: tabId, blockdef: { meta: { view: "vdom", "vdom:route": rh.getSource(), }, }, magnified: data.target?.magnified, focused: true, }); return oref; } else if (data.target?.toolbar?.toolbar) { const oldVDomBlockId = globalStore.get(this.model.vdomToolbarBlockId); console.log("vdom:toolbar", data.target.toolbar); globalStore.set(this.model.vdomToolbarTarget, data.target.toolbar); const oref = await RpcApi.CreateSubBlockCommand(this, { parentblockid: this.blockId, blockdef: { meta: { view: "vdom", "vdom:route": rh.getSource(), }, }, }); const [_, newVDomBlockId] = splitORef(oref); if (!isBlank(oldVDomBlockId)) { // dispose of the old vdom block setTimeout(() => { RpcApi.DeleteSubBlockCommand(this, { blockid: oldVDomBlockId }); }, 500); } setTimeout(() => { RpcApi.SetMetaCommand(this, { oref: makeORef("block", this.model.blockId), meta: { "term:vdomtoolbarblockid": newVDomBlockId, }, }); }, 50); return oref; } else { // in the terminal // check if there is a current active vdom block const oldVDomBlockId = globalStore.get(this.model.vdomBlockId); const oref = await RpcApi.CreateSubBlockCommand(this, { parentblockid: this.blockId, blockdef: { meta: { view: "vdom", "vdom:route": rh.getSource(), }, }, }); const [_, newVDomBlockId] = splitORef(oref); if (!isBlank(oldVDomBlockId)) { // dispose of the old vdom block setTimeout(() => { RpcApi.DeleteSubBlockCommand(this, { blockid: oldVDomBlockId }); }, 500); } setTimeout(() => { RpcApi.SetMetaCommand(this, { oref: makeORef("block", this.model.blockId), meta: { "term:mode": "vdom", "term:vdomblockid": newVDomBlockId, }, }); }, 50); return oref; } } async handle_termgetscrollbacklines( rh: RpcResponseHelper, data: CommandTermGetScrollbackLinesData ): Promise<CommandTermGetScrollbackLinesRtnData> { const termWrap = this.model.termRef.current; if (!termWrap || !termWrap.terminal) { return { totallines: 0, linestart: data.linestart, lines: [], lastupdated: 0, }; } const buffer = termWrap.terminal.buffer.active; const totalLines = buffer.length; if (data.lastcommand) { if (globalStore.get(termWrap.shellIntegrationStatusAtom) == null) { throw new Error("Cannot get last command data without shell integration"); } let startBufferIndex = 0; let endBufferIndex = totalLines; if (termWrap.promptMarkers.length > 0) { // The last marker is the current prompt, so we want the second-to-last for the previous command // If there's only one marker, use it (edge case for first command) const markerIndex = termWrap.promptMarkers.length > 1 ? termWrap.promptMarkers.length - 2 : termWrap.promptMarkers.length - 1; const commandStartMarker = termWrap.promptMarkers[markerIndex]; startBufferIndex = commandStartMarker.line; // End at the last marker (current prompt) if there are multiple markers if (termWrap.promptMarkers.length > 1) { const currentPromptMarker = termWrap.promptMarkers[termWrap.promptMarkers.length - 1]; endBufferIndex = currentPromptMarker.line; } } const lines = bufferLinesToText(buffer, startBufferIndex, endBufferIndex); // Convert buffer indices to "from bottom" line numbers. // "from bottom" 0 = most recent line; higher numbers = older lines. // The buffer range [startBufferIndex, endBufferIndex) maps to // "from bottom" range [totalLines - endBufferIndex, totalLines - startBufferIndex). // The first returned line is at "from bottom" position: totalLines - endBufferIndex. let returnLines = lines; let returnStartLine = totalLines - endBufferIndex; if (lines.length > 1000) { // there is a small bug here since this is computing a physical start line // after the lines have already been combined (because of potential wrapping) // for now this isn't worth fixing, just noted returnLines = lines.slice(lines.length - 1000); returnStartLine = (totalLines - endBufferIndex) + (lines.length - 1000); } return { totallines: totalLines, linestart: returnStartLine, lines: returnLines, lastupdated: termWrap.lastUpdated, }; } const startLine = Math.max(0, data.linestart); const endLine = data.lineend === 0 ? totalLines : Math.min(totalLines, data.lineend); const startBufferIndex = totalLines - endLine; const endBufferIndex = totalLines - startLine; const lines = bufferLinesToText(buffer, startBufferIndex, endBufferIndex); return { totallines: totalLines, linestart: startLine, lines: lines, lastupdated: termWrap.lastUpdated, }; } } ================================================ FILE: frontend/app/view/term/term.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .connection-btn { min-height: 0; overflow: hidden; line-height: 1; display: flex; background-color: orangered; justify-content: flex-start; width: 200px; } .view-term { display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; position: relative; .term-header { display: flex; flex-direction: row; padding: 4px 10px; height: 35px; gap: 10px; align-items: center; flex-shrink: 0; border-bottom: 1px solid var(--border-color); } .term-toolbar { height: 20px; border-bottom: 1px solid var(--border-color); overflow: hidden; } .term-cmd-toolbar { display: flex; flex-direction: row; height: 24px; border-bottom: 1px solid var(--border-color); overflow: hidden; align-items: center; .term-cmd-toolbar-text { font: var(--fixed-font); flex-grow: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 5px; } } .term-connectelem { flex-grow: 1; min-height: 0; overflow: hidden; line-height: 1; margin: 5px 1px 5px 4px; } .term-htmlelem { display: flex; flex-direction: column; width: 100%; flex-grow: 1; min-height: 0; overflow: hidden; .block-content { padding: 0; } } &.term-mode-term { .term-connectelem { display: flex; } .term-htmlelem { display: none; } } &.term-mode-vdom { .term-connectelem { display: none; } .term-htmlelem { display: flex; } .ijson iframe { width: 100%; height: 100%; border: none; } } .term-stickers { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: var(--zindex-termstickers); pointer-events: none; .term-sticker-image { img { object-fit: contain; width: 100%; height: 100%; } } .term-sticker-svg { svg { object-fit: contain; width: 100%; height: 100%; } } } .terminal { width: 100%; .xterm-viewport { &::-webkit-scrollbar { width: 6px; /* this needs to match fitAddon.scrollbarWidth in termwrap.ts */ height: 6px; } &::-webkit-scrollbar-track { background-color: var(--scrollbar-background-color); } &::-webkit-scrollbar-thumb { background-color: transparent; border-radius: 4px; margin: 0 1px 0 1px; &:hover { background-color: var(--scrollbar-thumb-hover-color) !important; } &:active { background-color: var(--scrollbar-thumb-active-color) !important; } } } &:hover { .xterm-viewport::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb-color); } } } } ================================================ FILE: frontend/app/view/term/term.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { SubBlock } from "@/app/block/block"; import type { BlockNodeModel } from "@/app/block/blocktypes"; import { NullErrorBoundary } from "@/app/element/errorboundary"; import { Search, useSearch } from "@/app/element/search"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { globalStore } from "@/app/store/jotaiStore"; import { useTabModel } from "@/app/store/tab-model"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import type { TermViewModel } from "@/app/view/term/term-model"; import { atoms, getOverrideConfigAtom, getSettingsPrefixAtom, WOS } from "@/store/global"; import { fireAndForget, useAtomValueSafe } from "@/util/util"; import { computeBgStyleFromMeta } from "@/util/waveutil"; import { ISearchOptions } from "@xterm/addon-search"; import clsx from "clsx"; import debug from "debug"; import * as jotai from "jotai"; import * as React from "react"; import { TermLinkTooltip } from "./term-tooltip"; import { TermStickers } from "./termsticker"; import { TermThemeUpdater } from "./termtheme"; import { computeTheme, normalizeCursorStyle } from "./termutil"; import { TermWrap } from "./termwrap"; import "./xterm.css"; const dlog = debug("wave:term"); interface TerminalViewProps { blockId: string; model: TermViewModel; } const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => { const connStatus = jotai.useAtomValue(model.connStatus); const [lastConnStatus, setLastConnStatus] = React.useState<ConnStatus>(connStatus); React.useEffect(() => { if (!model.termRef.current?.hasResized) { return; } const isConnected = connStatus?.status == "connected"; const wasConnected = lastConnStatus?.status == "connected"; const curConnName = connStatus?.connection; const lastConnName = lastConnStatus?.connection; if (isConnected == wasConnected && curConnName == lastConnName) { return; } model.termRef.current?.resyncController("resync handler"); setLastConnStatus(connStatus); }, [connStatus]); return null; }); const TermVDomToolbarNode = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => { React.useEffect(() => { const unsub = waveEventSubscribeSingle({ eventType: "blockclose", scope: WOS.makeORef("block", vdomBlockId), handler: (event) => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { "term:mode": null, "term:vdomtoolbarblockid": null, }, }); }, }); return () => { unsub(); }; }, []); const vdomNodeModel: BlockNodeModel = React.useMemo( () => ({ blockId: vdomBlockId, isFocused: jotai.atom(false), isMagnified: jotai.atom(false), focusNode: () => {}, toggleMagnify: () => {}, onClose: () => { if (vdomBlockId != null) { RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId }); } }, }), [vdomBlockId] ); const toolbarTarget = jotai.useAtomValue(model.vdomToolbarTarget); const heightStr = toolbarTarget?.height ?? "1.5em"; return ( <div key="vdomToolbar" className="term-toolbar" style={{ height: heightStr }}> <SubBlock key="vdom" nodeModel={vdomNodeModel} /> </div> ); }; const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => { React.useEffect(() => { const unsub = waveEventSubscribeSingle({ eventType: "blockclose", scope: WOS.makeORef("block", vdomBlockId), handler: (event) => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { "term:mode": null, "term:vdomblockid": null, }, }); }, }); return () => { unsub(); }; }, []); const vdomNodeModel: BlockNodeModel = React.useMemo(() => { const isFocusedAtom = jotai.atom((get) => { return get(model.nodeModel.isFocused) && get(model.termMode) == "vdom"; }); return { blockId: vdomBlockId, isFocused: isFocusedAtom, isMagnified: jotai.atom(false), focusNode: () => { model.nodeModel.focusNode(); }, toggleMagnify: () => {}, onClose: () => { if (vdomBlockId != null) { RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId }); } }, }; }, [vdomBlockId, model]); return ( <div key="htmlElem" className="term-htmlelem"> <SubBlock key="vdom" nodeModel={vdomNodeModel} /> </div> ); }; const TermVDomNode = ({ blockId, model }: TerminalViewProps) => { const vdomBlockId = jotai.useAtomValue(model.vdomBlockId); if (vdomBlockId == null) { return null; } return <TermVDomNodeSingleId key={vdomBlockId} vdomBlockId={vdomBlockId} blockId={blockId} model={model} />; }; const TermToolbarVDomNode = ({ blockId, model }: TerminalViewProps) => { const vdomToolbarBlockId = jotai.useAtomValue(model.vdomToolbarBlockId); if (vdomToolbarBlockId == null) { return null; } return ( <TermVDomToolbarNode key={vdomToolbarBlockId} vdomBlockId={vdomToolbarBlockId} blockId={blockId} model={model} /> ); }; const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) => { const viewRef = React.useRef<HTMLDivElement>(null); const connectElemRef = React.useRef<HTMLDivElement>(null); const [termWrapInst, setTermWrapInst] = React.useState<TermWrap | null>(null); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const termSettingsAtom = getSettingsPrefixAtom("term"); const termSettings = jotai.useAtomValue(termSettingsAtom); let termMode = blockData?.meta?.["term:mode"] ?? "term"; if (termMode != "term" && termMode != "vdom") { termMode = "term"; } const termModeRef = React.useRef(termMode); const tabModel = useTabModel(); const termFontSize = jotai.useAtomValue(model.fontSizeAtom); const fullConfig = globalStore.get(atoms.fullConfigAtom); const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.["term:fontfamily"]; const isFocused = jotai.useAtomValue(model.nodeModel.isFocused); const isMI = jotai.useAtomValue(tabModel.isTermMultiInput); const isBasicTerm = termMode != "vdom" && blockData?.meta?.controller != "cmd"; // needs to match isBasicTerm // search const searchProps = useSearch({ anchorRef: viewRef, viewModel: model, caseSensitive: false, wholeWord: false, regex: false, }); const searchIsOpen = jotai.useAtomValue<boolean>(searchProps.isOpen); const caseSensitive = useAtomValueSafe<boolean>(searchProps.caseSensitive); const wholeWord = useAtomValueSafe<boolean>(searchProps.wholeWord); const regex = useAtomValueSafe<boolean>(searchProps.regex); const searchVal = jotai.useAtomValue<string>(searchProps.searchValue); const searchDecorations = React.useMemo( () => ({ matchOverviewRuler: "#000000", activeMatchColorOverviewRuler: "#000000", activeMatchBorder: "#FF9632", matchBorder: "#FFFF00", }), [] ); const searchOpts = React.useMemo<ISearchOptions>( () => ({ regex, wholeWord, caseSensitive, decorations: searchDecorations, }), [regex, wholeWord, caseSensitive] ); const handleSearchError = React.useCallback((e: Error) => { console.warn("search error:", e); }, []); const executeSearch = React.useCallback( (searchText: string, direction: "next" | "previous") => { if (searchText === "") { model.termRef.current?.searchAddon.clearDecorations(); return; } try { model.termRef.current?.searchAddon[direction === "next" ? "findNext" : "findPrevious"]( searchText, searchOpts ); } catch (e) { handleSearchError(e); } }, [searchOpts, handleSearchError] ); searchProps.onSearch = React.useCallback( (searchText: string) => executeSearch(searchText, "previous"), [executeSearch] ); searchProps.onPrev = React.useCallback(() => executeSearch(searchVal, "previous"), [executeSearch, searchVal]); searchProps.onNext = React.useCallback(() => executeSearch(searchVal, "next"), [executeSearch, searchVal]); // Return input focus to the terminal when the search is closed React.useEffect(() => { if (!searchIsOpen) { model.giveFocus(); } }, [searchIsOpen]); // rerun search when the searchOpts change React.useEffect(() => { model.termRef.current?.searchAddon.clearDecorations(); searchProps.onSearch(searchVal); }, [searchOpts]); // end search React.useEffect(() => { const fullConfig = globalStore.get(atoms.fullConfigAtom); const termThemeName = globalStore.get(model.termThemeNameAtom); const termTransparency = globalStore.get(model.termTransparencyAtom); const termMacOptionIsMetaAtom = getOverrideConfigAtom(blockId, "term:macoptionismeta"); const [termTheme, _] = computeTheme(fullConfig, termThemeName, termTransparency); let termScrollback = 2000; if (termSettings?.["term:scrollback"]) { termScrollback = Math.floor(termSettings["term:scrollback"]); } if (blockData?.meta?.["term:scrollback"]) { termScrollback = Math.floor(blockData.meta["term:scrollback"]); } if (termScrollback < 0) { termScrollback = 0; } if (termScrollback > 50000) { termScrollback = 50000; } const termAllowBPM = globalStore.get(model.termBPMAtom) ?? true; const termMacOptionIsMeta = globalStore.get(termMacOptionIsMetaAtom) ?? false; const termCursorStyle = normalizeCursorStyle(globalStore.get(getOverrideConfigAtom(blockId, "term:cursor"))); const termCursorBlink = globalStore.get(getOverrideConfigAtom(blockId, "term:cursorblink")) ?? false; const wasFocused = model.termRef.current != null && globalStore.get(model.nodeModel.isFocused); const termWrap = new TermWrap( tabModel.tabId, blockId, connectElemRef.current, { theme: termTheme, fontSize: termFontSize, fontFamily: termSettings?.["term:fontfamily"] ?? connFontFamily ?? "Hack", drawBoldTextInBrightColors: false, fontWeight: "normal", fontWeightBold: "bold", allowTransparency: true, scrollback: termScrollback, allowProposedApi: true, // Required by @xterm/addon-search to enable search functionality and decorations ignoreBracketedPasteMode: !termAllowBPM, macOptionIsMeta: termMacOptionIsMeta, cursorStyle: termCursorStyle, cursorBlink: termCursorBlink, }, { keydownHandler: model.handleTerminalKeydown.bind(model), useWebGl: !termSettings?.["term:disablewebgl"], sendDataHandler: model.sendDataToController.bind(model), nodeModel: model.nodeModel, } ); (window as any).term = termWrap; model.termRef.current = termWrap; setTermWrapInst(termWrap); const rszObs = new ResizeObserver(() => { if (termWrap.cachedAtBottomForResize == null) { termWrap.cachedAtBottomForResize = termWrap.wasRecentlyAtBottom(); } termWrap.handleResize_debounced(); }); rszObs.observe(connectElemRef.current); termWrap.onSearchResultsDidChange = (results) => { globalStore.set(searchProps.resultsIndex, results.resultIndex); globalStore.set(searchProps.resultsCount, results.resultCount); }; fireAndForget(termWrap.initTerminal.bind(termWrap)); if (wasFocused) { setTimeout(() => { model.giveFocus(); }, 10); } return () => { termWrap.dispose(); rszObs.disconnect(); setTermWrapInst(null); }; }, [blockId, termSettings, termFontSize, connFontFamily]); React.useEffect(() => { if (termModeRef.current == "vdom" && termMode == "term") { // focus the terminal model.giveFocus(); } termModeRef.current = termMode; }, [termMode]); React.useEffect(() => { if (isMI && isBasicTerm && isFocused && model.termRef.current != null) { model.termRef.current.multiInputCallback = (data: string) => { model.multiInputHandler(data); }; } else { if (model.termRef.current != null) { model.termRef.current.multiInputCallback = null; } } }, [isMI, isBasicTerm, isFocused]); const stickerConfig = { charWidth: 8, charHeight: 16, rows: model.termRef.current?.terminal.rows ?? 24, cols: model.termRef.current?.terminal.cols ?? 80, blockId: blockId, }; const termBg = computeBgStyleFromMeta(blockData?.meta); const handleContextMenu = React.useCallback( (e: React.MouseEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); const menuItems = model.getContextMenuItems(); ContextMenuModel.getInstance().showContextMenu(menuItems, e); }, [model] ); return ( <div className={clsx("view-term", "term-mode-" + termMode)} ref={viewRef} onContextMenu={handleContextMenu}> {termBg && <div key="term-bg" className="absolute inset-0 z-0 pointer-events-none" style={termBg} />} <TermResyncHandler blockId={blockId} model={model} /> <TermThemeUpdater blockId={blockId} model={model} termRef={model.termRef} /> <TermStickers config={stickerConfig} /> <TermToolbarVDomNode key="vdom-toolbar" blockId={blockId} model={model} /> <TermVDomNode key="vdom" blockId={blockId} model={model} /> <div key="connect-elem" className="term-connectelem" ref={connectElemRef} /> <NullErrorBoundary debugName="TermLinkTooltip"> <TermLinkTooltip termWrap={termWrapInst} /> </NullErrorBoundary> <Search {...searchProps} /> </div> ); }; export { TerminalView }; ================================================ FILE: frontend/app/view/term/termsticker.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { createBlock } from "@/store/global"; import { getWebServerEndpoint } from "@/util/endpoints"; import { stringToBase64 } from "@/util/util"; import clsx from "clsx"; import * as React from "react"; import "./term.scss"; type StickerType = { position: "absolute"; top?: number; left?: number; right?: number; bottom?: number; width?: number; height?: number; color?: string; opacity?: number; pointerevents?: boolean; fontsize?: number; transform?: string; stickertype: "icon" | "image" | "gauge"; icon?: string; imgsrc?: string; clickcmd?: string; clickblockdef?: BlockDef; }; type StickerTermConfig = { charWidth: number; charHeight: number; rows: number; cols: number; blockId: string; }; function convertWidthDimToPx(dim: number, config: StickerTermConfig) { if (dim == null) { return null; } return dim * config.charWidth; } function convertHeightDimToPx(dim: number, config: StickerTermConfig) { if (dim == null) { return null; } return dim * config.charHeight; } function TermSticker({ sticker, config }: { sticker: StickerType; config: StickerTermConfig }) { const style: React.CSSProperties = { position: sticker.position, top: convertHeightDimToPx(sticker.top, config), left: convertWidthDimToPx(sticker.left, config), right: convertWidthDimToPx(sticker.right, config), bottom: convertHeightDimToPx(sticker.bottom, config), width: convertWidthDimToPx(sticker.width, config), height: convertHeightDimToPx(sticker.height, config), color: sticker.color, fontSize: sticker.fontsize, transform: sticker.transform, opacity: sticker.opacity, fill: sticker.color, stroke: sticker.color, }; if (sticker.pointerevents) { style.pointerEvents = "auto"; } if (style.width != null) { style.overflowX = "hidden"; } if (style.height != null) { style.overflowY = "hidden"; } let clickHandler = null; if (sticker.pointerevents && (sticker.clickcmd || sticker.clickblockdef)) { style.cursor = "pointer"; clickHandler = () => { console.log("clickHandler", sticker.clickcmd, sticker.clickblockdef); if (sticker.clickcmd) { const b64data = stringToBase64(sticker.clickcmd); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: config.blockId, inputdata64: b64data }); } if (sticker.clickblockdef) { createBlock(sticker.clickblockdef); } }; } if (sticker.stickertype == "icon") { return ( <div className="term-sticker" style={style} onClick={clickHandler}> <i className={clsx("fa", "fa-" + sticker.icon)} /> </div> ); } if (sticker.stickertype == "image") { if (sticker.imgsrc == null) { return null; } const streamingUrl = getWebServerEndpoint() + "/wave/stream-local-file?path=" + encodeURIComponent(sticker.imgsrc); return ( <div className="term-sticker term-sticker-image" style={style} onClick={clickHandler}> <img src={streamingUrl} /> </div> ); } return null; } export function TermStickers({ config }: { config: StickerTermConfig }) { let stickers: StickerType[] = []; if (config.blockId.startsWith("d1eaddcb")) { stickers.push({ position: "absolute", top: 5, right: 7, stickertype: "icon", icon: "paw", color: "#40cc40aa", fontsize: 30, transform: "rotate(-18deg)", pointerevents: true, clickcmd: "ls\n", }); stickers.push({ position: "absolute", top: 8, right: 8, stickertype: "icon", icon: "paw", color: "#4040ccaa", fontsize: 30, transform: "rotate(-20deg)", pointerevents: true, clickcmd: "git status\n", }); stickers.push({ position: "absolute", top: 2, right: 25, width: 20, stickertype: "gauge", opacity: 0.7, }); } return ( <div className="term-stickers"> {stickers.map((sticker, i) => ( <TermSticker key={i} sticker={sticker} config={config} /> ))} </div> ); } ================================================ FILE: frontend/app/view/term/termtheme.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { TermViewModel } from "@/app/view/term/term-model"; import { computeTheme } from "@/app/view/term/termutil"; import { TermWrap } from "@/app/view/term/termwrap"; import { atoms } from "@/store/global"; import { useAtomValue } from "jotai"; import { useEffect } from "react"; interface TermThemeProps { blockId: string; termRef: React.RefObject<TermWrap>; model: TermViewModel; } const TermThemeUpdater = ({ blockId, model, termRef }: TermThemeProps) => { const fullConfig = useAtomValue(atoms.fullConfigAtom); const blockTermTheme = useAtomValue(model.termThemeNameAtom); const transparency = useAtomValue(model.termTransparencyAtom); const [theme, _] = computeTheme(fullConfig, blockTermTheme, transparency); useEffect(() => { if (termRef.current?.terminal) { termRef.current.terminal.options.theme = theme; } }, [theme]); return null; }; export { TermThemeUpdater }; ================================================ FILE: frontend/app/view/term/termutil.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export const DefaultTermTheme = "default-dark"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import * as TermTypes from "@xterm/xterm"; import base64 from "base64-js"; import { colord } from "colord"; export type GenClipboardItem = { text?: string; image?: Blob }; export function normalizeCursorStyle(cursorStyle: string): TermTypes.Terminal["options"]["cursorStyle"] { if (cursorStyle === "underline" || cursorStyle === "bar") { return cursorStyle; } return "block"; } function applyTransparencyToColor(hexColor: string, transparency: number): string { const alpha = 1 - transparency; // transparency is already 0-1 return colord(hexColor).alpha(alpha).toHex(); } // returns (theme, bgcolor, transparency (0 - 1.0)) export function computeTheme( fullConfig: FullConfigType, themeName: string, termTransparency: number ): [TermThemeType, string] { let theme: TermThemeType = fullConfig?.termthemes?.[themeName]; if (theme == null) { theme = fullConfig?.termthemes?.[DefaultTermTheme] || ({} as any); } const themeCopy = { ...theme }; if (termTransparency != null && termTransparency > 0) { if (themeCopy.background) { themeCopy.background = applyTransparencyToColor(themeCopy.background, termTransparency); } if (themeCopy.selectionBackground) { themeCopy.selectionBackground = applyTransparencyToColor(themeCopy.selectionBackground, termTransparency); } } const bgcolor = themeCopy.background; themeCopy.background = "#00000000"; return [themeCopy, bgcolor]; } export const MIME_TO_EXT: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp", "image/svg+xml": "svg", "image/tiff": "tiff", "image/heic": "heic", "image/heif": "heif", "image/avif": "avif", "image/x-icon": "ico", "image/vnd.microsoft.icon": "ico", }; /** * Creates a temporary file from a Blob (typically an image). * Validates size, generates a unique filename, saves to temp directory, * and returns the file path. * * @param blob - The Blob to save * @returns The path to the created temporary file * @throws Error if blob is too large (>5MB) or data URL is invalid */ export async function createTempFileFromBlob(blob: Blob): Promise<string> { // Check size limit (5MB) if (blob.size > 5 * 1024 * 1024) { throw new Error("Image too large (>5MB)"); } // Get file extension from MIME type if (!blob.type.startsWith("image/") || !MIME_TO_EXT[blob.type]) { throw new Error(`Unsupported or invalid image type: ${blob.type}`); } const ext = MIME_TO_EXT[blob.type]; // Generate unique filename with timestamp and random component const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 8); const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; const arrayBuffer = await new Promise<ArrayBuffer>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as ArrayBuffer); reader.onerror = reject; reader.readAsArrayBuffer(blob); }); const base64Data = base64.fromByteArray(new Uint8Array(arrayBuffer)); // Write image to temp file and get path const tempPath = await RpcApi.WriteTempFileCommand(TabRpcClient, { filename, data64: base64Data, }); return tempPath; } /** * Extracts text or image data from a ClipboardItem using prioritized extraction modes. * * Mode 1 (Images): If image types are present, returns the first image * Mode 2 (Plain Text): If text/plain, text/plain;*, or "text" is found * Mode 3 (HTML): If text/html is found, extracts text content via DOM * Mode 4 (Generic): If empty string or null type exists * * @param item - ClipboardItem to extract data from * @returns Object with either text or image, or null if no supported content found */ export async function extractClipboardData(item: ClipboardItem): Promise<GenClipboardItem | null> { // Mode #1: Check for image first const imageTypes = item.types.filter((type) => type.startsWith("image/")); if (imageTypes.length > 0) { const blob = await item.getType(imageTypes[0]); return { image: blob }; } // Mode #2: Try text/plain, text/plain;*, or "text" const plainTextType = item.types.find((t) => t === "text" || t === "text/plain" || t.startsWith("text/plain;")); if (plainTextType) { const blob = await item.getType(plainTextType); const text = await blob.text(); return text ? { text } : null; } // Mode #3: Try text/html - extract text via DOM const htmlType = item.types.find((t) => t === "text/html" || t.startsWith("text/html;")); if (htmlType) { const blob = await item.getType(htmlType); const html = await blob.text(); if (!html) { return null; } const tempDiv = document.createElement("div"); tempDiv.innerHTML = html; const text = tempDiv.textContent || ""; return text ? { text } : null; } // Mode #4: Try empty string or null type const genericType = item.types.find((t) => t === ""); if (genericType != null) { const blob = await item.getType(genericType); const text = await blob.text(); return text ? { text } : null; } return null; } /** * Finds the first DataTransferItem matching the specified kind and type predicate. * * @param items - The DataTransferItemList to search * @param kind - The kind to match ("file" or "string") * @param typePredicate - Function that returns true if the type matches * @returns The first matching DataTransferItem, or null if none found */ function findFirstDataTransferItem( items: DataTransferItemList, kind: string, typePredicate: (type: string) => boolean ): DataTransferItem | null { for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === kind && typePredicate(item.type)) { return item; } } return null; } /** * Finds all DataTransferItems matching the specified kind and type predicate. * * @param items - The DataTransferItemList to search * @param kind - The kind to match ("file" or "string") * @param typePredicate - Function that returns true if the type matches * @returns Array of matching DataTransferItems */ function findAllDataTransferItems( items: DataTransferItemList, kind: string, typePredicate: (type: string) => boolean ): DataTransferItem[] { const results: DataTransferItem[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === kind && typePredicate(item.type)) { results.push(item); } } return results; } /** * Extracts clipboard data from a DataTransferItemList using prioritized extraction modes. * * The function uses a hierarchical approach to determine what data to extract: * * Mode 1 (Image Files): If any image file items are present, extracts only image files * - Returns array of {image: Blob} for each image/* MIME type * - Ignores all non-image items when image files are present * - Non-image files (e.g., PDFs) allow fallthrough to text modes * * Mode 2 (Plain Text): If text/plain is found (and no image files) * - Returns single-item array with first text/plain content as {text: string} * - Matches: "text", "text/plain", or types starting with "text/plain" * * Mode 3 (HTML): If text/html is found (and no image files or plain text) * - Extracts text content from first HTML item using DOM parsing * - Returns single-item array as {text: string} * * Mode 4 (Generic String): If string item with empty/null type exists * - Returns first string item with no type identifier * - Returns single-item array as {text: string} * * @param items - The DataTransferItemList to process * @returns Array of GenClipboardItem objects, or empty array if no supported content found */ export async function extractDataTransferItems(items: DataTransferItemList): Promise<GenClipboardItem[]> { // Mode #1: If image files are present, only extract image files const imageFiles = findAllDataTransferItems(items, "file", (type) => type.startsWith("image/")); if (imageFiles.length > 0) { const results: GenClipboardItem[] = []; for (const item of imageFiles) { const blob = item.getAsFile(); if (blob) { results.push({ image: blob }); } } return results; } // Mode #2: If text/plain is present, only extract the first text/plain const plainTextItem = findFirstDataTransferItem( items, "string", (type) => type === "text" || type === "text/plain" || type.startsWith("text/plain;") ); if (plainTextItem) { return new Promise((resolve) => { plainTextItem.getAsString((text) => { resolve(text ? [{ text }] : []); }); }); } // Mode #3: If text/html is present, extract text from first HTML const htmlItem = findFirstDataTransferItem( items, "string", (type) => type === "text/html" || type.startsWith("text/html;") ); if (htmlItem) { return new Promise((resolve) => { htmlItem.getAsString((html) => { if (!html) { resolve([]); return; } const tempDiv = document.createElement("div"); tempDiv.innerHTML = html; const text = tempDiv.textContent || ""; resolve(text ? [{ text }] : []); }); }); } // Mode #4: If there's a string item with empty/null type, extract first one const genericStringItem = findFirstDataTransferItem(items, "string", (type) => type === "" || type == null); if (genericStringItem) { return new Promise((resolve) => { genericStringItem.getAsString((text) => { resolve(text ? [{ text }] : []); }); }); } return []; } /** * Extracts all clipboard data from a ClipboardEvent using multiple fallback methods. * Tries ClipboardEvent.clipboardData.items first, then Clipboard API, then simple getData(). * * @param e - The ClipboardEvent (optional) * @returns Array of objects containing text and/or image data */ export async function extractAllClipboardData(e?: ClipboardEvent): Promise<Array<GenClipboardItem>> { const results: Array<GenClipboardItem> = []; try { // First try using ClipboardEvent.clipboardData.items if (e?.clipboardData?.items) { return await extractDataTransferItems(e.clipboardData.items); } // Fallback: Try Clipboard API const clipboardItems = await navigator.clipboard.read(); for (const item of clipboardItems) { const data = await extractClipboardData(item); if (data) { results.push(data); } } return results; } catch (err) { console.error("Clipboard read error:", err); // Final fallback: simple text paste if (e?.clipboardData) { const text = e.clipboardData.getData("text/plain"); if (text) { results.push({ text }); } } return results; } } /** * Converts terminal buffer lines to text, properly handling wrapped lines. * Wrapped lines (long lines split across multiple buffer rows) are concatenated * without adding newlines between them, while preserving actual line breaks. * * @param buffer - The xterm.js buffer to extract lines from * @param startIndex - Starting buffer index (inclusive, 0-based) * @param endIndex - Ending buffer index (exclusive, 0-based) * @returns Array of logical lines (with wrapped lines concatenated) */ export function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number, endIndex: number): string[] { const lines: string[] = []; let currentLine = ""; let isFirstLine = true; // Clamp indices to valid buffer range to avoid out-of-bounds access on the // underlying circular buffer, which could return stale/wrong data. const clampedStart = Math.max(0, Math.min(startIndex, buffer.length)); const clampedEnd = Math.max(0, Math.min(endIndex, buffer.length)); for (let i = clampedStart; i < clampedEnd; i++) { const line = buffer.getLine(i); if (line) { const lineText = line.translateToString(true); // If this line is wrapped (continuation of previous line), concatenate without newline if (line.isWrapped && !isFirstLine) { currentLine += lineText; } else { // This is a new logical line if (!isFirstLine) { lines.push(currentLine); } currentLine = lineText; isFirstLine = false; } } } // Don't forget the last line if (!isFirstLine) { lines.push(currentLine); } // Trim trailing blank lines only when the requested range extends to the // actual end of the buffer. A terminal allocates a fixed number of rows // (e.g. 80) but only the first few may contain real content; the rest are // empty placeholder rows. We strip those so callers don't receive a wall // of empty strings. // // Crucially, if the caller requested a specific sub-range (e.g. lines // 100-150) and lines 140-150 happen to be blank, those blanks are // intentional and must NOT be removed. We only trim when the range // reaches the very end of the buffer. if (clampedEnd >= buffer.length) { while (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } } return lines; } ================================================ FILE: frontend/app/view/term/termwrap.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; import { setBadge } from "@/app/store/badge"; import { getFileSubject } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { fetchWaveFile, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, isDev, openLink, WOS, } from "@/store/global"; import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { base64ToArray, fireAndForget } from "@/util/util"; import { CanvasAddon } from "@xterm/addon-canvas"; import { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import * as TermTypes from "@xterm/xterm"; import { Terminal } from "@xterm/xterm"; import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; import { FitAddon } from "./fitaddon"; import { handleOsc16162Command, handleOsc52Command, handleOsc7Command, type ShellIntegrationStatus, } from "./osc-handlers"; import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData, normalizeCursorStyle } from "./termutil"; const dlog = debug("wave:termwrap"); const TermFileName = "term"; const TermCacheFileName = "cache:term:full"; const MinDataProcessedForCache = 100 * 1024; export const SupportsImageInput = true; const IMEDedupWindowMs = 20; const MaxRepaintTransactionMs = 2000; // detect webgl support function detectWebGLSupport(): boolean { try { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("webgl2"); return !!ctx; } catch (e) { return false; } } export const WebGLSupported = detectWebGLSupport(); let loggedWebGL = false; type TermWrapOptions = { keydownHandler?: (e: KeyboardEvent) => boolean; useWebGl?: boolean; sendDataHandler?: (data: string) => void; nodeModel?: BlockNodeModel; }; export class TermWrap { tabId: string; blockId: string; ptyOffset: number; dataBytesProcessed: number; terminal: Terminal; connectElem: HTMLDivElement; fitAddon: FitAddon; searchAddon: SearchAddon; serializeAddon: SerializeAddon; mainFileSubject: SubjectWithRef<WSFileEventData>; loaded: boolean; heldData: Uint8Array[]; handleResize_debounced: () => void; hasResized: boolean; multiInputCallback: (data: string) => void; sendDataHandler: (data: string) => void; onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void; toDispose: TermTypes.IDisposable[] = []; webglAddon: WebglAddon | null = null; canvasAddon: CanvasAddon | null = null; webglContextLossDisposable: TermTypes.IDisposable | null = null; webglEnabledAtom: jotai.PrimitiveAtom<boolean>; pasteActive: boolean = false; lastUpdated: number; promptMarkers: TermTypes.IMarker[] = []; shellIntegrationStatusAtom: jotai.PrimitiveAtom<ShellIntegrationStatus | null>; lastCommandAtom: jotai.PrimitiveAtom<string | null>; nodeModel: BlockNodeModel; // this can be null hoveredLinkUri: string | null = null; onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void; // IME composition state tracking isComposing: boolean = false; composingData: string = ""; lastCompositionEnd: number = 0; lastComposedText: string = ""; firstDataAfterCompositionSent: boolean = false; // Paste deduplication // xterm.js paste() method triggers onData event, which can cause duplicate sends lastPasteData: string = ""; lastPasteTime: number = 0; // for scrollToBottom support during a resize lastAtBottomTime: number = Date.now(); lastScrollAtBottom: boolean = true; cachedAtBottomForResize: boolean | null = null; viewportScrollTop: number = 0; // dev only (for debugging) recentWrites: { idx: number; data: string; ts: number }[] = []; recentWritesCounter: number = 0; // for repaint transaction scrolling behavior lastClearScrollbackTs: number = 0; lastMode2026SetTs: number = 0; lastMode2026ResetTs: number = 0; inSyncTransaction: boolean = false; inRepaintTransaction: boolean = false; constructor( tabId: string, blockId: string, connectElem: HTMLDivElement, options: TermTypes.ITerminalOptions & TermTypes.ITerminalInitOnlyOptions, waveOptions: TermWrapOptions ) { this.loaded = false; this.tabId = tabId; this.blockId = blockId; this.sendDataHandler = waveOptions.sendDataHandler; this.nodeModel = waveOptions.nodeModel; this.ptyOffset = 0; this.dataBytesProcessed = 0; this.hasResized = false; this.lastUpdated = Date.now(); this.promptMarkers = []; this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<ShellIntegrationStatus | null>; this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>; this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom<boolean>; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); this.fitAddon.scrollbarWidth = 6; // this needs to match scrollbar width in term.scss this.serializeAddon = new SerializeAddon(); this.searchAddon = new SearchAddon(); this.terminal.loadAddon(this.searchAddon); this.terminal.loadAddon(this.fitAddon); this.terminal.loadAddon(this.serializeAddon); this.terminal.loadAddon( new WebLinksAddon( (e, uri) => { e.preventDefault(); switch (PLATFORM) { case PlatformMacOS: if (e.metaKey) { fireAndForget(() => openLink(uri)); } break; default: if (e.ctrlKey) { fireAndForget(() => openLink(uri)); } break; } }, { hover: (e, uri) => { this.hoveredLinkUri = uri; this.onLinkHover?.(uri, e.clientX, e.clientY); }, leave: () => { this.hoveredLinkUri = null; this.onLinkHover?.(null, 0, 0); }, } ) ); this.setTermRenderer(WebGLSupported && waveOptions.useWebGl ? "webgl" : "canvas"); // Register OSC handlers this.terminal.parser.registerOscHandler(7, (data: string) => { return handleOsc7Command(data, this.blockId, this.loaded); }); this.terminal.parser.registerOscHandler(52, (data: string) => { return handleOsc52Command(data, this.blockId, this.loaded, this); }); this.terminal.parser.registerOscHandler(16162, (data: string) => { return handleOsc16162Command(data, this.blockId, this.loaded, this); }); this.toDispose.push( this.terminal.parser.registerCsiHandler({ final: "J" }, (params) => { if (params[0] === 3) { this.lastClearScrollbackTs = Date.now(); if (this.inSyncTransaction) { console.log("[termwrap] repaint transaction starting"); this.inRepaintTransaction = true; } } return false; }) ); this.toDispose.push( this.terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => { if (params[0] === 2026) { this.lastMode2026SetTs = Date.now(); this.inSyncTransaction = true; } return false; }) ); this.toDispose.push( this.terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => { if (params[0] === 2026) { this.lastMode2026ResetTs = Date.now(); this.inSyncTransaction = false; const wasRepaint = this.inRepaintTransaction; this.inRepaintTransaction = false; if (wasRepaint && Date.now() - this.lastClearScrollbackTs <= MaxRepaintTransactionMs) { setTimeout(() => { console.log("[termwrap] repaint transaction complete, scrolling to bottom"); this.terminal.scrollToBottom(); }, 20); } } return false; }) ); this.toDispose.push( this.terminal.onBell(() => { if (!this.loaded) { return true; } console.log("BEL received in terminal", this.blockId); const bellSoundEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:bellsound")) ?? false; if (bellSoundEnabled) { fireAndForget(() => RpcApi.ElectronSystemBellCommand(TabRpcClient, { route: "electron" })); } const bellIndicatorEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:bellindicator")) ?? false; if (bellIndicatorEnabled) { setBadge(this.blockId, { icon: "bell", color: "#fbbf24", priority: 1 }); } return true; }) ); this.terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => { if (e.isComposing && !e.ctrlKey && !e.altKey && !e.metaKey) { return true; } if (!waveOptions.keydownHandler) { return true; } return waveOptions.keydownHandler(e); }); this.connectElem = connectElem; this.mainFileSubject = null; this.heldData = []; this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); this.terminal.open(this.connectElem); this.handleResize(); const pasteHandler = this.pasteHandler.bind(this); this.connectElem.addEventListener("paste", pasteHandler, true); this.toDispose.push({ dispose: () => { this.connectElem.removeEventListener("paste", pasteHandler, true); }, }); const viewportElem = this.connectElem.querySelector(".xterm-viewport") as HTMLElement; if (viewportElem) { const scrollHandler = (e: any) => { this.handleViewportScroll(viewportElem); }; viewportElem.addEventListener("scroll", scrollHandler); this.toDispose.push({ dispose: () => { viewportElem.removeEventListener("scroll", scrollHandler); }, }); } } getZoneId(): string { return this.blockId; } setCursorStyle(cursorStyle: string) { this.terminal.options.cursorStyle = normalizeCursorStyle(cursorStyle); } setCursorBlink(cursorBlink: boolean) { this.terminal.options.cursorBlink = cursorBlink ?? false; } setTermRenderer(renderer: "webgl" | "canvas") { if (renderer === "webgl") { if (this.webglAddon != null) { return; } if (!WebGLSupported) { renderer = "canvas"; if (this.canvasAddon != null) { return; } } } else { if (this.canvasAddon != null) { return; } } if (this.webglAddon != null) { this.webglContextLossDisposable?.dispose(); this.webglContextLossDisposable = null; this.webglAddon.dispose(); this.webglAddon = null; globalStore.set(this.webglEnabledAtom, false); } if (this.canvasAddon != null) { this.canvasAddon.dispose(); this.canvasAddon = null; } if (renderer === "webgl") { const addon = new WebglAddon(); this.webglContextLossDisposable = addon.onContextLoss(() => { this.setTermRenderer("canvas"); }); this.terminal.loadAddon(addon); this.webglAddon = addon; globalStore.set(this.webglEnabledAtom, true); if (!loggedWebGL) { console.log("loaded webgl!"); loggedWebGL = true; } } else { const addon = new CanvasAddon(); this.terminal.loadAddon(addon); this.canvasAddon = addon; } } getTermRenderer(): "webgl" | "canvas" { return this.webglAddon != null ? "webgl" : "canvas"; } isWebGlEnabled(): boolean { return this.webglAddon != null; } resetCompositionState() { this.isComposing = false; this.composingData = ""; this.lastComposedText = ""; this.lastCompositionEnd = 0; this.firstDataAfterCompositionSent = false; } private handleCompositionStart = (e: CompositionEvent) => { dlog("compositionstart", e.data); this.isComposing = true; this.composingData = ""; }; private handleCompositionUpdate = (e: CompositionEvent) => { dlog("compositionupdate", e.data); this.composingData = e.data || ""; }; private handleCompositionEnd = (e: CompositionEvent) => { dlog("compositionend", e.data); this.isComposing = false; this.lastComposedText = e.data || ""; this.lastCompositionEnd = Date.now(); this.firstDataAfterCompositionSent = false; }; async initTerminal() { const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect"); this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this))); this.toDispose.push( this.terminal.onSelectionChange( debounce(50, () => { if (!globalStore.get(copyOnSelectAtom)) { return; } // Don't copy-on-select when the search bar has focus — navigating // search results changes the terminal selection programmatically. const active = document.activeElement; if (active != null && active.closest(".search-container") != null) { return; } const selectedText = this.terminal.getSelection(); if (selectedText.length > 0) { navigator.clipboard.writeText(selectedText); } }) ) ); if (this.onSearchResultsDidChange != null) { this.toDispose.push(this.searchAddon.onDidChangeResults(this.onSearchResultsDidChange.bind(this))); } // Register IME composition event listeners on the xterm.js textarea const textareaElem = this.connectElem.querySelector("textarea"); if (textareaElem) { textareaElem.addEventListener("compositionstart", this.handleCompositionStart); textareaElem.addEventListener("compositionupdate", this.handleCompositionUpdate); textareaElem.addEventListener("compositionend", this.handleCompositionEnd); // Handle blur during composition - reset state to avoid stale data const blurHandler = () => { if (this.isComposing) { dlog("Terminal lost focus during composition, resetting IME state"); this.resetCompositionState(); } }; textareaElem.addEventListener("blur", blurHandler); this.toDispose.push({ dispose: () => { textareaElem.removeEventListener("compositionstart", this.handleCompositionStart); textareaElem.removeEventListener("compositionupdate", this.handleCompositionUpdate); textareaElem.removeEventListener("compositionend", this.handleCompositionEnd); textareaElem.removeEventListener("blur", blurHandler); }, }); } this.mainFileSubject = getFileSubject(this.getZoneId(), TermFileName); this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this)); try { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), }); if (rtInfo && rtInfo["shell:integration"]) { const shellState = rtInfo["shell:state"] as ShellIntegrationStatus; globalStore.set(this.shellIntegrationStatusAtom, shellState || null); } else { globalStore.set(this.shellIntegrationStatusAtom, null); } const lastCmd = rtInfo ? rtInfo["shell:lastcmd"] : null; globalStore.set(this.lastCommandAtom, lastCmd || null); } catch (e) { console.log("Error loading runtime info:", e); } try { await this.loadInitialTerminalData(); } finally { this.loaded = true; } this.runProcessIdleTimeout(); } dispose() { this.promptMarkers.forEach((marker) => { try { marker.dispose(); } catch (_) {} }); this.promptMarkers = []; this.webglContextLossDisposable?.dispose(); this.webglContextLossDisposable = null; this.terminal.dispose(); this.toDispose.forEach((d) => { try { d.dispose(); } catch (_) {} }); this.mainFileSubject.release(); } handleTermData(data: string) { if (!this.loaded) { return; } // IME fix: suppress isComposing=true events unless they immediately follow // a compositionend (within 20ms). This handles CapsLock input method switching // where the composition buffer gets flushed as a spurious isComposing=true event if (this.isComposing) { const timeSinceCompositionEnd = Date.now() - this.lastCompositionEnd; if (timeSinceCompositionEnd > IMEDedupWindowMs) { dlog("Suppressed IME data (composing, not near compositionend):", data); return; } } this.sendDataHandler?.(data); this.multiInputCallback?.(data); } addFocusListener(focusFn: () => void) { this.terminal.textarea.addEventListener("focus", focusFn); } handleNewFileSubjectData(msg: WSFileEventData) { if (msg.fileop == "truncate") { this.terminal.clear(); this.heldData = []; } else if (msg.fileop == "append") { const decodedData = base64ToArray(msg.data64); if (this.loaded) { this.doTerminalWrite(decodedData, null); } else { this.heldData.push(decodedData); } } else { console.log("bad fileop for terminal", msg); return; } } doTerminalWrite(data: string | Uint8Array, setPtyOffset?: number): Promise<void> { if (isDev() && this.loaded) { const dataStr = data instanceof Uint8Array ? new TextDecoder().decode(data) : data; this.recentWrites.push({ idx: this.recentWritesCounter++, ts: Date.now(), data: dataStr }); if (this.recentWrites.length > 50) { this.recentWrites.shift(); } } let resolve: () => void = null; const prtn = new Promise<void>((presolve, _) => { resolve = presolve; }); this.terminal.write(data, () => { if (setPtyOffset != null) { this.ptyOffset = setPtyOffset; } else { this.ptyOffset += data.length; this.dataBytesProcessed += data.length; } this.lastUpdated = Date.now(); resolve(); }); return prtn; } async loadInitialTerminalData(): Promise<void> { const startTs = Date.now(); const zoneId = this.getZoneId(); const { data: cacheData, fileInfo: cacheFile } = await fetchWaveFile(zoneId, TermCacheFileName); let ptyOffset = 0; if (cacheFile != null) { ptyOffset = cacheFile.meta["ptyoffset"] ?? 0; if (cacheData.byteLength > 0) { const curTermSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; const fileTermSize: TermSize = cacheFile.meta["termsize"]; let didResize = false; if ( fileTermSize != null && (fileTermSize.rows != curTermSize.rows || fileTermSize.cols != curTermSize.cols) ) { console.log("terminal restore size mismatch, temp resize", fileTermSize, curTermSize); this.terminal.resize(fileTermSize.cols, fileTermSize.rows); didResize = true; } this.doTerminalWrite(cacheData, ptyOffset); if (didResize) { this.terminal.resize(curTermSize.cols, curTermSize.rows); } } } const { data: mainData, fileInfo: mainFile } = await fetchWaveFile(zoneId, TermFileName, ptyOffset); console.log( `terminal loaded cachefile:${cacheData?.byteLength ?? 0} main:${mainData?.byteLength ?? 0} bytes, ${Date.now() - startTs}ms` ); if (mainFile != null) { await this.doTerminalWrite(mainData, null); } } async resyncController(reason: string) { dlog("resync controller", this.blockId, reason); const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } }; try { await RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: this.tabId, blockid: this.blockId, rtopts: rtOpts, }); } catch (e) { console.log(`error controller resync (${reason})`, this.blockId, e); } } setAtBottom(atBottom: boolean) { if (this.lastScrollAtBottom && !atBottom) { this.lastAtBottomTime = Date.now(); } this.lastScrollAtBottom = atBottom; if (atBottom) { this.lastAtBottomTime = Date.now(); } } wasRecentlyAtBottom(): boolean { if (this.lastScrollAtBottom) { return true; } return Date.now() - this.lastAtBottomTime <= 1000; } handleViewportScroll(viewportElem: HTMLElement) { const { scrollTop, scrollHeight, clientHeight } = viewportElem; const atBottom = scrollTop + clientHeight >= scrollHeight - clientHeight * 0.5; this.setAtBottom(atBottom); const delta = this.viewportScrollTop - scrollTop; if (isDev() && delta >= 500) { console.log( `[termwrap] large-scroll blockId=${this.blockId} delta=${Math.round(delta)}px scrollTop=${scrollTop} wasNearBottom=${atBottom}` ); } this.viewportScrollTop = scrollTop; } handleResize() { const oldRows = this.terminal.rows; const oldCols = this.terminal.cols; const atBottom = this.cachedAtBottomForResize ?? this.wasRecentlyAtBottom(); if (!atBottom) { this.cachedAtBottomForResize = null; } this.fitAddon.fit(); if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) { const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; console.log( "[termwrap] resize", `${oldRows}x${oldCols}`, "->", `${this.terminal.rows}x${this.terminal.cols}`, "atBottom:", atBottom ); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize }); } dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized); if (!this.hasResized) { this.hasResized = true; this.resyncController("initial resize"); } if (atBottom) { setTimeout(() => { console.log("[termwrap] resize scroll-to-bottom"); this.cachedAtBottomForResize = null; this.terminal.scrollToBottom(); this.setAtBottom(true); }, 20); } } processAndCacheData() { if (this.dataBytesProcessed < MinDataProcessedForCache) { return; } const serializedOutput = this.serializeAddon.serialize(); const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length, termSize); fireAndForget(() => services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset, termSize) ); this.dataBytesProcessed = 0; } runProcessIdleTimeout() { setTimeout(() => { window.requestIdleCallback(() => { this.processAndCacheData(); this.runProcessIdleTimeout(); }); }, 5000); } async pasteHandler(e?: ClipboardEvent): Promise<void> { this.pasteActive = true; e?.preventDefault(); e?.stopPropagation(); try { const clipboardData = await extractAllClipboardData(e); let firstImage = true; for (const data of clipboardData) { if (data.image && SupportsImageInput) { if (!firstImage) { await new Promise((r) => setTimeout(r, 150)); } const tempPath = await createTempFileFromBlob(data.image); this.terminal.paste(tempPath + " "); firstImage = false; } if (data.text) { this.terminal.paste(data.text); } } } catch (err) { console.error("Paste error:", err); } finally { setTimeout(() => { this.pasteActive = false; }, 30); } } getScrollbackContent(): string { if (!this.terminal) { return ""; } const buffer = this.terminal.buffer.active; const lines = bufferLinesToText(buffer, 0, buffer.length); return lines.join("\n"); } } ================================================ FILE: frontend/app/view/term/xterm.css ================================================ /** * Copyright (c) 2014 The xterm.js authors. All rights reserved. * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) * https://github.com/chjj/term.js * @license MIT * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * * Originally forked from (with the author's permission): * Fabrice Bellard's javascript vt100 for jslinux: * http://bellard.org/jslinux/ * Copyright (c) 2011 Fabrice Bellard * The original design remains. The terminal itself * has been extended to include xterm CSI codes, among * other features. */ /** * Default styles for xterm.js */ .xterm { cursor: text; position: relative; user-select: none; -ms-user-select: none; -webkit-user-select: none; } .xterm.focus, .xterm:focus { outline: none; } .xterm .xterm-helpers { position: absolute; top: 0; /** * The z-index of the helpers must be higher than the canvases in order for * IMEs to appear on top. */ z-index: 5; } .xterm .xterm-helper-textarea { padding: 0; border: 0; margin: 0; /* Move textarea out of the screen to the far left, so that the cursor is not visible */ position: absolute; opacity: 0; left: -9999em; top: 0; width: 0; height: 0; z-index: -5; /** Prevent wrapping so the IME appears against the textarea at the correct position */ white-space: nowrap; overflow: hidden; resize: none; } .xterm .composition-view { /* TODO: Composition position got messed up somewhere */ background: #000; color: #fff; display: none; position: absolute; white-space: nowrap; z-index: 1; } .xterm .composition-view.active { display: block; } .xterm .xterm-viewport { /* On OS X this is required in order for the scroll bar to appear fully opaque */ background-color: #000; overflow-y: scroll; cursor: default; position: absolute; right: 0; /* if this gets updated, must update fitaddon.ts */ left: 0; top: 0; bottom: 0; } .xterm .xterm-screen { position: relative; } .xterm .xterm-screen canvas { position: absolute; left: 0; top: 0; } .xterm .xterm-scroll-area { visibility: hidden; } .xterm-char-measure-element { display: inline-block; visibility: hidden; position: absolute; top: 0; left: -9999em; line-height: normal; } .xterm.enable-mouse-events { /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ cursor: default; } .xterm.xterm-cursor-pointer, .xterm .xterm-cursor-pointer { cursor: pointer; } .xterm.column-select.focus { /* Column selection mode */ cursor: crosshair; } .xterm .xterm-accessibility:not(.debug), .xterm .xterm-message { position: absolute; left: 0; top: 0; bottom: 0; right: 0; z-index: 10; color: transparent; pointer-events: none; } .xterm .xterm-accessibility-tree:not(.debug) *::selection { color: transparent; } .xterm .xterm-accessibility-tree { user-select: text; white-space: pre; } .xterm .live-region { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; } .xterm-dim { /* Dim should not apply to background, so the opacity of the foreground color is applied * explicitly in the generated class and reset to 1 here */ opacity: 1 !important; } .xterm-underline-1 { text-decoration: underline; } .xterm-underline-2 { text-decoration: double underline; } .xterm-underline-3 { text-decoration: wavy underline; } .xterm-underline-4 { text-decoration: dotted underline; } .xterm-underline-5 { text-decoration: dashed underline; } .xterm-overline { text-decoration: overline; } .xterm-overline.xterm-underline-1 { text-decoration: overline underline; } .xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } .xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } .xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } .xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } .xterm-strikethrough { text-decoration: line-through; } .xterm-screen .xterm-decoration-container .xterm-decoration { z-index: 6; position: absolute; } .xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { z-index: 7; } .xterm-decoration-overview-ruler { z-index: 8; position: absolute; top: 0; right: 0; pointer-events: none; } .xterm-decoration-top { z-index: 2; position: relative; } ================================================ FILE: frontend/app/view/tsunami/tsunami.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { getApi, globalStore, WOS } from "@/app/store/global"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WebView, WebViewModel } from "@/app/view/webview/webview"; import * as services from "@/store/services"; import * as jotai from "jotai"; import { memo, useEffect } from "react"; class TsunamiViewModel extends WebViewModel { shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>; shellProcStatusUnsubFn: () => void; appMeta: jotai.PrimitiveAtom<AppMeta>; appMetaUnsubFn: () => void; isRestarting: jotai.PrimitiveAtom<boolean>; viewIcon: jotai.Atom<IconButtonDecl>; viewName: jotai.Atom<string>; constructor(initOpts: ViewModelInitType) { super(initOpts); this.viewType = "tsunami"; this.isRestarting = jotai.atom(false); // Hide navigation bar (URL bar, back/forward/home buttons) this.hideNav = jotai.atom(true); // Set custom partition for tsunami WebView isolation this.partitionOverride = jotai.atom(`tsunami:${this.blockId}`); this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom<BlockControllerRuntimeStatus>; const initialShellProcStatus = services.BlockService.GetControllerStatus(this.blockId); initialShellProcStatus.then((rts) => { this.updateShellProcStatus(rts); }); this.shellProcStatusUnsubFn = waveEventSubscribeSingle({ eventType: "controllerstatus", scope: WOS.makeORef("block", this.blockId), handler: (event) => { this.updateShellProcStatus(event.data); }, }); this.appMeta = jotai.atom(null) as jotai.PrimitiveAtom<AppMeta>; this.viewIcon = jotai.atom((get) => { const meta = get(this.appMeta); const icon = meta?.icon || "cube"; const iconColor = meta?.iconcolor; return { elemtype: "iconbutton" as const, icon: icon, iconColor: iconColor, }; }); this.viewName = jotai.atom((get) => { const meta = get(this.appMeta); return meta?.title || "WaveApp"; }); const initialRTInfo = RpcApi.GetRTInfoCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), }); initialRTInfo.then((rtInfo) => { if (rtInfo && rtInfo["tsunami:appmeta"]) { globalStore.set(this.appMeta, rtInfo["tsunami:appmeta"]); } }); this.appMetaUnsubFn = waveEventSubscribeSingle({ eventType: "tsunami:updatemeta", scope: WOS.makeORef("block", this.blockId), handler: (event) => { globalStore.set(this.appMeta, event.data); }, }); } get viewComponent(): ViewComponent { return TsunamiView; } updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { console.log("tsunami-status", fullStatus); if (fullStatus == null) { return; } const curStatus = globalStore.get(this.shellProcFullStatus); if (curStatus == null || curStatus.version < fullStatus.version) { globalStore.set(this.shellProcFullStatus, fullStatus); } } triggerRestartAtom() { globalStore.set(this.isRestarting, true); setTimeout(() => { globalStore.set(this.isRestarting, false); }, 300); } private doControllerResync(forceRestart: boolean, logContext: string, triggerRestart: boolean = true) { if (triggerRestart) { if (globalStore.get(this.isRestarting)) { return; } this.triggerRestartAtom(); } const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: this.tabModel.tabId, blockid: this.blockId, forcerestart: forceRestart, }); prtn.catch((e) => console.log(`error controller resync (${logContext})`, e)); } resyncController() { this.doControllerResync(false, "resync", false); } destroyController() { const prtn = RpcApi.ControllerDestroyCommand(TabRpcClient, this.blockId); prtn.catch((e) => console.log("error destroying controller", e)); } async restartController() { if (globalStore.get(this.isRestarting)) { return; } this.triggerRestartAtom(); try { // Stop the controller first await RpcApi.ControllerDestroyCommand(TabRpcClient, this.blockId); // Wait a bit for the controller to fully stop await new Promise((resolve) => setTimeout(resolve, 300)); // Then resync to restart it await RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: this.tabModel.tabId, blockid: this.blockId, forcerestart: false, }); } catch (e) { console.log("error restarting controller", e); } } restartAndForceRebuild() { this.doControllerResync(true, "force rebuild"); } forceRestartController() { // Keep this for backward compatibility with the Start button this.doControllerResync(true, "force restart"); } async remixInBuilder() { const blockData = globalStore.get(this.blockAtom); const appId = blockData?.meta?.["tsunami:appid"]; if (!appId || !appId.startsWith("local/")) { return; } try { const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId }); const draftAppId = result.draftappid; getApi().openBuilder(draftAppId); } catch (err) { console.error("Failed to create draft from local app:", err); } } dispose() { if (this.shellProcStatusUnsubFn) { this.shellProcStatusUnsubFn(); } if (this.appMetaUnsubFn) { this.appMetaUnsubFn(); } } getSettingsMenuItems(): ContextMenuItem[] { const items = super.getSettingsMenuItems(); // Filter out homepage and navigation-related menu items for tsunami view const filteredItems = items.filter((item) => { const label = item.label?.toLowerCase() || ""; return ( !label.includes("homepage") && !label.includes("home page") && !label.includes("navigation") && !label.includes("nav") ); }); // Check if we should show the Remix option const blockData = globalStore.get(this.blockAtom); const appId = blockData?.meta?.["tsunami:appid"]; const showRemixOption = appId && appId.startsWith("local/"); // Add tsunami-specific menu items at the beginning const tsunamiItems: ContextMenuItem[] = [ { label: "Stop WaveApp", click: () => this.destroyController(), }, { label: "Restart WaveApp", click: () => this.restartController(), }, { label: "Restart WaveApp and Force Rebuild", click: () => this.restartAndForceRebuild(), }, { type: "separator", }, ]; if (showRemixOption) { tsunamiItems.push( { label: "Remix WaveApp in Builder", click: () => this.remixInBuilder(), }, { type: "separator", } ); } return [...tsunamiItems, ...filteredItems]; } } const TsunamiView = memo((props: ViewComponentProps<TsunamiViewModel>) => { const { model } = props; const shellProcFullStatus = jotai.useAtomValue(model.shellProcFullStatus); const blockData = jotai.useAtomValue(model.blockAtom); const isRestarting = jotai.useAtomValue(model.isRestarting); const domReady = jotai.useAtomValue(model.domReady); useEffect(() => { model.resyncController(); }, [model]); const appPath = blockData?.meta?.["tsunami:apppath"]; const appId = blockData?.meta?.["tsunami:appid"]; const controller = blockData?.meta?.controller; // Check for configuration errors const errors = []; if (!appPath && !appId) { errors.push("App path or app ID must be set (tsunami:apppath or tsunami:appid)"); } if (controller !== "tsunami") { errors.push("Invalid controller (must be 'tsunami')"); } // Show errors if any exist if (errors.length > 0) { return ( <div className="w-full h-full flex flex-col items-center justify-center gap-4"> <h1 className="text-4xl font-bold text-main-text-color">Tsunami</h1> <div className="flex flex-col gap-2"> {errors.map((error, index) => ( <div key={index} className="text-sm" style={{ color: "var(--color-error)" }}> {error} </div> ))} </div> </div> ); } // Check if we should show the webview const shouldShowWebView = shellProcFullStatus?.shellprocstatus === "running" && shellProcFullStatus?.tsunamiport && shellProcFullStatus.tsunamiport !== 0; if (shouldShowWebView) { const tsunamiUrl = `http://localhost:${shellProcFullStatus.tsunamiport}/?clientid=wave:${model.blockId}`; return ( <div className="w-full h-full"> <WebView {...props} initialSrc={tsunamiUrl} /> </div> ); } const status = shellProcFullStatus?.shellprocstatus ?? "init"; const isNotRunning = status === "done" || status === "init"; return ( <div className="w-full h-full flex flex-col items-center justify-center gap-4"> <h1 className="text-4xl font-bold text-main-text-color">Tsunami</h1> {(appPath || appId) && <div className="text-sm text-main-text-color opacity-70">{appPath || appId}</div>} {isNotRunning && !isRestarting && ( <button onClick={() => model.forceRestartController()} className="px-4 py-2 bg-accent-color text-primary-text-color rounded hover:bg-accent-color/80 transition-colors cursor-pointer" > Start </button> )} {isRestarting && <div className="text-sm text-success-color">Starting...</div>} </div> ); }); TsunamiView.displayName = "TsunamiView"; export { TsunamiViewModel }; ================================================ FILE: frontend/app/view/vdom/vdom-model.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; import { getBlockMetaKeyAtom, globalStore, WOS } from "@/app/store/global"; import type { TabModel } from "@/app/store/tab-model"; import { makeORef } from "@/app/store/wos"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { VDomView } from "@/app/view/vdom/vdom"; import { applyCanvasOp, mergeBackendUpdates, restoreVDomElems } from "@/app/view/vdom/vdom-utils"; import { getWebServerEndpoint } from "@/util/endpoints"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import debug from "debug"; import * as jotai from "jotai"; const dlog = debug("wave:vdom"); type AtomContainer = { val: any; beVal: any; usedBy: Set<string>; }; type RefContainer = { refFn: (elem: HTMLElement) => void; vdomRef: VDomRef; elem: HTMLElement; updated: boolean; }; function makeVDomIdMap(vdom: VDomElem, idMap: Map<string, VDomElem>) { if (vdom == null) { return; } if (vdom.waveid != null) { idMap.set(vdom.waveid, vdom); } if (vdom.children == null) { return; } for (let child of vdom.children) { makeVDomIdMap(child, idMap); } } function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) { if (reactEvent == null) { return; } if (propName == "onChange") { const changeEvent = reactEvent as React.ChangeEvent<any>; event.targetvalue = changeEvent.target?.value; event.targetchecked = changeEvent.target?.checked; } if (propName == "onClick" || propName == "onMouseDown") { const mouseEvent = reactEvent as React.MouseEvent<any>; event.mousedata = { button: mouseEvent.button, buttons: mouseEvent.buttons, alt: mouseEvent.altKey, control: mouseEvent.ctrlKey, shift: mouseEvent.shiftKey, meta: mouseEvent.metaKey, clientx: mouseEvent.clientX, clienty: mouseEvent.clientY, pagex: mouseEvent.pageX, pagey: mouseEvent.pageY, screenx: mouseEvent.screenX, screeny: mouseEvent.screenY, movementx: mouseEvent.movementX, movementy: mouseEvent.movementY, }; if (PLATFORM == PlatformMacOS) { event.mousedata.cmd = event.mousedata.meta; event.mousedata.option = event.mousedata.alt; } else { event.mousedata.cmd = event.mousedata.alt; event.mousedata.option = event.mousedata.meta; } } if (propName == "onKeyDown") { const waveKeyEvent = adaptFromReactOrNativeKeyEvent(reactEvent as React.KeyboardEvent); event.keydata = waveKeyEvent; } } class VDomWshClient extends WshClient { model: VDomModel; constructor(model: VDomModel) { super(makeFeBlockRouteId(model.blockId)); this.model = model; } handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { dlog("async-initiation", rh.getSource(), data); this.model.queueUpdate(true); } } export class VDomModel { blockId: string; nodeModel: BlockNodeModel; tabModel: TabModel; viewType: string; viewIcon: jotai.Atom<string>; viewName: jotai.Atom<string>; viewRef: React.RefObject<HTMLDivElement> = { current: null }; vdomRoot: jotai.PrimitiveAtom<VDomElem> = jotai.atom(); atoms: Map<string, AtomContainer> = new Map(); // key is atomname refs: Map<string, RefContainer> = new Map(); // key is refid batchedEvents: VDomEvent[] = []; messages: VDomMessage[] = []; needsResync: boolean = true; vdomNodeVersion: WeakMap<VDomElem, jotai.PrimitiveAtom<number>> = new WeakMap(); compoundAtoms: Map<string, jotai.PrimitiveAtom<{ [key: string]: any }>> = new Map(); rootRefId: string = crypto.randomUUID(); backendRoute: jotai.Atom<string>; backendOpts: VDomBackendOpts; shouldDispose: boolean; disposed: boolean; hasPendingRequest: boolean; needsUpdate: boolean; maxNormalUpdateIntervalMs: number = 100; needsImmediateUpdate: boolean; lastUpdateTs: number = 0; queuedUpdate: { timeoutId: any; ts: number; quick: boolean }; contextActive: jotai.PrimitiveAtom<boolean>; wshClient: VDomWshClient; persist: jotai.Atom<boolean>; routeGoneUnsub: () => void; routeConfirmed: boolean = false; refOutputStore: Map<string, any> = new Map(); globalVersion: jotai.PrimitiveAtom<number> = jotai.atom(0); hasBackendWork: boolean = false; noPadding: jotai.PrimitiveAtom<boolean>; constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.viewType = "vdom"; this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; this.contextActive = jotai.atom(false); this.reset(); this.viewIcon = jotai.atom("bolt"); this.viewName = jotai.atom("Wave App"); this.backendRoute = jotai.atom((get) => { const blockData = get(WOS.getWaveObjectAtom<Block>(makeORef("block", this.blockId))); return blockData?.meta?.["vdom:route"]; }); this.noPadding = jotai.atom(true); this.persist = getBlockMetaKeyAtom(this.blockId, "vdom:persist"); this.wshClient = new VDomWshClient(this); DefaultRouter.registerRoute(this.wshClient.routeId, this.wshClient); const curBackendRoute = globalStore.get(this.backendRoute); if (curBackendRoute) { this.queueUpdate(true); } this.routeGoneUnsub = waveEventSubscribeSingle({ eventType: "route:down", scope: curBackendRoute, handler: (_event) => { this.disposed = true; const shouldPersist = globalStore.get(this.persist); if (!shouldPersist) { this.nodeModel?.onClose?.(); } }, }); RpcApi.WaitForRouteCommand(TabRpcClient, { routeid: curBackendRoute, waitms: 4000 }, { timeout: 5000 }).then( (routeOk: boolean) => { if (routeOk) { this.routeConfirmed = true; this.queueUpdate(true); } else { this.disposed = true; const shouldPersist = globalStore.get(this.persist); if (!shouldPersist) { this.nodeModel?.onClose?.(); } } } ); } get viewComponent(): ViewComponent { return VDomView; } dispose() { DefaultRouter.unregisterRoute(this.wshClient.routeId); this.routeGoneUnsub?.(); } reset() { globalStore.set(this.vdomRoot, null); this.atoms.clear(); this.refs.clear(); this.batchedEvents = []; this.messages = []; this.needsResync = true; this.vdomNodeVersion = new WeakMap(); this.compoundAtoms.clear(); this.rootRefId = crypto.randomUUID(); this.backendOpts = {}; this.shouldDispose = false; this.disposed = false; this.hasPendingRequest = false; this.needsUpdate = false; this.maxNormalUpdateIntervalMs = 100; this.needsImmediateUpdate = false; this.lastUpdateTs = 0; this.queuedUpdate = null; this.refOutputStore.clear(); this.globalVersion = jotai.atom(0); this.hasBackendWork = false; globalStore.set(this.contextActive, false); } getBackendRoute(): string { const blockData = globalStore.get(WOS.getWaveObjectAtom<Block>(makeORef("block", this.blockId))); return blockData?.meta?.["vdom:route"]; } transformVDomUrl(url: string): string { if (url == null || url == "") { return null; } if (!url.startsWith("vdom://")) { return url; } const absUrl = url.substring(7); return this.makeVDomUrl(absUrl); } makeVDomUrl(path: string): string { if (path == null || path == "") { return null; } if (!path.startsWith("/")) { return null; } const backendRouteId = this.getBackendRouteId(); if (backendRouteId == null) { return null; } const wsEndpoint = getWebServerEndpoint(); const fullUrl = wsEndpoint + "/vdom/" + backendRouteId + path; return fullUrl; } keyDownHandler(e: WaveKeyboardEvent): boolean { if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) { this.shouldDispose = true; this.queueUpdate(true); return true; } if (this.backendOpts?.globalkeyboardevents) { if (e.cmd || e.meta) { return false; } this.batchedEvents.push({ globaleventtype: "onKeyDown", waveid: null, eventtype: "onKeyDown", keydata: e, }); this.queueUpdate(); return true; } return false; } hasRefUpdates() { for (let ref of this.refs.values()) { if (ref.updated) { return true; } } return false; } getRefUpdates(): VDomRefUpdate[] { let updates: VDomRefUpdate[] = []; for (let ref of this.refs.values()) { if (ref.updated || (ref.vdomRef.trackposition && ref.elem != null)) { const ru: VDomRefUpdate = { refid: ref.vdomRef.refid, hascurrent: ref.vdomRef.hascurrent, }; if (ref.vdomRef.trackposition && ref.elem != null) { ru.position = { offsetheight: ref.elem.offsetHeight, offsetwidth: ref.elem.offsetWidth, scrollheight: ref.elem.scrollHeight, scrollwidth: ref.elem.scrollWidth, scrolltop: ref.elem.scrollTop, boundingclientrect: ref.elem.getBoundingClientRect(), }; } updates.push(ru); ref.updated = false; } } return updates; } queueUpdate(quick: boolean = false, delay: number = 10) { if (this.disposed) { return; } this.needsUpdate = true; let nowTs = Date.now(); if (delay > this.maxNormalUpdateIntervalMs) { delay = this.maxNormalUpdateIntervalMs; } if (quick) { if (this.queuedUpdate) { if (this.queuedUpdate.quick || this.queuedUpdate.ts <= nowTs) { return; } clearTimeout(this.queuedUpdate.timeoutId); this.queuedUpdate = null; } let timeoutId = setTimeout(() => { this._sendRenderRequest(true); }, 0); this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs, quick: true }; return; } if (this.queuedUpdate) { return; } let lastUpdateDiff = nowTs - this.lastUpdateTs; let timeoutMs: number = null; if (lastUpdateDiff >= this.maxNormalUpdateIntervalMs) { // it has been a while since the last update, so use delay timeoutMs = delay; } else { timeoutMs = this.maxNormalUpdateIntervalMs - lastUpdateDiff; } if (timeoutMs < delay) { timeoutMs = delay; } let timeoutId = setTimeout(() => { this._sendRenderRequest(false); }, timeoutMs); this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs + timeoutMs, quick: false }; } async _sendRenderRequest(force: boolean) { this.queuedUpdate = null; if (this.disposed || !this.routeConfirmed) { return; } if (this.hasPendingRequest) { if (force) { this.needsImmediateUpdate = true; } return; } if (!force && !this.needsUpdate) { return; } const backendRoute = globalStore.get(this.backendRoute); if (backendRoute == null) { console.log("vdom-model", "no backend route"); return; } this.hasPendingRequest = true; this.needsImmediateUpdate = false; try { const feUpdate = this.createFeUpdate(); dlog("fe-update", feUpdate); const beUpdateGen = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute }); let baseUpdate: VDomBackendUpdate = null; for await (const beUpdate of beUpdateGen) { if (baseUpdate === null) { baseUpdate = beUpdate; } else { mergeBackendUpdates(baseUpdate, beUpdate); } } if (baseUpdate !== null) { restoreVDomElems(baseUpdate); dlog("be-update", baseUpdate); this.handleBackendUpdate(baseUpdate); } dlog("update cycle done"); } finally { this.lastUpdateTs = Date.now(); this.hasPendingRequest = false; } if (this.needsImmediateUpdate) { this.queueUpdate(true); } } getAtomContainer(atomName: string): AtomContainer { let container = this.atoms.get(atomName); if (container == null) { container = { val: null, beVal: null, usedBy: new Set(), }; this.atoms.set(atomName, container); } return container; } getOrCreateRefContainer(vdomRef: VDomRef): RefContainer { let container = this.refs.get(vdomRef.refid); if (container == null) { container = { refFn: (elem: HTMLElement) => { container.elem = elem; const hasElem = elem != null; if (vdomRef.hascurrent != hasElem) { container.updated = true; vdomRef.hascurrent = hasElem; } }, vdomRef: vdomRef, elem: null, updated: false, }; this.refs.set(vdomRef.refid, container); } return container; } tagUseAtoms(waveId: string, atomNames: Set<string>) { for (let atomName of atomNames) { let container = this.getAtomContainer(atomName); container.usedBy.add(waveId); } } tagUnuseAtoms(waveId: string, atomNames: Set<string>) { for (let atomName of atomNames) { let container = this.getAtomContainer(atomName); container.usedBy.delete(waveId); } } getVDomNodeVersionAtom(vdom: VDomElem) { let atom = this.vdomNodeVersion.get(vdom); if (atom == null) { atom = jotai.atom(0); this.vdomNodeVersion.set(vdom, atom); } return atom; } incVDomNodeVersion(vdom: VDomElem) { if (vdom == null) { return; } const atom = this.getVDomNodeVersionAtom(vdom); globalStore.set(atom, globalStore.get(atom) + 1); } addErrorMessage(message: string) { this.messages.push({ messagetype: "error", message: message, }); } handleRenderUpdates(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) { if (!update.renderupdates) { return; } for (let renderUpdate of update.renderupdates) { if (renderUpdate.updatetype == "root") { globalStore.set(this.vdomRoot, renderUpdate.vdom); continue; } if (renderUpdate.updatetype == "append") { let parent = idMap.get(renderUpdate.waveid); if (parent == null) { this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); continue; } if (parent.children == null) { parent.children = []; } parent.children.push(renderUpdate.vdom); this.incVDomNodeVersion(parent); continue; } if (renderUpdate.updatetype == "replace") { let parent = idMap.get(renderUpdate.waveid); if (parent == null) { this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); continue; } if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); continue; } parent.children[renderUpdate.index] = renderUpdate.vdom; this.incVDomNodeVersion(parent); continue; } if (renderUpdate.updatetype == "remove") { let parent = idMap.get(renderUpdate.waveid); if (parent == null) { this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); continue; } if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); continue; } parent.children.splice(renderUpdate.index, 1); this.incVDomNodeVersion(parent); continue; } if (renderUpdate.updatetype == "insert") { let parent = idMap.get(renderUpdate.waveid); if (parent == null) { this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); continue; } if (parent.children == null) { parent.children = []; } if (renderUpdate.index < 0 || parent.children.length < renderUpdate.index) { this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); continue; } parent.children.splice(renderUpdate.index, 0, renderUpdate.vdom); this.incVDomNodeVersion(parent); continue; } this.addErrorMessage(`Unknown updatetype ${renderUpdate.updatetype}`); } } setAtomValue(atomName: string, value: any, fromBe: boolean, idMap: Map<string, VDomElem>) { dlog("setAtomValue", atomName, value, fromBe); let container = this.getAtomContainer(atomName); container.val = value; if (fromBe) { container.beVal = value; } for (let id of container.usedBy) { this.incVDomNodeVersion(idMap.get(id)); } } handleStateSync(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) { if (update.statesync == null) { return; } for (let sync of update.statesync) { this.setAtomValue(sync.atom, sync.value, true, idMap); } } getRefElem(refId: string): HTMLElement { if (refId == this.rootRefId) { return this.viewRef.current; } const ref = this.refs.get(refId); return ref?.elem; } handleRefOperations(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) { if (update.refoperations == null) { return; } for (let refOp of update.refoperations) { const elem = this.getRefElem(refOp.refid); if (elem == null) { this.addErrorMessage(`Could not find ref with id ${refOp.refid}`); continue; } if (elem instanceof HTMLCanvasElement) { applyCanvasOp(elem, refOp, this.refOutputStore); continue; } if (refOp.op == "focus") { if (elem == null) { this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`); continue; } try { elem.focus(); } catch (e) { this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: ${e.message}`); } } else { this.addErrorMessage(`Unknown ref operation ${refOp.refid} ${refOp.op}`); } } } handleBackendUpdate(update: VDomBackendUpdate) { if (update == null) { return; } globalStore.set(this.contextActive, true); const idMap = new Map<string, VDomElem>(); const vdomRoot = globalStore.get(this.vdomRoot); if (update.opts != null) { this.backendOpts = update.opts; } makeVDomIdMap(vdomRoot, idMap); this.handleRenderUpdates(update, idMap); this.handleStateSync(update, idMap); this.handleRefOperations(update, idMap); if (update.messages) { for (let message of update.messages) { console.log("vdom-message", this.blockId, message.messagetype, message.message); if (message.stacktrace) { console.log("vdom-message-stacktrace", message.stacktrace); } } } globalStore.set(this.globalVersion, globalStore.get(this.globalVersion) + 1); if (update.haswork) { this.hasBackendWork = true; } } renderDone(version: number) { // called when the render is done dlog("renderDone", version); if (this.hasRefUpdates() || this.hasBackendWork) { this.hasBackendWork = false; this.queueUpdate(true); } } callVDomFunc(fnDecl: VDomFunc, e: React.SyntheticEvent, compId: string, propName: string) { const vdomEvent: VDomEvent = { waveid: compId, eventtype: propName, }; if (fnDecl.globalevent) { vdomEvent.globaleventtype = fnDecl.globalevent; } annotateEvent(vdomEvent, propName, e); this.batchedEvents.push(vdomEvent); this.queueUpdate(true); } createFeUpdate(): VDomFrontendUpdate { const blockORef = makeORef("block", this.blockId); const blockAtom = WOS.getWaveObjectAtom<Block>(blockORef); const blockData = globalStore.get(blockAtom); const isBlockFocused = globalStore.get(this.nodeModel.isFocused); const renderContext: VDomRenderContext = { blockid: this.blockId, focused: isBlockFocused, width: this.viewRef?.current?.offsetWidth ?? 0, height: this.viewRef?.current?.offsetHeight ?? 0, rootrefid: this.rootRefId, background: false, }; const feUpdate: VDomFrontendUpdate = { type: "frontendupdate", ts: Date.now(), blockid: this.blockId, rendercontext: renderContext, dispose: this.shouldDispose, resync: this.needsResync, events: this.batchedEvents, refupdates: this.getRefUpdates(), }; this.needsResync = false; this.batchedEvents = []; if (this.shouldDispose) { this.disposed = true; } return feUpdate; } getBackendRouteId(): string { const fullRoute = globalStore.get(this.backendRoute); if (fullRoute == null || !fullRoute.startsWith("proc:")) { return null; } return fullRoute?.split(":")[1]; } } ================================================ FILE: frontend/app/view/vdom/vdom-utils.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { VDomModel } from "@/app/view/vdom/vdom-model"; import type { CssNode, List, ListItem } from "css-tree"; import * as csstree from "css-tree"; const TextTag = "#text"; // TODO support binding export function getTextChildren(elem: VDomElem): string { if (elem.tag == TextTag) { return elem.text; } if (!elem.children) { return null; } const textArr = elem.children.map((child) => { return getTextChildren(child); }); return textArr.join(""); } export function convertVDomId(model: VDomModel, id: string): string { return model.blockId + "::" + id; } export function validateAndWrapCss(model: VDomModel, cssText: string, wrapperClassName: string) { try { const ast = csstree.parse(cssText); csstree.walk(ast, { enter(node: CssNode, item: ListItem<CssNode>, list: List<CssNode>) { // Remove disallowed @rules const blockedRules = ["import", "font-face", "keyframes", "namespace", "supports"]; if (node.type === "Atrule" && blockedRules.includes(node.name)) { list.remove(item); } // Remove :root selectors if ( node.type === "Selector" && node.children.some((child) => child.type === "PseudoClassSelector" && child.name === "root") ) { list.remove(item); } if (node.type === "IdSelector") { node.name = convertVDomId(model, node.name); } // Transform url(#id) references in filter and mask properties (svg) if (node.type === "Declaration" && ["filter", "mask"].includes(node.property)) { if (node.value && node.value.type === "Value" && "children" in node.value) { const urlNode = node.value.children .toArray() .find( (child: CssNode): child is CssNode & { value: string } => child && child.type === "Url" && typeof (child as any).value === "string" ); if (urlNode && urlNode.value && urlNode.value.startsWith("#")) { urlNode.value = "#" + convertVDomId(model, urlNode.value.substring(1)); } } } // transform url(vdom:///foo.jpg) if (node.type === "Url" && node.value != null && node.value.startsWith("vdom://")) { const newUrl = model.transformVDomUrl(node.value); if (newUrl == null) { list.remove(item); } else { node.value = newUrl; } } }, }); const sanitizedCss = csstree.generate(ast); return `.${wrapperClassName} { ${sanitizedCss} }`; } catch (error) { // TODO better error handling console.error("CSS processing error:", error); return null; } } function cssTransformStyleValue(model: VDomModel, property: string, value: string): string { try { const ast = csstree.parse(value, { context: "value" }); csstree.walk(ast, { enter(node: CssNode, item: ListItem<CssNode>, list: List<CssNode>) { // Transform url(#id) in filter/mask properties if (node.type === "Url" && (property === "filter" || property === "mask")) { if (node.value.startsWith("#")) { node.value = `#${convertVDomId(model, node.value.substring(1))}`; } } // transform vdom:// urls if (node.type === "Url" && node.value != null && node.value.startsWith("vdom://")) { const newUrl = model.transformVDomUrl(node.value); if (newUrl == null) { list.remove(item); } else { node.value = newUrl; } } }, }); return csstree.generate(ast); } catch (error) { console.error("Error processing style value:", error); return value; } } export function validateAndWrapReactStyle(model: VDomModel, style: Record<string, any>): Record<string, any> { const sanitizedStyle: Record<string, any> = {}; let updated = false; for (const [property, value] of Object.entries(style)) { if (value == null || value === "") { continue; } if (typeof value !== "string") { sanitizedStyle[property] = value; // For non-string values, just copy as-is continue; } if (value.includes("vdom://") || value.includes("url(#")) { updated = true; sanitizedStyle[property] = cssTransformStyleValue(model, property, value); } else { sanitizedStyle[property] = value; } } if (!updated) { return style; } return sanitizedStyle; } export function restoreVDomElems(backendUpdate: VDomBackendUpdate) { if (!backendUpdate.transferelems || !backendUpdate.renderupdates) { return; } // Step 1: Map of waveid to VDomElem, skipping any without a waveid const elemMap = new Map<string, VDomElem>(); backendUpdate.transferelems.forEach((transferElem) => { if (!transferElem.waveid) { return; } elemMap.set(transferElem.waveid, { waveid: transferElem.waveid, tag: transferElem.tag, props: transferElem.props, children: [], // Will populate children later text: transferElem.text, }); }); // Step 2: Build VDomElem trees by linking children backendUpdate.transferelems.forEach((transferElem) => { const parent = elemMap.get(transferElem.waveid); if (!parent || !transferElem.children || transferElem.children.length === 0) { return; } parent.children = transferElem.children.map((childId) => elemMap.get(childId)).filter((child) => child != null); // Explicit null check }); // Step 3: Update renderupdates with rebuilt VDomElem trees backendUpdate.renderupdates.forEach((update) => { if (update.vdomwaveid) { update.vdom = elemMap.get(update.vdomwaveid); } }); } export function mergeBackendUpdates(baseUpdate: VDomBackendUpdate, nextUpdate: VDomBackendUpdate) { // Verify the updates are from the same block/sequence if (baseUpdate.blockid !== nextUpdate.blockid || baseUpdate.ts !== nextUpdate.ts) { console.error("Attempted to merge updates from different blocks or timestamps"); return; } // Merge TransferElems if (nextUpdate.transferelems?.length > 0) { if (!baseUpdate.transferelems) { baseUpdate.transferelems = []; } baseUpdate.transferelems.push(...nextUpdate.transferelems); } // Merge StateSync if (nextUpdate.statesync?.length > 0) { if (!baseUpdate.statesync) { baseUpdate.statesync = []; } baseUpdate.statesync.push(...nextUpdate.statesync); } } export function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map<string, any>) { const ctx = canvas.getContext("2d"); if (!ctx) { console.error("Canvas 2D context not available."); return; } let { op, params, outputref } = canvasOp; if (params == null) { params = []; } if (op == null || op == "") { return; } // Resolve any reference parameters in params const resolvedParams: any[] = []; params.forEach((param) => { if (typeof param === "string" && param.startsWith("#ref:")) { const refId = param.slice(5); // Remove "#ref:" prefix resolvedParams.push(refStore.get(refId)); } else if (typeof param === "string" && param.startsWith("#spreadRef:")) { const refId = param.slice(11); // Remove "#spreadRef:" prefix const arrayRef = refStore.get(refId); if (Array.isArray(arrayRef)) { resolvedParams.push(...arrayRef); // Spread array elements } else { console.error(`Reference ${refId} is not an array and cannot be spread.`); } } else { resolvedParams.push(param); } }); // Apply the operation on the canvas context if (op === "dropRef" && params.length > 0 && typeof params[0] === "string") { refStore.delete(params[0]); } else if (op === "addRef" && outputref) { refStore.set(outputref, resolvedParams[0]); } else if (typeof ctx[op as keyof CanvasRenderingContext2D] === "function") { (ctx[op as keyof CanvasRenderingContext2D] as (...args: unknown[]) => unknown).apply(ctx, resolvedParams); } else if (op in ctx) { (ctx as any)[op] = resolvedParams[0]; } else { console.error(`Unsupported canvas operation: ${op}`); } } ================================================ FILE: frontend/app/view/vdom/vdom.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Markdown } from "@/app/element/markdown"; import { VDomModel } from "@/app/view/vdom/vdom-model"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import clsx from "clsx"; import debug from "debug"; import * as jotai from "jotai"; import * as React from "react"; import { convertVDomId, getTextChildren, validateAndWrapCss, validateAndWrapReactStyle, } from "@/app/view/vdom/vdom-utils"; const TextTag = "#text"; const FragmentTag = "#fragment"; const WaveTextTag = "wave:text"; const WaveNullTag = "wave:null"; const StyleTagName = "style"; const WaveStyleTagName = "wave:style"; const VDomObjType_Ref = "ref"; const VDomObjType_Binding = "binding"; const VDomObjType_Func = "func"; const dlog = debug("wave:vdom"); type VDomReactTagType = (props: { elem: VDomElem; model: VDomModel }) => React.ReactElement; const WaveTagMap: Record<string, VDomReactTagType> = { "wave:markdown": WaveMarkdown, }; const AllowedSimpleTags: { [tagName: string]: boolean } = { div: true, b: true, i: true, p: true, s: true, span: true, a: true, img: true, h1: true, h2: true, h3: true, h4: true, h5: true, h6: true, ul: true, ol: true, li: true, input: true, button: true, textarea: true, select: true, option: true, form: true, label: true, table: true, thead: true, tbody: true, tr: true, th: true, td: true, hr: true, br: true, pre: true, code: true, canvas: true, }; const AllowedSvgTags = { // SVG tags svg: true, circle: true, ellipse: true, line: true, path: true, polygon: true, polyline: true, rect: true, g: true, text: true, tspan: true, textPath: true, use: true, defs: true, linearGradient: true, radialGradient: true, stop: true, clipPath: true, mask: true, pattern: true, image: true, marker: true, symbol: true, filter: true, feBlend: true, feColorMatrix: true, feComponentTransfer: true, feComposite: true, feConvolveMatrix: true, feDiffuseLighting: true, feDisplacementMap: true, feFlood: true, feGaussianBlur: true, feImage: true, feMerge: true, feMorphology: true, feOffset: true, feSpecularLighting: true, feTile: true, feTurbulence: true, }; const IdAttributes = { id: true, for: true, "aria-labelledby": true, "aria-describedby": true, "aria-controls": true, "aria-owns": true, form: true, headers: true, usemap: true, list: true, }; const SvgUrlIdAttributes = { "clip-path": true, mask: true, filter: true, fill: true, stroke: true, "marker-start": true, "marker-mid": true, "marker-end": true, "text-decoration": true, }; function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void { return (e: any) => { if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) { dlog("key event", fnDecl, e); let waveEvent = adaptFromReactOrNativeKeyEvent(e); for (let keyDesc of fnDecl["#keys"] || []) { if (checkKeyPressed(waveEvent, keyDesc)) { e.preventDefault(); e.stopPropagation(); model.callVDomFunc(fnDecl, e, compId, propName); return; } } return; } if (fnDecl.preventdefault) { e.preventDefault(); } if (fnDecl.stoppropagation) { e.stopPropagation(); } model.callVDomFunc(fnDecl, e, compId, propName); }; } function convertElemToTag(elem: VDomElem, model: VDomModel): React.ReactNode { if (elem == null) { return null; } if (elem.tag == TextTag) { return elem.text; } return React.createElement(VDomTag, { key: elem.waveid, elem, model }); } function isObject(v: any): boolean { return v != null && !Array.isArray(v) && typeof v === "object"; } function isArray(v: any): boolean { return Array.isArray(v); } function resolveBinding(binding: VDomBinding, model: VDomModel): [any, string[]] { const bindName = binding.bind; if (bindName == null || bindName == "") { return [null, []]; } // for now we only recognize $.[atomname] bindings if (!bindName.startsWith("$.")) { return [null, []]; } const atomName = bindName.substring(2); if (atomName == "") { return [null, []]; } const atom = model.getAtomContainer(atomName); if (atom == null) { return [null, []]; } return [atom.val, [atomName]]; } type GenericPropsType = { [key: string]: any }; // returns props, and a set of atom keys used in the props function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set<string>] { let props: GenericPropsType = {}; let atomKeys = new Set<string>(); if (elem.props == null) { return [props, atomKeys]; } for (let key in elem.props) { let val = elem.props[key]; if (val == null) { continue; } if (key == "ref") { if (val == null) { continue; } if (isObject(val) && val.type == VDomObjType_Ref) { const valRef = val as VDomRef; const refContainer = model.getOrCreateRefContainer(valRef); props[key] = refContainer.refFn; } continue; } if (isObject(val) && val.type == VDomObjType_Func) { const valFunc = val as VDomFunc; props[key] = convertVDomFunc(model, valFunc, elem.waveid, key); continue; } if (isObject(val) && val.type == VDomObjType_Binding) { const [propVal, atomDeps] = resolveBinding(val as VDomBinding, model); props[key] = propVal; for (let atomDep of atomDeps) { atomKeys.add(atomDep); } continue; } if (key == "style" && isObject(val)) { // assuming the entire style prop wasn't bound, look through the individual keys and bind them for (let styleKey in val) { let styleVal = val[styleKey]; if (isObject(styleVal) && styleVal.type == VDomObjType_Binding) { const [stylePropVal, styleAtomDeps] = resolveBinding(styleVal as VDomBinding, model); val[styleKey] = stylePropVal; for (let styleAtomDep of styleAtomDeps) { atomKeys.add(styleAtomDep); } } } val = validateAndWrapReactStyle(model, val); props[key] = val; continue; } if (IdAttributes[key]) { props[key] = convertVDomId(model, val); continue; } if (AllowedSvgTags[elem.tag]) { if ((elem.tag == "use" && key == "href") || (elem.tag == "textPath" && key == "href")) { if (val == null || !val.startsWith("#")) { continue; } props[key] = convertVDomId(model, "#" + val.substring(1)); continue; } if (SvgUrlIdAttributes[key]) { if (val == null || !val.startsWith("url(#") || !val.endsWith(")")) { continue; } props[key] = "url(#" + convertVDomId(model, val.substring(4, val.length - 1)) + ")"; continue; } } if (key == "src" && val != null && val.startsWith("vdom://")) { // transform vdom:// urls const newUrl = model.transformVDomUrl(val); if (newUrl == null) { continue; } props[key] = newUrl; continue; } props[key] = val; } return [props, atomKeys]; } function convertChildren(elem: VDomElem, model: VDomModel): React.ReactNode[] { if (elem.children == null || elem.children.length == 0) { return null; } let childrenComps: React.ReactNode[] = []; for (let child of elem.children) { if (child == null) { continue; } childrenComps.push(convertElemToTag(child, model)); } if (childrenComps.length == 0) { return null; } return childrenComps; } function stringSetsEqual(set1: Set<string>, set2: Set<string>): boolean { if (set1.size != set2.size) { return false; } for (let elem of set1) { if (!set2.has(elem)) { return false; } } return true; } function useVDom(model: VDomModel, elem: VDomElem): GenericPropsType { const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem)); const [oldAtomKeys, setOldAtomKeys] = React.useState<Set<string>>(new Set()); let [props, atomKeys] = convertProps(elem, model); React.useEffect(() => { if (stringSetsEqual(atomKeys, oldAtomKeys)) { return; } model.tagUnuseAtoms(elem.waveid, oldAtomKeys); model.tagUseAtoms(elem.waveid, atomKeys); setOldAtomKeys(atomKeys); }, [atomKeys]); React.useEffect(() => { return () => { model.tagUnuseAtoms(elem.waveid, oldAtomKeys); }; }, []); return props; } function WaveMarkdown({ elem, model }: { elem: VDomElem; model: VDomModel }) { const props = useVDom(model, elem); return ( <Markdown text={props?.text} style={props?.style} className={props?.className} scrollable={props?.scrollable} rehype={props?.rehype} /> ); } function StyleTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { const styleText = getTextChildren(elem); if (styleText == null) { return null; } const wrapperClassName = "vdom-" + model.blockId; // TODO handle errors const sanitizedCss = validateAndWrapCss(model, styleText, wrapperClassName); if (sanitizedCss == null) { return null; } return <style>{sanitizedCss}</style>; } function WaveStyle({ src, model, onMount }: { src: string; model: VDomModel; onMount?: () => void }) { const [styleContent, setStyleContent] = React.useState<string | null>(null); React.useEffect(() => { async function fetchAndSanitizeCss() { try { const response = await fetch(src); if (!response.ok) { console.error(`Failed to load CSS from ${src}`); return; } const cssText = await response.text(); const wrapperClassName = "vdom-" + model.blockId; const sanitizedCss = validateAndWrapCss(model, cssText, wrapperClassName); if (sanitizedCss) { setStyleContent(sanitizedCss); } else { onMount?.(); console.error("Failed to sanitize CSS"); } } catch (error) { console.error("Error fetching CSS:", error); onMount?.(); } } fetchAndSanitizeCss(); }, [src, model]); // Trigger onMount after styleContent has been set and mounted React.useEffect(() => { if (styleContent) { onMount?.(); } }, [styleContent, onMount]); if (!styleContent) { return null; } return <style>{styleContent}</style>; } function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { const props = useVDom(model, elem); if (elem.tag == WaveNullTag) { return null; } if (elem.tag == WaveTextTag) { return props.text; } const waveTag = WaveTagMap[elem.tag]; if (waveTag) { return waveTag({ elem, model }); } if (elem.tag == StyleTagName) { return <StyleTag elem={elem} model={model} />; } if (elem.tag == WaveStyleTagName) { return <WaveStyle src={props.src} model={model} />; } if (!AllowedSimpleTags[elem.tag] && !AllowedSvgTags[elem.tag]) { return <div>{"Invalid Tag <" + elem.tag + ">"}</div>; } let childrenComps = convertChildren(elem, model); if (elem.tag == FragmentTag) { return childrenComps; } props.key = "e-" + elem.waveid; return React.createElement(elem.tag, props, childrenComps); } function vdomText(text: string): VDomElem { return { tag: "#text", text: text, }; } const testVDom: VDomElem = { waveid: "testid1", tag: "div", children: [ { waveid: "testh1", tag: "h1", children: [vdomText("Hello World")], }, { waveid: "testp", tag: "p", children: [vdomText("This is a paragraph (from VDOM)")], }, ], }; function VDomRoot({ model }: { model: VDomModel }) { let version = jotai.useAtomValue(model.globalVersion); let rootNode = jotai.useAtomValue(model.vdomRoot); React.useEffect(() => { model.renderDone(version); }, [version]); if (model.viewRef.current == null || rootNode == null) { return null; } dlog("render", version, rootNode); let rtn = convertElemToTag(rootNode, model); return <div className="vdom">{rtn}</div>; } type VDomViewProps = { model: VDomModel; blockId: string; }; function VDomInnerView({ blockId, model }: VDomViewProps) { let [styleMounted, setStyleMounted] = React.useState(!model.backendOpts?.globalstyles); const handleStylesMounted = () => { setStyleMounted(true); }; return ( <> {model.backendOpts?.globalstyles ? ( <WaveStyle src={model.makeVDomUrl("/wave/global.css")} model={model} onMount={handleStylesMounted} /> ) : null} {styleMounted ? <VDomRoot model={model} /> : null} </> ); } function VDomView({ blockId, model }: VDomViewProps) { let viewRef = React.useRef(null); let contextActive = jotai.useAtomValue(model.contextActive); model.viewRef = viewRef; const vdomClass = "vdom-" + blockId; return ( <div className={clsx("overflow-auto w-full min-h-full", vdomClass)} ref={viewRef}> {contextActive ? <VDomInnerView blockId={blockId} model={model} /> : null} </div> ); } export { VDomView }; ================================================ FILE: frontend/app/view/waveai/waveai.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .waveai { display: flex; flex-direction: column; height: 100%; width: 100%; .waveai-chat { flex: 1 1 auto; overflow: hidden; .chat-window-container { overflow-y: auto; margin-bottom: 0; height: 100%; .chat-window { flex-flow: column nowrap; display: flex; gap: 8px; // This is the filler that will push the chat messages to the bottom until the chat window is full .filler { flex: 1 1 auto; } .chat-msg-container { display: flex; gap: 8px; .chat-msg { margin: 10px 0; display: flex; align-items: flex-start; border-radius: 8px; &.chat-msg-header { display: flex; flex-direction: column; justify-content: flex-start; .icon-box { padding-top: 0; border-radius: 4px; background-color: rgb(from var(--highlight-bg-color) r g b / 0.05); display: flex; padding: 6px; } } &.chat-msg-assistant { color: var(--main-text-color); background-color: rgb(from var(--highlight-bg-color) r g b / 0.1); margin-right: auto; padding: 10px; max-width: 85%; .markdown { width: 100%; pre { white-space: pre-wrap; word-break: break-word; max-width: 100%; overflow-x: auto; margin-left: 0; } } } &.chat-msg-user { margin-left: auto; padding: 10px; max-width: 85%; background-color: rgb(from var(--accent-color) r g b / 0.15); } &.chat-msg-error { color: var(--main-text-color); background-color: rgb(from var(--error-color) r g b / 0.25); margin-right: auto; padding: 10px; max-width: 85%; .markdown { width: 100%; pre { white-space: pre-wrap; word-break: break-word; max-width: 100%; overflow-x: auto; margin-left: 0; } } } &.typing-indicator { margin-top: 4px; } } } } } } .waveai-controls { flex: 0 0 auto; display: flex; flex-direction: row; align-items: center; justify-content: flex-start; gap: 10px; padding: 8px 6px; .waveai-input-wrapper { padding: 8px 12px; flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; border-radius: 6px; border: 1px solid rgb(from var(--highlight-bg-color) r g b / 0.42); .waveai-input { color: var(--main-text-color); background-color: inherit; resize: none; width: 100%; border: transparent; outline: none; overflow: auto; overflow-wrap: anywhere; height: 21px; } } .waveai-submit-button { border-radius: 100%; width: 27px; aspect-ratio: 1 /1; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; padding: 0; } } } ================================================ FILE: frontend/app/view/waveai/waveai.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; import { Button } from "@/app/element/button"; import { Markdown } from "@/app/element/markdown"; import { TypingIndicator } from "@/app/element/typingindicator"; import { ClientModel } from "@/app/store/client-model"; import { globalStore } from "@/app/store/jotaiStore"; import type { TabModel } from "@/app/store/tab-model"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { atoms, createBlock, fetchWaveFile, getApi, WOS } from "@/store/global"; import { BlockService, ObjectService } from "@/store/services"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, isBlank, makeIconClass, mergeMeta } from "@/util/util"; import { atom, Atom, PrimitiveAtom, useAtomValue, WritableAtom } from "jotai"; import { splitAtom } from "jotai/utils"; import type { OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { debounce, throttle } from "throttle-debounce"; import "./waveai.scss"; interface ChatMessageType { id: string; user: string; text: string; isUpdating?: boolean; } const outline = "2px solid var(--accent-color)"; const slidingWindowSize = 30; interface ChatItemProps { chatItemAtom: Atom<ChatMessageType>; model: WaveAiModel; } function promptToMsg(prompt: WaveAIPromptMessageType): ChatMessageType { return { id: crypto.randomUUID(), user: prompt.role, text: prompt.content, }; } class AiWshClient extends WshClient { blockId: string; model: WaveAiModel; constructor(blockId: string, model: WaveAiModel) { super(makeFeBlockRouteId(blockId)); this.blockId = blockId; this.model = model; } handle_aisendmessage(rh: RpcResponseHelper, data: AiMessageData) { if (isBlank(data.message)) { return; } this.model.sendMessage(data.message); } } export class WaveAiModel implements ViewModel { viewType: string; blockId: string; nodeModel: BlockNodeModel; tabModel: TabModel; blockAtom: Atom<Block>; presetKey: Atom<string>; presetMap: Atom<{ [k: string]: MetaType }>; mergedPresets: Atom<MetaType>; aiOpts: Atom<WaveAIOptsType>; viewIcon?: Atom<string | IconButtonDecl>; viewName?: Atom<string>; viewText?: Atom<string | HeaderElem[]>; preIconButton?: Atom<IconButtonDecl>; endIconButtons?: Atom<IconButtonDecl[]>; messagesAtom: PrimitiveAtom<Array<ChatMessageType>>; messagesSplitAtom: SplitAtom<Array<ChatMessageType>>; latestMessageAtom: Atom<ChatMessageType>; addMessageAtom: WritableAtom<unknown, [message: ChatMessageType], void>; updateLastMessageAtom: WritableAtom<unknown, [text: string, isUpdating: boolean], void>; removeLastMessageAtom: WritableAtom<unknown, [], void>; simulateAssistantResponseAtom: WritableAtom<unknown, [userMessage: ChatMessageType], Promise<void>>; textAreaRef: React.RefObject<HTMLTextAreaElement>; locked: PrimitiveAtom<boolean>; cancel: boolean; aiWshClient: AiWshClient; constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; this.aiWshClient = new AiWshClient(blockId, this); DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.aiWshClient); this.locked = atom(false); this.cancel = false; this.viewType = "waveai"; this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`); this.viewIcon = atom("sparkles"); this.viewName = atom("Wave AI"); this.messagesAtom = atom([]); this.messagesSplitAtom = splitAtom(this.messagesAtom); this.latestMessageAtom = atom((get) => get(this.messagesAtom).slice(-1)[0]); this.presetKey = atom((get) => { const metaPresetKey = get(this.blockAtom).meta["ai:preset"]; const globalPresetKey = get(atoms.settingsAtom)["ai:preset"]; return metaPresetKey ?? globalPresetKey; }); this.presetMap = atom((get) => { const fullConfig = get(atoms.fullConfigAtom); const presets = fullConfig.presets; const settings = fullConfig.settings; return Object.fromEntries( Object.entries(presets) .filter(([k]) => k.startsWith("ai@")) .map(([k, v]) => { const aiPresetKeys = Object.keys(v).filter((k) => k.startsWith("ai:")); const newV = { ...v }; newV["display:name"] = aiPresetKeys.length == 1 && aiPresetKeys.includes("ai:*") ? `${newV["display:name"] ?? "Default"} (${settings["ai:model"]})` : newV["display:name"]; return [k, newV]; }) ); }); this.addMessageAtom = atom(null, (get, set, message: ChatMessageType) => { const messages = get(this.messagesAtom); set(this.messagesAtom, [...messages, message]); }); this.updateLastMessageAtom = atom(null, (get, set, text: string, isUpdating: boolean) => { const messages = get(this.messagesAtom); const lastMessage = messages[messages.length - 1]; if (lastMessage.user == "assistant") { const updatedMessage = { ...lastMessage, text: lastMessage.text + text, isUpdating }; set(this.messagesAtom, [...messages.slice(0, -1), updatedMessage]); } }); this.removeLastMessageAtom = atom(null, (get, set) => { const messages = get(this.messagesAtom); messages.pop(); set(this.messagesAtom, [...messages]); }); this.simulateAssistantResponseAtom = atom(null, async (_, set, userMessage: ChatMessageType) => { // unused at the moment. can replace the temp() function in the future const typingMessage: ChatMessageType = { id: crypto.randomUUID(), user: "assistant", text: "", }; // Add a typing indicator set(this.addMessageAtom, typingMessage); const parts = userMessage.text.split(" "); let currentPart = 0; while (currentPart < parts.length) { const part = parts[currentPart] + " "; set(this.updateLastMessageAtom, part, true); currentPart++; } set(this.updateLastMessageAtom, "", false); }); this.mergedPresets = atom((get) => { const meta = get(this.blockAtom).meta; let settings = get(atoms.settingsAtom); let presetKey = get(this.presetKey); let presets = get(atoms.fullConfigAtom).presets; let selectedPresets = presets?.[presetKey] ?? {}; let mergedPresets: MetaType = {}; mergedPresets = mergeMeta(settings, selectedPresets, "ai"); mergedPresets = mergeMeta(mergedPresets, meta, "ai"); return mergedPresets; }); this.aiOpts = atom((get) => { const mergedPresets = get(this.mergedPresets); const opts: WaveAIOptsType = { model: mergedPresets["ai:model"] ?? null, apitype: mergedPresets["ai:apitype"] ?? null, orgid: mergedPresets["ai:orgid"] ?? null, apitoken: mergedPresets["ai:apitoken"] ?? null, apiversion: mergedPresets["ai:apiversion"] ?? null, maxtokens: mergedPresets["ai:maxtokens"] ?? null, timeoutms: mergedPresets["ai:timeoutms"] ?? 60000, baseurl: mergedPresets["ai:baseurl"] ?? null, proxyurl: mergedPresets["ai:proxyurl"] ?? null, }; return opts; }); this.viewText = atom((get) => { const viewTextChildren: HeaderElem[] = []; const aiOpts = get(this.aiOpts); const presets = get(this.presetMap); const presetKey = get(this.presetKey); const presetName = presets[presetKey]?.["display:name"] ?? ""; const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); // Handle known API providers switch (aiOpts?.apitype) { case "anthropic": viewTextChildren.push({ elemtype: "iconbutton", icon: "globe", title: `Using Remote Anthropic API (${aiOpts.model})`, noAction: true, }); break; case "perplexity": viewTextChildren.push({ elemtype: "iconbutton", icon: "globe", title: `Using Remote Perplexity API (${aiOpts.model})`, noAction: true, }); break; default: if (isCloud) { viewTextChildren.push({ elemtype: "iconbutton", icon: "cloud", title: "Using Wave's AI Proxy (gpt-5-mini)", noAction: true, }); } else { const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint"; const modelName = aiOpts.model; if (baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1")) { viewTextChildren.push({ elemtype: "iconbutton", icon: "location-dot", title: `Using Local Model @ ${baseUrl} (${modelName})`, noAction: true, }); } else { viewTextChildren.push({ elemtype: "iconbutton", icon: "globe", title: `Using Remote Model @ ${baseUrl} (${modelName})`, noAction: true, }); } } } const dropdownItems = Object.entries(presets) .sort((a, b) => ((a[1]["display:order"] ?? 0) > (b[1]["display:order"] ?? 0) ? 1 : -1)) .map( (preset) => ({ label: preset[1]["display:name"], onClick: () => fireAndForget(() => ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { "ai:preset": preset[0], }) ), }) as MenuItem ); dropdownItems.push({ label: "Add AI preset...", onClick: () => { fireAndForget(async () => { const path = `${getApi().getConfigDir()}/presets/ai.json`; const blockDef: BlockDef = { meta: { view: "preview", file: path, }, }; await createBlock(blockDef, false, true); }); }, }); viewTextChildren.push({ elemtype: "menubutton", text: presetName, title: "Select AI Configuration", items: dropdownItems, }); return viewTextChildren; }); this.endIconButtons = atom((_) => { let clearButton: IconButtonDecl = { elemtype: "iconbutton", icon: "delete-left", title: "Clear Chat History", click: this.clearMessages.bind(this), }; return [clearButton]; }); } get viewComponent(): ViewComponent { return WaveAi; } dispose() { DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); } async populateMessages(): Promise<void> { const history = await this.fetchAiData(); globalStore.set(this.messagesAtom, history.map(promptToMsg)); } async fetchAiData(): Promise<Array<WaveAIPromptMessageType>> { const { data } = await fetchWaveFile(this.blockId, "aidata"); if (!data) { return []; } const history: Array<WaveAIPromptMessageType> = JSON.parse(new TextDecoder().decode(data)); return history.slice(Math.max(history.length - slidingWindowSize, 0)); } giveFocus(): boolean { if (this?.textAreaRef?.current) { this.textAreaRef.current?.focus(); return true; } return false; } getAiName(): string { const blockMeta = globalStore.get(this.blockAtom)?.meta ?? {}; const settings = globalStore.get(atoms.settingsAtom) ?? {}; const name = blockMeta["ai:name"] ?? settings["ai:name"] ?? null; return name; } setLocked(locked: boolean) { globalStore.set(this.locked, locked); } sendMessage(text: string, user: string = "user") { const clientId = ClientModel.getInstance().clientId; this.setLocked(true); const newMessage: ChatMessageType = { id: crypto.randomUUID(), user, text, }; globalStore.set(this.addMessageAtom, newMessage); // send message to backend and get response const opts = globalStore.get(this.aiOpts); const newPrompt: WaveAIPromptMessageType = { role: "user", content: text, }; const handleAiStreamingResponse = async () => { const typingMessage: ChatMessageType = { id: crypto.randomUUID(), user: "assistant", text: "", }; // Add a typing indicator globalStore.set(this.addMessageAtom, typingMessage); const history = await this.fetchAiData(); const beMsg: WaveAIStreamRequest = { clientid: clientId, opts: opts, prompt: [...history, newPrompt], }; let fullMsg = ""; try { const aiGen = RpcApi.StreamWaveAiCommand(TabRpcClient, beMsg, { timeout: opts.timeoutms }); for await (const msg of aiGen) { fullMsg += msg.text ?? ""; globalStore.set(this.updateLastMessageAtom, msg.text ?? "", true); if (this.cancel) { break; } } if (fullMsg == "") { // remove a message if empty globalStore.set(this.removeLastMessageAtom); // only save the author's prompt await BlockService.SaveWaveAiData(this.blockId, [...history, newPrompt]); } else { const responsePrompt: WaveAIPromptMessageType = { role: "assistant", content: fullMsg, }; //mark message as complete globalStore.set(this.updateLastMessageAtom, "", false); // save a complete message prompt and response await BlockService.SaveWaveAiData(this.blockId, [...history, newPrompt, responsePrompt]); } } catch (error) { const updatedHist = [...history, newPrompt]; if (fullMsg == "") { globalStore.set(this.removeLastMessageAtom); } else { globalStore.set(this.updateLastMessageAtom, "", false); const responsePrompt: WaveAIPromptMessageType = { role: "assistant", content: fullMsg, }; updatedHist.push(responsePrompt); } const errMsg: string = (error as Error).message; const errorMessage: ChatMessageType = { id: crypto.randomUUID(), user: "error", text: errMsg, }; globalStore.set(this.addMessageAtom, errorMessage); globalStore.set(this.updateLastMessageAtom, "", false); const errorPrompt: WaveAIPromptMessageType = { role: "error", content: errMsg, }; updatedHist.push(errorPrompt); await BlockService.SaveWaveAiData(this.blockId, updatedHist); } this.setLocked(false); this.cancel = false; }; fireAndForget(handleAiStreamingResponse); } useWaveAi() { return { sendMessage: this.sendMessage.bind(this) as (text: string) => void, }; } async clearMessages() { await BlockService.SaveWaveAiData(this.blockId, []); globalStore.set(this.messagesAtom, []); } keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { if (checkKeyPressed(waveEvent, "Cmd:l")) { fireAndForget(this.clearMessages.bind(this)); return true; } return false; } } const ChatItem = ({ chatItemAtom, model }: ChatItemProps) => { const chatItem = useAtomValue(chatItemAtom); const { user, text } = chatItem; const fontSize = useAtomValue(model.mergedPresets)?.["ai:fontsize"]; const fixedFontSize = useAtomValue(model.mergedPresets)?.["ai:fixedfontsize"]; const renderContent = useMemo(() => { if (user == "error") { return ( <> <div className="chat-msg chat-msg-header"> <div className="icon-box"> <i className="fa-sharp fa-solid fa-circle-exclamation"></i> </div> </div> <div className="chat-msg chat-msg-error"> <Markdown text={text} scrollable={false} fontSizeOverride={fontSize} fixedFontSizeOverride={fixedFontSize} /> </div> </> ); } if (user == "assistant") { return text ? ( <> <div className="chat-msg chat-msg-header"> <div className="icon-box"> <i className="fa-sharp fa-solid fa-sparkles"></i> </div> </div> <div className="chat-msg chat-msg-assistant"> <Markdown text={text} scrollable={false} fontSizeOverride={fontSize} fixedFontSizeOverride={fixedFontSize} /> </div> </> ) : ( <> <div className="chat-msg-header"> <i className="fa-sharp fa-solid fa-sparkles"></i> </div> <TypingIndicator className="chat-msg typing-indicator" /> </> ); } return ( <> <div className="chat-msg chat-msg-user"> <Markdown className="msg-text" text={text} scrollable={false} fontSizeOverride={fontSize} fixedFontSizeOverride={fixedFontSize} /> </div> </> ); }, [text, user, fontSize, fixedFontSize]); return <div className={"chat-msg-container"}>{renderContent}</div>; }; interface ChatWindowProps { chatWindowRef: React.RefObject<HTMLDivElement>; msgWidths: object; model: WaveAiModel; } const ChatWindow = memo( forwardRef<OverlayScrollbarsComponentRef, ChatWindowProps>(({ chatWindowRef, msgWidths, model }, ref) => { const isUserScrolling = useRef(false); const osRef = useRef<OverlayScrollbarsComponentRef>(null); const splitMessages = useAtomValue(model.messagesSplitAtom) as Atom<ChatMessageType>[]; const latestMessage = useAtomValue(model.latestMessageAtom); const prevMessagesLenRef = useRef(splitMessages.length); useImperativeHandle(ref, () => osRef.current as OverlayScrollbarsComponentRef); const handleNewMessage = useCallback( throttle(100, (messagesLen: number) => { if (osRef.current?.osInstance()) { const { viewport } = osRef.current.osInstance().elements(); if (prevMessagesLenRef.current !== messagesLen || !isUserScrolling.current) { viewport.scrollTo({ behavior: "auto", top: chatWindowRef.current?.scrollHeight || 0, }); } prevMessagesLenRef.current = messagesLen; } }), [] ); useEffect(() => { handleNewMessage(splitMessages.length); }, [splitMessages, latestMessage]); // Wait 300 ms after the user stops scrolling to determine if the user is within 300px of the bottom of the chat window. // If so, unset the user scrolling flag. const determineUnsetScroll = useCallback( debounce(300, () => { const { viewport } = osRef.current.osInstance().elements(); if (viewport.scrollTop > chatWindowRef.current?.clientHeight - viewport.clientHeight - 100) { isUserScrolling.current = false; } }), [] ); const handleUserScroll = useCallback( throttle(100, () => { isUserScrolling.current = true; determineUnsetScroll(); }), [] ); useEffect(() => { if (osRef.current?.osInstance()) { const { viewport } = osRef.current.osInstance().elements(); viewport.addEventListener("wheel", handleUserScroll, { passive: true }); viewport.addEventListener("touchmove", handleUserScroll, { passive: true }); return () => { viewport.removeEventListener("wheel", handleUserScroll); viewport.removeEventListener("touchmove", handleUserScroll); if (osRef.current && osRef.current.osInstance()) { osRef.current.osInstance().destroy(); } }; } }, []); const handleScrollbarInitialized = (instance: OverlayScrollbars) => { const { viewport } = instance.elements(); viewport.removeAttribute("tabindex"); viewport.scrollTo({ behavior: "auto", top: chatWindowRef.current?.scrollHeight || 0, }); }; const handleScrollbarUpdated = (instance: OverlayScrollbars) => { const { viewport } = instance.elements(); viewport.removeAttribute("tabindex"); }; return ( <OverlayScrollbarsComponent ref={osRef} className="chat-window-container" options={{ scrollbars: { autoHide: "leave" } }} events={{ initialized: handleScrollbarInitialized, updated: handleScrollbarUpdated }} > <div ref={chatWindowRef} className="chat-window" style={msgWidths}> <div className="filler"></div> {splitMessages.map((chitem, idx) => ( <ChatItem key={idx} chatItemAtom={chitem} model={model} /> ))} </div> </OverlayScrollbarsComponent> ); }) ); interface ChatInputProps { value: string; baseFontSize: number; onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; onMouseDown: (e: React.MouseEvent<HTMLTextAreaElement>) => void; model: WaveAiModel; } const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>( ({ value, onChange, onKeyDown, onMouseDown, baseFontSize, model }, ref) => { const textAreaRef = useRef<HTMLTextAreaElement>(null); useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement); useEffect(() => { model.textAreaRef = textAreaRef; }, []); const adjustTextAreaHeight = useCallback( (value: string) => { if (textAreaRef.current == null) { return; } // Adjust the height of the textarea to fit the text const textAreaMaxLines = 5; const textAreaLineHeight = baseFontSize * 1.5; const textAreaMinHeight = textAreaLineHeight; const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines; if (value === "") { textAreaRef.current.style.height = `${textAreaLineHeight}px`; return; } textAreaRef.current.style.height = `${textAreaLineHeight}px`; const scrollHeight = textAreaRef.current.scrollHeight; const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); textAreaRef.current.style.height = newHeight + "px"; }, [baseFontSize] ); useEffect(() => { adjustTextAreaHeight(value); }, [value]); return ( <textarea ref={textAreaRef} autoComplete="off" autoCorrect="off" className="waveai-input" onMouseDown={onMouseDown} // When the user clicks on the textarea onChange={onChange} onKeyDown={onKeyDown} style={{ fontSize: baseFontSize }} placeholder="Ask anything..." value={value} ></textarea> ); } ); const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { const { sendMessage } = model.useWaveAi(); const waveaiRef = useRef<HTMLDivElement>(null); const chatWindowRef = useRef<HTMLDivElement>(null); const osRef = useRef<OverlayScrollbarsComponentRef>(null); const inputRef = useRef<HTMLTextAreaElement>(null); const [value, setValue] = useState(""); const [selectedBlockIdx, setSelectedBlockIdx] = useState<number | null>(null); const baseFontSize: number = 14; const msgWidths = {}; const locked = useAtomValue(model.locked); const aiOpts = useAtomValue(model.aiOpts); const isUsingProxy = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); // a weird workaround to initialize ansynchronously useEffect(() => { fireAndForget(model.populateMessages.bind(model)); }, []); const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { setValue(e.target.value); }; const updatePreTagOutline = (clickedPre?: HTMLElement | null) => { const pres = chatWindowRef.current?.querySelectorAll("pre"); if (!pres) return; pres.forEach((preElement, idx) => { if (preElement === clickedPre) { setSelectedBlockIdx(idx); } else { preElement.style.outline = "none"; } }); if (clickedPre) { clickedPre.style.outline = outline; } }; useEffect(() => { if (selectedBlockIdx !== null) { const pres = chatWindowRef.current?.querySelectorAll("pre"); if (pres && pres[selectedBlockIdx]) { pres[selectedBlockIdx].style.outline = outline; } } }, [selectedBlockIdx]); const handleTextAreaMouseDown = () => { updatePreTagOutline(); setSelectedBlockIdx(null); }; const handleEnterKeyPressed = useCallback(() => { // using globalStore to avoid potential timing problems // useAtom means the component must rerender once before // the unlock is detected. this automatically checks on the // callback firing instead const locked = globalStore.get(model.locked); if (locked || value === "") return; sendMessage(value); setValue(""); setSelectedBlockIdx(null); }, [value]); const updateScrollTop = () => { const pres = chatWindowRef.current?.querySelectorAll("pre"); if (!pres || selectedBlockIdx === null) return; const block = pres[selectedBlockIdx]; if (!block || !osRef.current?.osInstance()) return; const { viewport, scrollOffsetElement } = osRef.current.osInstance().elements(); const chatWindowTop = scrollOffsetElement.scrollTop; const chatWindowHeight = chatWindowRef.current.clientHeight; const chatWindowBottom = chatWindowTop + chatWindowHeight; const elemTop = block.offsetTop; const elemBottom = elemTop + block.offsetHeight; const elementIsInView = elemBottom <= chatWindowBottom && elemTop >= chatWindowTop; if (!elementIsInView) { let scrollPosition; if (elemBottom > chatWindowBottom) { scrollPosition = elemTop - chatWindowHeight + block.offsetHeight + 15; } else if (elemTop < chatWindowTop) { scrollPosition = elemTop - 15; } viewport.scrollTo({ behavior: "auto", top: scrollPosition, }); } }; const shouldSelectCodeBlock = (key: "ArrowUp" | "ArrowDown") => { const textarea = inputRef.current; const cursorPosition = textarea?.selectionStart || 0; const textBeforeCursor = textarea?.value.slice(0, cursorPosition) || ""; return ( (textBeforeCursor.indexOf("\n") === -1 && cursorPosition === 0 && key === "ArrowUp") || selectedBlockIdx !== null ); }; const handleArrowUpPressed = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { if (shouldSelectCodeBlock("ArrowUp")) { e.preventDefault(); const pres = chatWindowRef.current?.querySelectorAll("pre"); let blockIndex = selectedBlockIdx; if (!pres) return; if (blockIndex === null) { setSelectedBlockIdx(pres.length - 1); } else if (blockIndex > 0) { blockIndex--; setSelectedBlockIdx(blockIndex); } updateScrollTop(); } }; const handleArrowDownPressed = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { if (shouldSelectCodeBlock("ArrowDown")) { e.preventDefault(); const pres = chatWindowRef.current?.querySelectorAll("pre"); let blockIndex = selectedBlockIdx; if (!pres) return; if (blockIndex === null) return; if (blockIndex < pres.length - 1 && blockIndex >= 0) { setSelectedBlockIdx(++blockIndex); updateScrollTop(); } else { inputRef.current.focus(); setSelectedBlockIdx(null); } updateScrollTop(); } }; const handleTextAreaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const waveEvent = adaptFromReactOrNativeKeyEvent(e); if (checkKeyPressed(waveEvent, "Enter")) { e.preventDefault(); handleEnterKeyPressed(); } else if (checkKeyPressed(waveEvent, "ArrowUp")) { handleArrowUpPressed(e); } else if (checkKeyPressed(waveEvent, "ArrowDown")) { handleArrowDownPressed(e); } }; let buttonClass = "waveai-submit-button"; let buttonIcon = makeIconClass("arrow-up", false); let buttonTitle = "run"; if (locked) { buttonClass = "waveai-submit-button stop"; buttonIcon = makeIconClass("stop", false); buttonTitle = "stop"; } const handleButtonPress = useCallback(() => { if (locked) { model.cancel = true; } else { handleEnterKeyPressed(); } }, [locked, handleEnterKeyPressed]); const handleOpenAIPanel = useCallback(() => { WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); }, []); return ( <div ref={waveaiRef} className="waveai"> {isUsingProxy && ( <div className="flex items-start gap-3 px-4 py-2 bg-orange-500/25 border-b border-orange-500/50 text-sm"> <i className="fa-sharp fa-solid fa-triangle-exclamation text-orange-300 mt-0.5"></i> <span className="text-primary/90"> Wave AI Proxy is deprecated and will be removed. Please use the new{" "} <button onClick={handleOpenAIPanel} className="text-accent hover:text-accent/80 underline cursor-pointer" > Wave AI panel </button>{" "} instead (better model, terminal integration, tool support, image uploads). </span> </div> )} <div className="waveai-chat"> <ChatWindow ref={osRef} chatWindowRef={chatWindowRef} msgWidths={msgWidths} model={model} /> </div> <div className="waveai-controls"> <div className="waveai-input-wrapper"> <ChatInput ref={inputRef} value={value} model={model} onChange={handleTextAreaChange} onKeyDown={handleTextAreaKeyDown} onMouseDown={handleTextAreaMouseDown} baseFontSize={baseFontSize} /> </div> <Button className={buttonClass} onClick={handleButtonPress}> <i className={buttonIcon} title={buttonTitle} /> </Button> </div> </div> ); }; export { WaveAi }; ================================================ FILE: frontend/app/view/waveconfig/secretscontent.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { SecretNameRegex, type WaveConfigViewModel } from "@/app/view/waveconfig/waveconfig-model"; import { cn } from "@/util/util"; import { useAtomValue, useSetAtom } from "jotai"; import { memo, useMemo } from "react"; interface ErrorDisplayProps { message: string; variant?: "error" | "warning"; } const ErrorDisplay = memo(({ message, variant = "error" }: ErrorDisplayProps) => { const icon = variant === "error" ? "fa-circle-exclamation" : "fa-triangle-exclamation"; const baseClasses = "flex items-center gap-2 p-4 border rounded-lg"; const variantClasses = variant === "error" ? "bg-red-500/10 border-red-500/20 text-red-400" : "bg-yellow-500/10 border-yellow-500/20 text-yellow-400"; return ( <div className={`${baseClasses} ${variantClasses}`}> <i className={`fa-sharp fa-solid ${icon}`} /> <span>{message}</span> </div> ); }); ErrorDisplay.displayName = "ErrorDisplay"; const LoadingSpinner = memo(({ message }: { message: string }) => { return ( <div className="flex flex-col items-center justify-center gap-3 py-12"> <i className="fa-sharp fa-solid fa-spinner fa-spin text-2xl text-zinc-400" /> <span className="text-zinc-400">{message}</span> </div> ); }); LoadingSpinner.displayName = "LoadingSpinner"; const EmptyState = memo(({ onAddSecret }: { onAddSecret: () => void }) => { return ( <div className="flex flex-col items-center justify-center gap-4 py-12 h-full bg-zinc-800/50 rounded-lg"> <i className="fa-sharp fa-solid fa-key text-4xl text-zinc-600" /> <h3 className="text-lg font-semibold text-zinc-400">No Secrets</h3> <p className="text-zinc-500">Add a secret to get started</p> <button className="flex items-center gap-2 px-4 py-2 bg-accent-600 hover:bg-accent-500 rounded cursor-pointer transition-colors" onClick={onAddSecret} > <i className="fa-sharp fa-solid fa-plus" /> <span className="font-medium">Add New Secret</span> </button> </div> ); }); EmptyState.displayName = "EmptyState"; const CLIInfoBubble = memo(() => { return ( <div className="flex flex-col gap-2 p-4 m-4 bg-zinc-800/50 rounded-lg"> <div className="flex items-center gap-2"> <i className="fa-sharp fa-solid fa-terminal text-zinc-400" /> <div className="text-sm font-medium text-zinc-300">CLI Access</div> </div> <div className="font-mono text-xs bg-black/20 px-3 py-2 rounded leading-relaxed text-zinc-300"> wsh secret list <br /> wsh secret get [name] <br /> wsh secret set [name]=[value] </div> </div> ); }); CLIInfoBubble.displayName = "CLIInfoBubble"; interface SecretListViewProps { secretNames: string[]; onSelectSecret: (name: string) => void; onAddSecret: () => void; } const SecretListView = memo(({ secretNames, onSelectSecret, onAddSecret }: SecretListViewProps) => { return ( <div className="flex flex-col h-full w-full rounded-lg"> <div className="flex flex-col divide-y divide-zinc-700"> {secretNames.map((name) => ( <div key={name} className={cn( "flex items-center gap-3 p-4 hover:bg-zinc-700/50 cursor-pointer transition-colors" )} onClick={() => onSelectSecret(name)} > <i className="fa-sharp fa-solid fa-key text-accent-500" /> <span className="flex-1 font-mono">{name}</span> <i className="fa-sharp fa-solid fa-chevron-right text-zinc-500 text-sm" /> </div> ))} <div className={cn( "flex items-center justify-center gap-2 p-4 hover:bg-zinc-700/50 cursor-pointer transition-colors border-t-2 border-zinc-600" )} onClick={onAddSecret} > <i className="fa-sharp fa-solid fa-plus text-accent-500" /> <span className="font-medium text-accent-500">Add New Secret</span> </div> </div> <CLIInfoBubble /> </div> ); }); SecretListView.displayName = "SecretListView"; interface AddSecretFormProps { newSecretName: string; newSecretValue: string; isLoading: boolean; onNameChange: (name: string) => void; onValueChange: (value: string) => void; onCancel: () => void; onSubmit: () => void; } const AddSecretForm = memo( ({ newSecretName, newSecretValue, isLoading, onNameChange, onValueChange, onCancel, onSubmit, }: AddSecretFormProps) => { const isNameInvalid = newSecretName !== "" && !SecretNameRegex.test(newSecretName); return ( <div className="flex flex-col gap-4 min-h-full p-6 bg-zinc-800/50 rounded-lg"> <h3 className="text-lg font-semibold">Add New Secret</h3> <div className="flex flex-col gap-2"> <label className="text-sm font-medium">Secret Name</label> <input type="text" className={cn( "px-3 py-2 bg-zinc-800 border rounded focus:outline-none", isNameInvalid ? "border-red-500 focus:border-red-500" : "border-zinc-600 focus:border-accent-500" )} value={newSecretName} onChange={(e) => onNameChange(e.target.value)} placeholder="MY_SECRET_NAME" disabled={isLoading} /> <div className="text-xs text-zinc-400"> Must start with a letter and contain only letters, numbers, and underscores </div> </div> <div className="flex flex-col gap-2"> <label className="text-sm font-medium">Secret Value</label> <textarea className="px-3 py-2 bg-zinc-800 border border-zinc-600 rounded focus:outline-none focus:border-accent-500 font-mono text-sm" value={newSecretValue} onChange={(e) => onValueChange(e.target.value)} placeholder="Enter secret value..." disabled={isLoading} rows={4} /> </div> <div className="flex gap-2 justify-end"> <button className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" onClick={onCancel} disabled={isLoading} > Cancel </button> <button className="px-4 py-2 bg-accent-600 hover:bg-accent-500 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" onClick={onSubmit} disabled={isLoading || isNameInvalid || newSecretName.trim() === ""} > {isLoading ? ( <> <i className="fa-sharp fa-solid fa-spinner fa-spin" /> Adding... </> ) : ( "Add Secret" )} </button> </div> </div> ); } ); AddSecretForm.displayName = "AddSecretForm"; interface SecretDetailViewProps { model: WaveConfigViewModel; } const SecretDetailView = memo(({ model }: SecretDetailViewProps) => { const secretName = useAtomValue(model.selectedSecretAtom); const secretValue = useAtomValue(model.secretValueAtom); const secretShown = useAtomValue(model.secretShownAtom); const isLoading = useAtomValue(model.isLoadingAtom); const setSecretValue = useSetAtom(model.secretValueAtom); if (!secretName) { return null; } return ( <div className="flex flex-col gap-4 min-h-full p-6 bg-zinc-800/50 rounded-lg"> <div className="flex items-center gap-2"> <i className="fa-sharp fa-solid fa-key text-accent-500" /> <h3 className="text-lg font-semibold">{secretName}</h3> </div> <div className="flex flex-col gap-2"> <label className="text-sm font-medium">Secret Value</label> <textarea ref={(ref) => { model.secretValueRef = ref; if (ref) { ref.focus(); } }} className="px-3 py-2 bg-zinc-800 border border-zinc-600 rounded focus:outline-none focus:border-accent-500 font-mono text-sm" value={secretValue} onChange={(e) => setSecretValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { model.closeSecretView(); } }} disabled={isLoading} rows={6} placeholder={!secretShown ? "Enter new secret value..." : ""} /> {!secretShown && ( <div className="text-sm text-zinc-400"> The current secret value is not shown by default for security purposes.{" "} {isLoading ? ( <span className="text-zinc-500"> <i className="fa-sharp fa-solid fa-spinner fa-spin" /> Loading... </span> ) : ( <button className="text-accent-500 underline hover:text-accent-400 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" onClick={() => model.showSecret()} disabled={isLoading} > Show Secret </button> )} </div> )} </div> <div className="flex gap-2 justify-between"> <button className="px-4 py-2 bg-red-600 hover:bg-red-500 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" onClick={() => model.deleteSecret()} disabled={isLoading} title="Delete this secret" > {isLoading ? ( <> <i className="fa-sharp fa-solid fa-spinner fa-spin" /> Deleting... </> ) : ( <> <i className="fa-sharp fa-solid fa-trash" /> Delete </> )} </button> <div className="flex gap-2"> <button className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" onClick={() => model.closeSecretView()} disabled={isLoading} > Cancel </button> <button className="px-4 py-2 bg-accent-600 hover:bg-accent-500 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" onClick={() => model.saveSecret()} disabled={isLoading} > {isLoading ? ( <> <i className="fa-sharp fa-solid fa-spinner fa-spin" /> Saving... </> ) : ( "Save" )} </button> </div> </div> </div> ); }); SecretDetailView.displayName = "SecretDetailView"; interface SecretsContentProps { model: WaveConfigViewModel; } export const SecretsContent = memo(({ model }: SecretsContentProps) => { const secretNames = useAtomValue(model.secretNamesAtom); const selectedSecret = useAtomValue(model.selectedSecretAtom); const isLoading = useAtomValue(model.isLoadingAtom); const errorMessage = useAtomValue(model.errorMessageAtom); const storageBackendError = useAtomValue(model.storageBackendErrorAtom); const isAddingNew = useAtomValue(model.isAddingNewAtom); const newSecretName = useAtomValue(model.newSecretNameAtom); const newSecretValue = useAtomValue(model.newSecretValueAtom); const setNewSecretName = useSetAtom(model.newSecretNameAtom); const setNewSecretValue = useSetAtom(model.newSecretValueAtom); const sortedSecretNames = useMemo(() => { return [...secretNames].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); }, [secretNames]); if (storageBackendError) { return ( <div className="w-full h-full"> <div className="p-4"> <ErrorDisplay message={storageBackendError} variant="warning" /> </div> </div> ); } if (isLoading && secretNames.length === 0 && !selectedSecret) { return ( <div className="w-full h-full"> <div> <LoadingSpinner message="Loading secrets..." /> </div> </div> ); } const renderContent = () => { if (isAddingNew) { return ( <AddSecretForm newSecretName={newSecretName} newSecretValue={newSecretValue} isLoading={isLoading} onNameChange={setNewSecretName} onValueChange={setNewSecretValue} onCancel={() => model.cancelAddingSecret()} onSubmit={() => model.addNewSecret()} /> ); } if (selectedSecret) { return <SecretDetailView model={model} />; } if (secretNames.length === 0) { return <EmptyState onAddSecret={() => model.startAddingSecret()} />; } return ( <SecretListView secretNames={sortedSecretNames} onSelectSecret={(name) => model.viewSecret(name)} onAddSecret={() => model.startAddingSecret()} /> ); }; return ( <div className="w-full h-full"> {errorMessage && ( <div className="p-4"> <ErrorDisplay message={errorMessage} /> </div> )} {renderContent()} </div> ); }); SecretsContent.displayName = "SecretsContent"; ================================================ FILE: frontend/app/view/waveconfig/waveaivisual.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { WaveConfigViewModel } from "@/app/view/waveconfig/waveconfig-model"; import { memo } from "react"; interface WaveAIVisualContentProps { model: WaveConfigViewModel; } export const WaveAIVisualContent = memo(({ model }: WaveAIVisualContentProps) => { return ( <div className="flex flex-col gap-4 p-6 h-full"> <div className="text-lg font-semibold">Wave AI Modes - Visual Editor</div> <div className="text-muted-foreground">Visual editor coming soon...</div> </div> ); }); WaveAIVisualContent.displayName = "WaveAIVisualContent"; ================================================ FILE: frontend/app/view/waveconfig/waveconfig-model.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; import { globalStore } from "@/app/store/jotaiStore"; import type { TabModel } from "@/app/store/tab-model"; import { makeORef } from "@/app/store/wos"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { SecretsContent } from "@/app/view/waveconfig/secretscontent"; import { WaveConfigView } from "@/app/view/waveconfig/waveconfig"; import type { WaveConfigEnv } from "@/app/view/waveconfig/waveconfigenv"; import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; import type * as MonacoTypes from "monaco-editor"; import * as React from "react"; type ValidationResult = { success: true } | { error: string }; type ConfigValidator = (parsed: any) => ValidationResult; export type ConfigFile = { name: string; path: string; language?: string; deprecated?: boolean; description?: string; docsUrl?: string; validator?: ConfigValidator; isSecrets?: boolean; hasJsonView?: boolean; visualComponent?: React.ComponentType<{ model: WaveConfigViewModel }>; }; export const SecretNameRegex = /^[A-Za-z][A-Za-z0-9_]*$/; function validateBgJson(parsed: any): ValidationResult { const keys = Object.keys(parsed); for (const key of keys) { if (!key.startsWith("bg@")) { return { error: `Invalid key "${key}": all top-level keys must start with "bg@"` }; } } return { success: true }; } function validateAiJson(parsed: any): ValidationResult { const keys = Object.keys(parsed); for (const key of keys) { if (!key.startsWith("ai@")) { return { error: `Invalid key "${key}": all top-level keys must start with "ai@"` }; } } return { success: true }; } function validateWaveAiJson(parsed: any): ValidationResult { const keys = Object.keys(parsed); const keyPattern = /^[a-zA-Z0-9_@.-]+$/; for (const key of keys) { if (!keyPattern.test(key)) { return { error: `Invalid key "${key}": keys must only contain letters, numbers, underscores, @, dots, and hyphens`, }; } } return { success: true }; } function makeConfigFiles(isWindows: boolean): ConfigFile[] { return [ { name: "General", path: "settings.json", language: "json", docsUrl: "https://docs.waveterm.dev/config", hasJsonView: true, }, { name: "Connections", path: "connections.json", language: "json", docsUrl: "https://docs.waveterm.dev/connections", description: isWindows ? "SSH hosts and WSL distros" : "SSH hosts", hasJsonView: true, }, { name: "Sidebar Widgets", path: "widgets.json", language: "json", docsUrl: "https://docs.waveterm.dev/customwidgets", hasJsonView: true, }, { name: "Wave AI Modes", path: "waveai.json", language: "json", description: "Local models and BYOK", docsUrl: "https://docs.waveterm.dev/waveai-modes", validator: validateWaveAiJson, hasJsonView: true, // visualComponent: WaveAIVisualContent, }, { name: "Tab Backgrounds", path: "presets/bg.json", language: "json", docsUrl: "https://docs.waveterm.dev/presets#background-configurations", validator: validateBgJson, hasJsonView: true, }, { name: "Secrets", path: "secrets", isSecrets: true, hasJsonView: false, visualComponent: SecretsContent, }, ]; } const deprecatedConfigFiles: ConfigFile[] = [ { name: "Presets", path: "presets.json", language: "json", deprecated: true, hasJsonView: true, }, { name: "AI Presets", path: "presets/ai.json", language: "json", deprecated: true, docsUrl: "https://docs.waveterm.dev/ai-presets", validator: validateAiJson, hasJsonView: true, }, ]; export class WaveConfigViewModel implements ViewModel { blockId: string; viewType = "waveconfig"; viewIcon = atom("gear"); viewName = atom("Wave Config"); viewComponent = WaveConfigView; noPadding = atom(true); nodeModel: BlockNodeModel; tabModel: TabModel; env: WaveConfigEnv; selectedFileAtom: PrimitiveAtom<ConfigFile>; fileContentAtom: PrimitiveAtom<string>; originalContentAtom: PrimitiveAtom<string>; hasEditedAtom: PrimitiveAtom<boolean>; isLoadingAtom: PrimitiveAtom<boolean>; isSavingAtom: PrimitiveAtom<boolean>; errorMessageAtom: PrimitiveAtom<string>; validationErrorAtom: PrimitiveAtom<string>; isMenuOpenAtom: PrimitiveAtom<boolean>; presetsJsonExistsAtom: PrimitiveAtom<boolean>; activeTabAtom: PrimitiveAtom<"visual" | "json">; configErrorFilesAtom: Atom<Set<string>>; configDir: string; saveShortcut: string; editorRef: React.RefObject<MonacoTypes.editor.IStandaloneCodeEditor>; secretNamesAtom: PrimitiveAtom<string[]>; selectedSecretAtom: PrimitiveAtom<string | null>; secretValueAtom: PrimitiveAtom<string>; secretShownAtom: PrimitiveAtom<boolean>; isAddingNewAtom: PrimitiveAtom<boolean>; newSecretNameAtom: PrimitiveAtom<string>; newSecretValueAtom: PrimitiveAtom<string>; storageBackendErrorAtom: PrimitiveAtom<string | null>; secretValueRef: HTMLTextAreaElement | null = null; constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) { this.blockId = blockId; this.nodeModel = nodeModel; this.tabModel = tabModel; this.env = waveEnv as WaveConfigEnv; this.configDir = this.env.electron.getConfigDir(); const platform = this.env.electron.getPlatform(); this.saveShortcut = platform === "darwin" ? "Cmd+S" : "Alt+S"; this.selectedFileAtom = atom(null) as PrimitiveAtom<ConfigFile>; this.fileContentAtom = atom(""); this.originalContentAtom = atom(""); this.hasEditedAtom = atom(false); this.isLoadingAtom = atom(false); this.isSavingAtom = atom(false); this.errorMessageAtom = atom(null) as PrimitiveAtom<string>; this.validationErrorAtom = atom(null) as PrimitiveAtom<string>; this.isMenuOpenAtom = atom(false); this.presetsJsonExistsAtom = atom(false); this.activeTabAtom = atom<"visual" | "json">("visual"); this.configErrorFilesAtom = atom((get) => { const fullConfig = get(this.env.atoms.fullConfigAtom); const errorSet = new Set<string>(); for (const cerr of fullConfig?.configerrors ?? []) { errorSet.add(cerr.file); } return errorSet; }); this.editorRef = React.createRef(); this.secretNamesAtom = atom<string[]>([]); this.selectedSecretAtom = atom<string | null>(null) as PrimitiveAtom<string | null>; this.secretValueAtom = atom<string>(""); this.secretShownAtom = atom<boolean>(false); this.isAddingNewAtom = atom<boolean>(false); this.newSecretNameAtom = atom<string>(""); this.newSecretValueAtom = atom<string>(""); this.storageBackendErrorAtom = atom<string | null>(null) as PrimitiveAtom<string | null>; this.checkPresetsJsonExists(); this.initialize(); } async checkPresetsJsonExists() { try { const fullPath = `${this.configDir}/presets.json`; const fileInfo = await this.env.rpc.FileInfoCommand(TabRpcClient, { info: { path: fullPath }, }); if (!fileInfo.notfound) { globalStore.set(this.presetsJsonExistsAtom, true); } } catch { // File doesn't exist } } initialize() { const selectedFile = globalStore.get(this.selectedFileAtom); if (!selectedFile) { const metaFileAtom = this.env.getBlockMetaKeyAtom(this.blockId, "file"); const savedFilePath = globalStore.get(metaFileAtom); const configFiles = this.getConfigFiles(); const deprecatedConfigFiles = this.getDeprecatedConfigFiles(); let fileToLoad: ConfigFile | null = null; if (savedFilePath) { fileToLoad = configFiles.find((f) => f.path === savedFilePath) || deprecatedConfigFiles.find((f) => f.path === savedFilePath) || null; } if (!fileToLoad) { fileToLoad = configFiles[0]; } if (fileToLoad) { this.loadFile(fileToLoad); } } } getConfigFiles(): ConfigFile[] { return makeConfigFiles(this.env.isWindows()); } getDeprecatedConfigFiles(): ConfigFile[] { const presetsJsonExists = globalStore.get(this.presetsJsonExistsAtom); return deprecatedConfigFiles.filter((f) => { if (f.path === "presets.json") { return presetsJsonExists; } return true; }); } hasChanges(): boolean { return globalStore.get(this.hasEditedAtom); } markAsEdited() { globalStore.set(this.hasEditedAtom, true); } async loadFile(file: ConfigFile) { globalStore.set(this.isLoadingAtom, true); globalStore.set(this.errorMessageAtom, null); globalStore.set(this.hasEditedAtom, false); if (file.isSecrets) { globalStore.set(this.selectedFileAtom, file); this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { file: file.path }, }); globalStore.set(this.isLoadingAtom, false); this.checkStorageBackend(); this.refreshSecrets(); return; } try { const fullPath = `${this.configDir}/${file.path}`; const fileData = await this.env.rpc.FileReadCommand(TabRpcClient, { info: { path: fullPath }, }); const content = fileData?.data64 ? base64ToString(fileData.data64) : ""; globalStore.set(this.originalContentAtom, content); if (content.trim() === "") { globalStore.set(this.fileContentAtom, "{\n\n}"); } else { globalStore.set(this.fileContentAtom, content); } globalStore.set(this.selectedFileAtom, file); this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { file: file.path }, }); } catch (err) { globalStore.set(this.errorMessageAtom, `Failed to load ${file.name}: ${err.message || String(err)}`); globalStore.set(this.fileContentAtom, ""); globalStore.set(this.originalContentAtom, ""); } finally { globalStore.set(this.isLoadingAtom, false); } } async saveFile() { const selectedFile = globalStore.get(this.selectedFileAtom); if (!selectedFile) return; const fileContent = globalStore.get(this.fileContentAtom); if (fileContent.trim() === "") { globalStore.set(this.isSavingAtom, true); globalStore.set(this.errorMessageAtom, null); globalStore.set(this.validationErrorAtom, null); try { const fullPath = `${this.configDir}/${selectedFile.path}`; await this.env.rpc.FileWriteCommand(TabRpcClient, { info: { path: fullPath }, data64: stringToBase64(""), }); globalStore.set(this.fileContentAtom, ""); globalStore.set(this.originalContentAtom, ""); globalStore.set(this.hasEditedAtom, false); } catch (err) { globalStore.set( this.errorMessageAtom, `Failed to save ${selectedFile.name}: ${err.message || String(err)}` ); } finally { globalStore.set(this.isSavingAtom, false); } return; } try { const parsed = JSON.parse(fileContent); if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) { globalStore.set(this.validationErrorAtom, "JSON must be an object, not an array, primitive, or null"); return; } if (selectedFile.validator) { const validationResult = selectedFile.validator(parsed); if ("error" in validationResult) { globalStore.set(this.validationErrorAtom, validationResult.error); return; } } const formatted = JSON.stringify(parsed, null, 2); globalStore.set(this.isSavingAtom, true); globalStore.set(this.errorMessageAtom, null); globalStore.set(this.validationErrorAtom, null); try { const fullPath = `${this.configDir}/${selectedFile.path}`; await this.env.rpc.FileWriteCommand(TabRpcClient, { info: { path: fullPath }, data64: stringToBase64(formatted), }); globalStore.set(this.fileContentAtom, formatted); globalStore.set(this.originalContentAtom, formatted); globalStore.set(this.hasEditedAtom, false); } catch (err) { globalStore.set( this.errorMessageAtom, `Failed to save ${selectedFile.name}: ${err.message || String(err)}` ); } finally { globalStore.set(this.isSavingAtom, false); } } catch (err) { globalStore.set(this.validationErrorAtom, `Invalid JSON: ${err.message || String(err)}`); } } clearError() { globalStore.set(this.errorMessageAtom, null); } clearValidationError() { globalStore.set(this.validationErrorAtom, null); } async checkStorageBackend() { try { const backend = await this.env.rpc.GetSecretsLinuxStorageBackendCommand(TabRpcClient); if (backend === "basic_text" || backend === "unknown") { globalStore.set( this.storageBackendErrorAtom, "No appropriate secret manager found. Cannot manage secrets securely." ); } else { globalStore.set(this.storageBackendErrorAtom, null); } } catch (error) { globalStore.set(this.storageBackendErrorAtom, `Error checking storage backend: ${error.message}`); } } async refreshSecrets() { globalStore.set(this.isLoadingAtom, true); globalStore.set(this.errorMessageAtom, null); try { const names = await this.env.rpc.GetSecretsNamesCommand(TabRpcClient); globalStore.set(this.secretNamesAtom, names || []); } catch (error) { globalStore.set(this.errorMessageAtom, `Failed to load secrets: ${error.message}`); } finally { globalStore.set(this.isLoadingAtom, false); } } async viewSecret(name: string) { globalStore.set(this.errorMessageAtom, null); globalStore.set(this.selectedSecretAtom, name); globalStore.set(this.secretShownAtom, false); globalStore.set(this.secretValueAtom, ""); } closeSecretView() { globalStore.set(this.selectedSecretAtom, null); globalStore.set(this.secretValueAtom, ""); globalStore.set(this.errorMessageAtom, null); } async showSecret() { const selectedSecret = globalStore.get(this.selectedSecretAtom); if (!selectedSecret) { return; } globalStore.set(this.isLoadingAtom, true); globalStore.set(this.errorMessageAtom, null); try { const secrets = await this.env.rpc.GetSecretsCommand(TabRpcClient, [selectedSecret]); const value = secrets[selectedSecret]; if (value !== undefined) { globalStore.set(this.secretValueAtom, value); globalStore.set(this.secretShownAtom, true); } else { globalStore.set(this.errorMessageAtom, `Secret not found: ${selectedSecret}`); } } catch (error) { globalStore.set(this.errorMessageAtom, `Failed to load secret: ${error.message}`); } finally { globalStore.set(this.isLoadingAtom, false); } } async saveSecret() { const selectedSecret = globalStore.get(this.selectedSecretAtom); const secretValue = globalStore.get(this.secretValueAtom); if (!selectedSecret) { return; } globalStore.set(this.isLoadingAtom, true); globalStore.set(this.errorMessageAtom, null); try { await this.env.rpc.SetSecretsCommand(TabRpcClient, { [selectedSecret]: secretValue }); this.env.rpc.RecordTEventCommand( TabRpcClient, { event: "action:other", props: { "action:type": "waveconfig:savesecret", }, }, { noresponse: true } ); this.closeSecretView(); } catch (error) { globalStore.set(this.errorMessageAtom, `Failed to save secret: ${error.message}`); } finally { globalStore.set(this.isLoadingAtom, false); } } async deleteSecret() { const selectedSecret = globalStore.get(this.selectedSecretAtom); if (!selectedSecret) { return; } globalStore.set(this.isLoadingAtom, true); globalStore.set(this.errorMessageAtom, null); try { await this.env.rpc.SetSecretsCommand(TabRpcClient, { [selectedSecret]: null }); this.closeSecretView(); await this.refreshSecrets(); } catch (error) { globalStore.set(this.errorMessageAtom, `Failed to delete secret: ${error.message}`); } finally { globalStore.set(this.isLoadingAtom, false); } } startAddingSecret() { globalStore.set(this.isAddingNewAtom, true); globalStore.set(this.newSecretNameAtom, ""); globalStore.set(this.newSecretValueAtom, ""); globalStore.set(this.errorMessageAtom, null); } cancelAddingSecret() { globalStore.set(this.isAddingNewAtom, false); globalStore.set(this.newSecretNameAtom, ""); globalStore.set(this.newSecretValueAtom, ""); globalStore.set(this.errorMessageAtom, null); } async addNewSecret() { const name = globalStore.get(this.newSecretNameAtom).trim(); const value = globalStore.get(this.newSecretValueAtom); if (!name) { globalStore.set(this.errorMessageAtom, "Secret name cannot be empty"); return; } if (!SecretNameRegex.test(name)) { globalStore.set( this.errorMessageAtom, "Invalid secret name: must start with a letter and contain only letters, numbers, and underscores" ); return; } const existingNames = globalStore.get(this.secretNamesAtom); if (existingNames.includes(name)) { globalStore.set(this.errorMessageAtom, `Secret "${name}" already exists`); return; } globalStore.set(this.isLoadingAtom, true); globalStore.set(this.errorMessageAtom, null); try { await this.env.rpc.SetSecretsCommand(TabRpcClient, { [name]: value }); this.env.rpc.RecordTEventCommand( TabRpcClient, { event: "action:other", props: { "action:type": "waveconfig:savesecret", }, }, { noresponse: true } ); globalStore.set(this.isAddingNewAtom, false); globalStore.set(this.newSecretNameAtom, ""); globalStore.set(this.newSecretValueAtom, ""); await this.refreshSecrets(); } catch (error) { globalStore.set(this.errorMessageAtom, `Failed to add secret: ${error.message}`); } finally { globalStore.set(this.isLoadingAtom, false); } } giveFocus(): boolean { const selectedFile = globalStore.get(this.selectedFileAtom); if (selectedFile?.isSecrets && this.secretValueRef) { this.secretValueRef.focus(); return true; } if (this.editorRef?.current) { this.editorRef.current.focus(); return true; } return false; } } ================================================ FILE: frontend/app/view/waveconfig/waveconfig.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; import { globalStore } from "@/app/store/jotaiStore"; import { tryReinjectKey } from "@/app/store/keymodel"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import type { ConfigFile, WaveConfigViewModel } from "@/app/view/waveconfig/waveconfig-model"; import type { WaveConfigEnv } from "@/app/view/waveconfig/waveconfigenv"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { cn } from "@/util/util"; import { useAtom, useAtomValue } from "jotai"; import type * as MonacoTypes from "monaco-editor"; import { memo, useCallback, useEffect } from "react"; interface ConfigSidebarProps { model: WaveConfigViewModel; } const ConfigSidebar = memo(({ model }: ConfigSidebarProps) => { const selectedFile = useAtomValue(model.selectedFileAtom); const [isMenuOpen, setIsMenuOpen] = useAtom(model.isMenuOpenAtom); const configFiles = model.getConfigFiles(); const deprecatedConfigFiles = model.getDeprecatedConfigFiles(); const configErrorFiles = useAtomValue(model.configErrorFilesAtom); const handleFileSelect = (file: ConfigFile) => { model.loadFile(file); setIsMenuOpen(false); }; return ( <div className="flex flex-col w-48 border-r border-border @w600:h-full @max-w600:absolute @max-w600:left-0.5 @max-w600:top-0 @max-w600:bottom-0.5 @max-w600:z-10 @max-w600:bg-background @max-w600:shadow-xl @max-w600:rounded-bl"> <div className="flex items-center justify-between px-4 py-2 border-b border-border @w600:hidden"> <span className="font-semibold">Config Files</span> <button onClick={() => setIsMenuOpen(false)} className="hover:bg-secondary/50 rounded p-1 cursor-pointer transition-colors" > ✕ </button> </div> {configFiles.map((file) => ( <div key={file.path} onClick={() => handleFileSelect(file)} className={`px-4 py-2 border-b border-border cursor-pointer transition-colors ${ selectedFile?.path === file.path ? "bg-accentbg text-primary" : "hover:bg-secondary/50" }`} > <div className="flex items-center gap-1"> <div className="whitespace-nowrap overflow-hidden text-ellipsis flex-1">{file.name}</div> {configErrorFiles.has(file.path) && ( <i className="fa fa-solid fa-circle-exclamation text-error text-[14px] shrink-0" /> )} </div> {file.description && ( <div className="text-xs text-muted mt-0.5 whitespace-nowrap overflow-hidden text-ellipsis"> {file.description} </div> )} </div> ))} {deprecatedConfigFiles.length > 0 && ( <> {deprecatedConfigFiles.map((file) => ( <div key={file.path} onClick={() => handleFileSelect(file)} className={`px-4 py-2 border-b border-border cursor-pointer transition-colors ${ selectedFile?.path === file.path ? "bg-accentbg text-primary" : "hover:bg-secondary/50" }`} > <div className="flex items-center gap-2 overflow-hidden"> <span className="text-secondary truncate">{file.name}</span> <span className={`text-xs px-1.5 py-0.5 rounded shrink-0 ${ selectedFile?.path === file.path ? "text-primary/80 bg-secondary/50" : "text-muted-foreground/70 bg-secondary/30" }`} > deprecated </span> {configErrorFiles.has(file.path) && ( <i className="fa fa-solid fa-circle-exclamation text-error text-[14px] ml-auto shrink-0" /> )} </div> </div> ))} </> )} </div> ); }); ConfigSidebar.displayName = "ConfigSidebar"; const WaveConfigView = memo(({ blockId, model }: ViewComponentProps<WaveConfigViewModel>) => { const env = useWaveEnv<WaveConfigEnv>(); const selectedFile = useAtomValue(model.selectedFileAtom); const [fileContent, setFileContent] = useAtom(model.fileContentAtom); const isLoading = useAtomValue(model.isLoadingAtom); const isSaving = useAtomValue(model.isSavingAtom); const errorMessage = useAtomValue(model.errorMessageAtom); const validationError = useAtomValue(model.validationErrorAtom); const [isMenuOpen, setIsMenuOpen] = useAtom(model.isMenuOpenAtom); const hasChanges = useAtomValue(model.hasEditedAtom); const [activeTab, setActiveTab] = useAtom(model.activeTabAtom); const fullConfig = useAtomValue(env.atoms.fullConfigAtom); const configErrors = fullConfig?.configerrors; const handleContentChange = useCallback( (newContent: string) => { setFileContent(newContent); model.markAsEdited(); }, [setFileContent, model] ); const handleEditorMount = useCallback( (editor: MonacoTypes.editor.IStandaloneCodeEditor) => { model.editorRef.current = editor; const keyDownDisposer = editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => { const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent); const handled = tryReinjectKey(waveEvent); if (handled) { e.stopPropagation(); e.preventDefault(); } }); const isFocused = globalStore.get(model.nodeModel.isFocused); if (isFocused) { editor.focus(); } return () => { keyDownDisposer.dispose(); model.editorRef.current = null; }; }, [model] ); useEffect(() => { const handleKeyDown = keydownWrapper((e: WaveKeyboardEvent) => { if (checkKeyPressed(e, "Cmd:s")) { if (hasChanges && !isSaving) { model.saveFile(); } return true; } return false; }); window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [hasChanges, isSaving, model]); const saveTooltip = `Save (${model.saveShortcut})`; return ( <div className="@container flex flex-col w-full h-full"> <div className="flex flex-row flex-1 min-h-0"> {isMenuOpen && ( <div className="absolute inset-0 bg-black/50 z-5 @w600:hidden" onClick={() => setIsMenuOpen(false)} /> )} <div className={`h-full ${isMenuOpen ? "" : "@max-w600:hidden"}`}> <ConfigSidebar model={model} /> </div> <div className="flex flex-col flex-1 min-w-0"> {selectedFile && ( <> <div className="flex flex-row items-center justify-between px-4 py-2 border-b border-border"> <div className="flex items-baseline gap-2 min-w-0"> <button onClick={() => setIsMenuOpen(true)} className="@w600:hidden hover:bg-secondary/50 rounded p-1 cursor-pointer transition-colors mr-2 shrink-0" > <i className="fa fa-bars" /> </button> <div className="text-lg font-semibold whitespace-nowrap shrink-0"> {selectedFile.name} </div> {selectedFile.docsUrl && ( <Tooltip content="View documentation"> <a href={`${selectedFile.docsUrl}?ref=waveconfig`} target="_blank" rel="noopener noreferrer" className="!text-muted-foreground hover:!text-primary transition-colors ml-1 shrink-0 cursor-pointer" > <i className="fa fa-book text-sm" /> </a> </Tooltip> )} <div className="text-xs text-muted-foreground font-mono pb-0.5 ml-1 truncate @max-w450:hidden"> {selectedFile.path} </div> </div> <div className="flex gap-2 items-baseline shrink-0"> {selectedFile.hasJsonView && ( <> {hasChanges && ( <span className="text-xs text-warning pb-0.5 @max-w450:hidden"> Unsaved changes </span> )} <Tooltip content={saveTooltip} placement="bottom" divClassName="shrink-0"> <button onClick={() => model.saveFile()} disabled={!hasChanges || isSaving} className={`px-3 py-1 rounded transition-colors text-sm ${ !hasChanges || isSaving ? "border border-border text-muted-foreground opacity-50" : "bg-accent/80 text-primary hover:bg-accent cursor-pointer" }`} > {isSaving ? "Saving..." : "Save"} </button> </Tooltip> </> )} </div> </div> {selectedFile.visualComponent && selectedFile.hasJsonView && ( <div className="flex gap-0 border-b border-border"> <button onClick={() => setActiveTab("visual")} className={cn( "px-4 pt-1 pb-1.5 cursor-pointer transition-colors text-secondary", activeTab === "visual" ? "bg-highlightbg text-primary" : "bg-transparent hover:bg-hover" )} > Visual </button> <button onClick={() => setActiveTab("json")} className={cn( "px-4 pt-1 pb-1.5 cursor-pointer transition-colors text-secondary", activeTab === "json" ? "bg-highlightbg text-primary" : "bg-transparent hover:bg-hover" )} > Raw JSON </button> </div> )} {errorMessage && ( <div className="bg-error text-primary px-4 py-2 border-b border-error flex items-center justify-between"> <span>{errorMessage}</span> <button onClick={() => model.clearError()} className="ml-2 hover:bg-black/20 rounded p-1 cursor-pointer transition-colors" > ✕ </button> </div> )} {validationError && ( <div className="bg-error text-primary px-4 py-2 border-b border-error flex items-center justify-between"> <span>{validationError}</span> <button onClick={() => model.clearValidationError()} className="ml-2 hover:bg-black/20 rounded p-1 cursor-pointer transition-colors" > ✕ </button> </div> )} <div className="flex-1 overflow-hidden"> {isLoading ? ( <div className="flex items-center justify-center h-full text-muted-foreground"> Loading... </div> ) : selectedFile.visualComponent && (!selectedFile.hasJsonView || activeTab === "visual") ? ( (() => { const VisualComponent = selectedFile.visualComponent; return <VisualComponent model={model} />; })() ) : ( <CodeEditor blockId={blockId} text={fileContent} fileName={`WAVECONFIGPATH/${selectedFile.path}`} language={selectedFile.language} readonly={false} onChange={handleContentChange} onMount={handleEditorMount} /> )} </div> </> )} </div> </div> {configErrors?.length > 0 && ( <div className="bg-error text-primary px-4 py-1 max-h-12 overflow-y-auto border-t border-error/50 shrink-0"> {configErrors.map((cerr, i) => ( <div key={i} className="text-sm"> <span className="font-semibold">Config Error: </span> {cerr.file}: {cerr.err} </div> ))} </div> )} </div> ); }); WaveConfigView.displayName = "WaveConfigView"; export { WaveConfigView }; ================================================ FILE: frontend/app/view/waveconfig/waveconfigenv.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { BlockMetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; export type WaveConfigEnv = WaveEnvSubset<{ electron: { getConfigDir: WaveEnv["electron"]["getConfigDir"]; getPlatform: WaveEnv["electron"]["getPlatform"]; }; rpc: { FileInfoCommand: WaveEnv["rpc"]["FileInfoCommand"]; FileReadCommand: WaveEnv["rpc"]["FileReadCommand"]; FileWriteCommand: WaveEnv["rpc"]["FileWriteCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; GetSecretsLinuxStorageBackendCommand: WaveEnv["rpc"]["GetSecretsLinuxStorageBackendCommand"]; GetSecretsNamesCommand: WaveEnv["rpc"]["GetSecretsNamesCommand"]; GetSecretsCommand: WaveEnv["rpc"]["GetSecretsCommand"]; SetSecretsCommand: WaveEnv["rpc"]["SetSecretsCommand"]; RecordTEventCommand: WaveEnv["rpc"]["RecordTEventCommand"]; }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; }; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"file">; isWindows: WaveEnv["isWindows"]; }>; ================================================ FILE: frontend/app/view/webview/webview.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .webview, .webview-container { height: 100%; width: 100%; border: none !important; outline: none !important; overflow: hidden; padding: 0; margin: 0; user-select: none; border-radius: 0 0 var(--block-border-radius) var(--block-border-radius); // try to force pixel alignment to prevent // subpixel rendering artifacts transform: translate3d(0, 0, 0); will-change: transform; } .webview-error { display: flex; position: absolute; background-color: black; top: 0; left: 0; height: 100%; width: 100%; z-index: 100; div { font-size: x-large; color: var(--error-color); display: flex; margin: auto; padding: 30px; } } .block-frame-div-url { background: rgba(255, 255, 255, 0.1); input { opacity: 1; } .wave-iconbutton { width: fit-content !important; margin-right: 5px; } } ================================================ FILE: frontend/app/view/webview/webview.test.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import { makeMockWaveEnv } from "@/preview/mock/mockwaveenv"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { atom } from "jotai"; import { getWebPreviewDisplayUrl, WebViewModel, WebViewPreviewFallback } from "./webview"; describe("webview preview fallback", () => { it("shows the requested URL", () => { const markup = renderToStaticMarkup(<WebViewPreviewFallback url="https://waveterm.dev/docs" />); expect(markup).toContain("electron webview unavailable"); expect(markup).toContain("https://waveterm.dev/docs"); }); it("falls back to about:blank when no URL is available", () => { expect(getWebPreviewDisplayUrl("")).toBe("about:blank"); expect(getWebPreviewDisplayUrl(null)).toBe("about:blank"); }); it("uses the supplied env for homepage atoms and config updates", async () => { const blockId = "webview-env-block"; const env = makeMockWaveEnv({ settings: { "web:defaulturl": "https://default.example", }, mockWaveObjs: { [`block:${blockId}`]: { otype: "block", oid: blockId, version: 1, meta: { pinnedurl: "https://block.example", }, } as Block, }, }); const model = new WebViewModel({ blockId, nodeModel: { isFocused: atom(true), focusNode: () => {}, } as any, tabModel: {} as any, waveEnv: env, }); expect(globalStore.get(model.homepageUrl)).toBe("https://block.example"); await model.setHomepageUrl("https://global.example", "global"); expect(globalStore.get(model.homepageUrl)).toBe("https://global.example"); expect(globalStore.get(env.getSettingsKeyAtom("web:defaulturl"))).toBe("https://global.example"); expect(globalStore.get(env.wos.getWaveObjectAtom<Block>(`block:${blockId}`))?.meta?.pinnedurl).toBeUndefined(); }); }); ================================================ FILE: frontend/app/view/webview/webview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; import { Search, useSearch } from "@/app/element/search"; import { globalStore } from "@/app/store/jotaiStore"; import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; import type { TabModel } from "@/app/store/tab-model"; import { makeORef } from "@/app/store/wos"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BlockHeaderSuggestionControl, SuggestionControlNoData, SuggestionControlNoResults, } from "@/app/suggestion/suggestion"; import { MockBoundary } from "@/app/waveenv/mockboundary"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { openLink } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; import { WebviewTag } from "electron"; import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react"; import "./webview.scss"; import type { WebViewEnv } from "./webviewenv"; // User agent strings for mobile emulation const USER_AGENT_IPHONE = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; const USER_AGENT_ANDROID = "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36"; let webviewPreloadUrl = null; function getWebviewPreloadUrl(env: WebViewEnv) { if (webviewPreloadUrl == null) { webviewPreloadUrl = env.electron.getWebviewPreload(); console.log("webviewPreloadUrl", webviewPreloadUrl); } if (webviewPreloadUrl == null) { return null; } return "file://" + webviewPreloadUrl; } export class WebViewModel implements ViewModel { viewType: string; blockId: string; tabModel: TabModel; noPadding?: Atom<boolean>; blockAtom: Atom<Block>; viewIcon: Atom<string | IconButtonDecl>; viewName: Atom<string>; viewText: Atom<HeaderElem[]>; hideViewName: Atom<boolean>; url: PrimitiveAtom<string>; homepageUrl: Atom<string>; urlInputFocused: PrimitiveAtom<boolean>; isLoading: PrimitiveAtom<boolean>; urlWrapperClassName: PrimitiveAtom<string>; refreshIcon: PrimitiveAtom<string>; webviewRef: React.RefObject<WebviewTag>; urlInputRef: React.RefObject<HTMLInputElement>; nodeModel: BlockNodeModel; endIconButtons?: Atom<IconButtonDecl[]>; mediaPlaying: PrimitiveAtom<boolean>; mediaMuted: PrimitiveAtom<boolean>; modifyExternalUrl?: (url: string) => string; domReady: PrimitiveAtom<boolean>; hideNav: Atom<boolean>; searchAtoms?: SearchAtoms; typeaheadOpen: PrimitiveAtom<boolean>; partitionOverride: PrimitiveAtom<string> | null; userAgentType: Atom<string>; env: WebViewEnv; constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) { this.nodeModel = nodeModel; this.tabModel = tabModel; this.viewType = "web"; this.blockId = blockId; this.env = waveEnv; this.noPadding = atom(true); this.blockAtom = this.env.wos.getWaveObjectAtom<Block>(`block:${blockId}`); this.url = atom(); const defaultUrlAtom = this.env.getSettingsKeyAtom("web:defaulturl"); this.homepageUrl = atom((get) => { const defaultUrl = get(defaultUrlAtom); const pinnedUrl = get(this.blockAtom)?.meta?.pinnedurl; return pinnedUrl ?? defaultUrl; }); this.urlWrapperClassName = atom(""); this.urlInputFocused = atom(false); this.isLoading = atom(false); this.refreshIcon = atom("rotate-right"); this.viewIcon = atom("globe"); this.viewName = atom("Web"); this.hideViewName = atom(true); this.urlInputRef = createRef<HTMLInputElement>(); this.webviewRef = createRef<WebviewTag>(); this.domReady = atom(false); this.hideNav = this.env.getBlockMetaKeyAtom(blockId, "web:hidenav"); this.typeaheadOpen = atom(false); this.partitionOverride = null; this.userAgentType = this.env.getBlockMetaKeyAtom(blockId, "web:useragenttype"); this.mediaPlaying = atom(false); this.mediaMuted = atom(false); this.viewText = atom((get) => { const homepageUrl = get(this.homepageUrl); const metaUrl = get(this.blockAtom)?.meta?.url; const currUrl = get(this.url); const urlWrapperClassName = get(this.urlWrapperClassName); const refreshIcon = get(this.refreshIcon); const mediaPlaying = get(this.mediaPlaying); const mediaMuted = get(this.mediaMuted); const url = currUrl ?? metaUrl ?? homepageUrl ?? ""; const rtn: HeaderElem[] = []; if (get(this.hideNav)) { return rtn; } rtn.push({ elemtype: "iconbutton", icon: "chevron-left", click: this.handleBack.bind(this), disabled: this.shouldDisableBackButton(), }); rtn.push({ elemtype: "iconbutton", icon: "chevron-right", click: this.handleForward.bind(this), disabled: this.shouldDisableForwardButton(), }); rtn.push({ elemtype: "iconbutton", icon: "house", click: this.handleHome.bind(this), disabled: this.shouldDisableHomeButton(), }); const divChildren: HeaderElem[] = []; divChildren.push({ elemtype: "input", value: url, ref: this.urlInputRef, className: "url-input", onChange: this.handleUrlChange.bind(this), onKeyDown: this.handleKeyDown.bind(this), onFocus: this.handleFocus.bind(this), onBlur: this.handleBlur.bind(this), }); if (mediaPlaying) { divChildren.push({ elemtype: "iconbutton", icon: mediaMuted ? "volume-slash" : "volume", click: this.handleMuteChange.bind(this), }); } divChildren.push({ elemtype: "iconbutton", icon: refreshIcon, click: this.handleRefresh.bind(this), }); rtn.push({ elemtype: "div", className: clsx("block-frame-div-url", urlWrapperClassName), onMouseOver: this.handleUrlWrapperMouseOver.bind(this), onMouseOut: this.handleUrlWrapperMouseOut.bind(this), children: divChildren, }); return rtn; }); this.endIconButtons = atom((get) => { if (get(this.hideNav)) { return null; } const url = get(this.url); const userAgentType = get(this.userAgentType); const buttons: IconButtonDecl[] = []; // Add mobile indicator icon if using mobile user agent if (userAgentType === "mobile:iphone" || userAgentType === "mobile:android") { const mobileIcon = userAgentType === "mobile:iphone" ? "mobile-screen" : "mobile-screen-button"; const mobileTitle = userAgentType === "mobile:iphone" ? "Mobile User Agent: iPhone" : "Mobile User Agent: Android"; buttons.push({ elemtype: "iconbutton", icon: mobileIcon, title: mobileTitle, noAction: true, }); } buttons.push({ elemtype: "iconbutton", icon: "arrow-up-right-from-square", title: "Open in External Browser", click: () => { console.log("open external", url); if (url != null && url != "") { const externalUrl = this.modifyExternalUrl?.(url) ?? url; return this.env.electron.openExternal(externalUrl); } }, }); return buttons; }); } get viewComponent(): ViewComponent { return WebView; } /** * Whether the back button in the header should be disabled. * @returns True if the WebView cannot go back or if the WebView call fails. False otherwise. */ shouldDisableBackButton() { try { return !this.webviewRef.current?.canGoBack(); } catch (_) {} return true; } /** * Whether the forward button in the header should be disabled. * @returns True if the WebView cannot go forward or if the WebView call fails. False otherwise. */ shouldDisableForwardButton() { try { return !this.webviewRef.current?.canGoForward(); } catch (_) {} return true; } /** * Whether the home button in the header should be disabled. * @returns True if the current url is the pinned url or the pinned url is not set. False otherwise. */ shouldDisableHomeButton() { try { const homepageUrl = globalStore.get(this.homepageUrl); return !homepageUrl || this.getUrl() === homepageUrl; } catch (_) {} return true; } handleHome(e?: React.MouseEvent<HTMLDivElement, MouseEvent>) { if (e) { e.preventDefault(); e.stopPropagation(); } this.loadUrl(globalStore.get(this.homepageUrl), "home"); } setMediaPlaying(isPlaying: boolean) { globalStore.set(this.mediaPlaying, isPlaying); } handleMuteChange(e: React.ChangeEvent<HTMLInputElement>) { if (e) { e.preventDefault(); e.stopPropagation(); } try { const newMutedVal = !this.webviewRef.current?.isAudioMuted(); globalStore.set(this.mediaMuted, newMutedVal); this.webviewRef.current?.setAudioMuted(newMutedVal); } catch (e) { console.error("Failed to change mute value", e); } } setTypeaheadOpen(open: boolean) { globalStore.set(this.typeaheadOpen, open); } async fetchBookmarkSuggestions( query: string, reqContext: SuggestionRequestContext ): Promise<FetchSuggestionsResponse> { const result = await this.env.rpc.FetchSuggestionsCommand(TabRpcClient, { suggestiontype: "bookmark", query, widgetid: reqContext.widgetid, reqnum: reqContext.reqnum, }); return result; } handleUrlWrapperMouseOver(e: React.MouseEvent<HTMLDivElement, MouseEvent>) { const urlInputFocused = globalStore.get(this.urlInputFocused); if (e.type === "mouseover" && !urlInputFocused) { globalStore.set(this.urlWrapperClassName, "hovered"); } } handleUrlWrapperMouseOut(e: React.MouseEvent<HTMLDivElement, MouseEvent>) { const urlInputFocused = globalStore.get(this.urlInputFocused); if (e.type === "mouseout" && !urlInputFocused) { globalStore.set(this.urlWrapperClassName, ""); } } handleBack(e?: React.MouseEvent<HTMLDivElement, MouseEvent>) { if (e) { e.preventDefault(); e.stopPropagation(); } this.webviewRef.current?.goBack(); } handleForward(e?: React.MouseEvent<HTMLDivElement, MouseEvent>) { if (e) { e.preventDefault(); e.stopPropagation(); } this.webviewRef.current?.goForward(); } handleRefresh(e: React.MouseEvent<HTMLDivElement, MouseEvent>) { e.preventDefault(); e.stopPropagation(); try { if (this.webviewRef.current) { if (globalStore.get(this.isLoading)) { this.webviewRef.current.stop(); } else { this.webviewRef.current.reload(); } } } catch (e) { console.warn("handleRefresh catch", e); } } handleUrlChange(event: React.ChangeEvent<HTMLInputElement>) { globalStore.set(this.url, event.target.value); } handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) { const waveEvent = adaptFromReactOrNativeKeyEvent(event); if (checkKeyPressed(waveEvent, "Enter")) { const url = globalStore.get(this.url); this.loadUrl(url, "enter"); this.urlInputRef.current?.blur(); return; } if (checkKeyPressed(waveEvent, "Escape")) { this.webviewRef.current?.focus(); } } handleFocus(event: React.FocusEvent<HTMLInputElement>) { globalStore.set(this.urlWrapperClassName, "focused"); globalStore.set(this.urlInputFocused, true); this.urlInputRef.current.focus(); event.target.select(); } handleBlur(event: React.FocusEvent<HTMLInputElement>) { globalStore.set(this.urlWrapperClassName, ""); globalStore.set(this.urlInputFocused, false); } /** * Update the URL in the state when a navigation event has occurred. * @param url The URL that has been navigated to. */ handleNavigate(url: string) { fireAndForget(() => this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { url }, }) ); globalStore.set(this.url, url); if (this.searchAtoms) { globalStore.set(this.searchAtoms.isOpen, false); } } ensureUrlScheme(url: string, searchTemplate: string) { if (url == null) { url = ""; } if (/^(http|https|file):/.test(url)) { // If the URL starts with http: or https:, return it as is return url; } // Check if the URL looks like a local URL const isLocal = /^(localhost|(\d{1,3}\.){3}\d{1,3})(:\d+)?$/.test(url.split("/")[0]); if (isLocal) { // If it is a local URL, ensure it has http:// scheme return `http://${url}`; } // Check if the URL looks like a domain const domainRegex = /^[a-z0-9.-]+\.[a-z]{2,}$/i; const isDomain = domainRegex.test(url.split("/")[0]); if (isDomain) { // If it looks like a domain, ensure it has https:// scheme return `https://${url}`; } // Otherwise, treat it as a search query if (searchTemplate == null) { return `https://www.google.com/search?q=${encodeURIComponent(url)}`; } return searchTemplate.replace("{query}", encodeURIComponent(url)); } /** * Load a new URL in the webview. * @param newUrl The new URL to load in the webview. */ loadUrl(newUrl: string, reason: string) { const defaultSearchAtom = this.env.getSettingsKeyAtom("web:defaultsearch"); const searchTemplate = globalStore.get(defaultSearchAtom); const nextUrl = this.ensureUrlScheme(newUrl, searchTemplate); console.log("webview loadUrl", reason, nextUrl, "cur=", this.webviewRef.current.getURL()); if (!this.webviewRef.current) { return; } if (this.webviewRef.current.getURL() != nextUrl) { fireAndForget(() => this.webviewRef.current.loadURL(nextUrl)); } if (newUrl != nextUrl) { globalStore.set(this.url, nextUrl); } } /** * Load a new URL in the webview and return a promise. * @param newUrl The new URL to load in the webview. * @param reason The reason for loading the URL. * @returns Promise that resolves when the URL is loaded. */ loadUrlPromise(newUrl: string, reason: string): Promise<void> { const defaultSearchAtom = this.env.getSettingsKeyAtom("web:defaultsearch"); const searchTemplate = globalStore.get(defaultSearchAtom); const nextUrl = this.ensureUrlScheme(newUrl, searchTemplate); console.log("webview loadUrlPromise", reason, nextUrl, "cur=", this.webviewRef.current?.getURL()); if (!this.webviewRef.current) { return Promise.reject(new Error("WebView ref not available")); } if (newUrl != nextUrl) { globalStore.set(this.url, nextUrl); } if (this.webviewRef.current.getURL() != nextUrl) { return this.webviewRef.current.loadURL(nextUrl); } return Promise.resolve(); } /** * Get the current URL from the state. * @returns The URL from the state. */ getUrl() { return globalStore.get(this.url); } setRefreshIcon(refreshIcon: string) { globalStore.set(this.refreshIcon, refreshIcon); } setIsLoading(isLoading: boolean) { globalStore.set(this.isLoading, isLoading); } async setHomepageUrl(url: string, scope: "global" | "block") { if (url != null && url != "") { switch (scope) { case "block": await this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { pinnedurl: url }, }); break; case "global": await this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { pinnedurl: null }, }); await this.env.rpc.SetConfigCommand(TabRpcClient, { "web:defaulturl": url }); break; } } } giveFocus(): boolean { console.log("webview giveFocus"); if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) { console.log("search is open, not giving focus"); return true; } const ctrlShiftState = globalStore.get(getSimpleControlShiftAtom()); if (ctrlShiftState) { // this is really weird, we don't get keyup events from webview const unsubFn = globalStore.sub(getSimpleControlShiftAtom(), () => { const state = globalStore.get(getSimpleControlShiftAtom()); if (!state) { unsubFn(); const isStillFocused = globalStore.get(this.nodeModel.isFocused); if (isStillFocused) { this.webviewRef.current?.focus(); } } }); return false; } this.webviewRef.current?.focus(); return true; } copyUrlToClipboard() { const url = this.getUrl(); if (url != null && url != "") { fireAndForget(() => navigator.clipboard.writeText(url)); } } clearHistory() { try { this.webviewRef.current?.clearHistory(); } catch (e) { console.error("Failed to clear history", e); } } async clearCookiesAndStorage() { try { const webContentsId = this.webviewRef.current?.getWebContentsId(); if (webContentsId) { await this.env.electron.clearWebviewStorage(webContentsId); } } catch (e) { console.error("Failed to clear cookies and storage", e); } } keyDownHandler(e: WaveKeyboardEvent): boolean { if (checkKeyPressed(e, "Cmd:l")) { this.urlInputRef?.current?.focus(); this.urlInputRef?.current?.select(); return true; } if (checkKeyPressed(e, "Cmd:r")) { this.webviewRef.current?.reload(); return true; } if (checkKeyPressed(e, "Cmd:ArrowLeft")) { this.handleBack(null); return true; } if (checkKeyPressed(e, "Cmd:ArrowRight")) { this.handleForward(null); return true; } if (checkKeyPressed(e, "Cmd:o")) { const curVal = globalStore.get(this.typeaheadOpen); globalStore.set(this.typeaheadOpen, !curVal); return true; } return false; } setZoomFactor(factor: number | null) { // null is ok (will reset to default) if (factor != null && factor < 0.1) { factor = 0.1; } if (factor != null && factor > 5) { factor = 5; } const domReady = globalStore.get(this.domReady); if (!domReady) { return; } this.webviewRef.current?.setZoomFactor(factor || 1); this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { "web:zoom": factor }, // allow null so we can remove the zoom factor here }); } getSettingsMenuItems(): ContextMenuItem[] { const zoomSubMenu: ContextMenuItem[] = []; let curZoom = 1; if (globalStore.get(this.domReady)) { curZoom = this.webviewRef.current?.getZoomFactor() || 1; } const makeZoomFactorMenuItem = (label: string, factor: number): ContextMenuItem => { return { label: label, type: "checkbox", click: () => { this.setZoomFactor(factor); }, checked: curZoom == factor, }; }; zoomSubMenu.push({ label: "Reset", click: () => { this.setZoomFactor(null); }, }); zoomSubMenu.push(makeZoomFactorMenuItem("25%", 0.25)); zoomSubMenu.push(makeZoomFactorMenuItem("50%", 0.5)); zoomSubMenu.push(makeZoomFactorMenuItem("70%", 0.7)); zoomSubMenu.push(makeZoomFactorMenuItem("80%", 0.8)); zoomSubMenu.push(makeZoomFactorMenuItem("90%", 0.9)); zoomSubMenu.push(makeZoomFactorMenuItem("100%", 1)); zoomSubMenu.push(makeZoomFactorMenuItem("110%", 1.1)); zoomSubMenu.push(makeZoomFactorMenuItem("120%", 1.2)); zoomSubMenu.push(makeZoomFactorMenuItem("130%", 1.3)); zoomSubMenu.push(makeZoomFactorMenuItem("150%", 1.5)); zoomSubMenu.push(makeZoomFactorMenuItem("175%", 1.75)); zoomSubMenu.push(makeZoomFactorMenuItem("200%", 2)); // User Agent Type submenu const curUserAgentType = globalStore.get(this.userAgentType) || "default"; const userAgentSubMenu: ContextMenuItem[] = [ { label: "Default", type: "checkbox", click: () => { fireAndForget(() => { return this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { "web:useragenttype": null }, }); }); }, checked: curUserAgentType === "default" || curUserAgentType === "", }, { label: "Mobile: iPhone", type: "checkbox", click: () => { fireAndForget(() => { return this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { "web:useragenttype": "mobile:iphone" }, }); }); }, checked: curUserAgentType === "mobile:iphone", }, { label: "Mobile: Android", type: "checkbox", click: () => { fireAndForget(() => { return this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { "web:useragenttype": "mobile:android" }, }); }); }, checked: curUserAgentType === "mobile:android", }, ]; const isNavHidden = globalStore.get(this.hideNav); return [ { label: "Copy URL to Clipboard", click: () => this.copyUrlToClipboard(), }, { label: "Set Block Homepage", click: () => fireAndForget(() => this.setHomepageUrl(this.getUrl(), "block")), }, { label: "Set Default Homepage", click: () => fireAndForget(() => this.setHomepageUrl(this.getUrl(), "global")), }, { type: "separator", }, { label: "User Agent Type", submenu: userAgentSubMenu, }, { type: "separator", }, { label: isNavHidden ? "Un-Hide Navigation" : "Hide Navigation", click: () => fireAndForget(() => { return this.env.rpc.SetMetaCommand(TabRpcClient, { oref: makeORef("block", this.blockId), meta: { "web:hidenav": !isNavHidden }, }); }), }, { label: "Set Zoom Factor", submenu: zoomSubMenu, }, { label: this.webviewRef.current?.isDevToolsOpened() ? "Close DevTools" : "Open DevTools", click: () => { if (this.webviewRef.current) { if (this.webviewRef.current.isDevToolsOpened()) { this.webviewRef.current.closeDevTools(); } else { this.webviewRef.current.openDevTools(); } } }, }, { type: "separator", }, { label: "Clear History", click: () => this.clearHistory(), }, { label: "Clear Cookies and Storage (All Web Widgets)", click: () => fireAndForget(() => this.clearCookiesAndStorage()), }, ]; } } const BookmarkTypeahead = memo( ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject<HTMLDivElement> }) => { const env = useWaveEnv<WebViewEnv>(); const openBookmarksJson = () => { fireAndForget(async () => { const path = `${env.electron.getConfigDir()}/presets/bookmarks.json`; const blockDef: BlockDef = { meta: { view: "preview", file: path, }, }; await env.createBlock(blockDef, false, true); model.setTypeaheadOpen(false); }); }; return ( <BlockHeaderSuggestionControl blockRef={blockRef} openAtom={model.typeaheadOpen} onClose={() => model.setTypeaheadOpen(false)} onSelect={(suggestion) => { if (suggestion == null || suggestion.type != "url") { return true; } model.loadUrl(suggestion["url:url"], "bookmark-typeahead"); return true; }} fetchSuggestions={model.fetchBookmarkSuggestions} placeholderText="Open Bookmark..." > <SuggestionControlNoData> <div className="text-center"> <p className="text-lg font-bold text-gray-100">No Bookmarks Configured</p> <p className="text-sm text-gray-400 mt-1"> Edit your <code className="font-mono">bookmarks.json</code> file to configure bookmarks. </p> <button onClick={openBookmarksJson} className="mt-3 px-4 py-2 text-sm font-medium text-black bg-accent hover:bg-accenthover rounded-lg cursor-pointer" > Open bookmarks.json </button> </div> </SuggestionControlNoData> <SuggestionControlNoResults> <div className="text-center"> <p className="text-sm text-gray-400">No matching bookmarks</p> <button onClick={openBookmarksJson} className="mt-3 px-4 py-2 text-sm font-medium text-black bg-accent hover:bg-accenthover rounded-lg cursor-pointer" > Edit bookmarks.json </button> </div> </SuggestionControlNoResults> </BlockHeaderSuggestionControl> ); } ); interface WebViewProps { blockId: string; model: WebViewModel; onFailLoad?: (url: string) => void; blockRef: React.RefObject<HTMLDivElement>; contentRef: React.RefObject<HTMLDivElement>; initialSrc?: string; } function getWebPreviewDisplayUrl(url?: string | null): string { return url?.trim() || "about:blank"; } function WebViewPreviewFallback({ url }: { url?: string | null }) { const displayUrl = getWebPreviewDisplayUrl(url); return ( <div className="flex h-full w-full items-center justify-center bg-panel"> <div className="mx-6 flex max-w-[720px] flex-col gap-3 rounded-lg border border-dashed border-border bg-background px-6 py-5 shadow-sm"> <div className="text-xs font-mono text-muted">preview mock · electron webview unavailable</div> <div className="text-sm text-foreground">web widget placeholder</div> <div className="rounded-md border border-border bg-panel px-3 py-2 font-mono text-xs text-foreground break-all"> {displayUrl} </div> </div> </div> ); } const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) => { const env = useWaveEnv<WebViewEnv>(); const blockData = useAtomValue(model.blockAtom); const defaultUrl = useAtomValue(model.homepageUrl); const defaultSearchAtom = env.getSettingsKeyAtom("web:defaultsearch"); const defaultSearch = useAtomValue(defaultSearchAtom); let metaUrl = blockData?.meta?.url || defaultUrl || ""; if (metaUrl) { metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch); } const metaUrlRef = useRef(metaUrl); const zoomFactor = useAtomValue(env.getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1; const partitionOverride = useAtomValueSafe(model.partitionOverride); const metaPartition = useAtomValue(env.getBlockMetaKeyAtom(model.blockId, "web:partition")); const webPartition = partitionOverride || metaPartition || undefined; const userAgentType = useAtomValue(model.userAgentType) || "default"; // Determine user agent string based on type let userAgent: string | undefined = undefined; if (userAgentType === "mobile:iphone") { userAgent = USER_AGENT_IPHONE; } else if (userAgentType === "mobile:android") { userAgent = USER_AGENT_ANDROID; } // Search const searchProps = useSearch({ anchorRef: model.webviewRef, viewModel: model }); const searchVal = useAtomValue<string>(searchProps.searchValue); const setSearchIndex = useSetAtom(searchProps.resultsIndex); const setNumSearchResults = useSetAtom(searchProps.resultsCount); searchProps.onSearch = useCallback((search: string) => { if (!globalStore.get(model.domReady)) { return; } try { if (search) { model.webviewRef.current?.findInPage(search, { findNext: true }); } else { model.webviewRef.current?.stopFindInPage("clearSelection"); } } catch (e) { console.error("Failed to search", e); } }, []); searchProps.onNext = useCallback(() => { if (!globalStore.get(model.domReady)) { return; } try { console.log("search next", searchVal); model.webviewRef.current?.findInPage(searchVal, { findNext: false, forward: true }); } catch (e) { console.error("Failed to search next", e); } }, [searchVal]); searchProps.onPrev = useCallback(() => { if (!globalStore.get(model.domReady)) { return; } try { console.log("search prev", searchVal); model.webviewRef.current?.findInPage(searchVal, { findNext: false, forward: false }); } catch (e) { console.error("Failed to search prev", e); } }, [searchVal]); const onFoundInPage = useCallback((event: any) => { const result = event.result; console.log("found in page", result); if (!result) { return; } setNumSearchResults(result.matches); setSearchIndex(result.activeMatchOrdinal - 1); }, []); // End Search // The initial value of the block metadata URL when the component first renders. Used to set the starting src value for the webview. const [metaUrlInitial] = useState(initialSrc || metaUrl); const prevUserAgentTypeRef = useRef(userAgentType); const [webContentsId, setWebContentsId] = useState(null); const domReady = useAtomValue(model.domReady); const [errorText, setErrorText] = useState(""); function setBgColor() { const webview = model.webviewRef.current; if (!webview) { return; } setTimeout(() => { webview .executeJavaScript( `!!document.querySelector('meta[name="color-scheme"]') && document.querySelector('meta[name="color-scheme"]').content?.includes('dark') || false` ) .then((hasDarkMode) => { if (hasDarkMode) { webview.style.backgroundColor = "black"; // Dark mode background } else { webview.style.backgroundColor = "white"; // Light mode background } }) .catch((e) => { webview.style.backgroundColor = "black"; // Dark mode background console.log("Error getting color scheme, defaulting to dark", e); }); }, 100); } useEffect(() => { return () => { globalStore.set(model.domReady, false); }; }, []); useEffect(() => { if (model.webviewRef.current == null || !domReady) { return; } try { const wcId = model.webviewRef.current.getWebContentsId?.(); if (wcId) { setWebContentsId(wcId); if (model.webviewRef.current.getZoomFactor() != zoomFactor) { model.webviewRef.current.setZoomFactor(zoomFactor); } } } catch (e) { console.error("Failed to get webcontentsid / setzoomlevel (webview)", e); } }, [model.webviewRef.current, domReady, zoomFactor]); // Load a new URL if the block metadata is updated. useEffect(() => { if (initialSrc) { // Skip URL loading if initialSrc is provided (it's already loaded via src attribute) return; } if (metaUrlRef.current != metaUrl) { metaUrlRef.current = metaUrl; model.loadUrl(metaUrl, "meta"); } }, [metaUrl, initialSrc]); // Reload webview when user agent type changes useEffect(() => { if (prevUserAgentTypeRef.current !== userAgentType && domReady && model.webviewRef.current) { let newUserAgent: string | undefined = undefined; if (userAgentType === "mobile:iphone") { newUserAgent = USER_AGENT_IPHONE; } else if (userAgentType === "mobile:android") { newUserAgent = USER_AGENT_ANDROID; } if (newUserAgent) { model.webviewRef.current.setUserAgent(newUserAgent); } else { model.webviewRef.current.setUserAgent(""); } model.webviewRef.current.reload(); } prevUserAgentTypeRef.current = userAgentType; }, [userAgentType, domReady]); useEffect(() => { const webview = model.webviewRef.current; if (!webview) { return; } const navigateListener = (e: any) => { setErrorText(""); if (e.isMainFrame) { model.handleNavigate(e.url); } }; const newWindowHandler = (e: any) => { e.preventDefault(); const newUrl = e.detail.url; fireAndForget(() => openLink(newUrl, true)); }; const startLoadingHandler = () => { model.setRefreshIcon("xmark-large"); model.setIsLoading(true); webview.style.backgroundColor = "transparent"; }; const stopLoadingHandler = () => { model.setRefreshIcon("rotate-right"); model.setIsLoading(false); setBgColor(); }; const failLoadHandler = (e: any) => { if (e.errorCode === -3) { console.warn("Suppressed ERR_ABORTED error", e); } else { const errorMessage = `Failed to load ${e.validatedURL}: ${e.errorDescription}`; console.error(errorMessage); setErrorText(errorMessage); if (onFailLoad) { const curUrl = model.webviewRef.current.getURL(); onFailLoad(curUrl); } } }; const webviewFocus = () => { env.electron.setWebviewFocus(webview.getWebContentsId()); model.nodeModel.focusNode(); }; const webviewBlur = () => { env.electron.setWebviewFocus(null); }; const handleDomReady = () => { globalStore.set(model.domReady, true); setBgColor(); }; const handleMediaPlaying = () => { model.setMediaPlaying(true); }; const handleMediaPaused = () => { model.setMediaPlaying(false); }; webview.addEventListener("did-frame-navigate", navigateListener); webview.addEventListener("did-navigate-in-page", navigateListener); webview.addEventListener("did-navigate", navigateListener); webview.addEventListener("did-start-loading", startLoadingHandler); webview.addEventListener("did-stop-loading", stopLoadingHandler); webview.addEventListener("new-window", newWindowHandler); webview.addEventListener("did-fail-load", failLoadHandler); webview.addEventListener("focus", webviewFocus); webview.addEventListener("blur", webviewBlur); webview.addEventListener("dom-ready", handleDomReady); webview.addEventListener("media-started-playing", handleMediaPlaying); webview.addEventListener("media-paused", handleMediaPaused); webview.addEventListener("found-in-page", onFoundInPage); // Clean up event listeners on component unmount return () => { webview.removeEventListener("did-frame-navigate", navigateListener); webview.removeEventListener("did-navigate", navigateListener); webview.removeEventListener("did-navigate-in-page", navigateListener); webview.removeEventListener("new-window", newWindowHandler); webview.removeEventListener("did-fail-load", failLoadHandler); webview.removeEventListener("did-start-loading", startLoadingHandler); webview.removeEventListener("did-stop-loading", stopLoadingHandler); webview.removeEventListener("focus", webviewFocus); webview.removeEventListener("blur", webviewBlur); webview.removeEventListener("dom-ready", handleDomReady); webview.removeEventListener("media-started-playing", handleMediaPlaying); webview.removeEventListener("media-paused", handleMediaPaused); webview.removeEventListener("found-in-page", onFoundInPage); }; }, []); return ( <Fragment> <MockBoundary fallback={<WebViewPreviewFallback url={metaUrl} />}> <webview id="webview" className="webview" ref={model.webviewRef} src={metaUrlInitial} data-blockid={model.blockId} data-webcontentsid={webContentsId} // needed for emain preload={getWebviewPreloadUrl(env)} // @ts-expect-error This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean. allowpopups="true" partition={webPartition} useragent={userAgent} /> </MockBoundary> {errorText && ( <div className="webview-error"> <div>{errorText}</div> </div> )} <Search {...searchProps} /> <BookmarkTypeahead model={model} blockRef={blockRef} /> </Fragment> ); }); export { WebView, WebViewPreviewFallback, getWebPreviewDisplayUrl }; ================================================ FILE: frontend/app/view/webview/webviewenv.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { BlockMetaKeyAtomFnType, SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; export type WebViewEnv = WaveEnvSubset<{ electron: { openExternal: WaveEnv["electron"]["openExternal"]; getWebviewPreload: WaveEnv["electron"]["getWebviewPreload"]; clearWebviewStorage: WaveEnv["electron"]["clearWebviewStorage"]; getConfigDir: WaveEnv["electron"]["getConfigDir"]; setWebviewFocus: WaveEnv["electron"]["setWebviewFocus"]; }; rpc: { FetchSuggestionsCommand: WaveEnv["rpc"]["FetchSuggestionsCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; }; wos: WaveEnv["wos"]; createBlock: WaveEnv["createBlock"]; getSettingsKeyAtom: SettingsKeyAtomFnType<"web:defaulturl" | "web:defaultsearch">; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType< "web:hidenav" | "web:useragenttype" | "web:zoom" | "web:partition" >; }>; ================================================ FILE: frontend/app/waveenv/mockboundary.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { isPreviewWindow } from "@/app/store/windowtype"; import React from "react"; type MockBoundaryProps = { fallback: React.ReactNode; children: React.ReactNode; }; export function MockBoundary({ fallback, children }: MockBoundaryProps) { if (isPreviewWindow()) { return <>{fallback}</>; } return <>{children}</>; } ================================================ FILE: frontend/app/waveenv/waveenv.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { AllServiceImpls } from "@/app/store/services"; import { RpcApiType } from "@/app/store/wshclientapi"; import { Atom, PrimitiveAtom } from "jotai"; import React from "react"; export type BlockMetaKeyAtomFnType<Keys extends keyof MetaType = keyof MetaType> = <T extends Keys>( blockId: string, key: T ) => Atom<MetaType[T]>; export type ConnConfigKeyAtomFnType<Keys extends keyof ConnKeywords = keyof ConnKeywords> = <T extends Keys>( connName: string, key: T ) => Atom<ConnKeywords[T]>; export type SettingsKeyAtomFnType<Keys extends keyof SettingsType = keyof SettingsType> = <T extends Keys>( key: T ) => Atom<SettingsType[T]>; type OmitNever<T> = { [K in keyof T as [T[K]] extends [never] ? never : K]: T[K]; }; type Subset<T, U> = OmitNever<{ [K in keyof T]: K extends keyof U ? T[K] : never; }>; type ComplexWaveEnvKeys = { rpc: WaveEnv["rpc"]; electron: WaveEnv["electron"]; atoms: WaveEnv["atoms"]; wos: WaveEnv["wos"]; services: WaveEnv["services"]; }; type WaveEnvMockFields = { isMock: WaveEnv["isMock"]; mockSetWaveObj: WaveEnv["mockSetWaveObj"]; mockModels: WaveEnv["mockModels"]; }; export type WaveEnvSubset<T> = WaveEnvMockFields & OmitNever<{ [K in keyof T]: K extends keyof ComplexWaveEnvKeys ? Subset<T[K], ComplexWaveEnvKeys[K]> : K extends keyof WaveEnv ? T[K] : never; }>; // default implementation for production is in ./waveenvimpl.ts export type WaveEnv = { isMock: boolean; electron: ElectronApi; rpc: RpcApiType; platform: NodeJS.Platform; isDev: () => boolean; isWindows: () => boolean; isMacOS: () => boolean; atoms: GlobalAtomsType; createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise<string>; services: typeof AllServiceImpls; callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => Promise<any>; showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void; getConnStatusAtom: (conn: string) => PrimitiveAtom<ConnStatus>; getLocalHostDisplayNameAtom: () => Atom<string>; wos: { getWaveObjectAtom: <T extends WaveObj>(oref: string) => Atom<T>; getWaveObjectLoadingAtom: (oref: string) => Atom<boolean>; isWaveObjectNullAtom: (oref: string) => Atom<boolean>; useWaveObjectValue: <T extends WaveObj>(oref: string) => [T, boolean]; }; getSettingsKeyAtom: SettingsKeyAtomFnType; getBlockMetaKeyAtom: BlockMetaKeyAtomFnType; getConnConfigKeyAtom: ConnConfigKeyAtomFnType; // the mock fields are only usable in the preview server (may be be null or throw errors in production) mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void; mockModels: Map<any, any>; }; export const WaveEnvContext = React.createContext<WaveEnv>(null); type EnvContract<T> = { [K in keyof T]?: T[K] extends (...args: any[]) => any ? T[K] : T[K] extends object ? EnvContract<T[K]> : T[K]; }; export function useWaveEnv<T extends EnvContract<WaveEnv> = WaveEnv>(): T { return React.useContext(WaveEnvContext) as T; } ================================================ FILE: frontend/app/waveenv/waveenvimpl.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ContextMenuModel } from "@/app/store/contextmenu"; import { AllServiceImpls } from "@/app/store/services"; import { atoms, createBlock, getBlockMetaKeyAtom, getConnConfigKeyAtom, getConnStatusAtom, getLocalHostDisplayNameAtom, getSettingsKeyAtom, isDev, WOS, } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { isMacOS, isWindows, PLATFORM } from "@/util/platformutil"; export function makeWaveEnvImpl(): WaveEnv { return { isMock: false, electron: (window as any).api, rpc: RpcApi, getSettingsKeyAtom, platform: PLATFORM, isDev, isWindows, isMacOS, atoms, createBlock, services: AllServiceImpls, callBackendService: WOS.callBackendService, showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => { ContextMenuModel.getInstance().showContextMenu(menu, e); }, getConnStatusAtom, getLocalHostDisplayNameAtom, wos: { getWaveObjectAtom: WOS.getWaveObjectAtom, getWaveObjectLoadingAtom: WOS.getWaveObjectLoadingAtom, isWaveObjectNullAtom: WOS.isWaveObjectNullAtom, useWaveObjectValue: WOS.useWaveObjectValue, }, getBlockMetaKeyAtom, getConnConfigKeyAtom, mockSetWaveObj: <T extends WaveObj>(_oref: string, _obj: T) => { throw new Error("mockSetWaveObj is only available in the preview server"); }, mockModels: new Map<any, any>(), }; } ================================================ FILE: frontend/app/workspace/widgetfilter.test.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { assert, test } from "vitest"; import { shouldIncludeWidgetForWorkspace } from "./widgetfilter"; test("shouldIncludeWidgetForWorkspace includes widgets with missing or empty workspaces", () => { assert(shouldIncludeWidgetForWorkspace({ blockdef: { meta: {} } }, "ws-1")); assert(shouldIncludeWidgetForWorkspace({ blockdef: { meta: {} }, workspaces: [] }, "ws-1")); }); test("shouldIncludeWidgetForWorkspace only includes configured workspace IDs", () => { assert(shouldIncludeWidgetForWorkspace({ blockdef: { meta: {} }, workspaces: ["ws-1", "ws-2"] }, "ws-1")); assert(!shouldIncludeWidgetForWorkspace({ blockdef: { meta: {} }, workspaces: ["ws-1", "ws-2"] }, "ws-3")); assert(!shouldIncludeWidgetForWorkspace({ blockdef: { meta: {} }, workspaces: ["ws-1"] }, null)); }); ================================================ FILE: frontend/app/workspace/widgetfilter.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 function shouldIncludeWidgetForWorkspace(widget: WidgetConfigType, workspaceId?: string): boolean { const workspaces = widget.workspaces; return !Array.isArray(workspaces) || workspaces.length === 0 || (workspaceId != null && workspaces.includes(workspaceId)); } export { shouldIncludeWidgetForWorkspace }; ================================================ FILE: frontend/app/workspace/widgets.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { shouldIncludeWidgetForWorkspace } from "@/app/workspace/widgetfilter"; import { modalsModel } from "@/store/modalmodel"; import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; import { autoUpdate, FloatingPortal, offset, shift, useDismiss, useFloating, useInteractions, } from "@floating-ui/react"; import clsx from "clsx"; import { useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useRef, useState } from "react"; export type WidgetsEnv = WaveEnvSubset<{ isDev: WaveEnv["isDev"]; electron: { openBuilder: WaveEnv["electron"]["openBuilder"]; }; rpc: { ListAllAppsCommand: WaveEnv["rpc"]["ListAllAppsCommand"]; }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; hasConfigErrors: WaveEnv["atoms"]["hasConfigErrors"]; workspaceId: WaveEnv["atoms"]["workspaceId"]; hasCustomAIPresetsAtom: WaveEnv["atoms"]["hasCustomAIPresetsAtom"]; }; createBlock: WaveEnv["createBlock"]; showContextMenu: WaveEnv["showContextMenu"]; }>; function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType }): WidgetConfigType[] { if (wmap == null) { return []; } const wlist = Object.values(wmap); wlist.sort((a, b) => { return (a["display:order"] ?? 0) - (b["display:order"] ?? 0); }); return wlist; } type WidgetPropsType = { widget: WidgetConfigType; mode: "normal" | "compact" | "supercompact"; env: WidgetsEnv; }; async function handleWidgetSelect(widget: WidgetConfigType, env: WidgetsEnv) { const blockDef = widget.blockdef; env.createBlock(blockDef, widget.magnified); } const Widget = memo(({ widget, mode, env }: WidgetPropsType) => { const [isTruncated, setIsTruncated] = useState(false); const labelRef = useRef<HTMLDivElement>(null); useEffect(() => { if (mode === "normal" && labelRef.current) { const element = labelRef.current; setIsTruncated(element.scrollWidth > element.clientWidth); } }, [mode, widget.label]); const shouldDisableTooltip = mode !== "normal" ? false : !isTruncated; return ( <Tooltip content={widget.description || widget.label} placement="left" disable={shouldDisableTooltip} divClassName={clsx( "flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer", mode === "supercompact" ? "text-sm" : "text-lg", widget["display:hidden"] && "hidden" )} divOnClick={() => handleWidgetSelect(widget, env)} > <div style={{ color: widget.color }}> <i className={makeIconClass(widget.icon, true, { defaultIcon: "browser" })}></i> </div> {mode === "normal" && !isBlank(widget.label) ? ( <div ref={labelRef} className="text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden text-ellipsis" > {widget.label} </div> ) : null} </Tooltip> ); }); function calculateGridSize(appCount: number): number { if (appCount <= 4) return 2; if (appCount <= 9) return 3; if (appCount <= 16) return 4; if (appCount <= 25) return 5; return 6; } function SettingsTooltipContent({ hasConfigErrors }: { hasConfigErrors: boolean }) { if (!hasConfigErrors) { return "Settings & Help"; } return ( <div className="flex flex-col p-1"> <div className="mb-1">Settings & Help</div> <div className="flex items-center gap-1 mt-0.5 text-error"> <i className="fa fa-solid fa-circle-exclamation"></i> <span>Config Errors</span> </div> </div> ); } type FloatingWindowPropsType = { isOpen: boolean; onClose: () => void; referenceElement: HTMLElement; hasConfigErrors?: boolean; }; const AppsFloatingWindow = memo(({ isOpen, onClose, referenceElement }: FloatingWindowPropsType) => { const [apps, setApps] = useState<AppInfo[]>([]); const [loading, setLoading] = useState(true); const env = useWaveEnv<WidgetsEnv>(); const { refs, floatingStyles, context } = useFloating({ open: isOpen, onOpenChange: onClose, placement: "left-start", middleware: [offset(-2), shift({ padding: 12 })], whileElementsMounted: autoUpdate, elements: { reference: referenceElement, }, }); const dismiss = useDismiss(context); const { getFloatingProps } = useInteractions([dismiss]); const handleOpenBuilder = useCallback(() => { env.electron.openBuilder(null); onClose(); }, [onClose, env]); useEffect(() => { if (!isOpen) return; const fetchApps = async () => { setLoading(true); try { const allApps = await env.rpc.ListAllAppsCommand(TabRpcClient); const localApps = allApps .filter((app) => !app.appid.startsWith("draft/")) .sort((a, b) => { const aName = a.appid.replace(/^local\//, ""); const bName = b.appid.replace(/^local\//, ""); return aName.localeCompare(bName); }); setApps(localApps); } catch (error) { console.error("Failed to fetch apps:", error); setApps([]); } finally { setLoading(false); } }; fetchApps(); }, [isOpen]); if (!isOpen) return null; const gridSize = calculateGridSize(apps.length); return ( <FloatingPortal> <div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} className="bg-modalbg border border-border rounded-lg shadow-xl z-50 overflow-hidden" > <div className="p-4"> {loading ? ( <div className="flex items-center justify-center p-8"> <i className="fa fa-solid fa-spinner fa-spin text-2xl text-muted"></i> </div> ) : apps.length === 0 ? ( <div className="text-muted text-sm p-4 text-center">No local apps found</div> ) : ( <div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${gridSize}, minmax(0, 1fr))`, maxWidth: `${gridSize * 80}px`, }} > {apps.map((app) => { const appMeta = app.manifest?.appmeta; const displayName = app.appid.replace(/^local\//, ""); const icon = appMeta?.icon || "cube"; const iconColor = appMeta?.iconcolor || "white"; return ( <div key={app.appid} className="flex flex-col items-center justify-center p-2 rounded hover:bg-hoverbg cursor-pointer transition-colors" onClick={() => { const blockDef: BlockDef = { meta: { view: "tsunami", controller: "tsunami", "tsunami:appid": app.appid, }, }; env.createBlock(blockDef); onClose(); }} > <div style={{ color: iconColor }} className="text-3xl mb-1"> <i className={makeIconClass(icon, false)}></i> </div> <div className="text-xxs text-center text-secondary break-words w-full px-1"> {displayName} </div> </div> ); })} </div> )} </div> <button type="button" className="w-full px-4 py-2 border-t border-border text-xs text-secondary text-center hover:bg-hoverbg hover:text-white transition-colors cursor-pointer flex items-center justify-center gap-2" onClick={handleOpenBuilder} > <i className="fa fa-solid fa-hammer"></i> Build/Edit Apps </button> </div> </FloatingPortal> ); }); const SettingsFloatingWindow = memo( ({ isOpen, onClose, referenceElement, hasConfigErrors }: FloatingWindowPropsType) => { const env = useWaveEnv<WidgetsEnv>(); const { refs, floatingStyles, context } = useFloating({ open: isOpen, onOpenChange: onClose, placement: "left-start", middleware: [offset(-2), shift({ padding: 12 })], whileElementsMounted: autoUpdate, elements: { reference: referenceElement, }, }); const dismiss = useDismiss(context); const { getFloatingProps } = useInteractions([dismiss]); if (!isOpen) return null; const menuItems = [ { icon: "gear", label: "Settings", hasError: hasConfigErrors, onClick: () => { const blockDef: BlockDef = { meta: { view: "waveconfig", }, }; env.createBlock(blockDef, false, true); onClose(); }, }, { icon: "lightbulb", label: "Tips", onClick: () => { const blockDef: BlockDef = { meta: { view: "tips", }, }; env.createBlock(blockDef, true, true); onClose(); }, }, { icon: "lock", label: "Secrets", onClick: () => { const blockDef: BlockDef = { meta: { view: "waveconfig", file: "secrets", }, }; env.createBlock(blockDef, false, true); onClose(); }, }, { icon: "book-open", label: "Release Notes", onClick: () => { modalsModel.pushModal("UpgradeOnboardingPatch", { isReleaseNotes: true }); onClose(); }, }, { icon: "circle-question", label: "Help", onClick: () => { const blockDef: BlockDef = { meta: { view: "help", }, }; env.createBlock(blockDef); onClose(); }, }, ]; return ( <FloatingPortal> <div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} className="bg-modalbg border border-border rounded-lg shadow-xl p-2 z-50" > {menuItems.map((item, idx) => ( <div key={idx} className="flex items-center gap-3 px-3 py-2 rounded hover:bg-hoverbg cursor-pointer transition-colors text-secondary hover:text-white" onClick={item.onClick} > <div className="text-lg w-5 flex justify-center"> <i className={makeIconClass(item.icon, false)}></i> </div> <div className="text-sm whitespace-nowrap">{item.label}</div> {item.hasError && ( <i className="fa fa-solid fa-circle-exclamation text-error text-[14px] ml-auto"></i> )} </div> ))} </div> </FloatingPortal> ); } ); SettingsFloatingWindow.displayName = "SettingsFloatingWindow"; const Widgets = memo(() => { const env = useWaveEnv<WidgetsEnv>(); const fullConfig = useAtomValue(env.atoms.fullConfigAtom); const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors); const workspaceId = useAtomValue(env.atoms.workspaceId); const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom); const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal"); const containerRef = useRef<HTMLDivElement>(null); const measurementRef = useRef<HTMLDivElement>(null); const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"] ?? false; const widgetsMap = fullConfig?.widgets ?? {}; const filteredWidgets = Object.fromEntries( Object.entries(widgetsMap).filter(([key, widget]) => { if (!hasCustomAIPresets && key === "defwidget@ai") { return false; } return shouldIncludeWidgetForWorkspace(widget, workspaceId); }) ); const widgets = sortByDisplayOrder(filteredWidgets); const [isAppsOpen, setIsAppsOpen] = useState(false); const appsButtonRef = useRef<HTMLDivElement>(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsButtonRef = useRef<HTMLDivElement>(null); const checkModeNeeded = useCallback(() => { if (!containerRef.current || !measurementRef.current) return; const containerHeight = containerRef.current.clientHeight; const normalHeight = measurementRef.current.scrollHeight; const gracePeriod = 10; let newMode: "normal" | "compact" | "supercompact" = "normal"; if (normalHeight > containerHeight - gracePeriod) { newMode = "compact"; // Calculate total widget count for supercompact check const totalWidgets = (widgets?.length || 0) + 1; const minHeightPerWidget = 32; const requiredHeight = totalWidgets * minHeightPerWidget; if (requiredHeight > containerHeight) { newMode = "supercompact"; } } if (newMode !== mode) { setMode(newMode); } }, [mode, widgets]); useEffect(() => { const resizeObserver = new ResizeObserver(() => { checkModeNeeded(); }); if (containerRef.current) { resizeObserver.observe(containerRef.current); } return () => { resizeObserver.disconnect(); }; }, [checkModeNeeded]); useEffect(() => { checkModeNeeded(); }, [widgets, checkModeNeeded]); const handleWidgetsBarContextMenu = (e: React.MouseEvent) => { e.preventDefault(); const menu: ContextMenuItem[] = [ { label: "Edit widgets.json", click: () => { fireAndForget(async () => { const blockDef: BlockDef = { meta: { view: "waveconfig", file: "widgets.json", }, }; await env.createBlock(blockDef, false, true); }); }, }, ]; env.showContextMenu(menu, e); }; return ( <> <div ref={containerRef} className="flex flex-col w-12 overflow-hidden py-1 -ml-1 select-none shrink-0" onContextMenu={handleWidgetsBarContextMenu} > {mode === "supercompact" ? ( <> <div className="grid grid-cols-2 gap-0 w-full"> {widgets?.map((data, idx) => ( <Widget key={`widget-${idx}`} widget={data} mode={mode} env={env} /> ))} </div> <div className="flex-grow" /> <div className="grid grid-cols-2 gap-0 w-full"> {env.isDev() || featureWaveAppBuilder ? ( <div ref={appsButtonRef} className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-sm overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer" onClick={() => setIsAppsOpen(!isAppsOpen)} > <Tooltip content="Local WaveApps" placement="left" disable={isAppsOpen}> <div> <i className={makeIconClass("cube", true)}></i> </div> </Tooltip> </div> ) : null} <div ref={settingsButtonRef} className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-sm overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer" onClick={() => setIsSettingsOpen(!isSettingsOpen)} > <Tooltip content={<SettingsTooltipContent hasConfigErrors={hasConfigErrors} />} placement="left" disable={isSettingsOpen} > <div className="relative"> <i className={makeIconClass("gear", true)}></i> {hasConfigErrors && ( <i className="fa fa-solid fa-circle-exclamation text-error absolute top-0 right-0 text-[10px] pointer-events-none"></i> )} </div> </Tooltip> </div> </div> </> ) : ( <> {widgets?.map((data, idx) => ( <Widget key={`widget-${idx}`} widget={data} mode={mode} env={env} /> ))} <div className="flex-grow" /> {env.isDev() || featureWaveAppBuilder ? ( <div ref={appsButtonRef} className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-lg overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer" onClick={() => setIsAppsOpen(!isAppsOpen)} > <Tooltip content="Local WaveApps" placement="left" disable={isAppsOpen}> <div className="flex flex-col items-center w-full"> <div> <i className={makeIconClass("cube", true)}></i> </div> {mode === "normal" && ( <div className="text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden text-ellipsis"> apps </div> )} </div> </Tooltip> </div> ) : null} <div ref={settingsButtonRef} className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-lg overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer" onClick={() => setIsSettingsOpen(!isSettingsOpen)} > <Tooltip content={<SettingsTooltipContent hasConfigErrors={hasConfigErrors} />} placement="left" disable={isSettingsOpen} > <div className="flex flex-col items-center w-full"> <div className="relative"> <i className={makeIconClass("gear", true)}></i> {hasConfigErrors && ( <i className={`fa fa-solid fa-circle-exclamation text-error absolute top-0 right-[-4px] pointer-events-none ${mode === "normal" ? "text-[14px]" : "text-[12px]"}`} ></i> )} </div> {mode === "normal" && ( <div className="text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden text-ellipsis"> settings </div> )} </div> </Tooltip> </div> </> )} {env.isDev() ? ( <div className="flex justify-center items-center w-full py-1 text-accent text-[30px]" title="Running Wave Dev Build" > <i className="fa fa-brands fa-dev fa-fw" /> </div> ) : null} </div> {(env.isDev() || featureWaveAppBuilder) && appsButtonRef.current && ( <AppsFloatingWindow isOpen={isAppsOpen} onClose={() => setIsAppsOpen(false)} referenceElement={appsButtonRef.current} /> )} {settingsButtonRef.current && ( <SettingsFloatingWindow isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} referenceElement={settingsButtonRef.current} hasConfigErrors={hasConfigErrors} /> )} <div ref={measurementRef} className="flex flex-col w-12 py-1 -ml-1 select-none absolute -z-10 opacity-0 pointer-events-none" > {widgets?.map((data, idx) => ( <Widget key={`measurement-widget-${idx}`} widget={data} mode="normal" env={env} /> ))} <div className="flex-grow" /> <div className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-lg"> <div> <i className={makeIconClass("gear", true)}></i> </div> <div className="text-xxs mt-0.5 w-full px-0.5 text-center">settings</div> </div> {env.isDev() ? ( <div className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-lg"> <div> <i className={makeIconClass("cube", true)}></i> </div> <div className="text-xxs mt-0.5 w-full px-0.5 text-center">apps</div> </div> ) : null} {env.isDev() ? ( <div className="flex justify-center items-center w-full py-1 text-accent text-[30px]" title="Running Wave Dev Build" > <i className="fa fa-brands fa-dev fa-fw" /> </div> ) : null} </div> </> ); }); export { Widgets }; ================================================ FILE: frontend/app/workspace/workspace-layout-model.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { globalStore } from "@/app/store/jotaiStore"; import { isBuilderWindow } from "@/app/store/windowtype"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; import { atoms, getApi, getOrefMetaKeyAtom, recordTEvent, refocusNode } from "@/store/global"; import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "lodash-es"; import { ImperativePanelGroupHandle, ImperativePanelHandle } from "react-resizable-panels"; const dlog = debug("wave:workspace"); const AIPanel_DefaultWidth = 300; const AIPanel_DefaultWidthRatio = 0.33; const AIPanel_MinWidth = 300; const AIPanel_MaxWidthRatio = 0.66; const VTabBar_DefaultWidth = 220; const VTabBar_MinWidth = 110; const VTabBar_MaxWidth = 280; function clampVTabWidth(w: number): number { return Math.max(VTabBar_MinWidth, Math.min(w, VTabBar_MaxWidth)); } function clampAIPanelWidth(w: number, windowWidth: number): number { const maxWidth = Math.floor(windowWidth * AIPanel_MaxWidthRatio); if (AIPanel_MinWidth > maxWidth) return AIPanel_MinWidth; return Math.max(AIPanel_MinWidth, Math.min(w, maxWidth)); } class WorkspaceLayoutModel { private static instance: WorkspaceLayoutModel | null = null; aiPanelRef: ImperativePanelHandle | null; vtabPanelRef: ImperativePanelHandle | null; outerPanelGroupRef: ImperativePanelGroupHandle | null; innerPanelGroupRef: ImperativePanelGroupHandle | null; panelContainerRef: HTMLDivElement | null; aiPanelWrapperRef: HTMLDivElement | null; panelVisibleAtom: jotai.PrimitiveAtom<boolean>; vtabVisibleAtom: jotai.PrimitiveAtom<boolean>; private inResize: boolean; private aiPanelVisible: boolean; private aiPanelWidth: number | null; private vtabWidth: number; private vtabVisible: boolean; private initialized: boolean = false; private transitionTimeoutRef: NodeJS.Timeout | null = null; private focusTimeoutRef: NodeJS.Timeout | null = null; private debouncedPersistAIWidth: (width: number) => void; private debouncedPersistVTabWidth: (width: number) => void; private constructor() { this.aiPanelRef = null; this.vtabPanelRef = null; this.outerPanelGroupRef = null; this.innerPanelGroupRef = null; this.panelContainerRef = null; this.aiPanelWrapperRef = null; this.inResize = false; this.aiPanelVisible = false; this.aiPanelWidth = null; this.vtabWidth = VTabBar_DefaultWidth; this.vtabVisible = false; this.panelVisibleAtom = jotai.atom(false); this.vtabVisibleAtom = jotai.atom(false); this.handleWindowResize = this.handleWindowResize.bind(this); this.handleOuterPanelLayout = this.handleOuterPanelLayout.bind(this); this.handleInnerPanelLayout = this.handleInnerPanelLayout.bind(this); this.debouncedPersistAIWidth = debounce((width: number) => { try { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("tab", this.getTabId()), meta: { "waveai:panelwidth": width }, }); } catch (e) { console.warn("Failed to persist AI panel width:", e); } }, 300); this.debouncedPersistVTabWidth = debounce((width: number) => { try { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("workspace", this.getWorkspaceId()), meta: { "layout:vtabbarwidth": width }, }); } catch (e) { console.warn("Failed to persist vtabbar width:", e); } }, 300); } static getInstance(): WorkspaceLayoutModel { if (!WorkspaceLayoutModel.instance) { WorkspaceLayoutModel.instance = new WorkspaceLayoutModel(); } return WorkspaceLayoutModel.instance; } // ---- Meta / persistence helpers ---- private getTabId(): string { return globalStore.get(atoms.staticTabId); } private getWorkspaceId(): string { return globalStore.get(atoms.workspace)?.oid ?? ""; } private getPanelOpenAtom(): jotai.Atom<boolean> { return getOrefMetaKeyAtom(WOS.makeORef("tab", this.getTabId()), "waveai:panelopen"); } private getPanelWidthAtom(): jotai.Atom<number> { return getOrefMetaKeyAtom(WOS.makeORef("tab", this.getTabId()), "waveai:panelwidth"); } private getVTabBarWidthAtom(): jotai.Atom<number> { return getOrefMetaKeyAtom(WOS.makeORef("workspace", this.getWorkspaceId()), "layout:vtabbarwidth"); } private initializeFromMeta(): void { if (this.initialized) return; this.initialized = true; try { const savedVisible = globalStore.get(this.getPanelOpenAtom()); const savedAIWidth = globalStore.get(this.getPanelWidthAtom()); const savedVTabWidth = globalStore.get(this.getVTabBarWidthAtom()); if (savedVisible != null) { this.aiPanelVisible = savedVisible; globalStore.set(this.panelVisibleAtom, savedVisible); } if (savedAIWidth != null) { this.aiPanelWidth = savedAIWidth; } if (savedVTabWidth != null && savedVTabWidth > 0) { this.vtabWidth = savedVTabWidth; } } catch (e) { console.warn("Failed to initialize from tab meta:", e); } } // ---- Resolved width getters (always clamped) ---- private getResolvedAIWidth(windowWidth: number): number { this.initializeFromMeta(); let w = this.aiPanelWidth; if (w == null) { w = Math.max(AIPanel_DefaultWidth, windowWidth * AIPanel_DefaultWidthRatio); this.aiPanelWidth = w; } return clampAIPanelWidth(w, windowWidth); } private getResolvedVTabWidth(): number { this.initializeFromMeta(); return clampVTabWidth(this.vtabWidth); } // ---- Core layout computation ---- // All layout decisions flow through computeLayout. // It takes the current state (visibility flags + stored px widths) // and produces the two percentage arrays for the panel groups. private computeLayout(windowWidth: number): { outer: number[]; inner: number[] } { const vtabW = this.vtabVisible ? this.getResolvedVTabWidth() : 0; const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; const leftGroupW = vtabW + aiW; // outer: [leftGroupPct, contentPct] const leftPct = windowWidth > 0 ? (leftGroupW / windowWidth) * 100 : 0; const contentPct = Math.max(0, 100 - leftPct); // inner: [vtabPct, aiPanelPct] relative to leftGroupW let vtabPct: number; let aiPct: number; if (leftGroupW > 0) { vtabPct = (vtabW / leftGroupW) * 100; aiPct = 100 - vtabPct; } else { vtabPct = 50; aiPct = 50; } return { outer: [leftPct, contentPct], inner: [vtabPct, aiPct] }; } private commitLayouts(windowWidth: number): void { if (!this.outerPanelGroupRef || !this.innerPanelGroupRef) return; const { outer, inner } = this.computeLayout(windowWidth); this.inResize = true; this.outerPanelGroupRef.setLayout(outer); this.innerPanelGroupRef.setLayout(inner); this.inResize = false; this.updateWrapperWidth(); } // ---- Drag handlers ---- // These convert the percentage-based callback from react-resizable-panels // back into pixel widths, update stored state, then re-commit. handleOuterPanelLayout(sizes: number[]): void { if (this.inResize) return; const windowWidth = window.innerWidth; const newLeftGroupPx = (sizes[0] / 100) * windowWidth; if (this.vtabVisible && this.aiPanelVisible) { // vtab stays constant, aipanel absorbs the change const vtabW = this.getResolvedVTabWidth(); const newAIW = clampAIPanelWidth(newLeftGroupPx - vtabW, windowWidth); this.aiPanelWidth = newAIW; this.debouncedPersistAIWidth(newAIW); } else if (this.vtabVisible) { const clamped = clampVTabWidth(newLeftGroupPx); this.vtabWidth = clamped; this.debouncedPersistVTabWidth(clamped); } else if (this.aiPanelVisible) { const clamped = clampAIPanelWidth(newLeftGroupPx, windowWidth); this.aiPanelWidth = clamped; this.debouncedPersistAIWidth(clamped); } this.commitLayouts(windowWidth); } handleInnerPanelLayout(sizes: number[]): void { if (this.inResize) return; if (!this.vtabVisible || !this.aiPanelVisible) return; const windowWidth = window.innerWidth; const vtabW = this.getResolvedVTabWidth(); const aiW = this.getResolvedAIWidth(windowWidth); const leftGroupW = vtabW + aiW; const newVTabW = (sizes[0] / 100) * leftGroupW; const clampedVTab = clampVTabWidth(newVTabW); const newAIW = clampAIPanelWidth(leftGroupW - clampedVTab, windowWidth); if (clampedVTab !== this.vtabWidth) { this.vtabWidth = clampedVTab; this.debouncedPersistVTabWidth(clampedVTab); } if (newAIW !== this.aiPanelWidth) { this.aiPanelWidth = newAIW; this.debouncedPersistAIWidth(newAIW); } this.commitLayouts(windowWidth); } handleWindowResize(): void { this.commitLayouts(window.innerWidth); } // ---- Registration & sync ---- syncVTabWidthFromMeta(): void { const savedVTabWidth = globalStore.get(this.getVTabBarWidthAtom()); if (savedVTabWidth != null && savedVTabWidth > 0 && savedVTabWidth !== this.vtabWidth) { this.vtabWidth = savedVTabWidth; this.commitLayouts(window.innerWidth); } } registerRefs( aiPanelRef: ImperativePanelHandle, outerPanelGroupRef: ImperativePanelGroupHandle, innerPanelGroupRef: ImperativePanelGroupHandle, panelContainerRef: HTMLDivElement, aiPanelWrapperRef: HTMLDivElement, vtabPanelRef?: ImperativePanelHandle, showLeftTabBar?: boolean ): void { this.aiPanelRef = aiPanelRef; this.vtabPanelRef = vtabPanelRef ?? null; this.outerPanelGroupRef = outerPanelGroupRef; this.innerPanelGroupRef = innerPanelGroupRef; this.panelContainerRef = panelContainerRef; this.aiPanelWrapperRef = aiPanelWrapperRef; this.vtabVisible = showLeftTabBar ?? false; globalStore.set(this.vtabVisibleAtom, this.vtabVisible); this.syncPanelCollapse(); this.commitLayouts(window.innerWidth); } private syncPanelCollapse(): void { if (this.aiPanelRef) { if (this.aiPanelVisible) { this.aiPanelRef.expand(); } else { this.aiPanelRef.collapse(); } } if (this.vtabPanelRef) { if (this.vtabVisible) { this.vtabPanelRef.expand(); } else { this.vtabPanelRef.collapse(); } } } // ---- Transitions ---- enableTransitions(duration: number): void { if (!this.panelContainerRef) return; const panels = this.panelContainerRef.querySelectorAll("[data-panel]"); panels.forEach((panel: HTMLElement) => { panel.style.transition = "flex 0.2s ease-in-out"; }); if (this.transitionTimeoutRef) { clearTimeout(this.transitionTimeoutRef); } this.transitionTimeoutRef = setTimeout(() => { if (!this.panelContainerRef) return; const panels = this.panelContainerRef.querySelectorAll("[data-panel]"); panels.forEach((panel: HTMLElement) => { panel.style.transition = "none"; }); }, duration); } // ---- Wrapper width (AI panel inner content width) ---- updateWrapperWidth(): void { if (!this.aiPanelWrapperRef) return; const width = this.getResolvedAIWidth(window.innerWidth); this.aiPanelWrapperRef.style.width = `${width}px`; } // ---- Public getters ---- getAIPanelVisible(): boolean { this.initializeFromMeta(); return this.aiPanelVisible; } getAIPanelWidth(): number { return this.getResolvedAIWidth(window.innerWidth); } // ---- Initial percentage helpers (used by workspace.tsx for defaultSize) ---- getLeftGroupInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number { this.initializeFromMeta(); const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0; const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; return ((vtabW + aiW) / windowWidth) * 100; } getInnerVTabInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number { if (!showLeftTabBar || isBuilderWindow()) return 0; this.initializeFromMeta(); const vtabW = this.getResolvedVTabWidth(); const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; const total = vtabW + aiW; if (total === 0) return 50; return (vtabW / total) * 100; } getInnerAIPanelInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number { this.initializeFromMeta(); const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0; const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; const total = vtabW + aiW; if (total === 0) return 50; return (aiW / total) * 100; } // ---- Toggle visibility ---- setAIPanelVisible(visible: boolean, opts?: { nofocus?: boolean }): void { if (this.focusTimeoutRef != null) { clearTimeout(this.focusTimeoutRef); this.focusTimeoutRef = null; } const wasVisible = this.aiPanelVisible; this.aiPanelVisible = visible; if (visible && !wasVisible) { recordTEvent("action:openwaveai"); } globalStore.set(this.panelVisibleAtom, visible); getApi().setWaveAIOpen(visible); RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("tab", this.getTabId()), meta: { "waveai:panelopen": visible }, }); this.enableTransitions(250); this.syncPanelCollapse(); this.commitLayouts(window.innerWidth); if (visible) { if (!opts?.nofocus) { this.focusTimeoutRef = setTimeout(() => { WaveAIModel.getInstance().focusInput(); this.focusTimeoutRef = null; }, 350); } } else { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { layoutModel.focusFirstNode(); return; } const blockId = focusedNode?.data?.blockId; if (blockId != null) { refocusNode(blockId); } } } setShowLeftTabBar(showLeftTabBar: boolean): void { if (this.vtabVisible === showLeftTabBar) return; this.vtabVisible = showLeftTabBar; globalStore.set(this.vtabVisibleAtom, showLeftTabBar); this.enableTransitions(250); this.syncPanelCollapse(); this.commitLayouts(window.innerWidth); } } export { WorkspaceLayoutModel }; ================================================ FILE: frontend/app/workspace/workspace.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { AIPanel } from "@/app/aipanel/aipanel"; import { ErrorBoundary } from "@/app/element/errorboundary"; import { CenteredDiv } from "@/app/element/quickelems"; import { ModalsRenderer } from "@/app/modals/modalsrenderer"; import { TabBar } from "@/app/tab/tabbar"; import { TabContent } from "@/app/tab/tabcontent"; import { VTabBar } from "@/app/tab/vtabbar"; import { Widgets } from "@/app/workspace/widgets"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { atoms, getApi, getSettingsKeyAtom } from "@/store/global"; import { isMacOS } from "@/util/platformutil"; import { useAtomValue } from "jotai"; import { memo, useEffect, useRef } from "react"; import { ImperativePanelGroupHandle, ImperativePanelHandle, Panel, PanelGroup, PanelResizeHandle, } from "react-resizable-panels"; const MacOSTabBarSpacer = memo(() => { return ( <div className="w-full shrink-0" style={ { height: "calc(8px * var(--zoomfactor-inv))", WebkitAppRegion: "drag", backdropFilter: "blur(20px)", background: "rgba(0, 0, 0, 0.35)", } as React.CSSProperties } /> ); }); MacOSTabBarSpacer.displayName = "MacOSTabBarSpacer"; const WorkspaceElem = memo(() => { const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); const tabId = useAtomValue(atoms.staticTabId); const ws = useAtomValue(atoms.workspace); const tabBarPosition = useAtomValue(getSettingsKeyAtom("app:tabbar")) ?? "top"; const showLeftTabBar = tabBarPosition === "left"; const aiPanelVisible = useAtomValue(workspaceLayoutModel.panelVisibleAtom); const vtabVisible = useAtomValue(workspaceLayoutModel.vtabVisibleAtom); const windowWidth = window.innerWidth; const leftGroupInitialPct = workspaceLayoutModel.getLeftGroupInitialPercentage(windowWidth, showLeftTabBar); const innerVTabInitialPct = workspaceLayoutModel.getInnerVTabInitialPercentage(windowWidth, showLeftTabBar); const innerAIPanelInitialPct = workspaceLayoutModel.getInnerAIPanelInitialPercentage(windowWidth, showLeftTabBar); const outerPanelGroupRef = useRef<ImperativePanelGroupHandle>(null); const innerPanelGroupRef = useRef<ImperativePanelGroupHandle>(null); const aiPanelRef = useRef<ImperativePanelHandle>(null); const vtabPanelRef = useRef<ImperativePanelHandle>(null); const panelContainerRef = useRef<HTMLDivElement>(null); const aiPanelWrapperRef = useRef<HTMLDivElement>(null); // showLeftTabBar is passed as a seed value only; subsequent changes are handled by setShowLeftTabBar below. // Do NOT add showLeftTabBar as a dep here — re-registering refs on config changes would redundantly re-run commitLayouts. useEffect(() => { if ( aiPanelRef.current && outerPanelGroupRef.current && innerPanelGroupRef.current && panelContainerRef.current && aiPanelWrapperRef.current ) { workspaceLayoutModel.registerRefs( aiPanelRef.current, outerPanelGroupRef.current, innerPanelGroupRef.current, panelContainerRef.current, aiPanelWrapperRef.current, vtabPanelRef.current ?? undefined, showLeftTabBar ); } }, []); useEffect(() => { const isVisible = workspaceLayoutModel.getAIPanelVisible(); getApi().setWaveAIOpen(isVisible); }, []); useEffect(() => { workspaceLayoutModel.setShowLeftTabBar(showLeftTabBar); }, [showLeftTabBar]); useEffect(() => { window.addEventListener("resize", workspaceLayoutModel.handleWindowResize); return () => window.removeEventListener("resize", workspaceLayoutModel.handleWindowResize); }, []); useEffect(() => { const handleFocus = () => workspaceLayoutModel.syncVTabWidthFromMeta(); window.addEventListener("focus", handleFocus); return () => window.removeEventListener("focus", handleFocus); }, []); const innerHandleVisible = vtabVisible && aiPanelVisible; const innerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${innerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`; const outerHandleVisible = vtabVisible || aiPanelVisible; const outerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${outerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`; return ( <div className="flex flex-col w-full flex-grow overflow-hidden"> {!(showLeftTabBar && isMacOS()) && <TabBar key={ws.oid} workspace={ws} noTabs={showLeftTabBar} />} {showLeftTabBar && isMacOS() && <MacOSTabBarSpacer />} <div ref={panelContainerRef} className="flex flex-row flex-grow overflow-hidden"> <ErrorBoundary key={tabId}> <PanelGroup direction="horizontal" onLayout={workspaceLayoutModel.handleOuterPanelLayout} ref={outerPanelGroupRef} > <Panel order={0} defaultSize={leftGroupInitialPct} className="overflow-hidden"> <PanelGroup direction="horizontal" onLayout={workspaceLayoutModel.handleInnerPanelLayout} ref={innerPanelGroupRef} > <Panel ref={vtabPanelRef} collapsible defaultSize={innerVTabInitialPct} order={0} className="overflow-hidden" > {showLeftTabBar && <VTabBar workspace={ws} />} </Panel> <PanelResizeHandle className={innerHandleClass} /> <Panel ref={aiPanelRef} collapsible defaultSize={innerAIPanelInitialPct} order={1} className="overflow-hidden" > <div ref={aiPanelWrapperRef} className={`w-full h-full pr-0.5 ${aiPanelVisible ? "" : "opacity-0"}`} > {tabId !== "" && <AIPanel roundTopLeft={showLeftTabBar} />} </div> </Panel> </PanelGroup> </Panel> <PanelResizeHandle className={outerHandleClass} /> <Panel order={1} defaultSize={100 - leftGroupInitialPct}> {tabId === "" ? ( <CenteredDiv>No Active Tab</CenteredDiv> ) : ( <div className="flex flex-row h-full"> <TabContent key={tabId} tabId={tabId} noTopPadding={showLeftTabBar && isMacOS()} /> <Widgets /> </div> )} </Panel> </PanelGroup> <ModalsRenderer /> </ErrorBoundary> </div> </div> ); }); WorkspaceElem.displayName = "WorkspaceElem"; export { WorkspaceElem as Workspace }; ================================================ FILE: frontend/builder/app-selection-modal.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { FlexiModal } from "@/app/modals/modal"; import { globalStore } from "@/app/store/jotaiStore"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms, getApi } from "@/store/global"; import * as WOS from "@/store/wos"; import { formatRelativeTime } from "@/util/util"; import { useEffect, useState } from "react"; const MaxAppNameLength = 50; const AppNameRegex = /^[a-zA-Z0-9_-]+$/; function CreateNewWaveApp({ onCreateApp }: { onCreateApp: (appName: string) => Promise<void> }) { const [newAppName, setNewAppName] = useState(""); const [inputError, setInputError] = useState(""); const [isCreating, setIsCreating] = useState(false); const validateAppName = (name: string) => { if (!name.trim()) { setInputError(""); return false; } if (name.length > MaxAppNameLength) { setInputError(`Name must be ${MaxAppNameLength} characters or less`); return false; } if (!AppNameRegex.test(name)) { setInputError("Only letters, numbers, hyphens, and underscores allowed"); return false; } setInputError(""); return true; }; const handleCreate = async () => { const trimmedName = newAppName.trim(); if (!validateAppName(trimmedName)) { return; } setIsCreating(true); try { await onCreateApp(trimmedName); } finally { setIsCreating(false); } }; return ( <div className="min-h-[80px]"> <h3 className="text-base font-medium mb-1 text-muted-foreground">Create New WaveApp</h3> <div className="relative"> <div className="flex w-full"> <input type="text" value={newAppName} onChange={(e) => { const value = e.target.value; setNewAppName(value); validateAppName(value); }} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing && newAppName.trim() && !inputError) { handleCreate(); } }} placeholder="my-app" maxLength={MaxAppNameLength} className={`flex-1 px-3 py-2 bg-panel border rounded-l focus:outline-none transition-colors ${ inputError ? "border-error" : "border-border focus:border-accent" }`} autoFocus disabled={isCreating} /> <button onClick={handleCreate} disabled={!newAppName.trim() || !!inputError || isCreating} className={`px-4 py-2 rounded-r transition-colors font-medium whitespace-nowrap ${ !newAppName.trim() || inputError || isCreating ? "bg-panel border border-l-0 border-border text-muted cursor-not-allowed" : "bg-accent text-black hover:bg-accent-hover cursor-pointer" }`} > Create </button> </div> {inputError && ( <div className="absolute left-0 top-full mt-1 text-xs text-error flex items-center gap-1.5 whitespace-nowrap"> <i className="fa-solid fa-circle-exclamation"></i> <span>{inputError}</span> </div> )} </div> </div> ); } export function AppSelectionModal() { const [apps, setApps] = useState<AppInfo[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); useEffect(() => { loadApps(); }, []); const loadApps = async () => { try { const appList = await RpcApi.ListAllEditableAppsCommand(TabRpcClient); const sortedApps = (appList || []).sort((a, b) => b.modtime - a.modtime); setApps(sortedApps); } catch (err) { console.error("Failed to load apps:", err); setError("Failed to load apps"); } finally { setLoading(false); } }; const handleSelectApp = async (appId: string) => { let appIdToUse = appId; // If selecting a local app, convert it to a draft first if (appId.startsWith("local/")) { try { const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId }); appIdToUse = result.draftappid; } catch (err) { console.error("Failed to create draft from local app:", err); setError(`Failed to create draft from ${appId}: ${err.message || String(err)}`); return; } } const builderId = globalStore.get(atoms.builderId); const oref = WOS.makeORef("builder", builderId); await RpcApi.SetRTInfoCommand(TabRpcClient, { oref, data: { "builder:appid": appIdToUse }, }); globalStore.set(atoms.builderAppId, appIdToUse); document.title = `WaveApp Builder (${appIdToUse})`; getApi().setBuilderWindowAppId(appIdToUse); }; const handleCreateNew = async (appName: string) => { const draftAppId = `draft/${appName}`; const builderId = globalStore.get(atoms.builderId); const oref = WOS.makeORef("builder", builderId); await RpcApi.SetRTInfoCommand(TabRpcClient, { oref, data: { "builder:appid": draftAppId }, }); globalStore.set(atoms.builderAppId, draftAppId); document.title = `WaveApp Builder (${draftAppId})`; getApi().setBuilderWindowAppId(draftAppId); }; const isDraftApp = (appId: string) => { return appId.startsWith("draft/"); }; const getAppDisplayName = (appId: string) => { const parts = appId.split("/"); if (parts.length === 2) { const isDraft = parts[0] === "draft"; return isDraft ? `${parts[1]} (draft)` : parts[1]; } return appId; }; if (loading) { return ( <FlexiModal className="min-w-[600px] w-[600px]"> <div className="text-center py-8">Loading apps...</div> </FlexiModal> ); } return ( <FlexiModal className="min-w-[600px] w-[600px] max-h-[90vh] overflow-y-auto"> <div className="w-full px-2 pt-0 pb-4"> <h2 className="text-2xl mb-2">Select a WaveApp to Edit</h2> {error && ( <div className="mb-6 px-4 py-3 bg-panel rounded"> <div className="flex items-center gap-3"> <i className="fa-solid fa-circle-exclamation text-warning"></i> <span>{error}</span> </div> </div> )} {apps.length > 0 && ( <div className="mb-2"> <h3 className="text-base font-medium mb-1 text-muted-foreground">Existing WaveApps</h3> <div className="space-y-2 max-h-[220px] overflow-y-auto"> {apps.map((appInfo) => ( <button key={appInfo.appid} onClick={() => handleSelectApp(appInfo.appid)} className="w-full text-left px-4 py-1.5 bg-panel hover:bg-hover border border-border rounded transition-colors cursor-pointer" > <div className="flex items-center gap-3"> <i className="fa-solid fa-cube self-center"></i> <div className="flex flex-col"> <span>{getAppDisplayName(appInfo.appid)}</span> <span className="text-[11px] text-muted mt-0.5"> Last updated: {formatRelativeTime(appInfo.modtime)} </span> </div> </div> </button> ))} </div> </div> )} {apps.length > 0 && ( <div className="flex items-center gap-4 my-2"> <div className="flex-1 border-t border-border"></div> <span className="text-muted-foreground text-sm">or</span> <div className="flex-1 border-t border-border"></div> </div> )} <CreateNewWaveApp onCreateApp={handleCreateNew} /> </div> </FlexiModal> ); } ================================================ FILE: frontend/builder/builder-app.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ModalsRenderer } from "@/app/modals/modalsrenderer"; import { globalStore } from "@/app/store/jotaiStore"; import { AppSelectionModal } from "@/builder/app-selection-modal"; import { BuilderWorkspace } from "@/builder/builder-workspace"; import { atoms, isDev } from "@/store/global"; import { appHandleKeyDown } from "@/store/keymodel"; import * as keyutil from "@/util/keyutil"; import { isBlank } from "@/util/util"; import { Provider, useAtomValue } from "jotai"; import { useEffect } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; type BuilderAppProps = { initOpts: BuilderInitOpts; onFirstRender: () => void; }; const BuilderKeyHandlers = () => { useEffect(() => { const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); document.addEventListener("keydown", staticKeyDownHandler); return () => { document.removeEventListener("keydown", staticKeyDownHandler); }; }, []); return null; }; function BuilderAppInner() { const builderAppId = useAtomValue(atoms.builderAppId); const hasDraftApp = !isBlank(builderAppId) && builderAppId.startsWith("draft/"); return ( <div className="w-full h-full flex flex-col bg-main-bg text-main-text"> <BuilderKeyHandlers /> <div className="h-9 shrink-0 border-b border-b-border flex items-center justify-center gap-2" style={{ WebkitAppRegion: "drag" } as React.CSSProperties} > {isDev() ? ( <div className="text-accent text-xl" title="Running Wave Dev Build"> <i className="fa fa-brands fa-dev fa-fw" /> </div> ) : null} <div className="text-sm font-medium"> WaveApp Builder{!isBlank(builderAppId) && ` (${builderAppId})`} </div> </div> <DndProvider backend={HTML5Backend}> {hasDraftApp ? <BuilderWorkspace /> : <AppSelectionModal />} </DndProvider> <ModalsRenderer /> </div> ); } export function BuilderApp({ initOpts, onFirstRender }: BuilderAppProps) { useEffect(() => { onFirstRender(); }, []); return ( <Provider store={globalStore}> <BuilderAppInner /> </Provider> ); } ================================================ FILE: frontend/builder/builder-apppanel.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Modal } from "@/app/modals/modal"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { modalsModel } from "@/app/store/modalmodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-apppanel-model"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; import { BuilderConfigDataTab } from "@/builder/tabs/builder-configdatatab"; import { BuilderFilesTab, DeleteFileModal, RenameFileModal } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; import { BuilderSecretTab } from "@/builder/tabs/builder-secrettab"; import { builderAppHasSelection } from "@/builder/utils/builder-focus-utils"; import { ErrorBoundary } from "@/element/errorboundary"; import { atoms } from "@/store/global"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useRef, useState } from "react"; const StatusDot = memo(() => { const model = BuilderAppPanelModel.getInstance(); const builderStatus = useAtomValue(model.builderStatusAtom); const getStatusDotColor = (status: string | null | undefined): string => { if (!status) return "bg-gray-500"; switch (status) { case "init": case "stopped": return "bg-gray-500"; case "building": return "bg-warning"; case "running": return "bg-success"; case "error": return "bg-error"; default: return "bg-gray-500"; } }; const statusDotColor = getStatusDotColor(builderStatus?.status); return <span className={cn("w-2 h-2 rounded-full", statusDotColor)} />; }); StatusDot.displayName = "StatusDot"; type TabButtonProps = { label: string; tabType: TabType; isActive: boolean; isAppFocused: boolean; onClick: () => void; showStatusDot?: boolean; }; const TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick, showStatusDot }: TabButtonProps) => { return ( <button className={cn( "px-4 py-2 text-sm font-medium transition-colors cursor-pointer", isActive ? `text-primary border-b-2 ${isAppFocused ? "border-accent" : "border-gray-500"}` : "text-secondary hover:text-primary border-b-2 border-transparent" )} onClick={onClick} > <span className="flex items-center gap-2"> {showStatusDot && <StatusDot />} {label} </span> </button> ); }); TabButton.displayName = "TabButton"; const ErrorStrip = memo(() => { const model = BuilderAppPanelModel.getInstance(); const errorMsg = useAtomValue(model.errorAtom); if (!errorMsg) return null; return ( <div className="shrink-0 bg-error/10 border-b border-error/30 px-4 py-2 flex items-center justify-between gap-4"> <div className="flex items-center gap-3 flex-1 min-w-0"> <i className="fa fa-triangle-exclamation text-error text-sm" /> <span className="text-error text-sm flex-1 truncate">{errorMsg}</span> </div> <button onClick={() => model.clearError()} className="shrink-0 text-error hover:text-error/80 transition-colors cursor-pointer" aria-label="Close error" > <i className="fa fa-xmark-large text-sm" /> </button> </div> ); }); ErrorStrip.displayName = "ErrorStrip"; const PublishAppModal = memo(({ appName }: { appName: string }) => { const builderAppId = useAtomValue(atoms.builderAppId); const [state, setState] = useState<"confirm" | "success" | "error">("confirm"); const [errorMessage, setErrorMessage] = useState<string>(""); const [publishedAppId, setPublishedAppId] = useState<string>(""); const handlePublish = async () => { if (!builderAppId) { setErrorMessage("No builder app ID found"); setState("error"); return; } try { const result = await RpcApi.PublishAppCommand(TabRpcClient, { appid: builderAppId }); setPublishedAppId(result.publishedappid); setState("success"); } catch (error) { setErrorMessage(error instanceof Error ? error.message : String(error)); setState("error"); } }; const handleClose = () => { modalsModel.popModal(); }; if (state === "success") { return ( <Modal className="p-4" onOk={handleClose} onClose={handleClose} okLabel="OK" cancelLabel=""> <div className="flex flex-col gap-4 mb-4"> <h2 className="text-xl font-semibold flex items-center gap-2"> <i className="fa fa-check-circle text-success" /> App Published Successfully </h2> <div className="flex flex-col gap-3"> <p className="text-primary"> Your app has been published to <span className="font-mono">{publishedAppId}</span> </p> </div> </div> </Modal> ); } if (state === "error") { return ( <Modal className="p-4" onOk={handleClose} onClose={handleClose} okLabel="OK" cancelLabel=""> <div className="flex flex-col gap-4 mb-4"> <h2 className="text-xl font-semibold flex items-center gap-2"> <i className="fa fa-triangle-exclamation text-error" /> Publish Failed </h2> <div className="flex flex-col gap-3"> <p className="text-error">{errorMessage}</p> </div> </div> </Modal> ); } return ( <Modal className="p-4" onOk={handlePublish} onCancel={handleClose} onClose={handleClose} okLabel="Publish" cancelLabel="Cancel" > <div className="flex flex-col gap-4 mb-4"> <h2 className="text-xl font-semibold">Publish App</h2> <div className="flex flex-col gap-3"> <p className="text-primary"> This will publish your app to <span className="font-mono">local/{appName}</span> </p> <p className="text-warning"> <i className="fa fa-triangle-exclamation mr-2" /> This will overwrite any existing app with the same name. Are you sure? </p> </div> </div> </Modal> ); }); PublishAppModal.displayName = "PublishAppModal"; const BuilderAppPanel = memo(() => { const model = BuilderAppPanelModel.getInstance(); const focusElemRef = useRef<HTMLInputElement>(null); const activeTab = useAtomValue(model.activeTab); const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); const isAppFocused = focusType === "app"; const builderAppId = useAtomValue(atoms.builderAppId); const builderId = useAtomValue(atoms.builderId); const hasSecrets = useAtomValue(model.hasSecretsAtom); useEffect(() => { model.initialize(); }, []); if (focusElemRef.current) { model.setFocusElemRef(focusElemRef.current); } const handleTabClick = (tab: TabType) => { model.setActiveTab(tab); BuilderFocusManager.getInstance().setAppFocused(); model.giveFocus(); }; const handleFocusCapture = useCallback((event: React.FocusEvent) => { BuilderFocusManager.getInstance().setAppFocused(); }, []); const handlePanelClick = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement; const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); if (isInteractive) { return; } const hasSelection = builderAppHasSelection(); if (hasSelection) { BuilderFocusManager.getInstance().setAppFocused(); return; } setTimeout(() => { if (!builderAppHasSelection()) { BuilderFocusManager.getInstance().setAppFocused(); model.giveFocus(); } }, 0); }, [model] ); const handleRestart = useCallback(() => { model.restartBuilder(); }, [model]); const handlePublishClick = useCallback(() => { if (!builderAppId) return; const appName = builderAppId.replace("draft/", ""); modalsModel.pushModal("PublishAppModal", { appName }); }, [builderAppId]); const handleSwitchAppClick = useCallback(() => { model.switchBuilderApp(); }, [model]); const handleKebabClick = useCallback( (e: React.MouseEvent) => { const menu: ContextMenuItem[] = [ { label: "Publish App", click: handlePublishClick, }, { type: "separator", }, { label: "Switch App", click: handleSwitchAppClick, }, ]; ContextMenuModel.getInstance().showContextMenu(menu, e); }, [handleSwitchAppClick, handlePublishClick] ); return ( <div className="w-full h-full flex flex-col border-b-3 border-border shadow-[0_2px_4px_rgba(0,0,0,0.1)]" data-builder-app-panel="true" onClick={handlePanelClick} onFocusCapture={handleFocusCapture} > <div key="focuselem" className="h-0 w-0"> <input type="text" value="" ref={focusElemRef} className="h-0 w-0 opacity-0 pointer-events-none" onChange={() => {}} /> </div> <div className="shrink-0 border-b border-border"> <div className="flex items-center justify-between"> <div className="flex"> <TabButton label="Preview" tabType="preview" isActive={activeTab === "preview"} isAppFocused={isAppFocused} onClick={() => handleTabClick("preview")} showStatusDot={true} /> <TabButton label="Code" tabType="code" isActive={activeTab === "code"} isAppFocused={isAppFocused} onClick={() => handleTabClick("code")} /> <TabButton label="Config/Data" tabType="configdata" isActive={activeTab === "configdata"} isAppFocused={isAppFocused} onClick={() => handleTabClick("configdata")} /> <TabButton label="Files" tabType="files" isActive={activeTab === "files"} isAppFocused={isAppFocused} onClick={() => handleTabClick("files")} /> {hasSecrets && ( <TabButton label="Secrets" tabType="secrets" isActive={activeTab === "secrets"} isAppFocused={isAppFocused} onClick={() => handleTabClick("secrets")} /> )} </div> <div className="flex items-center gap-2 mr-2"> <button className="px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer" onClick={handlePublishClick} > Publish App </button> <button className="px-2 py-1 text-sm font-medium rounded hover:bg-secondary/10 transition-colors cursor-pointer" onClick={handleKebabClick} aria-label="More options" > <i className="fa fa-ellipsis-vertical" /> </button> </div> </div> </div> <ErrorStrip /> <div className="flex-1 overflow-auto py-1"> <div className="w-full h-full" style={{ display: activeTab === "preview" ? "block" : "none" }}> <ErrorBoundary> <BuilderPreviewTab /> </ErrorBoundary> </div> <div className="w-full h-full" style={{ display: activeTab === "code" ? "block" : "none" }}> <ErrorBoundary> <BuilderCodeTab /> </ErrorBoundary> </div> <div className="w-full h-full" style={{ display: activeTab === "files" ? "block" : "none" }}> <ErrorBoundary> <BuilderFilesTab /> </ErrorBoundary> </div> <div className="w-full h-full" style={{ display: activeTab === "secrets" ? "block" : "none" }}> <ErrorBoundary> <BuilderSecretTab /> </ErrorBoundary> </div> <div className="w-full h-full" style={{ display: activeTab === "configdata" ? "block" : "none" }}> <ErrorBoundary> <BuilderConfigDataTab /> </ErrorBoundary> </div> </div> </div> ); }); BuilderAppPanel.displayName = "BuilderAppPanel"; export { BuilderAppPanel, DeleteFileModal, PublishAppModal, RenameFileModal }; ================================================ FILE: frontend/builder/builder-buildpanel.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { globalStore } from "@/app/store/jotaiStore"; import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; import { BuilderBuildPanelModel } from "@/builder/store/builder-buildpanel-model"; import { useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useRef } from "react"; import { debounce } from "throttle-debounce"; function handleBuildPanelContextMenu(e: React.MouseEvent, selectedText: string): void { e.preventDefault(); e.stopPropagation(); if (!selectedText) { return; } const menu: ContextMenuItem[] = [ { role: "copy" }, { type: "separator" }, { label: "Add to Context", click: () => { const model = WaveAIModel.getInstance(); const formattedText = `from builder output:\n\`\`\`\n${selectedText}\n\`\`\``; model.appendText(formattedText, true); model.focusInput(); }, }, ]; ContextMenuModel.getInstance().showContextMenu(menu, e); } const BuilderBuildPanel = memo(() => { const model = BuilderBuildPanelModel.getInstance(); const outputLines = useAtomValue(model.outputLines); const showDebug = useAtomValue(model.showDebug); const scrollRef = useRef<HTMLDivElement>(null); const preRef = useRef<HTMLPreElement>(null); useEffect(() => { model.initialize(); return () => { model.dispose(); }; }, []); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [outputLines]); const debouncedCopyOnSelect = useCallback( debounce(50, () => { const selection = window.getSelection(); if (selection && selection.toString().length > 0) { navigator.clipboard.writeText(selection.toString()); } }), [] ); const handleMouseUp = useCallback(() => { debouncedCopyOnSelect(); }, [debouncedCopyOnSelect]); const handleContextMenu = useCallback((e: React.MouseEvent) => { const selection = window.getSelection(); const selectedText = selection ? selection.toString() : ""; handleBuildPanelContextMenu(e, selectedText); }, []); const handleDebugToggle = useCallback(() => { globalStore.set(model.showDebug, !showDebug); }, [model, showDebug]); const handleRestart = useCallback(() => { BuilderAppPanelModel.getInstance().restartBuilder(); }, []); const handleSendToAI = useCallback(() => { const currentShowDebug = globalStore.get(model.showDebug); const currentOutputLines = globalStore.get(model.outputLines); const filtered = currentShowDebug ? currentOutputLines : currentOutputLines.filter((line) => !line.startsWith("[debug]") && line.trim().length > 0); const linesToSend = filtered.slice(-200); const text = linesToSend.join("\n"); const aiModel = WaveAIModel.getInstance(); const formattedText = `from builder output:\n\`\`\`\n${text}\n\`\`\`\n`; aiModel.appendText(formattedText, true, { scrollToBottom: true }); aiModel.focusInput(); }, [model]); const filteredLines = showDebug ? outputLines : outputLines.filter((line) => !line.startsWith("[debug]") && line.trim().length > 0); return ( <div className="w-full h-full flex flex-col bg-black rounded-br-2"> <div className="flex-shrink-0 px-3 py-2 border-b border-gray-700 flex items-center justify-between"> <span className="text-sm font-semibold text-gray-300">Build Output</span> <div className="flex items-center gap-4"> <label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"> <input type="checkbox" checked={showDebug} onChange={handleDebugToggle} className="cursor-pointer" /> Debug </label> <button className="px-3 py-1 text-sm font-medium rounded transition-colors bg-accent/80 text-white hover:bg-accent cursor-pointer" onClick={handleSendToAI} > Send Output to AI </button> <button className="px-3 py-1 text-sm font-medium rounded transition-colors bg-accent/80 text-white hover:bg-accent cursor-pointer" onClick={handleRestart} > Restart App </button> </div> </div> <div ref={scrollRef} className="flex-1 overflow-y-auto overflow-x-auto p-2"> <pre ref={preRef} className="font-mono text-xs text-gray-100 whitespace-pre" onMouseUp={handleMouseUp} onContextMenu={handleContextMenu} > {/* this comment fixes JSX blank line in pre tag */} {filteredLines.length === 0 ? ( <span className="text-secondary">Waiting for output...</span> ) : ( filteredLines.join("\n") )} </pre> </div> </div> ); }); BuilderBuildPanel.displayName = "BuilderBuildPanel"; export { BuilderBuildPanel }; ================================================ FILE: frontend/builder/builder-workspace.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { AIPanel } from "@/app/aipanel/aipanel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BuilderAppPanel } from "@/builder/builder-apppanel"; import { BuilderBuildPanel } from "@/builder/builder-buildpanel"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { atoms } from "@/store/global"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { debounce } from "throttle-debounce"; const DefaultLayoutPercentages = { chat: 50, app: 80, build: 20, }; const BuilderWorkspace = memo(() => { const builderId = useAtomValue(atoms.builderId); const [layout, setLayout] = useState<Record<string, number>>(null); const [isLoading, setIsLoading] = useState(true); const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); const isAppFocused = focusType === "app"; useEffect(() => { const loadLayout = async () => { if (!builderId) { setLayout(DefaultLayoutPercentages); setIsLoading(false); return; } try { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: `builder:${builderId}`, }); if (rtInfo?.["builder:layout"]) { setLayout(rtInfo["builder:layout"] as Record<string, number>); } else { setLayout(DefaultLayoutPercentages); } } catch (error) { console.error("Failed to load builder layout:", error); setLayout(DefaultLayoutPercentages); } finally { setIsLoading(false); } }; loadLayout(); }, [builderId]); const saveLayout = useCallback( debounce(500, (newLayout: Record<string, number>) => { if (!builderId) return; RpcApi.SetRTInfoCommand(TabRpcClient, { oref: `builder:${builderId}`, data: { "builder:layout": newLayout, }, }).catch((error) => { console.error("Failed to save builder layout:", error); }); }), [builderId] ); const handleHorizontalLayout = useCallback( (sizes: number[]) => { const newLayout = { ...layout, chat: sizes[0] }; setLayout(newLayout); saveLayout(newLayout); }, [layout, saveLayout] ); const handleVerticalLayout = useCallback( (sizes: number[]) => { const newLayout = { ...layout, app: sizes[0], build: sizes[1] }; setLayout(newLayout); saveLayout(newLayout); }, [layout, saveLayout] ); if (isLoading || !layout) { return null; } return ( <div className="flex-1 overflow-hidden"> <PanelGroup direction="horizontal" onLayout={handleHorizontalLayout}> <Panel defaultSize={layout.chat} minSize={20}> <AIPanel roundTopLeft={false} /> </Panel> <PanelResizeHandle className="w-0.5 bg-transparent hover:bg-gray-500/20 transition-colors" /> <Panel defaultSize={100 - layout.chat} minSize={20}> <div className={cn( "flex flex-col relative h-full", isAppFocused ? "border-2 border-accent" : "border-2 border-transparent" )} style={{ borderBottomRightRadius: 8, }} > <PanelGroup direction="vertical" onLayout={handleVerticalLayout}> <Panel defaultSize={layout.app} minSize={20}> <BuilderAppPanel /> </Panel> <PanelResizeHandle className="h-0.5 bg-transparent hover:bg-gray-500/20 transition-colors" /> <Panel defaultSize={layout.build} minSize={20} maxSize={50} style={{ borderBottomRightRadius: 8 }} > <BuilderBuildPanel /> </Panel> </PanelGroup> </div> </Panel> </PanelGroup> </div> ); }); BuilderWorkspace.displayName = "BuilderWorkspace"; export { BuilderWorkspace }; ================================================ FILE: frontend/builder/store/builder-apppanel-model.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms, getApi, WOS } from "@/store/global"; import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; import type * as MonacoTypes from "monaco-editor"; import { debounce } from "throttle-debounce"; export type TabType = "preview" | "files" | "code" | "secrets" | "configdata"; export type EnvVar = { name: string; value: string; visible?: boolean; }; export class BuilderAppPanelModel { private static instance: BuilderAppPanelModel | null = null; activeTab: PrimitiveAtom<TabType> = atom<TabType>("preview"); codeContentAtom: PrimitiveAtom<string> = atom<string>(""); originalContentAtom: PrimitiveAtom<string> = atom<string>(""); envVarsArrayAtom: PrimitiveAtom<EnvVar[]> = atom<EnvVar[]>([]); envVarIndexAtoms: Atom<EnvVar | null>[] = []; envVarsDirtyAtom: PrimitiveAtom<boolean> = atom<boolean>(false); isLoadingAtom: PrimitiveAtom<boolean> = atom<boolean>(false); errorAtom: PrimitiveAtom<string> = atom<string>(""); builderStatusAtom = atom<BuilderStatusData>(null) as PrimitiveAtom<BuilderStatusData>; hasSecretsAtom: PrimitiveAtom<boolean> = atom<boolean>(false); saveNeededAtom!: Atom<boolean>; focusElemRef: { current: HTMLInputElement | null } = { current: null }; monacoEditorRef: { current: MonacoTypes.editor.IStandaloneCodeEditor | null } = { current: null }; statusUnsubFn: (() => void) | null = null; appGoUpdateUnsubFn: (() => void) | null = null; debouncedRestart: (() => void) & { cancel: () => void }; initialized = false; private constructor() { this.debouncedRestart = debounce(800, () => { this.restartBuilder(); }); this.saveNeededAtom = atom((get) => { return get(this.codeContentAtom) !== get(this.originalContentAtom); }); } static getInstance(): BuilderAppPanelModel { if (!BuilderAppPanelModel.instance) { BuilderAppPanelModel.instance = new BuilderAppPanelModel(); } return BuilderAppPanelModel.instance; } setActiveTab(tab: TabType) { globalStore.set(this.activeTab, tab); } getActiveTab(): TabType { return globalStore.get(this.activeTab); } setCodeContent(content: string) { globalStore.set(this.codeContentAtom, content); } async initialize() { if (this.initialized) return; this.initialized = true; // builderId is set in initialization so is always available const builderId = globalStore.get(atoms.builderId); if (this.statusUnsubFn) { this.statusUnsubFn(); } this.statusUnsubFn = waveEventSubscribeSingle({ eventType: "builderstatus", scope: WOS.makeORef("builder", builderId), handler: (event) => { const status = event.data; const currentStatus = globalStore.get(this.builderStatusAtom); if (!currentStatus || !currentStatus.version || status.version > currentStatus.version) { globalStore.set(this.builderStatusAtom, status); this.updateSecretsLatch(status); } }, }); try { const status = await RpcApi.GetBuilderStatusCommand(TabRpcClient, builderId); globalStore.set(this.builderStatusAtom, status); this.updateSecretsLatch(status); } catch (err) { console.error("Failed to load builder status:", err); } // the apppanel does not render until appId is set, so this will never be null during initialization const appId = globalStore.get(atoms.builderAppId); await this.loadAppFile(appId); await this.loadEnvVars(builderId); this.appGoUpdateUnsubFn = waveEventSubscribeSingle({ eventType: "waveapp:appgoupdated", scope: appId, handler: () => { this.loadAppFile(appId); }, }); } updateSecretsLatch(status: BuilderStatusData) { if (!status?.manifest?.secrets) return; const secrets = status.manifest.secrets; if (Object.keys(secrets).length > 0) { globalStore.set(this.hasSecretsAtom, true); } } updateSecretBindings(newBindings: { [key: string]: string }) { const currentStatus = globalStore.get(this.builderStatusAtom); if (currentStatus) { globalStore.set(this.builderStatusAtom, { ...currentStatus, secretbindings: newBindings, }); } } async loadEnvVars(builderId: string) { try { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: WOS.makeORef("builder", builderId), }); const envVars = rtInfo?.["builder:env"] || {}; const envVarsArray = Object.entries(envVars).map(([name, value]) => ({ name, value, visible: false })); globalStore.set(this.envVarsArrayAtom, envVarsArray); globalStore.set(this.envVarsDirtyAtom, false); } catch (err) { console.error("Failed to load environment variables:", err); } } async saveEnvVars(builderId: string) { try { const envVarsArray = globalStore.get(this.envVarsArrayAtom); const envVars: Record<string, string> = {}; envVarsArray.forEach((v) => { const trimmedName = v.name.trim(); if (trimmedName) { envVars[trimmedName] = v.value; } }); const cleanedArray = Object.entries(envVars).map(([name, value]) => ({ name, value, visible: false })); await RpcApi.SetRTInfoCommand(TabRpcClient, { oref: WOS.makeORef("builder", builderId), data: { "builder:env": envVars, }, }); globalStore.set(this.envVarsArrayAtom, cleanedArray); globalStore.set(this.envVarsDirtyAtom, false); globalStore.set(this.errorAtom, ""); this.debouncedRestart(); } catch (err) { console.error("Failed to save environment variables:", err); globalStore.set(this.errorAtom, `Failed to save environment variables: ${err.message || "Unknown error"}`); } } getEnvVarIndexAtom(index: number): Atom<EnvVar | null> { if (!this.envVarIndexAtoms[index]) { this.envVarIndexAtoms[index] = atom((get) => { const array = get(this.envVarsArrayAtom); return array[index] ?? null; }); } return this.envVarIndexAtoms[index]; } addEnvVar() { const current = globalStore.get(this.envVarsArrayAtom); globalStore.set(this.envVarsArrayAtom, [...current, { name: "", value: "", visible: false }]); globalStore.set(this.envVarsDirtyAtom, true); } removeEnvVar(index: number) { const current = globalStore.get(this.envVarsArrayAtom); const newArray = current.filter((_, i) => i !== index); globalStore.set(this.envVarsArrayAtom, newArray); globalStore.set(this.envVarsDirtyAtom, true); } setEnvVarAtIndex(index: number, envVar: EnvVar, dirty: boolean) { const current = globalStore.get(this.envVarsArrayAtom); const newArray = [...current]; newArray[index] = envVar; globalStore.set(this.envVarsArrayAtom, newArray); if (dirty) { globalStore.set(this.envVarsDirtyAtom, true); } } async startBuilder() { const builderId = globalStore.get(atoms.builderId); try { await RpcApi.StartBuilderCommand(TabRpcClient, { builderid: builderId, }); } catch (err) { console.error("Failed to start builder:", err); globalStore.set(this.errorAtom, `Failed to start builder: ${err.message || "Unknown error"}`); } } async restartBuilder() { // the RPC call that starts the builder actually forces a restart, so this works return this.startBuilder(); } async switchBuilderApp() { const builderId = globalStore.get(atoms.builderId); try { await RpcApi.DeleteBuilderCommand(TabRpcClient, builderId); await new Promise((resolve) => setTimeout(resolve, 500)); await RpcApi.SetRTInfoCommand(TabRpcClient, { oref: WOS.makeORef("builder", builderId), data: { "builder:appid": null }, }); getApi().setBuilderWindowAppId(null); await new Promise((resolve) => setTimeout(resolve, 100)); getApi().doRefresh(); } catch (err) { console.error("Failed to switch builder app:", err); globalStore.set(this.errorAtom, `Failed to switch builder app: ${err.message || "Unknown error"}`); } } async loadAppFile(appId: string) { try { globalStore.set(this.isLoadingAtom, true); globalStore.set(this.errorAtom, ""); const result = await RpcApi.ReadAppFileCommand(TabRpcClient, { appid: appId, filename: "app.go", }); if (result.notfound) { globalStore.set(this.codeContentAtom, ""); globalStore.set(this.originalContentAtom, ""); } else { const decoded = base64ToString(result.data64); globalStore.set(this.codeContentAtom, decoded); globalStore.set(this.originalContentAtom, decoded); if (decoded.trim() !== "") { const currentStatus = globalStore.get(this.builderStatusAtom); if (currentStatus?.status !== "running" && currentStatus?.status !== "building") { await this.startBuilder(); } } } } catch (err) { console.error("Failed to load app.go:", err); globalStore.set(this.errorAtom, `Failed to load app.go: ${err.message || "Unknown error"}`); } finally { globalStore.set(this.isLoadingAtom, false); } } async saveAppFile(appId: string) { try { const content = globalStore.get(this.codeContentAtom); const encoded = stringToBase64(content); const result = await RpcApi.WriteAppGoFileCommand(TabRpcClient, { appid: appId, data64: encoded, }); const formattedContent = base64ToString(result.data64); globalStore.set(this.codeContentAtom, formattedContent); globalStore.set(this.originalContentAtom, formattedContent); globalStore.set(this.errorAtom, ""); this.debouncedRestart(); } catch (err) { console.error("Failed to save app.go:", err); globalStore.set(this.errorAtom, `Failed to save app.go: ${err.message || "Unknown error"}`); } } clearError() { globalStore.set(this.errorAtom, ""); } giveFocus() { const activeTab = globalStore.get(this.activeTab); if (activeTab === "code" && this.monacoEditorRef.current) { this.monacoEditorRef.current.focus(); } else { this.focusElemRef.current?.focus(); } } setFocusElemRef(ref: HTMLInputElement | null) { this.focusElemRef.current = ref; } setMonacoEditorRef(ref: MonacoTypes.editor.IStandaloneCodeEditor | null) { this.monacoEditorRef.current = ref; } dispose() { if (this.statusUnsubFn) { this.statusUnsubFn(); this.statusUnsubFn = null; } if (this.appGoUpdateUnsubFn) { this.appGoUpdateUnsubFn(); this.appGoUpdateUnsubFn = null; } this.debouncedRestart.cancel(); } } ================================================ FILE: frontend/builder/store/builder-buildpanel-model.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms, WOS } from "@/store/global"; import { atom, type PrimitiveAtom } from "jotai"; export class BuilderBuildPanelModel { private static instance: BuilderBuildPanelModel | null = null; outputLines: PrimitiveAtom<string[]> = atom<string[]>([]); showDebug: PrimitiveAtom<boolean> = atom<boolean>(false); outputUnsubFn: (() => void) | null = null; initialized = false; private constructor() {} static getInstance(): BuilderBuildPanelModel { if (!BuilderBuildPanelModel.instance) { BuilderBuildPanelModel.instance = new BuilderBuildPanelModel(); } return BuilderBuildPanelModel.instance; } async initialize() { if (this.initialized) return; this.initialized = true; const builderId = globalStore.get(atoms.builderId); if (!builderId) return; if (this.outputUnsubFn) { this.outputUnsubFn(); } this.outputUnsubFn = waveEventSubscribeSingle({ eventType: "builderoutput", scope: WOS.makeORef("builder", builderId), handler: (event) => { const data = event.data as { lines?: string[]; reset?: boolean }; if (!data) return; if (data.reset) { globalStore.set(this.outputLines, data.lines || []); } else if (data.lines && data.lines.length > 0) { globalStore.set(this.outputLines, (prev) => [...prev, ...data.lines]); } }, }); try { const output = await RpcApi.GetBuilderOutputCommand(TabRpcClient, builderId); globalStore.set(this.outputLines, output || []); } catch (err) { console.error("Failed to load builder output:", err); } } clearOutput() { globalStore.set(this.outputLines, []); } dispose() { if (this.outputUnsubFn) { this.outputUnsubFn(); this.outputUnsubFn = null; } this.initialized = false; } } ================================================ FILE: frontend/builder/store/builder-focusmanager.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import { atom, type PrimitiveAtom } from "jotai"; export type BuilderFocusType = "waveai" | "app"; export class BuilderFocusManager { private static instance: BuilderFocusManager | null = null; focusType: PrimitiveAtom<BuilderFocusType> = atom("app"); private constructor() {} static getInstance(): BuilderFocusManager { if (!BuilderFocusManager.instance) { BuilderFocusManager.instance = new BuilderFocusManager(); } return BuilderFocusManager.instance; } setWaveAIFocused() { globalStore.set(this.focusType, "waveai"); } setAppFocused() { globalStore.set(this.focusType, "app"); } getFocusType(): BuilderFocusType { return globalStore.get(this.focusType); } } ================================================ FILE: frontend/builder/tabs/builder-codetab.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; import { atoms } from "@/store/global"; import * as keyutil from "@/util/keyutil"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; import type * as MonacoTypes from "monaco-editor"; import { memo, useEffect } from "react"; const BuilderCodeTab = memo(() => { const model = BuilderAppPanelModel.getInstance(); const builderAppId = useAtomValue(atoms.builderAppId); const codeContent = useAtomValue(model.codeContentAtom); const isLoading = useAtomValue(model.isLoadingAtom); const error = useAtomValue(model.errorAtom); const saveNeeded = useAtomValue(model.saveNeededAtom); const activeTab = useAtomValue(model.activeTab); useEffect(() => { if (activeTab === "code" && model.monacoEditorRef.current) { setTimeout(() => { model.monacoEditorRef.current?.layout(); }, 0); } }, [activeTab, model.monacoEditorRef]); const handleCodeChange = (newText: string) => { model.setCodeContent(newText); }; const handleEditorMount = (editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: typeof MonacoTypes) => { model.setMonacoEditorRef(editor); return () => { model.setMonacoEditorRef(null); }; }; const handleSave = () => { if (builderAppId) { model.saveAppFile(builderAppId); } }; const handleKeyDown = keyutil.keydownWrapper((waveEvent: WaveKeyboardEvent) => { if (keyutil.checkKeyPressed(waveEvent, "Cmd:s")) { handleSave(); return true; } return false; }); if (!builderAppId) { return ( <div className="w-full h-full flex items-center justify-center"> <div className="text-secondary">No builder app selected</div> </div> ); } if (isLoading) { return ( <div className="w-full h-full flex items-center justify-center"> <div className="text-secondary">Loading app.go...</div> </div> ); } if (error) { return ( <div className="w-full h-full flex items-center justify-center"> <div className="text-red-500">{error}</div> </div> ); } return ( <div className="w-full h-full relative" onKeyDown={handleKeyDown}> <button className={cn( "absolute top-1 right-4 z-50 px-3 py-1 text-sm font-medium rounded transition-colors shadow-lg", saveNeeded ? "bg-accent/80 text-primary hover:bg-accent cursor-pointer" : "bg-gray-600 text-gray-400 cursor-default" )} onClick={saveNeeded ? handleSave : undefined} > Save </button> <CodeEditor blockId={builderAppId} text={codeContent} readonly={false} language="go" fileName="app.go" onChange={handleCodeChange} onMount={handleEditorMount} /> </div> ); }); BuilderCodeTab.displayName = "BuilderCodeTab"; export { BuilderCodeTab }; ================================================ FILE: frontend/builder/tabs/builder-configdatatab.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; import { CopyButton } from "@/element/copybutton"; import { atoms } from "@/store/global"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useState } from "react"; const NotRunningView = memo(() => { return ( <div className="w-full h-full flex items-center justify-center bg-background"> <div className="flex flex-col items-center gap-6 max-w-[500px] text-center px-8"> <i className="fa fa-triangle-exclamation text-6xl text-warning" /> <div className="flex flex-col gap-3"> <h2 className="text-2xl font-semibold text-primary">App Not Running</h2> <p className="text-base text-secondary leading-relaxed"> The tsunami app must be running to view config and data. Please start the app from the Preview tab first. </p> </div> </div> </div> ); }); NotRunningView.displayName = "NotRunningView"; const ErrorView = memo(({ errorMsg }: { errorMsg: string }) => { return ( <div className="w-full h-full flex items-center justify-center bg-background"> <div className="flex flex-col items-center gap-6 max-w-2xl text-center px-8"> <i className="fa fa-circle-xmark text-6xl text-error" /> <div className="flex flex-col gap-3"> <h2 className="text-2xl font-semibold text-error">Error Loading Data</h2> <div className="text-left bg-panel border border-error/30 rounded-lg p-4"> <pre className="text-sm text-secondary whitespace-pre-wrap font-mono">{errorMsg}</pre> </div> </div> </div> </div> ); }); ErrorView.displayName = "ErrorView"; const LoadingView = memo(() => { return ( <div className="w-full h-full flex items-center justify-center bg-background"> <div className="flex flex-col items-center gap-6"> <i className="fa fa-spinner fa-spin text-6xl text-secondary" /> <p className="text-base text-secondary">Loading data...</p> </div> </div> ); }); LoadingView.displayName = "LoadingView"; type ConfigDataState = { config: any; data: any; error: string | null; isLoading: boolean; }; const BuilderConfigDataTab = memo(() => { const model = BuilderAppPanelModel.getInstance(); const builderStatus = useAtomValue(model.builderStatusAtom); const builderId = useAtomValue(atoms.builderId); const activeTab = useAtomValue(model.activeTab); const [state, setState] = useState<ConfigDataState>({ config: null, data: null, error: null, isLoading: false, }); const isRunning = builderStatus?.status === "running" && builderStatus?.port && builderStatus.port !== 0; const fetchData = useCallback(async () => { if (!isRunning || !builderStatus?.port) { return; } setState((prev) => ({ ...prev, isLoading: true, error: null })); try { const baseUrl = `http://localhost:${builderStatus.port}`; const [configResponse, dataResponse] = await Promise.all([ fetch(`${baseUrl}/api/config`), fetch(`${baseUrl}/api/data`), ]); if (!configResponse.ok) { throw new Error(`Failed to fetch config: ${configResponse.statusText}`); } if (!dataResponse.ok) { throw new Error(`Failed to fetch data: ${dataResponse.statusText}`); } const config = await configResponse.json(); const data = await dataResponse.json(); setState({ config, data, error: null, isLoading: false, }); } catch (err) { setState({ config: null, data: null, error: err instanceof Error ? err.message : String(err), isLoading: false, }); } }, [isRunning, builderStatus?.port]); const handleRefresh = useCallback(async () => { setState({ config: null, data: null, error: null, isLoading: true, }); await new Promise((resolve) => setTimeout(resolve, 200)); await fetchData(); }, [fetchData]); const handleCopyConfig = useCallback(() => { if (state.config) { navigator.clipboard.writeText(JSON.stringify(state.config, null, 2)); } }, [state.config]); const handleCopyData = useCallback(() => { if (state.data) { navigator.clipboard.writeText(JSON.stringify(state.data, null, 2)); } }, [state.data]); useEffect(() => { if (activeTab === "configdata" && isRunning) { fetchData(); } else if (!isRunning) { setState({ config: null, data: null, error: null, isLoading: false, }); } }, [activeTab, isRunning, fetchData]); if (!isRunning) { return <NotRunningView />; } if (state.isLoading) { return <LoadingView />; } if (state.error) { return <ErrorView errorMsg={state.error} />; } if (!state.config && !state.data) { return <LoadingView />; } return ( <div className="w-full h-full flex flex-col bg-background"> <div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-border"> <h3 className="text-lg font-semibold text-primary">Config & Data</h3> <button onClick={handleRefresh} className="px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer flex items-center gap-2" > <i className="fa fa-refresh" /> Refresh </button> </div> <div className="flex-1 overflow-auto p-4"> <div className="flex flex-col gap-6"> <div className="flex flex-col gap-2"> <div className="flex items-center justify-between"> <h4 className="text-base font-semibold text-primary flex items-center gap-2"> <i className="fa fa-gear" /> Config </h4> <CopyButton title="Copy Config" onClick={handleCopyConfig} /> </div> <div className="bg-panel border border-border rounded-lg p-4 overflow-auto"> <pre className="text-xs text-primary font-mono whitespace-pre"> {JSON.stringify(state.config, null, 2)} </pre> </div> </div> <div className="flex flex-col gap-2"> <div className="flex items-center justify-between"> <h4 className="text-base font-semibold text-primary flex items-center gap-2"> <i className="fa fa-database" /> Data </h4> <CopyButton title="Copy Data" onClick={handleCopyData} /> </div> <div className="bg-panel border border-border rounded-lg p-4 overflow-auto"> <pre className="text-xs text-primary font-mono whitespace-pre"> {JSON.stringify(state.data, null, 2)} </pre> </div> </div> </div> </div> </div> ); }); BuilderConfigDataTab.displayName = "BuilderConfigDataTab"; export { BuilderConfigDataTab }; ================================================ FILE: frontend/builder/tabs/builder-filestab.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { formatFileSize } from "@/app/aipanel/ai-utils"; import { Modal } from "@/app/modals/modal"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { modalsModel } from "@/app/store/modalmodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { arrayToBase64 } from "@/util/util"; import { atoms } from "@/store/global"; import { useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useRef, useState } from "react"; const MaxFileSize = 5 * 1024 * 1024; // 5MB const ReadOnlyFileNames = ["static/tw.css"]; type FileEntry = { name: string; size: number; modified: string; isReadOnly: boolean; }; const RenameFileModal = memo( ({ appId, fileName, onSuccess }: { appId: string; fileName: string; onSuccess: () => void }) => { const displayName = fileName.replace("static/", ""); const [newName, setNewName] = useState(displayName); const [error, setError] = useState(""); const [isRenaming, setIsRenaming] = useState(false); const handleRename = async () => { const trimmedName = newName.trim(); if (!trimmedName) { setError("File name cannot be empty"); return; } if (trimmedName.includes("/") || trimmedName.includes("\\")) { setError("File name cannot contain / or \\"); return; } if (trimmedName === displayName) { modalsModel.popModal(); return; } setIsRenaming(true); try { await RpcApi.RenameAppFileCommand(TabRpcClient, { appid: appId, fromfilename: fileName, tofilename: `static/${trimmedName}`, }); onSuccess(); modalsModel.popModal(); } catch (err) { console.log("Error renaming file:", err); setError(err instanceof Error ? err.message : String(err)); } finally { setIsRenaming(false); } }; const handleClose = () => { modalsModel.popModal(); }; return ( <Modal className="p-4 min-w-[500px]" onOk={handleRename} onCancel={handleClose} onClose={handleClose} okLabel="Rename" cancelLabel="Cancel" okDisabled={isRenaming || !newName.trim()} > <div className="flex flex-col gap-4 mb-4"> <h2 className="text-xl font-semibold">Rename File</h2> <div className="flex flex-col gap-2"> <div className="text-sm text-secondary mb-1"> Current name: <span className="font-medium text-primary">{displayName}</span> </div> <input type="text" value={newName} onChange={(e) => { setNewName(e.target.value); setError(""); }} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing && newName.trim() && !error) { handleRename(); } }} className="px-3 py-2 bg-panel border border-border rounded focus:outline-none focus:border-accent" autoFocus disabled={isRenaming} spellCheck={false} /> {error && <div className="text-sm text-error">{error}</div>} </div> </div> </Modal> ); } ); RenameFileModal.displayName = "RenameFileModal"; const DeleteFileModal = memo( ({ appId, fileName, onSuccess }: { appId: string; fileName: string; onSuccess: () => void }) => { const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState(""); const handleDelete = async () => { setIsDeleting(true); setError(""); try { await RpcApi.DeleteAppFileCommand(TabRpcClient, { appid: appId, filename: fileName, }); onSuccess(); modalsModel.popModal(); } catch (err) { console.log("Error deleting file:", err); setError(err instanceof Error ? err.message : String(err)); } finally { setIsDeleting(false); } }; const handleClose = () => { modalsModel.popModal(); }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !isDeleting) { e.preventDefault(); handleDelete(); } else if (e.key === "Escape") { e.preventDefault(); handleClose(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isDeleting]); return ( <Modal className="p-4 min-w-[500px]" onOk={handleDelete} onCancel={handleClose} onClose={handleClose} okLabel="Delete" cancelLabel="Cancel" okDisabled={isDeleting} > <div className="flex flex-col gap-4 mb-4"> <h2 className="text-xl font-semibold">Delete File</h2> <p> Are you sure you want to delete <strong>{fileName.replace("static/", "")}</strong>? </p> <p className="text-sm text-secondary">This action cannot be undone.</p> {error && <div className="text-sm text-error">{error}</div>} </div> </Modal> ); } ); DeleteFileModal.displayName = "DeleteFileModal"; const BuilderFilesTab = memo(() => { const builderAppId = useAtomValue(atoms.builderAppId); const [files, setFiles] = useState<FileEntry[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [isDragging, setIsDragging] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; fileName: string } | null>(null); const fileInputRef = useRef<HTMLInputElement>(null); const loadFiles = useCallback(async () => { if (!builderAppId) return; setLoading(true); setError(""); try { const result = await RpcApi.ListAllAppFilesCommand(TabRpcClient, { appid: builderAppId }); const fileEntries: FileEntry[] = result.entries .filter((entry) => !entry.dir && entry.name.startsWith("static/")) .map((entry) => ({ name: entry.name, size: entry.size || 0, modified: entry.modified, isReadOnly: ReadOnlyFileNames.includes(entry.name), })) .sort((a, b) => a.name.localeCompare(b.name)); setFiles(fileEntries); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }, [builderAppId]); const handleRefresh = useCallback(async () => { // Clear files and add delay so UX shows the refresh is happening setFiles([]); await new Promise((resolve) => setTimeout(resolve, 100)); await loadFiles(); }, [loadFiles]); useEffect(() => { loadFiles(); }, [loadFiles]); useEffect(() => { const handleClickOutside = () => setContextMenu(null); if (contextMenu) { document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); } }, [contextMenu]); const handleFileUpload = async (fileList: FileList) => { if (!builderAppId || fileList.length === 0) return; const file = fileList[0]; if (file.size > MaxFileSize) { setError(`File size exceeds maximum allowed size of ${formatFileSize(MaxFileSize)}`); return; } setError(""); setLoading(true); try { const arrayBuffer = await file.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); const base64Encoded = arrayToBase64(uint8Array); await RpcApi.WriteAppFileCommand(TabRpcClient, { appid: builderAppId, filename: `static/${file.name}`, data64: base64Encoded, }); await loadFiles(); } catch (err) { console.error("Error uploading file:", err); setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); handleFileUpload(e.dataTransfer.files); }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }; const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) { handleFileUpload(e.target.files); } }; const handleContextMenu = (e: React.MouseEvent, fileName: string) => { const menu: ContextMenuItem[] = [ { label: "Rename File", click: () => { modalsModel.pushModal("RenameFileModal", { appId: builderAppId, fileName, onSuccess: loadFiles }); }, }, { type: "separator", }, { label: "Delete File", click: () => { modalsModel.pushModal("DeleteFileModal", { appId: builderAppId, fileName, onSuccess: loadFiles }); }, }, ]; ContextMenuModel.getInstance().showContextMenu(menu, e); }; return ( <div className={`w-full h-full flex flex-col p-4 border-2 border-dashed transition-colors ${ isDragging ? "bg-accent/5 border-accent" : "border-transparent" }`} onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave} > <div className="flex items-center justify-between mb-4"> <h2 className="text-lg font-semibold">Static Files</h2> <div className="flex gap-2"> <button className="px-3 py-1 text-sm font-medium rounded bg-panel border border-border hover:bg-hover transition-colors cursor-pointer" onClick={handleRefresh} disabled={loading} title="Refresh file list" > <i className="fa fa-refresh" /> </button> <button className="px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer" onClick={() => fileInputRef.current?.click()} disabled={loading} > <i className="fa fa-plus mr-2" /> Add File </button> </div> <input ref={fileInputRef} type="file" onChange={handleFileInputChange} className="hidden" /> </div> {error && ( <div className="mb-4 p-3 bg-error/10 border border-error/30 rounded text-sm text-error flex items-center gap-2"> <i className="fa fa-triangle-exclamation" /> <span>{error}</span> </div> )} <div className="mb-3 p-2 bg-blue-500/10 border border-blue-500/30 rounded text-sm text-secondary"> Drag and drop files here or click "Add File". Maximum file size: {formatFileSize(MaxFileSize)} </div> <div className="flex-1 overflow-auto"> {loading && files.length === 0 ? ( <div className="text-center text-secondary py-8">Loading files...</div> ) : files.length === 0 ? ( <div className="text-center text-secondary py-12"> <i className="fa fa-file text-4xl mb-3 opacity-50" /> <p>No files yet. Drag and drop files here or click "Add File" to get started.</p> </div> ) : ( <div className="space-y-1"> {files.map((file) => ( <div key={file.name} className="flex items-center gap-3 p-2 bg-panel hover:bg-hover border border-border rounded transition-colors" onContextMenu={(e) => !file.isReadOnly && handleContextMenu(e, file.name)} > <i className="fa fa-file text-secondary" /> <div className="flex-1 min-w-0"> <div className="font-medium truncate">{file.name.replace("static/", "")}</div> <div className="text-xs text-secondary"> {formatFileSize(file.size)} {file.isReadOnly && ( <span className="ml-2 text-warning"> <i className="fa fa-lock mr-1" /> Generated by framework (read-only) </span> )} </div> </div> <div className="text-xs text-secondary">{file.modified}</div> {!file.isReadOnly && ( <button className="px-2 py-1 hover:bg-hover rounded transition-colors cursor-pointer" onClick={(e) => handleContextMenu(e, file.name)} title="File options" > <i className="fa fa-ellipsis-vertical" /> </button> )} </div> ))} </div> )} </div> </div> ); }); BuilderFilesTab.displayName = "BuilderFilesTab"; export { BuilderFilesTab, DeleteFileModal, RenameFileModal }; ================================================ FILE: frontend/builder/tabs/builder-previewtab.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; import { BuilderBuildPanelModel } from "@/builder/store/builder-buildpanel-model"; import { atoms } from "@/store/global"; import { useAtomValue } from "jotai"; import { memo, useState } from "react"; const EmptyStateView = memo(() => { return ( <div className="w-full h-full flex items-center justify-center bg-background"> <div className="flex flex-col items-center gap-6 max-w-[500px] text-center px-8"> <div className="text-6xl">🏗️</div> <div className="flex flex-col gap-3"> <h2 className="text-2xl font-semibold text-primary">No App to Preview</h2> <p className="text-base text-secondary leading-relaxed"> Get started by using the AI chat interface on the left to create your WaveApp. Describe what you want to build, and the AI will help you generate the code. </p> </div> <div className="text-base text-secondary mt-2"> Your app will appear here once <span className="font-mono">app.go</span> is created </div> </div> </div> ); }); EmptyStateView.displayName = "EmptyStateView"; const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => { const displayMsg = errorMsg && errorMsg.trim() ? errorMsg : "Unknown Error"; const waveAIModel = WaveAIModel.getInstance(); const buildPanelModel = BuilderBuildPanelModel.getInstance(); const appPanelModel = BuilderAppPanelModel.getInstance(); const outputLines = useAtomValue(buildPanelModel.outputLines); const isStreaming = useAtomValue(waveAIModel.isAIStreaming); const isSecretError = displayMsg.includes("ERR-SECRET"); const getBuildContext = () => { const filteredLines = outputLines.filter((line) => !line.startsWith("[debug]")); const buildOutput = filteredLines.join("\n").trim(); return `Build Error:\n\`\`\`\n${displayMsg}\n\`\`\`\n\nBuild Output:\n\`\`\`\n${buildOutput}\n\`\`\``; }; const handleAddToContext = () => { const context = getBuildContext(); waveAIModel.appendText(context, true); waveAIModel.focusInput(); }; const handleAskAIToFix = async () => { const context = getBuildContext(); waveAIModel.appendText("Please help me fix this build error:\n\n" + context, true); await waveAIModel.handleSubmit(); }; const handleGoToSecrets = () => { appPanelModel.setActiveTab("secrets"); }; if (isSecretError) { return ( <div className="w-full h-full flex items-center justify-center bg-background"> <div className="flex flex-col items-center gap-6 max-w-2xl text-center px-8"> <div className="text-6xl">🔐</div> <div className="flex flex-col gap-3"> <h2 className="text-2xl font-semibold text-error">Secrets Required</h2> <p className="text-base text-secondary leading-relaxed"> This app requires secrets that must be configured. Please use the Secrets tab to set and bind the required secrets for your app to run. </p> <div className="text-left bg-panel border border-error/30 rounded-lg p-4 max-h-96 overflow-auto mt-2"> <pre className="text-sm text-secondary whitespace-pre-wrap font-mono">{displayMsg}</pre> </div> <button onClick={handleGoToSecrets} className="px-6 py-2 mt-2 bg-accent/80 text-primary font-semibold rounded hover:bg-accent transition-colors cursor-pointer" > Go to Secrets Tab </button> </div> </div> </div> ); } return ( <div className="w-full h-full flex items-center justify-center bg-background"> <div className="flex flex-col items-center gap-6 max-w-2xl text-center px-8"> <div className="flex flex-col gap-3"> <h2 className="text-2xl font-semibold text-error">Build Error</h2> <div className="text-left bg-panel border border-error/30 rounded-lg p-4 max-h-96 overflow-auto"> <pre className="text-sm text-secondary whitespace-pre-wrap font-mono">{displayMsg}</pre> </div> {!isStreaming && ( <div className="flex gap-3 mt-2 justify-center"> <button onClick={handleAddToContext} className="px-4 py-2 bg-panel text-primary border border-border rounded hover:bg-panel/80 transition-colors cursor-pointer" > Add Error to AI Context </button> <button onClick={handleAskAIToFix} className="px-4 py-2 bg-accent/80 text-primary font-semibold rounded hover:bg-accent transition-colors cursor-pointer" > Ask AI to Fix </button> </div> )} </div> </div> </div> ); }); ErrorStateView.displayName = "ErrorStateView"; const BuildingStateView = memo(() => { return ( <div className="w-full h-full flex items-center justify-center bg-background"> <div className="flex flex-col items-center gap-6 max-w-[500px] text-center px-8"> <div className="text-6xl">⚙️</div> <div className="flex flex-col gap-3"> <h2 className="text-2xl font-semibold text-primary">App is Building...</h2> <p className="text-base text-secondary leading-relaxed"> Your WaveApp is being compiled and prepared. This may take a few moments. </p> </div> </div> </div> ); }); BuildingStateView.displayName = "BuildingStateView"; const StoppedStateView = memo(({ onStart }: { onStart: () => void }) => { const [isStarting, setIsStarting] = useState(false); const handleStart = () => { setIsStarting(true); onStart(); setTimeout(() => setIsStarting(false), 2000); }; return ( <div className="w-full h-full flex items-center justify-center bg-background"> <div className="flex flex-col items-center gap-6 max-w-[500px] text-center px-8"> <div className="flex flex-col gap-3"> <h2 className="text-2xl font-semibold text-primary">App is Not Running</h2> <p className="text-base text-secondary leading-relaxed"> Your WaveApp is currently not running. Click the button below to start it. </p> </div> {!isStarting && ( <button onClick={handleStart} className="px-6 py-2 bg-accent text-primary font-semibold rounded hover:bg-accent/80 transition-colors cursor-pointer" > Start App </button> )} {isStarting && <div className="text-base text-success">Starting...</div>} </div> </div> ); }); StoppedStateView.displayName = "StoppedStateView"; const BuilderPreviewTab = memo(() => { const model = BuilderAppPanelModel.getInstance(); const isLoading = useAtomValue(model.isLoadingAtom); const originalContent = useAtomValue(model.originalContentAtom); const builderStatus = useAtomValue(model.builderStatusAtom); const builderId = useAtomValue(atoms.builderId); const fileExists = originalContent.length > 0; if (isLoading) { return null; } if (builderStatus?.status === "error") { return <ErrorStateView errorMsg={builderStatus?.errormsg || ""} />; } if (!fileExists) { return <EmptyStateView />; } const status = builderStatus?.status || "init"; if (status === "init") { return null; } if (status === "building") { return <BuildingStateView />; } if (status === "stopped") { return <StoppedStateView onStart={() => model.startBuilder()} />; } const shouldShowWebView = status === "running" && builderStatus?.port && builderStatus.port !== 0; if (shouldShowWebView) { const previewUrl = `http://localhost:${builderStatus.port}/?clientid=wave:${builderId}`; return ( <div className="w-full h-full"> <webview src={previewUrl} className="w-full h-full" /> </div> ); } return null; }); BuilderPreviewTab.displayName = "BuilderPreviewTab"; export { BuilderPreviewTab }; ================================================ FILE: frontend/builder/tabs/builder-secrettab.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms } from "@/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { useAtomValue } from "jotai"; import { memo, useState, useEffect } from "react"; import { Check, AlertTriangle } from "lucide-react"; import { Tooltip } from "@/app/element/tooltip"; import { Modal } from "@/app/modals/modal"; import { modalsModel } from "@/app/store/modalmodel"; type SecretRowProps = { secretName: string; secretMeta: SecretMeta; currentBinding: string; availableSecrets: string[]; onMapDefault: (secretName: string) => void; onSetAndMapDefault: (secretName: string) => void; }; const SecretRow = memo(({ secretName, secretMeta, currentBinding, availableSecrets, onMapDefault, onSetAndMapDefault }: SecretRowProps) => { const isMapped = currentBinding.trim().length > 0; const isValid = isMapped && availableSecrets.includes(currentBinding); const isInvalid = isMapped && !isValid; const hasMatchingSecret = availableSecrets.includes(secretName); return ( <div className="flex items-center gap-4 py-2 border-b border-border"> <Tooltip content={!isMapped ? "Secret is Not Mapped" : isValid ? "Secret Has a Valid Mapping" : "Secret Binding is Invalid"}> <div className="flex items-center"> {!isMapped && <AlertTriangle className="w-5 h-5 text-yellow-500" />} {isInvalid && <AlertTriangle className="w-5 h-5 text-red-500" />} {isValid && <Check className="w-5 h-5 text-green-500" />} </div> </Tooltip> <div className="flex-1 flex items-center gap-2"> <span className="font-medium text-primary">{secretName}</span> {!secretMeta.optional && ( <span className="px-2 py-0.5 text-xs bg-red-500/20 text-red-500 rounded">Required</span> )} {secretMeta.optional && ( <span className="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-500 rounded">Optional</span> )} {secretMeta.desc && <span className="text-sm text-secondary">— {secretMeta.desc}</span>} </div> <div className="flex items-center gap-2"> {!isMapped && hasMatchingSecret && ( <button onClick={() => onMapDefault(secretName)} className="px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer whitespace-nowrap" > Map Default </button> )} {!isMapped && !hasMatchingSecret && ( <button onClick={() => onSetAndMapDefault(secretName)} className="px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer whitespace-nowrap" > Set and Map Default </button> )} </div> </div> ); }); SecretRow.displayName = "SecretRow"; type SetSecretDialogProps = { secretName: string; onSetAndMap: (secretName: string, secretValue: string) => Promise<void>; }; const SetSecretDialog = memo(({ secretName, onSetAndMap }: SetSecretDialogProps) => { const [secretValue, setSecretValue] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(""); const handleSubmit = async () => { if (!secretValue.trim()) return; setIsSubmitting(true); setError(""); try { await onSetAndMap(secretName, secretValue); modalsModel.popModal(); } catch (err) { console.error("Failed to set secret:", err); setError(err instanceof Error ? err.message : String(err)); } finally { setIsSubmitting(false); } }; const handleClose = () => { modalsModel.popModal(); }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); handleClose(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, []); if (error) { return ( <Modal className="p-4 min-w-[500px]" onOk={handleClose} onClose={handleClose} okLabel="OK"> <div className="flex flex-col gap-4 mb-4"> <h2 className="text-xl font-semibold">Error Setting Secret</h2> <div className="text-sm text-error">{error}</div> </div> </Modal> ); } return ( <Modal className="p-4 min-w-[500px]" onOk={handleSubmit} onCancel={handleClose} onClose={handleClose} okLabel="Set and Map" cancelLabel="Cancel" okDisabled={!secretValue.trim() || isSubmitting} > <div className="flex flex-col gap-4 mb-4"> <h2 className="text-xl font-semibold">Set and Map Secret</h2> <div className="flex flex-col gap-2"> <div className="text-sm font-medium mb-1"> Secret Name: <span className="text-accent">{secretName}</span> </div> <textarea value={secretValue} onChange={(e) => setSecretValue(e.target.value)} placeholder="Paste secret value here..." className="w-full px-3 py-2 bg-panel border border-border rounded focus:outline-none focus:border-accent resize-none" rows={4} autoFocus disabled={isSubmitting} /> <div className="text-xs text-secondary"> Secrets are stored securely in Wave's secret store </div> </div> </div> </Modal> ); }); SetSecretDialog.displayName = "SetSecretDialog"; const BuilderSecretTab = memo(() => { const model = BuilderAppPanelModel.getInstance(); const builderStatus = useAtomValue(model.builderStatusAtom); const error = useAtomValue(model.errorAtom); const [availableSecrets, setAvailableSecrets] = useState<string[]>([]); const manifest = builderStatus?.manifest; const secrets = manifest?.secrets || {}; const secretBindings = builderStatus?.secretbindings || {}; useEffect(() => { const fetchSecrets = async () => { try { const secrets = await RpcApi.GetSecretsNamesCommand(TabRpcClient); setAvailableSecrets(secrets || []); } catch (err) { console.error("Failed to fetch secrets:", err); } }; fetchSecrets(); }, []); if (!builderStatus || !manifest) { return ( <div className="w-full h-full flex items-center justify-center"> <div className="text-secondary text-center"> App manifest not available. Secrets will be shown once the app builds successfully. </div> </div> ); } const sortedSecretEntries = Object.entries(secrets).sort(([nameA, metaA], [nameB, metaB]) => { if (!metaA.optional && metaB.optional) return -1; if (metaA.optional && !metaB.optional) return 1; return nameA.localeCompare(nameB); }); const handleMapDefault = async (secretName: string) => { const newBindings = { ...secretBindings, [secretName]: secretName }; try { const appId = globalStore.get(atoms.builderAppId); await RpcApi.WriteAppSecretBindingsCommand(TabRpcClient, { appid: appId, bindings: newBindings, }); model.updateSecretBindings(newBindings); globalStore.set(model.errorAtom, ""); model.restartBuilder(); } catch (err) { console.error("Failed to save secret bindings:", err); globalStore.set(model.errorAtom, `Failed to save secret bindings: ${err.message || "Unknown error"}`); } }; const handleSetAndMapDefault = (secretName: string) => { modalsModel.pushModal("SetSecretDialog", { secretName, onSetAndMap: handleSetAndMap }); }; const handleSetAndMap = async (secretName: string, secretValue: string) => { await RpcApi.SetSecretsCommand(TabRpcClient, { [secretName]: secretValue }); setAvailableSecrets((prev) => [...prev, secretName]); const newBindings = { ...secretBindings, [secretName]: secretName }; try { const appId = globalStore.get(atoms.builderAppId); await RpcApi.WriteAppSecretBindingsCommand(TabRpcClient, { appid: appId, bindings: newBindings, }); model.updateSecretBindings(newBindings); globalStore.set(model.errorAtom, ""); model.restartBuilder(); } catch (err) { console.error("Failed to save secret bindings:", err); globalStore.set(model.errorAtom, `Failed to save secret bindings: ${err.message || "Unknown error"}`); } }; const allRequiredBound = sortedSecretEntries.filter(([_, meta]) => !meta.optional).every(([name]) => secretBindings[name]?.trim()) || false; return ( <div className="w-full h-full flex flex-col p-4"> <h2 className="text-lg font-semibold mb-2">Secret Bindings</h2> <div className="mb-4 p-2 bg-blue-500/10 border border-blue-500/30 rounded text-sm text-secondary"> Map app secrets to Wave secret store names. Required secrets must be bound before the app can run successfully. Changes are saved automatically. </div> {!allRequiredBound && ( <div className="mb-4 p-2 bg-yellow-500/10 border border-yellow-500/30 rounded text-sm text-yellow-600"> Some required secrets are not bound yet. </div> )} {error && <div className="mb-4 p-2 bg-red-500/20 text-red-500 rounded text-sm">{error}</div>} <div className="flex-1 overflow-auto"> {sortedSecretEntries.length === 0 ? ( <div className="text-secondary text-center py-8"> No secrets defined in this app manifest. </div> ) : ( <div className="space-y-1"> {sortedSecretEntries.map(([secretName, secretMeta]) => ( <SecretRow key={secretName} secretName={secretName} secretMeta={secretMeta} currentBinding={secretBindings[secretName] || ""} availableSecrets={availableSecrets} onMapDefault={handleMapDefault} onSetAndMapDefault={handleSetAndMapDefault} /> ))} </div> )} </div> </div> ); }); BuilderSecretTab.displayName = "BuilderSecretTab"; export { BuilderSecretTab, SetSecretDialog }; ================================================ FILE: frontend/builder/utils/builder-focus-utils.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export function findBuilderAppPanel(element: HTMLElement): HTMLElement | null { let current: HTMLElement = element; while (current) { if (current.hasAttribute("data-builder-app-panel")) { return current; } current = current.parentElement; } return null; } export function builderAppHasFocusWithin(focusTarget?: Element | null): boolean { if (focusTarget !== undefined) { if (focusTarget instanceof HTMLElement) { return findBuilderAppPanel(focusTarget) != null; } return false; } const focused = document.activeElement; if (focused instanceof HTMLElement) { const appPanel = findBuilderAppPanel(focused); if (appPanel) return true; } const sel = document.getSelection(); if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { let anchor = sel.anchorNode; if (anchor instanceof Text) { anchor = anchor.parentElement; } if (anchor instanceof HTMLElement) { const appPanel = findBuilderAppPanel(anchor); if (appPanel) return true; } } return false; } export function builderAppHasSelection(): boolean { const sel = document.getSelection(); if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { return false; } let anchor = sel.anchorNode; if (anchor instanceof Text) { anchor = anchor.parentElement; } if (anchor instanceof HTMLElement) { return findBuilderAppPanel(anchor) != null; } return false; } ================================================ FILE: frontend/layout/index.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { TileLayout } from "./lib/TileLayout"; import { LayoutModel } from "./lib/layoutModel"; import { deleteLayoutModelForTab, getLayoutModelForStaticTab, useDebouncedNodeInnerRect } from "./lib/layoutModelHooks"; import { newLayoutNode } from "./lib/layoutNode"; import type { ContentRenderer, LayoutNode, LayoutTreeAction, LayoutTreeClearPendingAction, LayoutTreeCommitPendingAction, LayoutTreeComputeMoveNodeAction, LayoutTreeDeleteNodeAction, LayoutTreeFocusNodeAction, LayoutTreeInsertNodeAction, LayoutTreeInsertNodeAtIndexAction, LayoutTreeMagnifyNodeToggleAction, LayoutTreeMoveNodeAction, LayoutTreeResizeNodeAction, LayoutTreeSetPendingAction, LayoutTreeStateSetter, LayoutTreeSwapNodeAction, NodeModel, PreviewRenderer, } from "./lib/types"; import { DropDirection, LayoutTreeActionType, NavigateDirection } from "./lib/types"; export { deleteLayoutModelForTab, DropDirection, getLayoutModelForStaticTab, LayoutModel, LayoutTreeActionType, NavigateDirection, newLayoutNode, TileLayout, useDebouncedNodeInnerRect, }; export type { ContentRenderer, LayoutNode, LayoutTreeAction, LayoutTreeClearPendingAction, LayoutTreeCommitPendingAction, LayoutTreeComputeMoveNodeAction, LayoutTreeDeleteNodeAction, LayoutTreeFocusNodeAction, LayoutTreeInsertNodeAction, LayoutTreeInsertNodeAtIndexAction, LayoutTreeMagnifyNodeToggleAction, LayoutTreeMoveNodeAction, LayoutTreeResizeNodeAction, LayoutTreeSetPendingAction, LayoutTreeStateSetter, LayoutTreeSwapNodeAction, NodeModel, PreviewRenderer, }; ================================================ FILE: frontend/layout/lib/TileLayout.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { getSettingsKeyAtom } from "@/app/store/global"; import clsx from "clsx"; import { toPng } from "html-to-image"; import { Atom, useAtomValue, useSetAtom } from "jotai"; import React, { CSSProperties, ReactNode, Suspense, memo, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { DropTargetMonitor, XYCoord, useDrag, useDragLayer, useDrop } from "react-dnd"; import { debounce, throttle } from "throttle-debounce"; import { useDevicePixelRatio } from "use-device-pixel-ratio"; import { LayoutModel } from "./layoutModel"; import { useNodeModel, useTileLayout } from "./layoutModelHooks"; import "./tilelayout.scss"; import { LayoutNode, LayoutTreeActionType, LayoutTreeComputeMoveNodeAction, ResizeHandleProps, TileLayoutContents, } from "./types"; import { determineDropDirection } from "./utils"; const tileItemType = "TILE_ITEM"; export interface TileLayoutProps { /** * The atom containing the layout tree state. */ tabAtom: Atom<Tab>; /** * callbacks and information about the contents (or styling) of the TileLayout or contents */ contents: TileLayoutContents; /** * A callback for getting the cursor point in reference to the current window. This removes Electron as a runtime dependency, allowing for better integration with Storybook. * @returns The cursor position relative to the current window. */ getCursorPoint?: () => Point; } const DragPreviewWidth = 300; const DragPreviewHeight = 300; function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutProps) { const layoutModel = useTileLayout(tabAtom, contents); const overlayTransform = useAtomValue(layoutModel.overlayTransform); const setActiveDrag = useSetAtom(layoutModel.activeDrag); const setReady = useSetAtom(layoutModel.ready); const isResizing = useAtomValue(layoutModel.isResizing); const { activeDrag, dragClientOffset, dragItemType } = useDragLayer((monitor) => ({ activeDrag: monitor.isDragging(), dragClientOffset: monitor.getClientOffset(), dragItemType: monitor.getItemType(), })); useEffect(() => { const activeTileDrag = activeDrag && dragItemType == tileItemType; setActiveDrag(activeTileDrag); }, [activeDrag, dragItemType]); const checkForCursorBounds = useCallback( debounce(100, (dragClientOffset: XYCoord) => { const cursorPoint = dragClientOffset ?? getCursorPoint?.(); if (cursorPoint && layoutModel.displayContainerRef?.current) { const displayContainerRect = layoutModel.displayContainerRef.current.getBoundingClientRect(); const normalizedX = cursorPoint.x - displayContainerRect.x; const normalizedY = cursorPoint.y - displayContainerRect.y; if ( normalizedX <= 0 || normalizedX >= displayContainerRect.width || normalizedY <= 0 || normalizedY >= displayContainerRect.height ) { layoutModel.treeReducer({ type: LayoutTreeActionType.ClearPendingAction }); } } }), [getCursorPoint] ); // Effect to detect when the cursor leaves the TileLayout hit trap so we can remove any placeholders. This cannot be done using pointer capture // because that conflicts with the DnD layer. useEffect(() => checkForCursorBounds(dragClientOffset), [dragClientOffset]); // Ensure that we don't see any jostling in the layout when we're rendering it the first time. // `animate` will be disabled until after the transforms have all applied the first time. const [animate, setAnimate] = useState(false); useEffect(() => { setTimeout(() => { setAnimate(true); setReady(true); }, 50); }, []); const gapSizePx = useAtomValue(layoutModel.gapSizePx); const animationTimeS = useAtomValue(layoutModel.animationTimeS); const tileStyle = useMemo( () => ({ "--gap-size-px": `${gapSizePx}px`, "--animation-time-s": `${animationTimeS}s`, }) as CSSProperties, [gapSizePx, animationTimeS] ); return ( <Suspense> <div className={clsx("tile-layout", contents.className, { animate: animate && !isResizing })} style={tileStyle} > <div key="display" ref={layoutModel.displayContainerRef} className="display-container"> <ResizeHandleWrapper layoutModel={layoutModel} /> <DisplayNodesWrapper layoutModel={layoutModel} /> <NodeBackdrops layoutModel={layoutModel} /> </div> <Placeholder key="placeholder" layoutModel={layoutModel} style={{ top: 10000, ...overlayTransform }} /> <OverlayNodeWrapper layoutModel={layoutModel} /> </div> </Suspense> ); } export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent; function NodeBackdrops({ layoutModel }: { layoutModel: LayoutModel }) { const [blockBlurAtom] = useState(() => getSettingsKeyAtom("window:magnifiedblockblursecondarypx")); const blockBlur = useAtomValue(blockBlurAtom); const ephemeralNode = useAtomValue(layoutModel.ephemeralNode); const magnifiedNodeId = useAtomValue(layoutModel.magnifiedNodeIdAtom); const [showMagnifiedBackdrop, setShowMagnifiedBackdrop] = useState(!!ephemeralNode); const [showEphemeralBackdrop, setShowEphemeralBackdrop] = useState(!!magnifiedNodeId); const debouncedSetMagnifyBackdrop = useCallback( debounce(100, () => setShowMagnifiedBackdrop(true)), [] ); useEffect(() => { if (magnifiedNodeId && !showMagnifiedBackdrop) { debouncedSetMagnifyBackdrop(); } if (!magnifiedNodeId) { setShowMagnifiedBackdrop(false); } if (ephemeralNode && !showEphemeralBackdrop) { setShowEphemeralBackdrop(true); } if (!ephemeralNode) { setShowEphemeralBackdrop(false); } }, [ephemeralNode, magnifiedNodeId]); const blockBlurStr = `${blockBlur}px`; return ( <> {showMagnifiedBackdrop && ( <div className="magnified-node-backdrop" onClick={() => { layoutModel.magnifyNodeToggle(magnifiedNodeId); }} style={{ "--block-blur": blockBlurStr } as CSSProperties} /> )} {showEphemeralBackdrop && ( <div className="ephemeral-node-backdrop" onClick={() => { layoutModel.closeNode(ephemeralNode?.id); }} style={{ "--block-blur": blockBlurStr } as CSSProperties} /> )} </> ); } interface DisplayNodesWrapperProps { /** * The layout tree state. */ layoutModel: LayoutModel; } const DisplayNodesWrapper = ({ layoutModel }: DisplayNodesWrapperProps) => { const leafs = useAtomValue(layoutModel.leafs); return useMemo( () => leafs.map((node) => { return <DisplayNode key={node.id} layoutModel={layoutModel} node={node} />; }), [leafs] ); }; interface DisplayNodeProps { layoutModel: LayoutModel; /** * The leaf node object, containing the data needed to display the leaf contents to the user. */ node: LayoutNode; } /** * The draggable and displayable portion of a leaf node in a layout tree. */ const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => { const nodeModel = useNodeModel(layoutModel, node); const tileNodeRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null); const addlProps = useAtomValue(nodeModel.additionalProps); const devicePixelRatio = useDevicePixelRatio(); const isEphemeral = useAtomValue(nodeModel.isEphemeral); const isMagnified = useAtomValue(nodeModel.isMagnified); const [{ isDragging }, drag, dragPreview] = useDrag( () => ({ type: tileItemType, canDrag: () => !(isEphemeral || isMagnified), item: () => node, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }), [node, addlProps, isEphemeral, isMagnified] ); const [previewElementGeneration, setPreviewElementGeneration] = useState(0); const previewElement = useMemo(() => { setPreviewElementGeneration(previewElementGeneration + 1); return ( <div key="preview" className="tile-preview-container"> <div className="tile-preview" ref={previewRef} style={{ width: DragPreviewWidth, height: DragPreviewHeight, transform: `scale(${1 / devicePixelRatio})`, }} > {layoutModel.renderPreview?.(nodeModel)} </div> </div> ); }, [devicePixelRatio, nodeModel]); const [previewImage, setPreviewImage] = useState<HTMLImageElement>(null); const [previewImageGeneration, setPreviewImageGeneration] = useState(0); const generatePreviewImage = useCallback(() => { const offsetX = (DragPreviewWidth * devicePixelRatio - DragPreviewWidth) / 2 + 10; const offsetY = (DragPreviewHeight * devicePixelRatio - DragPreviewHeight) / 2 + 10; if (previewImage !== null && previewElementGeneration === previewImageGeneration) { dragPreview(previewImage, { offsetY, offsetX }); } else if (previewRef.current) { setPreviewImageGeneration(previewElementGeneration); toPng(previewRef.current).then((url) => { const img = new Image(); img.src = url; setPreviewImage(img); dragPreview(img, { offsetY, offsetX }); }); } }, [ dragPreview, previewRef.current, previewElementGeneration, previewImageGeneration, previewImage, devicePixelRatio, ]); const leafContent = useMemo(() => { return ( <div key="leaf" className="tile-leaf"> {layoutModel.renderContent(nodeModel)} </div> ); }, [nodeModel]); // Register the display node as a draggable item useEffect(() => { drag(nodeModel.dragHandleRef); }, [drag, nodeModel.dragHandleRef.current]); return ( <div className={clsx("tile-node", { dragging: isDragging, })} key={node.id} ref={tileNodeRef} id={node.id} style={addlProps?.transform} onPointerEnter={generatePreviewImage} onPointerOver={(event) => event.stopPropagation()} > {leafContent} {previewElement} </div> ); }; interface OverlayNodeWrapperProps { layoutModel: LayoutModel; } const OverlayNodeWrapper = memo(({ layoutModel }: OverlayNodeWrapperProps) => { const leafs = useAtomValue(layoutModel.leafs); const overlayTransform = useAtomValue(layoutModel.overlayTransform); const overlayNodes = useMemo( () => leafs.map((node) => { return <OverlayNode key={node.id} layoutModel={layoutModel} node={node} />; }), [leafs] ); return ( <div key="overlay" className="overlay-container" style={{ top: 10000, ...overlayTransform }}> {overlayNodes} </div> ); }); interface OverlayNodeProps { /** * The layout tree state. */ layoutModel: LayoutModel; node: LayoutNode; } /** * An overlay representing the true flexbox layout of the LayoutTreeState. This holds the drop targets for moving around nodes and is used to calculate the * dimensions of the corresponding DisplayNode for each LayoutTreeState leaf. */ const OverlayNode = memo(({ node, layoutModel }: OverlayNodeProps) => { const nodeModel = useNodeModel(layoutModel, node); const additionalProps = useAtomValue(nodeModel.additionalProps); const overlayRef = useRef<HTMLDivElement>(null); const [, drop] = useDrop( () => ({ accept: tileItemType, canDrop: (_, monitor) => { const dragItem = monitor.getItem<LayoutNode>(); if (monitor.isOver({ shallow: true }) && dragItem.id !== node.id) { return true; } return false; }, drop: (_, monitor) => { if (!monitor.didDrop()) { layoutModel.onDrop(); } }, hover: throttle(50, (_, monitor: DropTargetMonitor<unknown, unknown>) => { if (monitor.isOver({ shallow: true })) { if (monitor.canDrop() && layoutModel.displayContainerRef?.current && additionalProps?.rect) { const dragItem = monitor.getItem<LayoutNode>(); // console.log("computing operation", layoutNode, dragItem, additionalProps.rect); const offset = monitor.getClientOffset(); const containerRect = layoutModel.displayContainerRef.current.getBoundingClientRect(); offset.x -= containerRect.x; offset.y -= containerRect.y; layoutModel.treeReducer({ type: LayoutTreeActionType.ComputeMove, nodeId: node.id, nodeToMoveId: dragItem.id, direction: determineDropDirection(additionalProps.rect, offset), } as LayoutTreeComputeMoveNodeAction); } else { layoutModel.treeReducer({ type: LayoutTreeActionType.ClearPendingAction, }); } } }), }), [node.id, additionalProps?.rect, layoutModel.displayContainerRef, layoutModel.onDrop, layoutModel.treeReducer] ); // Register the overlay node as a drop target useEffect(() => { drop(overlayRef); }, []); return <div ref={overlayRef} className="overlay-node" id={node.id} style={additionalProps?.transform} />; }); interface ResizeHandleWrapperProps { layoutModel: LayoutModel; } const ResizeHandleWrapper = memo(({ layoutModel }: ResizeHandleWrapperProps) => { const resizeHandles = useAtomValue(layoutModel.resizeHandles) as Atom<ResizeHandleProps>[]; return resizeHandles.map((resizeHandleAtom, i) => ( <ResizeHandle key={`resize-handle-${i}`} layoutModel={layoutModel} resizeHandleAtom={resizeHandleAtom} /> )); }); interface ResizeHandleComponentProps { resizeHandleAtom: Atom<ResizeHandleProps>; layoutModel: LayoutModel; } const ResizeHandle = memo(({ resizeHandleAtom, layoutModel }: ResizeHandleComponentProps) => { const resizeHandleProps = useAtomValue(resizeHandleAtom); const resizeHandleRef = useRef<HTMLDivElement>(null); // The pointer currently captured, or undefined. const [trackingPointer, setTrackingPointer] = useState<number>(undefined); // Calculates the new size of the two nodes on either side of the handle, based on the position of the cursor const handlePointerMove = useCallback( throttle(10, (event: React.PointerEvent<HTMLDivElement>) => { if (trackingPointer === event.pointerId) { const { clientX, clientY } = event; layoutModel.onResizeMove(resizeHandleProps, clientX, clientY); } }), [trackingPointer, layoutModel.onResizeMove, resizeHandleProps] ); // We want to use pointer capture so the operation continues even if the pointer leaves the bounds of the handle function onPointerDown(event: React.PointerEvent<HTMLDivElement>) { resizeHandleRef.current?.setPointerCapture(event.pointerId); } // This indicates that we're ready to start tracking the resize operation via the pointer function onPointerCapture(event: React.PointerEvent<HTMLDivElement>) { setTrackingPointer(event.pointerId); } // We want to wait a bit before committing the pending resize operation in case some events haven't arrived yet. const onPointerRelease = useCallback( debounce(30, (event: React.PointerEvent<HTMLDivElement>) => { setTrackingPointer(undefined); layoutModel.onResizeEnd(); }), [layoutModel] ); return ( <div ref={resizeHandleRef} className={clsx("resize-handle", `flex-${resizeHandleProps.flexDirection}`)} onPointerDown={onPointerDown} onGotPointerCapture={onPointerCapture} onLostPointerCapture={onPointerRelease} style={resizeHandleProps.transform} onPointerMove={handlePointerMove} > <div className="line" /> </div> ); }); interface PlaceholderProps { /** * The layout tree state. */ layoutModel: LayoutModel; /** * Any styling to apply to the placeholder container div. */ style: React.CSSProperties; } /** * An overlay to preview pending actions on the layout tree. */ const Placeholder = memo(({ layoutModel, style }: PlaceholderProps) => { const [placeholderOverlay, setPlaceholderOverlay] = useState<ReactNode>(null); const placeholderTransform = useAtomValue(layoutModel.placeholderTransform); useEffect(() => { if (placeholderTransform) { setPlaceholderOverlay(<div className="placeholder" style={placeholderTransform} />); } else { setPlaceholderOverlay(null); } }, [placeholderTransform]); return ( <div className="placeholder-container" style={style}> {placeholderOverlay} </div> ); }); ================================================ FILE: frontend/layout/lib/layoutAtom.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WOS } from "@/app/store/global"; import { Atom, Getter } from "jotai"; export function getLayoutStateAtomFromTab(tabAtom: Atom<Tab>, get: Getter): Atom<LayoutState> { const tabData = get(tabAtom); if (!tabData) return; const layoutStateOref = WOS.makeORef("layout", tabData.layoutstate); const layoutStateAtom = WOS.getWaveObjectAtom<LayoutState>(layoutStateOref); return layoutStateAtom; } ================================================ FILE: frontend/layout/lib/layoutModel.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { FocusManager } from "@/app/store/focusManager"; import { getSettingsKeyAtom } from "@/app/store/global"; import { BlockService } from "@/app/store/services"; import * as WOS from "@/app/store/wos"; import { atomWithThrottle, boundNumber, fireAndForget } from "@/util/util"; import { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai"; import { splitAtom } from "jotai/utils"; import { createRef, CSSProperties } from "react"; import { debounce } from "throttle-debounce"; import { getLayoutStateAtomFromTab } from "./layoutAtom"; import { balanceNode, findNode, newLayoutNode, walkNodes } from "./layoutNode"; import { clearTree, computeMoveNode, deleteNode, focusNode, insertNode, insertNodeAtIndex, magnifyNodeToggle, moveNode, replaceNode, resizeNode, splitHorizontal, splitVertical, swapNode, } from "./layoutTree"; import { ContentRenderer, FlexDirection, LayoutNode, LayoutNodeAdditionalProps, LayoutTreeAction, LayoutTreeActionType, LayoutTreeClearTreeAction, LayoutTreeComputeMoveNodeAction, LayoutTreeDeleteNodeAction, LayoutTreeFocusNodeAction, LayoutTreeInsertNodeAction, LayoutTreeInsertNodeAtIndexAction, LayoutTreeMagnifyNodeToggleAction, LayoutTreeMoveNodeAction, LayoutTreeReplaceNodeAction, LayoutTreeResizeNodeAction, LayoutTreeSetPendingAction, LayoutTreeSplitHorizontalAction, LayoutTreeSplitVerticalAction, LayoutTreeState, LayoutTreeSwapNodeAction, NavigateDirection, NavigationResult, NodeModel, PreviewRenderer, ResizeHandleProps, TileLayoutContents, } from "./types"; import { getCenter, navigateDirectionToOffset, setTransform } from "./utils"; interface ResizeContext { handleId: string; pixelToSizeRatio: number; displayContainerRect?: Dimensions; resizeHandleStartPx: number; beforeNodeId: string; beforeNodeStartSize: number; afterNodeId: string; afterNodeStartSize: number; } const DefaultGapSizePx = 3; const MinNodeSizePx = 40; const DefaultAnimationTimeS = 0.15; export class LayoutModel { /** * Local atom holding the current tree state (source of truth during runtime) */ private localTreeStateAtom: PrimitiveAtom<LayoutTreeState>; /** * The tree state (local cache) */ treeState: LayoutTreeState; /** * Reference to the tab atom for accessing WaveObject */ private tabAtom: Atom<Tab>; /** * WaveObject atom for persistence */ private waveObjectAtom: Atom<LayoutState>; /** * Debounce timer for persistence */ private persistDebounceTimer: NodeJS.Timeout | null; /** * Set of action IDs that have been processed (prevents duplicate processing) */ private processedActionIds: Set<string>; /** * The jotai getter that is used to read atom values. */ getter: Getter; /** * The jotai setter that is used to update atom values. */ setter: Setter; /** * Callback that is invoked to render the block associated with a leaf node. */ renderContent?: ContentRenderer; /** * Callback that is invoked to render the drag preview for a leaf node. */ renderPreview?: PreviewRenderer; /** * Callback that is invoked when a node is closed. */ onNodeDelete?: (data: TabLayoutData) => Promise<void>; /** * The size of the gap between nodes in CSS pixels. */ gapSizePx: PrimitiveAtom<number>; /** * The time a transition animation takes, in seconds. */ animationTimeS: PrimitiveAtom<number>; /** * List of nodes that are leafs and should be rendered as a DisplayNode. */ leafs: PrimitiveAtom<LayoutNode[]>; /** * An ordered list of node ids starting from the top left corner to the bottom right corner. */ leafOrder: PrimitiveAtom<LeafOrderEntry[]>; /** * Atom representing the number of leaf nodes in a layout. */ numLeafs: Atom<number>; /** * A map of node models for currently-active leafs. */ private nodeModels: Map<string, NodeModel>; /** * Split atom containing the properties of all of the resize handles that should be placed in the layout. */ resizeHandles: SplitAtom<ResizeHandleProps>; /** * Layout node derived properties that are not persisted to the backend. * @see updateTreeHelper for the logic to update these properties. */ additionalProps: PrimitiveAtom<Record<string, LayoutNodeAdditionalProps>>; /** * Set if there is currently an uncommitted action pending on the layout tree. * @see LayoutTreeActionType for the different types of actions. */ pendingTreeAction: AtomWithThrottle<LayoutTreeAction>; /** * Whether a node is currently being dragged. */ activeDrag: PrimitiveAtom<boolean>; /** * Whether the overlay container should be shown. * @see overlayTransform contains the actual CSS transform that moves the overlay into view. */ showOverlay: PrimitiveAtom<boolean>; /** * Whether the nodes within the layout should be displaying content. */ ready: PrimitiveAtom<boolean>; /** * RefObject for the display container, that holds the display nodes. This is used to get the size of the whole layout. */ displayContainerRef: React.RefObject<HTMLDivElement>; /** * CSS properties for the placeholder element. */ placeholderTransform: Atom<CSSProperties>; /** * CSS properties for the overlay container. */ overlayTransform: Atom<CSSProperties>; /** * The currently focused node. */ private focusedNodeIdStack: string[]; /** * Atom pointing to the currently focused node. */ focusedNode: Atom<LayoutNode>; // TODO: Nodes that need to be placed at higher z-indices should probably be handled by an ordered list, rather than individual properties. /** * The currently magnified node. */ magnifiedNodeId: string; /** * Atom for the magnified node ID (derived from local tree state) */ magnifiedNodeIdAtom: Atom<string>; /** * The last node to be magnified, other than the current magnified node, if set. This node should sit at a higher z-index than the others so that it floats above the other nodes as it returns to its original position. */ lastMagnifiedNodeId: string; /** * Atom holding an ephemeral node that is not part of the layout tree. This node displays above all other nodes. */ ephemeralNode: PrimitiveAtom<LayoutNode>; /** * The last node to be an ephemeral node. This node should sit at a higher z-index than the others so that it floats above the other nodes as it returns to its original position. */ lastEphemeralNodeId: string; magnifiedNodeSizeAtom: Atom<number>; /** * The size of the resize handles, in CSS pixels. * The resize handle size is double the gap size, or double the default gap size, whichever is greater. * @see gapSizePx @see DefaultGapSizePx */ private resizeHandleSizePx: Atom<number>; /** * A context used by the resize handles to keep track of precomputed values for the current resize operation. */ private resizeContext?: ResizeContext; /** * True if a resize handle is currently being dragged or the whole TileLayout container is being resized. */ isResizing: Atom<boolean>; /** * True if the whole TileLayout container is being resized. */ private isContainerResizing: PrimitiveAtom<boolean>; constructor( tabAtom: Atom<Tab>, getter: Getter, setter: Setter, renderContent?: ContentRenderer, renderPreview?: PreviewRenderer, onNodeDelete?: (data: TabLayoutData) => Promise<void>, gapSizePx?: number, animationTimeS?: number ) { this.tabAtom = tabAtom; this.getter = getter; this.setter = setter; this.renderContent = renderContent; this.renderPreview = renderPreview; this.onNodeDelete = onNodeDelete; this.gapSizePx = atom(gapSizePx ?? DefaultGapSizePx); this.resizeHandleSizePx = atom((get) => { const gapSizePx = get(this.gapSizePx); return 2 * (gapSizePx > 5 ? gapSizePx : DefaultGapSizePx); }); this.animationTimeS = atom(animationTimeS ?? DefaultAnimationTimeS); this.persistDebounceTimer = null; this.processedActionIds = new Set(); this.waveObjectAtom = getLayoutStateAtomFromTab(tabAtom, getter); this.localTreeStateAtom = atom<LayoutTreeState>({ rootNode: undefined, focusedNodeId: undefined, magnifiedNodeId: undefined, leafOrder: undefined, pendingBackendActions: undefined, }); this.treeState = { rootNode: undefined, focusedNodeId: undefined, magnifiedNodeId: undefined, leafOrder: undefined, pendingBackendActions: undefined, }; this.leafs = atom([]); this.leafOrder = atom([]); this.numLeafs = atom((get) => get(this.leafOrder).length); this.nodeModels = new Map(); this.additionalProps = atom({}); const resizeHandleListAtom = atom((get) => { const addlProps = get(this.additionalProps); return Object.values(addlProps) .flatMap((props) => props.resizeHandles) .filter((v) => v); }); this.resizeHandles = splitAtom(resizeHandleListAtom); this.isContainerResizing = atom(false); this.isResizing = atom((get) => { const pendingAction = get(this.pendingTreeAction.throttledValueAtom); const isWindowResizing = get(this.isContainerResizing); return isWindowResizing || pendingAction?.type === LayoutTreeActionType.ResizeNode; }); this.displayContainerRef = createRef(); this.activeDrag = atom(false); this.showOverlay = atom(false); this.ready = atom(false); this.overlayTransform = atom<CSSProperties>((get) => { const activeDrag = get(this.activeDrag); const showOverlay = get(this.showOverlay); if (this.displayContainerRef.current) { const displayBoundingRect = this.displayContainerRef.current.getBoundingClientRect(); const newOverlayOffset = displayBoundingRect.top + 2 * displayBoundingRect.height; const newTransform = setTransform( { top: activeDrag || showOverlay ? 0 : newOverlayOffset, left: 0, width: displayBoundingRect.width, height: displayBoundingRect.height, }, false ); return newTransform; } }); this.ephemeralNode = atom(); this.magnifiedNodeSizeAtom = getSettingsKeyAtom("window:magnifiedblocksize"); this.magnifiedNodeIdAtom = atom((get) => { const treeState = get(this.localTreeStateAtom); return treeState.magnifiedNodeId; }); this.focusedNode = atom((get) => { const ephemeralNode = get(this.ephemeralNode); const treeState = get(this.localTreeStateAtom); if (ephemeralNode) { return ephemeralNode; } if (treeState.focusedNodeId == null) { return null; } return findNode(treeState.rootNode, treeState.focusedNodeId); }); this.focusedNodeIdStack = []; this.pendingTreeAction = atomWithThrottle<LayoutTreeAction>(null, 10); this.placeholderTransform = atom<CSSProperties>((get: Getter) => { const pendingAction = get(this.pendingTreeAction.throttledValueAtom); return this.getPlaceholderTransform(pendingAction); }); this.initializeFromWaveObject(); } private initializeFromWaveObject() { const waveObjState = this.getter(this.waveObjectAtom); const initialState: LayoutTreeState = { rootNode: waveObjState?.rootnode, focusedNodeId: waveObjState?.focusednodeid, magnifiedNodeId: waveObjState?.magnifiednodeid, leafOrder: undefined, pendingBackendActions: waveObjState?.pendingbackendactions, }; this.treeState = initialState; this.magnifiedNodeId = initialState.magnifiedNodeId; this.setter(this.localTreeStateAtom, { ...initialState }); if (initialState.pendingBackendActions?.length) { fireAndForget(() => this.processPendingBackendActions()); } else { this.updateTree(); } } onBackendUpdate() { const waveObj = this.getter(this.waveObjectAtom); const pendingActions = waveObj?.pendingbackendactions; if (pendingActions?.length) { fireAndForget(() => this.processPendingBackendActions()); } } private async processPendingBackendActions() { const waveObj = this.getter(this.waveObjectAtom); const actions = waveObj?.pendingbackendactions; if (!actions?.length) return; this.treeState.pendingBackendActions = undefined; for (const action of actions) { if (!action.actionid) { console.warn("Dropping layout action without actionid:", action); continue; } if (this.processedActionIds.has(action.actionid)) { continue; } this.processedActionIds.add(action.actionid); await this.handleBackendAction(action); } this.updateTree(); this.setter(this.localTreeStateAtom, { ...this.treeState }); this.persistToBackend(); } private async cleanupOrphanedBlocks() { const tab = this.getter(this.tabAtom); const layoutBlockIds = new Set<string>(); if (this.treeState.rootNode == null) { return; } walkNodes(this.treeState.rootNode, (node) => { if (node.data?.blockId) { layoutBlockIds.add(node.data.blockId); } }); for (const blockId of tab.blockids || []) { if (!layoutBlockIds.has(blockId)) { console.log("Cleaning up orphaned block:", blockId); if (this.onNodeDelete) { await this.onNodeDelete({ blockId }); } } } } private async handleBackendAction(action: LayoutActionData) { switch (action.actiontype) { case LayoutTreeActionType.InsertNode: { if (action.ephemeral) { this.newEphemeralNode(action.blockid); break; } const insertNodeAction: LayoutTreeInsertNodeAction = { type: LayoutTreeActionType.InsertNode, node: newLayoutNode(undefined, undefined, undefined, { blockId: action.blockid, }), magnified: action.magnified, focused: action.focused, }; this.treeReducer(insertNodeAction, false); break; } case LayoutTreeActionType.DeleteNode: { const leaf = this?.getNodeByBlockId(action.blockid); if (leaf) { await this.closeNode(leaf.id); } else { console.error( "Cannot apply eventbus layout action DeleteNode, could not find leaf node with blockId", action.blockid ); } break; } case LayoutTreeActionType.InsertNodeAtIndex: { if (!action.indexarr) { console.error("Cannot apply eventbus layout action InsertNodeAtIndex, indexarr field is missing."); break; } const insertAction: LayoutTreeInsertNodeAtIndexAction = { type: LayoutTreeActionType.InsertNodeAtIndex, node: newLayoutNode(undefined, action.nodesize, undefined, { blockId: action.blockid, }), indexArr: action.indexarr, magnified: action.magnified, focused: action.focused, }; this.treeReducer(insertAction, false); break; } case LayoutTreeActionType.ClearTree: { this.treeReducer( { type: LayoutTreeActionType.ClearTree, } as LayoutTreeClearTreeAction, false ); break; } case LayoutTreeActionType.ReplaceNode: { const targetNode = this?.getNodeByBlockId(action.targetblockid); if (!targetNode) { console.error( "Cannot apply eventbus layout action ReplaceNode, could not find target node with blockId", action.targetblockid ); break; } const replaceAction: LayoutTreeReplaceNodeAction = { type: LayoutTreeActionType.ReplaceNode, targetNodeId: targetNode.id, newNode: newLayoutNode(undefined, action.nodesize, undefined, { blockId: action.blockid, }), }; this.treeReducer(replaceAction, false); break; } case LayoutTreeActionType.SplitHorizontal: { const targetNode = this?.getNodeByBlockId(action.targetblockid); if (!targetNode) { console.error( "Cannot apply eventbus layout action SplitHorizontal, could not find target node with blockId", action.targetblockid ); break; } if (action.position != "before" && action.position != "after") { console.error( "Cannot apply eventbus layout action SplitHorizontal, invalid position", action.position ); break; } const newNode = newLayoutNode(undefined, action.nodesize, undefined, { blockId: action.blockid, }); const splitAction: LayoutTreeSplitHorizontalAction = { type: LayoutTreeActionType.SplitHorizontal, targetNodeId: targetNode.id, newNode: newNode, position: action.position, }; this.treeReducer(splitAction, false); break; } case LayoutTreeActionType.SplitVertical: { const targetNode = this?.getNodeByBlockId(action.targetblockid); if (!targetNode) { console.error( "Cannot apply eventbus layout action SplitVertical, could not find target node with blockId", action.targetblockid ); break; } if (action.position != "before" && action.position != "after") { console.error( "Cannot apply eventbus layout action SplitVertical, invalid position", action.position ); break; } const newNode = newLayoutNode(undefined, action.nodesize, undefined, { blockId: action.blockid, }); const splitAction: LayoutTreeSplitVerticalAction = { type: LayoutTreeActionType.SplitVertical, targetNodeId: targetNode.id, newNode: newNode, position: action.position, }; this.treeReducer(splitAction, false); break; } case "cleanuporphaned": { await this.cleanupOrphanedBlocks(); break; } default: console.warn("unsupported layout action", action); break; } } private persistToBackend() { if (this.persistDebounceTimer) { clearTimeout(this.persistDebounceTimer); } this.persistDebounceTimer = setTimeout(() => { const waveObj = this.getter(this.waveObjectAtom); if (!waveObj) return; waveObj.rootnode = this.treeState.rootNode; waveObj.focusednodeid = this.treeState.focusedNodeId; waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; waveObj.leaforder = this.treeState.leafOrder; waveObj.pendingbackendactions = this.treeState.pendingBackendActions; WOS.setObjectValue(waveObj, this.setter, true); this.persistDebounceTimer = null; }, 100); } /** * Register TileLayout callbacks that should be called on various state changes. * @param contents Contains callbacks provided by the TileLayout component. */ registerTileLayout(contents: TileLayoutContents) { this.renderContent = contents.renderContent; this.renderPreview = contents.renderPreview; this.onNodeDelete = contents.onNodeDelete; if (contents.gapSizePx !== undefined) { this.setter(this.gapSizePx, contents.gapSizePx); } const tab = this.getter(this.tabAtom); fireAndForget(() => BlockService.CleanupOrphanedBlocks(tab.oid)); } /** * Perform an action against the layout tree state. * @param action The action to perform. */ treeReducer(action: LayoutTreeAction, setState = true) { switch (action.type) { case LayoutTreeActionType.ComputeMove: this.setter( this.pendingTreeAction.throttledValueAtom, computeMoveNode(this.treeState, action as LayoutTreeComputeMoveNodeAction) ); break; case LayoutTreeActionType.Move: moveNode(this.treeState, action as LayoutTreeMoveNodeAction); break; case LayoutTreeActionType.InsertNode: insertNode(this.treeState, action as LayoutTreeInsertNodeAction); if ((action as LayoutTreeInsertNodeAction).focused) { FocusManager.getInstance().requestNodeFocus(); } break; case LayoutTreeActionType.InsertNodeAtIndex: insertNodeAtIndex(this.treeState, action as LayoutTreeInsertNodeAtIndexAction); if ((action as LayoutTreeInsertNodeAtIndexAction).focused) { FocusManager.getInstance().requestNodeFocus(); } break; case LayoutTreeActionType.DeleteNode: deleteNode(this.treeState, action as LayoutTreeDeleteNodeAction); break; case LayoutTreeActionType.Swap: swapNode(this.treeState, action as LayoutTreeSwapNodeAction); break; case LayoutTreeActionType.ResizeNode: resizeNode(this.treeState, action as LayoutTreeResizeNodeAction); break; case LayoutTreeActionType.SetPendingAction: { const pendingAction = (action as LayoutTreeSetPendingAction).action; if (pendingAction) { this.setter(this.pendingTreeAction.throttledValueAtom, pendingAction); } else { console.warn("No new pending action provided"); } break; } case LayoutTreeActionType.ClearPendingAction: this.setter(this.pendingTreeAction.throttledValueAtom, undefined); break; case LayoutTreeActionType.CommitPendingAction: { const pendingAction = this.getter(this.pendingTreeAction.currentValueAtom); if (!pendingAction) { console.error("unable to commit pending action, does not exist"); break; } this.treeReducer(pendingAction); this.setter(this.pendingTreeAction.throttledValueAtom, undefined); break; } case LayoutTreeActionType.FocusNode: focusNode(this.treeState, action as LayoutTreeFocusNodeAction); FocusManager.getInstance().requestNodeFocus(); break; case LayoutTreeActionType.MagnifyNodeToggle: magnifyNodeToggle(this.treeState, action as LayoutTreeMagnifyNodeToggleAction); FocusManager.getInstance().requestNodeFocus(); break; case LayoutTreeActionType.ClearTree: clearTree(this.treeState); break; case LayoutTreeActionType.ReplaceNode: replaceNode(this.treeState, action as LayoutTreeReplaceNodeAction); break; case LayoutTreeActionType.SplitHorizontal: splitHorizontal(this.treeState, action as LayoutTreeSplitHorizontalAction); break; case LayoutTreeActionType.SplitVertical: splitVertical(this.treeState, action as LayoutTreeSplitVerticalAction); break; default: console.error("Invalid reducer action", this.treeState, action); } if (this.magnifiedNodeId !== this.treeState.magnifiedNodeId) { this.lastMagnifiedNodeId = this.magnifiedNodeId; this.lastEphemeralNodeId = undefined; this.magnifiedNodeId = this.treeState.magnifiedNodeId; } if (setState) { this.updateTree(); this.setter(this.localTreeStateAtom, { ...this.treeState }); this.persistToBackend(); } } /** * Callback that is invoked when the upstream tree state has been updated. This ensures the model is updated if the atom is not fully loaded when the model is first instantiated. * @param force Whether to force the local tree state to update, regardless of whether the state is already up to date. */ async onTreeStateAtomUpdated(force = false) { if (force) { this.updateTree(); this.setter(this.localTreeStateAtom, { ...this.treeState }); } } /** * Set the upstream tree state atom to the value of the local tree state. * @param bumpGeneration Whether to bump the generation of the tree state before setting the atom. */ /** * Recursively walks the tree to find leaf nodes, update the resize handles, and compute additional properties for each node. * @param balanceTree Whether the tree should also be balanced as it is walked. This should be done if the tree state has just been updated. Defaults to true. */ updateTree(balanceTree = true) { if (this.displayContainerRef.current) { const newLeafs: LayoutNode[] = []; const newAdditionalProps = {}; const pendingAction = this.getter(this.pendingTreeAction.currentValueAtom); const resizeAction = pendingAction?.type === LayoutTreeActionType.ResizeNode ? (pendingAction as LayoutTreeResizeNodeAction) : null; const resizeHandleSizePx = this.getter(this.resizeHandleSizePx); const boundingRect = this.getBoundingRect(); const magnifiedNodeSize = this.getter(this.magnifiedNodeSizeAtom); const callback = (node: LayoutNode) => this.updateTreeHelper( node, newAdditionalProps, newLeafs, resizeHandleSizePx, magnifiedNodeSize, boundingRect, resizeAction ); if (balanceTree) this.treeState.rootNode = balanceNode(this.treeState.rootNode, callback); else walkNodes(this.treeState.rootNode, callback); // Process ephemeral node, if present. const ephemeralNode = this.getter(this.ephemeralNode); if (ephemeralNode) { this.updateEphemeralNodeProps( ephemeralNode, newAdditionalProps, newLeafs, magnifiedNodeSize, boundingRect ); } this.treeState.leafOrder = getLeafOrder(newLeafs, newAdditionalProps); this.validateFocusedNode(this.treeState.leafOrder); this.validateMagnifiedNode(this.treeState.leafOrder, newAdditionalProps); this.cleanupNodeModels(this.treeState.leafOrder); this.setter( this.leafs, newLeafs.sort((a, b) => a.id.localeCompare(b.id)) ); this.setter(this.leafOrder, this.treeState.leafOrder); this.setter(this.additionalProps, newAdditionalProps); } } /** * Per-node callback that is invoked recursively to find leaf nodes, update the resize handles, and compute additional properties associated with the given node. * @param node The node for which to update the resize handles and additional properties. * @param additionalPropsMap The new map that will contain the updated additional properties for all nodes in the tree. * @param leafs The new list that will contain all the leaf nodes in the tree. * @param resizeAction The pending resize action, if any. Used to set temporary size values on nodes that are being resized. */ private updateTreeHelper( node: LayoutNode, additionalPropsMap: Record<string, LayoutNodeAdditionalProps>, leafs: LayoutNode[], resizeHandleSizePx: number, magnifiedNodeSizePct: number, boundingRect: Dimensions, resizeAction?: LayoutTreeResizeNodeAction ) { if (!node.children?.length) { leafs.push(node); const addlProps = additionalPropsMap[node.id]; if (addlProps) { if (this.magnifiedNodeId === node.id) { const magnifiedNodeMarginPct = (1 - magnifiedNodeSizePct) / 2; const transform = setTransform( { top: boundingRect.height * magnifiedNodeMarginPct, left: boundingRect.width * magnifiedNodeMarginPct, width: boundingRect.width * magnifiedNodeSizePct, height: boundingRect.height * magnifiedNodeSizePct, }, true, true, "var(--zindex-layout-magnified-node)" ); addlProps.transform = transform; } if (this.lastMagnifiedNodeId === node.id) { addlProps.transform.zIndex = "var(--zindex-layout-last-magnified-node)"; } else if (this.lastEphemeralNodeId === node.id) { addlProps.transform.zIndex = "var(--zindex-layout-last-ephemeral-node)"; } } return; } function getNodeSize(node: LayoutNode) { return resizeAction?.resizeOperations.find((op) => op.nodeId === node.id)?.size ?? node.size; } const additionalProps: LayoutNodeAdditionalProps = node.id in additionalPropsMap ? additionalPropsMap[node.id] : { treeKey: "0" }; const nodeRect: Dimensions = node.id === this.treeState.rootNode.id ? boundingRect : additionalProps.rect; const nodeIsRow = node.flexDirection === FlexDirection.Row; const nodePixels = nodeIsRow ? nodeRect.width : nodeRect.height; const totalChildrenSize = node.children.reduce((acc, child) => acc + getNodeSize(child), 0); const pixelToSizeRatio = totalChildrenSize / nodePixels; let lastChildRect: Dimensions; const resizeHandles: ResizeHandleProps[] = []; node.children.forEach((child, i) => { const childSize = getNodeSize(child); const rect: Dimensions = { top: !nodeIsRow && lastChildRect ? lastChildRect.top + lastChildRect.height : nodeRect.top, left: nodeIsRow && lastChildRect ? lastChildRect.left + lastChildRect.width : nodeRect.left, width: nodeIsRow ? childSize / pixelToSizeRatio : nodeRect.width, height: nodeIsRow ? nodeRect.height : childSize / pixelToSizeRatio, }; const transform = setTransform(rect); additionalPropsMap[child.id] = { rect, transform, treeKey: additionalProps.treeKey + i, }; // We only want the resize handles in between nodes, this ensures we have n-1 handles. if (lastChildRect) { const resizeHandleIndex = resizeHandles.length; const halfResizeHandleSizePx = resizeHandleSizePx / 2; const resizeHandleDimensions: Dimensions = { top: nodeIsRow ? lastChildRect.top : lastChildRect.top + lastChildRect.height - halfResizeHandleSizePx, left: nodeIsRow ? lastChildRect.left + lastChildRect.width - halfResizeHandleSizePx : lastChildRect.left, width: nodeIsRow ? resizeHandleSizePx : lastChildRect.width, height: nodeIsRow ? lastChildRect.height : resizeHandleSizePx, }; resizeHandles.push({ id: `${node.id}-${resizeHandleIndex}`, parentNodeId: node.id, parentIndex: resizeHandleIndex, transform: setTransform(resizeHandleDimensions, true, false), flexDirection: node.flexDirection, centerPx: (nodeIsRow ? resizeHandleDimensions.left : resizeHandleDimensions.top) + halfResizeHandleSizePx, }); } lastChildRect = rect; }); additionalPropsMap[node.id] = { ...additionalProps, ...(node.data?.blockId ? { rect: nodeRect } : {}), pixelToSizeRatio, resizeHandles, }; } /** * Gets normalized dimensions for the TileLayout container. * @returns The normalized dimensions for the TileLayout container. */ getBoundingRect: () => Dimensions = () => { const boundingRect = this.displayContainerRef.current.getBoundingClientRect(); return { top: 0, left: 0, width: boundingRect.width, height: boundingRect.height }; }; /** * The id of the focused node in the layout. */ get focusedNodeId(): string { return this.focusedNodeIdStack[0]; } /** * Checks whether the focused node id has changed and, if so, whether to update the focused node stack. If the focused node was deleted, will pop the latest value from the stack. * @param leafOrder The new leaf order array to use when searching for stale nodes in the stack. */ private validateFocusedNode(leafOrder: LeafOrderEntry[]) { if (this.treeState.focusedNodeId !== this.focusedNodeId) { // Remove duplicates and stale entries from focus stack. const newFocusedNodeIdStack: string[] = []; for (const id of this.focusedNodeIdStack) { if (leafOrder.find((leafEntry) => leafEntry?.nodeid === id) && !newFocusedNodeIdStack.includes(id)) newFocusedNodeIdStack.push(id); } this.focusedNodeIdStack = newFocusedNodeIdStack; // Update the focused node and stack based on the changes in the tree state. if (!this.treeState.focusedNodeId) { if (this.focusedNodeIdStack.length > 0) { this.treeState.focusedNodeId = this.focusedNodeIdStack.shift(); } else if (leafOrder.length > 0) { // If no nodes are in the stack, use the top left node in the layout. this.treeState.focusedNodeId = leafOrder[0].nodeid; } } this.focusedNodeIdStack.unshift(this.treeState.focusedNodeId); } } /** * When a layout is modified and only one leaf is remaining, we need to make sure it is no longer magnified. * @param leafOrder The new leaf order array to use when validating the number of leafs remaining. * @param addlProps The new additional properties object for all leafs in the layout. */ private validateMagnifiedNode(leafOrder: LeafOrderEntry[], addlProps: Record<string, LayoutNodeAdditionalProps>) { if (leafOrder.length == 1) { const lastLeafId = leafOrder[0].nodeid; this.treeState.magnifiedNodeId = undefined; this.magnifiedNodeId = undefined; // Unset the transform for the sole leaf. if (lastLeafId in addlProps) addlProps[lastLeafId].transform = undefined; } } /** * Helper function for the placeholderTransform atom, which computes the new transform value when the pending action changes. * @param pendingAction The new pending action value. * @returns The computed placeholder transform. * * @see placeholderTransform the atom that invokes this function and persists the updated value. */ private getPlaceholderTransform(pendingAction: LayoutTreeAction): CSSProperties { if (pendingAction) { switch (pendingAction.type) { case LayoutTreeActionType.Move: { const action = pendingAction as LayoutTreeMoveNodeAction; let parentId: string; if (action.insertAtRoot) { parentId = this.treeState.rootNode.id; } else { parentId = action.parentId; } const parentNode = findNode(this.treeState.rootNode, parentId); if (action.index !== undefined && parentNode) { const targetIndex = boundNumber( action.index - 1, 0, parentNode.children ? parentNode.children.length - 1 : 0 ); const targetNode = parentNode?.children?.at(targetIndex) ?? parentNode; if (targetNode) { const targetBoundingRect = this.getNodeRect(targetNode); // Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent. // Default to placing the placeholder in the first half of the target node. const placeholderDimensions: Dimensions = { height: parentNode.flexDirection === FlexDirection.Column ? targetBoundingRect.height / 2 : targetBoundingRect.height, width: parentNode.flexDirection === FlexDirection.Row ? targetBoundingRect.width / 2 : targetBoundingRect.width, top: targetBoundingRect.top, left: targetBoundingRect.left, }; if (action.index > targetIndex) { if (action.index >= (parentNode.children?.length ?? 1)) { // If there are no more nodes after the specified index, place the placeholder in the second half of the target node (either right or bottom). placeholderDimensions.top += parentNode.flexDirection === FlexDirection.Column && targetBoundingRect.height / 2; placeholderDimensions.left += parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2; } else { // Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node placeholderDimensions.top += parentNode.flexDirection === FlexDirection.Column && (3 * targetBoundingRect.height) / 4; placeholderDimensions.left += parentNode.flexDirection === FlexDirection.Row && (3 * targetBoundingRect.width) / 4; } } return setTransform(placeholderDimensions); } } break; } case LayoutTreeActionType.Swap: { const action = pendingAction as LayoutTreeSwapNodeAction; const targetNodeId = action.node1Id; const targetBoundingRect = this.getNodeRectById(targetNodeId); const placeholderDimensions: Dimensions = { top: targetBoundingRect.top, left: targetBoundingRect.left, height: targetBoundingRect.height, width: targetBoundingRect.width, }; return setTransform(placeholderDimensions); } default: // No-op break; } } return; } /** * Gets the node model for the given node. * @param node The node for which to retrieve the node model. * @returns The node model for the given node. */ getNodeModel(node: LayoutNode): NodeModel { const nodeid = node.id; const blockId = node.data.blockId; const addlPropsAtom = this.getNodeAdditionalPropertiesAtom(nodeid); if (!this.nodeModels.has(nodeid)) { this.nodeModels.set(nodeid, { additionalProps: addlPropsAtom, innerRect: atom((get) => { const addlProps = get(addlPropsAtom); const numLeafs = get(this.numLeafs); const gapSizePx = get(this.gapSizePx); if (numLeafs > 1 && addlProps?.rect) { return { width: `${addlProps.transform.width} - ${gapSizePx}px`, height: `${addlProps.transform.height} - ${gapSizePx}px`, } as CSSProperties; } else { return null; } }), nodeId: nodeid, blockId, blockNum: atom((get) => get(this.leafOrder).findIndex((leafEntry) => leafEntry.nodeid === nodeid) + 1), isFocused: atom((get) => { const treeState = get(this.localTreeStateAtom); const isFocused = treeState.focusedNodeId === nodeid; const focusType = get(FocusManager.getInstance().focusType); return isFocused && focusType === "node"; }), numLeafs: this.numLeafs, isResizing: this.isResizing, isMagnified: atom((get) => { const treeState = get(this.localTreeStateAtom); return treeState.magnifiedNodeId === nodeid; }), anyMagnified: atom((get) => { const treeState = get(this.localTreeStateAtom); return treeState.magnifiedNodeId != null; }), isEphemeral: atom((get) => { const ephemeralNode = get(this.ephemeralNode); return ephemeralNode?.id === nodeid; }), addEphemeralNodeToLayout: () => this.addEphemeralNodeToLayout(), animationTimeS: this.animationTimeS, ready: this.ready, disablePointerEvents: this.activeDrag, onClose: () => fireAndForget(() => this.closeNode(nodeid)), // no longer used (instead we use keymodel uxCloseBlock) toggleMagnify: () => this.magnifyNodeToggle(nodeid), focusNode: () => this.focusNode(nodeid), dragHandleRef: createRef(), displayContainerRef: this.displayContainerRef, }); } const nodeModel = this.nodeModels.get(nodeid); return nodeModel; } /** * Remove orphaned node models when their corresponding leaf is deleted. * @param leafOrder The new leaf order array to use when locating orphaned nodes. */ private cleanupNodeModels(leafOrder: LeafOrderEntry[]) { const orphanedNodeModels = [...this.nodeModels.keys()].filter( (id) => !leafOrder.find((leafEntry) => leafEntry.nodeid == id) ); for (const id of orphanedNodeModels) { this.nodeModels.delete(id); } } /** * Switch focus to the next node in the given direction in the layout. * @param direction The direction in which to switch focus. */ switchNodeFocusInDirection(direction: NavigateDirection, inWaveAI: boolean): NavigationResult { const curNodeId = this.focusedNodeId; // If no node is focused, set focus to the first leaf. if (!curNodeId) { this.focusNode(this.getter(this.leafOrder)[0].nodeid); return { success: true }; } const offset = navigateDirectionToOffset(direction); const nodePositions: Map<string, Dimensions> = new Map(); const leafs = this.getter(this.leafs); const addlProps = this.getter(this.additionalProps); for (const leaf of leafs) { const pos = addlProps[leaf.id]?.rect; if (pos) { nodePositions.set(leaf.id, pos); } } let curNodePos: Dimensions; if (inWaveAI) { // For WaveAI, use a fake position to the left of all nodes curNodePos = { left: -10, top: 10, width: 0, height: 0 }; // Only allow "right" navigation from WaveAI if (direction !== NavigateDirection.Right) { const result: NavigationResult = { success: false }; if (direction === NavigateDirection.Up) { result.atTop = true; } else if (direction === NavigateDirection.Down) { result.atBottom = true; } else if (direction === NavigateDirection.Left) { result.atLeft = true; } return result; } } else { curNodePos = nodePositions.get(curNodeId); if (!curNodePos) { return { success: false }; } nodePositions.delete(curNodeId); } const boundingRect = this.displayContainerRef?.current.getBoundingClientRect(); if (!boundingRect) { return { success: false }; } const maxX = boundingRect.left + boundingRect.width; const maxY = boundingRect.top + boundingRect.height; const moveAmount = 10; const curPoint = getCenter(curNodePos); function findNodeAtPoint(m: Map<string, Dimensions>, p: Point): string { for (const [blockId, dimension] of m.entries()) { if ( p.x >= dimension.left && p.x <= dimension.left + dimension.width && p.y >= dimension.top && p.y <= dimension.top + dimension.height ) { return blockId; } } return null; } while (true) { curPoint.x += offset.x * moveAmount; curPoint.y += offset.y * moveAmount; if (curPoint.x < 0 || curPoint.x > maxX || curPoint.y < 0 || curPoint.y > maxY) { // Determine which boundary was hit const result: NavigationResult = { success: false }; if (curPoint.x < 0) { result.atLeft = true; } if (curPoint.x > maxX) { result.atRight = true; } if (curPoint.y < 0) { result.atTop = true; } if (curPoint.y > maxY) { result.atBottom = true; } return result; } const nodeId = findNodeAtPoint(nodePositions, curPoint); if (nodeId != null) { this.focusNode(nodeId); return { success: true }; } } } /** * Switch focus to a node using the given BlockNum * @param newBlockNum The BlockNum of the node to which focus should switch. * @see leafOrder - the indices in this array determine BlockNum */ switchNodeFocusByBlockNum(newBlockNum: number) { const leafOrder = this.getter(this.leafOrder); const newLeafIdx = newBlockNum - 1; if (newLeafIdx < 0 || newLeafIdx >= leafOrder.length) { return; } const leaf = leafOrder[newLeafIdx]; this.focusNode(leaf.nodeid); } /** * Set the layout to focus on the given node. * @param nodeId The id of the node that is being focused. */ focusNode(nodeId: string) { if (this.focusedNodeId === nodeId) return; let layoutNode = findNode(this.treeState?.rootNode, nodeId); if (!layoutNode) { const ephemeralNode = this.getter(this.ephemeralNode); if (ephemeralNode?.id === nodeId) { layoutNode = ephemeralNode; } else { console.error("unable to focus node, cannot find it in tree", nodeId); return; } } const action: LayoutTreeFocusNodeAction = { type: LayoutTreeActionType.FocusNode, nodeId: nodeId, }; this.treeReducer(action); } focusFirstNode() { const leafOrder = this.getter(this.leafOrder); if (leafOrder.length > 0) { this.focusNode(leafOrder[0].nodeid); } } getFirstBlockId(): string | undefined { const leafOrder = this.getter(this.leafOrder); if (leafOrder.length > 0) { return leafOrder[0].blockid; } return undefined; } /** * Toggle magnification of a given node. * @param nodeId The id of the node that is being magnified. */ magnifyNodeToggle(nodeId: string, setState = true) { const action: LayoutTreeMagnifyNodeToggleAction = { type: LayoutTreeActionType.MagnifyNodeToggle, nodeId: nodeId, }; // Unset the last ephemeral node id to ensure the magnify animation sits on top of the layout. this.lastEphemeralNodeId = undefined; this.treeReducer(action, setState); } /** * Close a given node and update the tree state. * @param nodeId The id of the node that is being closed. */ async closeNode(nodeId: string) { const nodeToDelete = findNode(this.treeState.rootNode, nodeId); if (!nodeToDelete) { // TODO: clean up the ephemeral node handling // The ephemeral node is not in the tree, so we need to handle it separately. const ephemeralNode = this.getter(this.ephemeralNode); if (ephemeralNode?.id === nodeId) { this.setter(this.ephemeralNode, undefined); this.treeState.focusedNodeId = undefined; this.updateTree(false); this.setter(this.localTreeStateAtom, { ...this.treeState }); this.persistToBackend(); await this.onNodeDelete?.(ephemeralNode.data); return; } console.error("unable to close node, cannot find it in tree", nodeId); return; } if (nodeId === this.magnifiedNodeId) { this.magnifyNodeToggle(nodeId); } const deleteAction: LayoutTreeDeleteNodeAction = { type: LayoutTreeActionType.DeleteNode, nodeId: nodeId, }; this.treeReducer(deleteAction); await this.onNodeDelete?.(nodeToDelete.data); } /** * Shorthand function for closing the focused node in a layout. */ async closeFocusedNode() { await this.closeNode(this.focusedNodeId); } newEphemeralNode(blockId: string) { if (this.getter(this.ephemeralNode)) { this.closeNode(this.getter(this.ephemeralNode).id); } const ephemeralNode = newLayoutNode(undefined, undefined, undefined, { blockId }); this.setter(this.ephemeralNode, ephemeralNode); const addlProps = this.getter(this.additionalProps); const leafs = this.getter(this.leafs); const boundingRect = this.getBoundingRect(); const magnifiedNodeSizePct = this.getter(this.magnifiedNodeSizeAtom); this.updateEphemeralNodeProps(ephemeralNode, addlProps, leafs, magnifiedNodeSizePct, boundingRect); this.setter(this.additionalProps, addlProps); this.focusNode(ephemeralNode.id); } addEphemeralNodeToLayout() { const ephemeralNode = this.getter(this.ephemeralNode); this.setter(this.ephemeralNode, undefined); if (this.magnifiedNodeId) { this.magnifyNodeToggle(this.magnifiedNodeId, false); } this.lastEphemeralNodeId = ephemeralNode.id; if (ephemeralNode) { const action: LayoutTreeInsertNodeAction = { type: LayoutTreeActionType.InsertNode, node: ephemeralNode, magnified: false, focused: false, }; this.treeReducer(action); } } updateEphemeralNodeProps( node: LayoutNode, addlPropsMap: Record<string, LayoutNodeAdditionalProps>, leafs: LayoutNode[], magnifiedNodeSizePct: number, boundingRect: Dimensions ) { const ephemeralNodeSizePct = this.magnifiedNodeId ? magnifiedNodeSizePct * magnifiedNodeSizePct : magnifiedNodeSizePct; const ephemeralNodeMarginPct = (1 - ephemeralNodeSizePct) / 2; const transform = setTransform( { top: boundingRect.height * ephemeralNodeMarginPct, left: boundingRect.width * ephemeralNodeMarginPct, width: boundingRect.width * ephemeralNodeSizePct, height: boundingRect.height * ephemeralNodeSizePct, }, true, true, "var(--zindex-layout-ephemeral-node)" ); addlPropsMap[node.id] = { treeKey: "-1", transform }; leafs.push(node); } /** * Callback that is invoked when a drag operation completes and the pending action should be committed. */ onDrop() { if (this.getter(this.pendingTreeAction.currentValueAtom)) { this.treeReducer({ type: LayoutTreeActionType.CommitPendingAction, }); } } /** * Callback that is invoked when the TileLayout container is being resized. */ onContainerResize = () => { this.updateTree(); this.setter(this.isContainerResizing, true); this.stopContainerResizing(); }; /** * Deferred action to restore animations once the TileLayout container is no longer being resized. */ stopContainerResizing = debounce(30, () => { this.setter(this.isContainerResizing, false); }); /** * Callback to update pending node sizes when a resize handle is dragged. * @param resizeHandle The resize handle that is being dragged. * @param x The X coordinate of the pointer device, in CSS pixels. * @param y The Y coordinate of the pointer device, in CSS pixels. */ onResizeMove(resizeHandle: ResizeHandleProps, x: number, y: number) { const parentIsRow = resizeHandle.flexDirection === FlexDirection.Row; // If the resize context is out of date, update it and save it for future events. if (this.resizeContext?.handleId !== resizeHandle.id) { const parentNode = findNode(this.treeState.rootNode, resizeHandle.parentNodeId); const beforeNode = parentNode.children![resizeHandle.parentIndex]; const afterNode = parentNode.children![resizeHandle.parentIndex + 1]; const addlProps = this.getter(this.additionalProps); const pixelToSizeRatio = addlProps[resizeHandle.parentNodeId]?.pixelToSizeRatio; if (beforeNode && afterNode && pixelToSizeRatio) { this.resizeContext = { handleId: resizeHandle.id, displayContainerRect: this.displayContainerRef.current?.getBoundingClientRect(), resizeHandleStartPx: resizeHandle.centerPx, beforeNodeId: beforeNode.id, afterNodeId: afterNode.id, beforeNodeStartSize: beforeNode.size, afterNodeStartSize: afterNode.size, pixelToSizeRatio, }; } else { console.error( "Invalid resize handle, cannot get the additional properties for the nodes in the resize handle properties." ); return; } } const clientPoint = parentIsRow ? x - this.resizeContext.displayContainerRect?.left : y - this.resizeContext.displayContainerRect?.top; const clientDiff = (this.resizeContext.resizeHandleStartPx - clientPoint) * this.resizeContext.pixelToSizeRatio; const minNodeSize = MinNodeSizePx * this.resizeContext.pixelToSizeRatio; const beforeNodeSize = this.resizeContext.beforeNodeStartSize - clientDiff; const afterNodeSize = this.resizeContext.afterNodeStartSize + clientDiff; // If either node will be too small after this resize, don't let it happen. if (beforeNodeSize < minNodeSize || afterNodeSize < minNodeSize) { return; } const resizeAction: LayoutTreeResizeNodeAction = { type: LayoutTreeActionType.ResizeNode, resizeOperations: [ { nodeId: this.resizeContext.beforeNodeId, size: beforeNodeSize, }, { nodeId: this.resizeContext.afterNodeId, size: afterNodeSize, }, ], }; const setPendingAction: LayoutTreeSetPendingAction = { type: LayoutTreeActionType.SetPendingAction, action: resizeAction, }; this.treeReducer(setPendingAction); this.updateTree(false); } /** * Callback to end the current resize operation and commit its pending action. */ onResizeEnd() { if (this.resizeContext) { this.resizeContext = undefined; this.treeReducer({ type: LayoutTreeActionType.CommitPendingAction }); } } /** * Get the layout node matching the specified blockId. * @param blockId The blockId that the returned node should contain. * @returns The node containing the specified blockId, null if not found. */ getNodeByBlockId(blockId: string): LayoutNode { for (const leaf of this.getter(this.leafs)) { if (leaf.data.blockId === blockId) { return leaf; } } return null; } /** * Get a jotai atom containing the additional properties associated with a given node. * @param nodeId The ID of the node for which to retrieve the additional properties. * @returns An atom containing the additional properties associated with the given node. */ getNodeAdditionalPropertiesAtom(nodeId: string): Atom<LayoutNodeAdditionalProps> { return atom((get) => { const addlProps = get(this.additionalProps); if (nodeId in addlProps) return addlProps[nodeId]; }); } /** * Get additional properties associated with a given node. * @param nodeId The ID of the node for which to retrieve the additional properties. * @returns The additional properties associated with the given node. */ getNodeAdditionalPropertiesById(nodeId: string): LayoutNodeAdditionalProps { const addlProps = this.getter(this.additionalProps); if (nodeId in addlProps) return addlProps[nodeId]; } /** * Get additional properties associated with a given node. * @param node The node for which to retrieve the additional properties. * @returns The additional properties associated with the given node. */ getNodeAdditionalProperties(node: LayoutNode): LayoutNodeAdditionalProps { return this.getNodeAdditionalPropertiesById(node.id); } /** * Get the CSS transform associated with a given node. * @param nodeId The ID of the node for which to retrieve the CSS transform. * @returns The CSS transform associated with the given node. */ getNodeTransformById(nodeId: string): CSSProperties { return this.getNodeAdditionalPropertiesById(nodeId)?.transform; } /** * Get the CSS transform associated with a given node. * @param node The node for which to retrieve the CSS transform. * @returns The CSS transform associated with the given node. */ getNodeTransform(node: LayoutNode): CSSProperties { return this.getNodeTransformById(node.id); } /** * Get the computed dimensions in CSS pixels of a given node. * @param nodeId The ID of the node for which to retrieve the computed dimensions. * @returns The computed dimensions of the given node, in CSS pixels. */ getNodeRectById(nodeId: string): Dimensions { return this.getNodeAdditionalPropertiesById(nodeId)?.rect; } /** * Get the computed dimensions in CSS pixels of a given node. * @param node The node for which to retrieve the computed dimensions. * @returns The computed dimensions of the given node, in CSS pixels. */ getNodeRect(node: LayoutNode): Dimensions { return this.getNodeRectById(node.id); } } function getLeafOrder( leafs: LayoutNode[], additionalProps: Record<string, LayoutNodeAdditionalProps> ): LeafOrderEntry[] { return leafs .map((node) => ({ nodeid: node.id, blockid: node.data.blockId }) as LeafOrderEntry) .sort((a, b) => { const treeKeyA = additionalProps[a.nodeid]?.treeKey; const treeKeyB = additionalProps[b.nodeid]?.treeKey; if (!treeKeyA || !treeKeyB) return; return treeKeyA.localeCompare(treeKeyB); }); } ================================================ FILE: frontend/layout/lib/layoutModelHooks.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useOnResize } from "@/app/hook/useDimensions"; import { atoms, globalStore, WOS } from "@/app/store/global"; import { fireAndForget } from "@/util/util"; import { Atom, useAtomValue } from "jotai"; import { CSSProperties, useCallback, useEffect, useState } from "react"; import { getLayoutStateAtomFromTab } from "./layoutAtom"; import { LayoutModel } from "./layoutModel"; import { LayoutNode, NodeModel, TileLayoutContents } from "./types"; const layoutModelMap: Map<string, LayoutModel> = new Map(); function getLayoutModelForTab(tabAtom: Atom<Tab>): LayoutModel { const tabData = globalStore.get(tabAtom); if (!tabData) return; const tabId = tabData.oid; if (layoutModelMap.has(tabId)) { const layoutModel = layoutModelMap.get(tabData.oid); if (layoutModel) { return layoutModel; } } const layoutModel = new LayoutModel(tabAtom, globalStore.get, globalStore.set); const staticTabId = globalStore.get(atoms.staticTabId); if (tabId === staticTabId) { const layoutStateAtom = getLayoutStateAtomFromTab(tabAtom, globalStore.get); globalStore.sub(layoutStateAtom, () => { layoutModel.onBackendUpdate(); }); } layoutModelMap.set(tabId, layoutModel); return layoutModel; } function getLayoutModelForTabById(tabId: string) { const tabOref = WOS.makeORef("tab", tabId); const tabAtom = WOS.getWaveObjectAtom<Tab>(tabOref); return getLayoutModelForTab(tabAtom); } export function getLayoutModelForStaticTab() { const tabId = globalStore.get(atoms.staticTabId); return getLayoutModelForTabById(tabId); } export function deleteLayoutModelForTab(tabId: string) { if (layoutModelMap.has(tabId)) layoutModelMap.delete(tabId); } function useLayoutModel(tabAtom: Atom<Tab>): LayoutModel { return getLayoutModelForTab(tabAtom); } export function useTileLayout(tabAtom: Atom<Tab>, tileContent: TileLayoutContents): LayoutModel { // Use tab data to ensure we can reload if the tab is disposed and remade (such as during Hot Module Reloading) useAtomValue(tabAtom); const layoutModel = useLayoutModel(tabAtom); useOnResize(layoutModel?.displayContainerRef, layoutModel?.onContainerResize); // Once the TileLayout is mounted, re-run the state update to get all the nodes to flow in the layout. useEffect(() => fireAndForget(() => layoutModel.onTreeStateAtomUpdated(true)), []); useEffect(() => layoutModel.registerTileLayout(tileContent), [tileContent]); return layoutModel; } export function useNodeModel(layoutModel: LayoutModel, layoutNode: LayoutNode): NodeModel { return layoutModel.getNodeModel(layoutNode); } export function useDebouncedNodeInnerRect(nodeModel: NodeModel): CSSProperties { const nodeInnerRect = useAtomValue(nodeModel.innerRect); const animationTimeS = useAtomValue(nodeModel.animationTimeS); const isMagnified = useAtomValue(nodeModel.isMagnified); const isResizing = useAtomValue(nodeModel.isResizing); const prefersReducedMotion = useAtomValue(atoms.prefersReducedMotionAtom); const [innerRect, setInnerRect] = useState<CSSProperties>(); const [innerRectDebounceTimeout, setInnerRectDebounceTimeout] = useState<NodeJS.Timeout>(); const setInnerRectDebounced = useCallback( (nodeInnerRect: CSSProperties) => { clearInnerRectDebounce(); setInnerRectDebounceTimeout( setTimeout(() => { setInnerRect(nodeInnerRect); }, animationTimeS * 1000) ); }, [animationTimeS] ); const clearInnerRectDebounce = useCallback(() => { if (innerRectDebounceTimeout) { clearTimeout(innerRectDebounceTimeout); setInnerRectDebounceTimeout(undefined); } }, [innerRectDebounceTimeout]); useEffect(() => { if (prefersReducedMotion || isMagnified || isResizing) { clearInnerRectDebounce(); setInnerRect(nodeInnerRect); } else { setInnerRectDebounced(nodeInnerRect); } }, [nodeInnerRect]); return innerRect; } ================================================ FILE: frontend/layout/lib/layoutNode.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { DEFAULT_MAX_CHILDREN } from "./layoutTree"; import { DefaultNodeSize, FlexDirection, LayoutNode } from "./types"; import { reverseFlexDirection } from "./utils"; /** * Creates a new node. * @param flexDirection The flex direction for the new node. * @param size The size for the new node. * @param children The children for the new node. * @param data The data for the new node. * @returns The new node. */ export function newLayoutNode( flexDirection?: FlexDirection, size?: number, children?: LayoutNode[], data?: TabLayoutData ): LayoutNode { const newNode: LayoutNode = { id: crypto.randomUUID(), flexDirection: flexDirection ?? FlexDirection.Row, size: size ?? DefaultNodeSize, children, data, }; if (!validateNode(newNode)) { throw new Error("Invalid node"); } return newNode; } /** * Adds new nodes to the tree at the given index. * @param node The parent node. * @param idx The index to insert at. * @param children The nodes to insert. * @returns The updated parent node. */ export function addChildAt(node: LayoutNode, idx: number, ...children: LayoutNode[]) { // console.log("adding", children, "to", node, "at index", idx); if (children.length === 0) return; if (!node.children) { addIntermediateNode(node); } const childrenToAdd = children.flatMap((v) => { if (v.flexDirection !== node.flexDirection) { return v; } else if (v.children) { return v.children; } else { v.flexDirection = reverseFlexDirection(node.flexDirection); return v; } }); if (node.children.length <= idx) { node.children.push(...childrenToAdd); } else if (idx >= 0) { node.children.splice(idx, 0, ...childrenToAdd); } } /** * Adds an intermediate node as a direct child of the given node, moving the given node's children or data into it. * * If the node contains children, they are moved two levels deeper to preserve their flex direction. If the node only has data, it is moved one level deeper. * @param node The node to add the intermediate node to. * @returns The updated node and the node that was added. */ export function addIntermediateNode(node: LayoutNode): LayoutNode { let intermediateNode: LayoutNode; if (node.data) { intermediateNode = newLayoutNode(reverseFlexDirection(node.flexDirection), undefined, undefined, node.data); node.children = [intermediateNode]; node.data = undefined; } else { const intermediateNodeInner = newLayoutNode(node.flexDirection, undefined, node.children); intermediateNode = newLayoutNode(reverseFlexDirection(node.flexDirection), undefined, [intermediateNodeInner]); node.children = [intermediateNode]; } const intermediateNodeId = intermediateNode.id; intermediateNode.id = node.id; node.id = intermediateNodeId; return intermediateNode; } /** * Attempts to remove the specified node from its parent. * @param parent The parent node. * @param childToRemove The node to remove. * @param startingIndex The index in children to start the search from. * @returns The updated parent node, or undefined if the node was not found. */ export function removeChild(parent: LayoutNode, childToRemove: LayoutNode, startingIndex: number = 0) { if (!parent.children) return; const idx = parent.children.indexOf(childToRemove, startingIndex); if (idx === -1) return; parent.children?.splice(idx, 1); } /** * Finds the node with the given id. * @param node The node to search in. * @param id The id to search for. * @returns The node with the given id or undefined if no node with the given id was found. */ export function findNode(node: LayoutNode, id: string): LayoutNode | undefined { if (!node) return; if (node.id === id) return node; if (!node.children) return; for (const child of node.children) { const result = findNode(child, id); if (result) return result; } return; } /** * Finds the node whose children contains the node with the given id. * @param node The node to start the search from. * @param id The id to search for. * @returns The parent node, or undefined if no node with the given id was found. */ export function findParent(node: LayoutNode, id: string): LayoutNode | undefined { if (node.id === id || !node.children) return; for (const child of node.children) { if (child.id === id) return node; const retVal = findParent(child, id); if (retVal) return retVal; } return; } /** * Determines whether a node is valid. * @param node The node to validate. * @returns True if the node is valid, false otherwise. */ export function validateNode(node: LayoutNode): boolean { if (!node.children == !node.data) { console.error("Either children or data must be defined for node, not both"); return false; } if (node.children?.length === 0) { console.error("Node cannot define an empty array of children"); return false; } return true; } /** * Recursively walk the layout tree starting at the specified node. Run the specified callbacks, if any. * @param node The node from which to start the walk. * @param beforeWalkCallback An optional callback to run before walking a node's children. * @param afterWalkCallback An optional callback to run after walking a node's children. */ export function walkNodes( node: LayoutNode, beforeWalkCallback?: (node: LayoutNode) => void, afterWalkCallback?: (node: LayoutNode) => void ) { if (!node) return; beforeWalkCallback?.(node); node.children?.forEach((child) => walkNodes(child, beforeWalkCallback, afterWalkCallback)); afterWalkCallback?.(node); } /** * Recursively corrects the tree to minimize nested single-child nodes, remove invalid nodes, and correct invalid flex direction order. * @param node The node to start the balancing from. * @param beforeWalkCallback Any optional callback to run before walking a node's children. * @param afterWalkCallback An optional callback to run after walking a node's children. * @returns The corrected node. */ export function balanceNode( node: LayoutNode, beforeWalkCallback?: (node: LayoutNode) => void, afterWalkCallback?: (node: LayoutNode) => void ): LayoutNode { walkNodes( node, (node) => { if (!validateNode(node)) throw new Error("Invalid node"); node.children = node.children?.flatMap((child) => { if (child.flexDirection === node.flexDirection) { child.flexDirection = reverseFlexDirection(node.flexDirection); } if (child.children?.length == 1 && child.children[0].children) { return child.children[0].children; } if (child.children?.length === 0) return; return child; }); beforeWalkCallback?.(node); }, (node) => { node.children = node.children?.filter((v) => v); if (node.children?.length === 1 && !node.children[0].children) { node.data = node.children[0].data; node.id = node.children[0].id; node.children = undefined; } afterWalkCallback?.(node); } ); return node; } /** * Finds the first node in the tree where a new node can be inserted. * * This will attempt to fill each node until it has maxChildren children. If a node is full, it will move to its children and * fill each of them until it has maxChildren children. It will ensure that each child fills evenly before moving to the next * layer down. * * @param node The node to start the search from. * @param maxChildren The maximum number of children a node can have. * @returns The node to insert into and the index at which to insert. */ export function findNextInsertLocation( node: LayoutNode, maxChildren = DEFAULT_MAX_CHILDREN ): { node: LayoutNode; index: number } { const insertLoc = findNextInsertLocationHelper(node, maxChildren, 1); return { node: insertLoc?.node, index: insertLoc?.index }; } /** * Traverse the layout tree using the supplied index array to find the node to insert at. * @param node The node to start the search from. * @param indexArr The array of indices to aid in the traversal. * @returns The node to insert into and the index at which to insert. */ export function findInsertLocationFromIndexArr( node: LayoutNode, indexArr: number[] ): { node: LayoutNode; index: number } { function normalizeIndex(index: number) { const childrenLength = node.children?.length ?? 1; const lastChildIndex = childrenLength - 1; if (index < 0) { return childrenLength - Math.max(index, -childrenLength); } return Math.min(index, lastChildIndex); } if (indexArr.length == 0) { return; } const nextIndex = normalizeIndex(indexArr.shift()); if (indexArr.length == 0 || !node.children) { return { node, index: nextIndex }; } return findInsertLocationFromIndexArr(node.children[nextIndex], indexArr); } function findNextInsertLocationHelper( node: LayoutNode, maxChildren: number, curDepth: number = 1 ): { node: LayoutNode; index: number; depth: number } { if (!node) return; if (!node.children) return { node, index: 1, depth: curDepth }; let insertLocs: { node: LayoutNode; index: number; depth: number }[] = []; if (node.children.length < maxChildren) { insertLocs.push({ node, index: node.children.length, depth: curDepth }); } for (const child of node.children.slice().reverse()) { insertLocs.push(findNextInsertLocationHelper(child, maxChildren, curDepth + 1)); } insertLocs = insertLocs .filter((a) => a) .sort((a, b) => Math.pow(a.depth, a.index + maxChildren) - Math.pow(b.depth, b.index + maxChildren)); return insertLocs[0]; } export function totalChildrenSize(node: LayoutNode): number { return node.children?.reduce((partialSum, child) => partialSum + child.size, 0); } ================================================ FILE: frontend/layout/lib/layoutTree.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { lazy } from "@/util/util"; import { addChildAt, addIntermediateNode, findInsertLocationFromIndexArr, findNextInsertLocation, findNode, findParent, removeChild, } from "./layoutNode"; import { DefaultNodeSize, DropDirection, FlexDirection, LayoutTreeActionType, LayoutTreeComputeMoveNodeAction, LayoutTreeDeleteNodeAction, LayoutTreeFocusNodeAction, LayoutTreeInsertNodeAction, LayoutTreeInsertNodeAtIndexAction, LayoutTreeMagnifyNodeToggleAction, LayoutTreeMoveNodeAction, LayoutTreeResizeNodeAction, LayoutTreeState, LayoutTreeSwapNodeAction, MoveOperation, } from "./types"; import { newLayoutNode } from "./layoutNode"; import { LayoutTreeReplaceNodeAction, LayoutTreeSplitHorizontalAction, LayoutTreeSplitVerticalAction } from "./types"; export const DEFAULT_MAX_CHILDREN = 5; /** * Computes an operation for inserting a new node into the tree in the given direction relative to the specified node. * * @param layoutState The state of the tree. * @param computeInsertAction The operation to compute. */ export function computeMoveNode(layoutState: LayoutTreeState, computeInsertAction: LayoutTreeComputeMoveNodeAction) { const rootNode = layoutState.rootNode; const { nodeId, nodeToMoveId, direction } = computeInsertAction; if (!nodeId || !nodeToMoveId) { console.warn("either nodeId or nodeToMoveId not set", nodeId, nodeToMoveId); return; } if (direction === undefined) { console.warn("No direction provided for insertItemInDirection"); return; } if (nodeId === nodeToMoveId) { console.warn("Cannot compute move node action since both nodes are equal"); return; } let newMoveOperation: MoveOperation; const parent = lazy(() => findParent(rootNode, nodeId)); const grandparent = lazy(() => findParent(rootNode, parent().id)); const indexInParent = lazy(() => parent()?.children.findIndex((child) => nodeId === child.id)); const indexInGrandparent = lazy(() => grandparent()?.children.findIndex((child) => parent().id === child.id)); const nodeToMoveParent = lazy(() => findParent(rootNode, nodeToMoveId)); const nodeToMoveIndexInParent = lazy(() => nodeToMoveParent()?.children.findIndex((child) => nodeToMoveId === child.id) ); const isRoot = rootNode.id === nodeId; // TODO: this should not be necessary. The drag layer is having trouble tracking changes to the LayoutNode fields, so I need to grab the node again here to get the latest data. const node = findNode(rootNode, nodeId); const nodeToMove = findNode(rootNode, nodeToMoveId); if (!node || !nodeToMove) { console.warn("node or nodeToMove not set", nodeId, nodeToMoveId); return; } switch (direction) { case DropDirection.OuterTop: if (node.flexDirection === FlexDirection.Column) { const grandparentNode = grandparent(); if (grandparentNode) { const index = indexInGrandparent(); newMoveOperation = { parentId: grandparentNode.id, node: nodeToMove, index, }; break; } } // falls through case DropDirection.Top: if (node.flexDirection === FlexDirection.Column) { newMoveOperation = { parentId: nodeId, index: 0, node: nodeToMove }; } else { if (isRoot) newMoveOperation = { node: nodeToMove, index: 0, insertAtRoot: true, }; const parentNode = parent(); if (parentNode) newMoveOperation = { parentId: parentNode.id, index: indexInParent() ?? 0, node: nodeToMove, }; } break; case DropDirection.OuterBottom: if (node.flexDirection === FlexDirection.Column) { const grandparentNode = grandparent(); if (grandparentNode) { const index = indexInGrandparent() + 1; newMoveOperation = { parentId: grandparentNode.id, node: nodeToMove, index, }; break; } } // falls through case DropDirection.Bottom: if (node.flexDirection === FlexDirection.Column) { newMoveOperation = { parentId: nodeId, index: 1, node: nodeToMove }; } else { if (isRoot) newMoveOperation = { node: nodeToMove, index: 1, insertAtRoot: true, }; const parentNode = parent(); if (parentNode) newMoveOperation = { parentId: parentNode.id, index: indexInParent() + 1, node: nodeToMove, }; } break; case DropDirection.OuterLeft: if (node.flexDirection === FlexDirection.Row) { const grandparentNode = grandparent(); if (grandparentNode) { const index = indexInGrandparent(); newMoveOperation = { parentId: grandparentNode.id, node: nodeToMove, index, }; break; } } // falls through case DropDirection.Left: if (node.flexDirection === FlexDirection.Row) { newMoveOperation = { parentId: nodeId, index: 0, node: nodeToMove }; } else { const parentNode = parent(); if (parentNode) newMoveOperation = { parentId: parentNode.id, index: indexInParent(), node: nodeToMove, }; } break; case DropDirection.OuterRight: if (node.flexDirection === FlexDirection.Row) { const grandparentNode = grandparent(); if (grandparentNode) { const index = indexInGrandparent() + 1; newMoveOperation = { parentId: grandparentNode.id, node: nodeToMove, index, }; break; } } // falls through case DropDirection.Right: if (node.flexDirection === FlexDirection.Row) { newMoveOperation = { parentId: nodeId, index: 1, node: nodeToMove }; } else { const parentNode = parent(); if (parentNode) newMoveOperation = { parentId: parentNode.id, index: indexInParent() + 1, node: nodeToMove, }; } break; case DropDirection.Center: if (nodeId !== rootNode.id && nodeToMoveId !== rootNode.id) { const swapAction: LayoutTreeSwapNodeAction = { type: LayoutTreeActionType.Swap, node1Id: nodeId, node2Id: nodeToMoveId, }; return swapAction; } else { console.warn("cannot swap"); } break; default: throw new Error(`Invalid direction: ${direction}`); } if ( newMoveOperation?.parentId !== nodeToMoveParent()?.id || (newMoveOperation.index !== nodeToMoveIndexInParent() && newMoveOperation.index !== nodeToMoveIndexInParent() + 1) ) return { type: LayoutTreeActionType.Move, ...newMoveOperation, } as LayoutTreeMoveNodeAction; } export function moveNode(layoutState: LayoutTreeState, action: LayoutTreeMoveNodeAction) { console.log("moveNode", layoutState, action); const rootNode = layoutState.rootNode; if (!action) { console.error("no move node action provided"); return; } if (action.parentId && action.insertAtRoot) { console.error("parent and insertAtRoot cannot both be defined in a move node action"); return; } const node = findNode(rootNode, action.node.id) ?? action.node; const parent = findNode(rootNode, action.parentId); const oldParent = findParent(rootNode, action.node.id); let startingIndex = 0; // If moving under the same parent, we need to make sure that we are removing the child from its old position, not its new one. // If the new index is before the old index, we need to start our search for the node to delete after the new index position. // If a node is being moved under the same parent, it can keep its size. Otherwise, it should get reset. if (oldParent && parent) { if (oldParent.id === parent.id) { const curIndexInParent = parent.children!.indexOf(node); if (curIndexInParent >= action.index) { startingIndex = action.index + 1; } } else { node.size = DefaultNodeSize; } } if (!parent && action.insertAtRoot) { if (!rootNode.children) { addIntermediateNode(rootNode); } addChildAt(rootNode, action.index, node); } else if (parent) { addChildAt(parent, action.index, node); } else { throw new Error("Invalid InsertOperation"); } // Remove nodeToInsert from its old parent if (oldParent) { removeChild(oldParent, node, startingIndex); } } export function insertNode(layoutState: LayoutTreeState, action: LayoutTreeInsertNodeAction) { if (!action?.node) { console.error("insertNode cannot run, no insert node action provided"); return; } if (!layoutState.rootNode) { layoutState.rootNode = action.node; } else { const insertLoc = findNextInsertLocation(layoutState.rootNode, DEFAULT_MAX_CHILDREN); addChildAt(insertLoc.node, insertLoc.index, action.node); if (action.magnified) { layoutState.magnifiedNodeId = action.node.id; layoutState.focusedNodeId = action.node.id; } } if (action.focused) { layoutState.focusedNodeId = action.node.id; } } export function insertNodeAtIndex(layoutState: LayoutTreeState, action: LayoutTreeInsertNodeAtIndexAction) { if (!action?.node || !action?.indexArr) { console.error("insertNodeAtIndex cannot run, either node or indexArr field is missing"); return; } if (!layoutState.rootNode) { layoutState.rootNode = action.node; } else { const insertLoc = findInsertLocationFromIndexArr(layoutState.rootNode, action.indexArr); if (!insertLoc) { console.error("insertNodeAtIndex unable to find insert location"); return; } addChildAt(insertLoc.node, insertLoc.index + 1, action.node); if (action.magnified) { layoutState.magnifiedNodeId = action.node.id; layoutState.focusedNodeId = action.node.id; } } if (action.focused) { layoutState.focusedNodeId = action.node.id; } } export function swapNode(layoutState: LayoutTreeState, action: LayoutTreeSwapNodeAction) { if (!action.node1Id || !action.node2Id) { console.error("invalid swapNode action, both node1 and node2 must be defined"); return; } if (action.node1Id === layoutState.rootNode.id || action.node2Id === layoutState.rootNode.id) { console.error("invalid swapNode action, the root node cannot be swapped"); return; } if (action.node1Id === action.node2Id) { console.error("invalid swapNode action, node1 and node2 are equal"); return; } const parentNode1 = findParent(layoutState.rootNode, action.node1Id); const parentNode2 = findParent(layoutState.rootNode, action.node2Id); const parentNode1Index = parentNode1.children!.findIndex((child) => child.id === action.node1Id); const parentNode2Index = parentNode2.children!.findIndex((child) => child.id === action.node2Id); const node1 = parentNode1.children![parentNode1Index]; const node2 = parentNode2.children![parentNode2Index]; const node1Size = node1.size; node1.size = node2.size; node2.size = node1Size; parentNode1.children[parentNode1Index] = node2; parentNode2.children[parentNode2Index] = node1; } export function deleteNode(layoutState: LayoutTreeState, action: LayoutTreeDeleteNodeAction) { if (!action?.nodeId) { console.error("no delete node action provided"); return; } if (!layoutState.rootNode) { console.error("no root node"); return; } if (layoutState.rootNode.id === action.nodeId) { layoutState.rootNode = undefined; } else { const parent = findParent(layoutState.rootNode, action.nodeId); if (parent) { const node = parent.children.find((child) => child.id === action.nodeId); removeChild(parent, node); if (layoutState.focusedNodeId === node.id) { layoutState.focusedNodeId = undefined; } } else { console.error("unable to delete node, not found in tree"); } } } export function resizeNode(layoutState: LayoutTreeState, action: LayoutTreeResizeNodeAction) { if (!action.resizeOperations) { console.error("invalid resizeNode operation. nodeSizes array must be defined."); } for (const resize of action.resizeOperations) { if (!resize.nodeId || resize.size < 0 || resize.size > 100) { console.error("invalid resizeNode operation. nodeId must be defined and size must be between 0 and 100"); return; } const node = findNode(layoutState.rootNode, resize.nodeId); node.size = resize.size; } } export function focusNode(layoutState: LayoutTreeState, action: LayoutTreeFocusNodeAction) { if (!action.nodeId) { console.error("invalid focusNode operation, nodeId must be defined."); return; } layoutState.focusedNodeId = action.nodeId; } export function magnifyNodeToggle(layoutState: LayoutTreeState, action: LayoutTreeMagnifyNodeToggleAction) { if (!action.nodeId) { console.error("invalid magnifyNodeToggle operation. nodeId must be defined."); return; } if (layoutState.rootNode.id === action.nodeId) { console.warn(`cannot toggle magnification of node ${action.nodeId} because it is the root node.`); return; } if (layoutState.magnifiedNodeId === action.nodeId) { layoutState.magnifiedNodeId = undefined; } else { layoutState.magnifiedNodeId = action.nodeId; layoutState.focusedNodeId = action.nodeId; } } export function clearTree(layoutState: LayoutTreeState) { layoutState.rootNode = undefined; layoutState.leafOrder = undefined; layoutState.focusedNodeId = undefined; layoutState.magnifiedNodeId = undefined; } export function replaceNode(layoutState: LayoutTreeState, action: LayoutTreeReplaceNodeAction) { const { targetNodeId, newNode } = action; if (layoutState.rootNode.id === targetNodeId) { newNode.size = layoutState.rootNode.size; // preserve size layoutState.rootNode = newNode; } else { const parent = findParent(layoutState.rootNode, targetNodeId); if (!parent) { console.error("replaceNode: Parent not found for", targetNodeId); return; } const index = parent.children.findIndex((child) => child.id === targetNodeId); if (index === -1) { console.error("replaceNode: Target node not found in parent's children", targetNodeId); return; } // Preserve the old node's size. const targetNode = parent.children[index]; newNode.size = targetNode.size; parent.children[index] = newNode; } if (action.focused) { layoutState.focusedNodeId = newNode.id; } } // ─── SPLIT HORIZONTAL ───────────────────────────────────────────────────────────── export function splitHorizontal(layoutState: LayoutTreeState, action: LayoutTreeSplitHorizontalAction) { const { targetNodeId, newNode, position } = action; const targetNode = findNode(layoutState.rootNode, targetNodeId); if (!targetNode) { console.error("splitHorizontal: Target node not found", targetNodeId); return; } const parent = findParent(layoutState.rootNode, targetNodeId); if (parent && parent.flexDirection === FlexDirection.Row) { const index = parent.children.findIndex((child) => child.id === targetNodeId); if (index === -1) { console.error("splitHorizontal: Target node not found in parent's children", targetNodeId); return; } const insertIndex = position === "before" ? index : index + 1; // Directly splice in the new node instead of calling addChildAt (which may flatten nodes) parent.children.splice(insertIndex, 0, newNode); } else { // Otherwise, if no parent or parent's flexDirection is not Row, we need to wrap // Create a new group node with horizontal layout. // IMPORTANT: pass an initial children array so the new node is valid. const groupNode = newLayoutNode(FlexDirection.Row, targetNode.size, [targetNode], undefined); // Now decide the ordering based on the "position" groupNode.children = position === "before" ? [newNode, targetNode] : [targetNode, newNode]; if (parent) { const index = parent.children.findIndex((child) => child.id === targetNodeId); if (index === -1) { console.error("splitHorizontal (wrap): Target node not found in parent's children", targetNodeId); return; } parent.children[index] = groupNode; } else { layoutState.rootNode = groupNode; } } if (action.focused) { layoutState.focusedNodeId = newNode.id; } } // ─── SPLIT VERTICAL ───────────────────────────────────────────────────────────── export function splitVertical(layoutState: LayoutTreeState, action: LayoutTreeSplitVerticalAction) { const { targetNodeId, newNode, position } = action; const targetNode = findNode(layoutState.rootNode, targetNodeId); if (!targetNode) { console.error("splitVertical: Target node not found", targetNodeId); return; } const parent = findParent(layoutState.rootNode, targetNodeId); if (parent && parent.flexDirection === FlexDirection.Column) { const index = parent.children.findIndex((child) => child.id === targetNodeId); if (index === -1) { console.error("splitVertical: Target node not found in parent's children", targetNodeId); return; } const insertIndex = position === "before" ? index : index + 1; // For vertical splits in an already vertical parent, splice directly. parent.children.splice(insertIndex, 0, newNode); } else { // Wrap target node in a new vertical group. // Create group node with an initial children array so that validation passes. const groupNode = newLayoutNode(FlexDirection.Column, targetNode.size, [targetNode], undefined); groupNode.children = position === "before" ? [newNode, targetNode] : [targetNode, newNode]; if (parent) { const index = parent.children.findIndex((child) => child.id === targetNodeId); if (index === -1) { console.error("splitVertical (wrap): Target node not found in parent's children", targetNodeId); return; } parent.children[index] = groupNode; } else { layoutState.rootNode = groupNode; } } if (action.focused) { layoutState.focusedNodeId = newNode.id; } } ================================================ FILE: frontend/layout/lib/nodeRefMap.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export class NodeRefMap { private map: Map<string, React.RefObject<HTMLDivElement>> = new Map(); generation: number = 0; set(id: string, ref: React.RefObject<HTMLDivElement>) { this.map.set(id, ref); this.generation++; } delete(id: string) { if (this.map.has(id)) { this.map.delete(id); this.generation++; } } get(id: string): React.RefObject<HTMLDivElement> { if (this.map.has(id)) { return this.map.get(id); } } } ================================================ FILE: frontend/layout/lib/tilelayout.scss ================================================ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .tile-layout { position: relative; height: 100%; width: 100%; overflow: hidden; --gap-size-px: 5px; .overlay-container, .display-container, .placeholder-container { position: absolute; display: flex; top: 0; left: 0; height: 100%; width: 100%; min-height: 4rem; min-width: 4rem; } .display-container { z-index: var(--zindex-layout-display-container); } .placeholder-container { z-index: var(--zindex-layout-placeholder-container); } .overlay-container { z-index: var(--zindex-layout-overlay-container); } .overlay-node { display: flex; flex: 0 1 auto; } .resize-handle { z-index: var(--zindex-layout-resize-handle); .line { visibility: hidden; } &.flex-row { cursor: ew-resize; .line { height: 100%; width: calc(50% + 1px); border-right: 2px solid var(--accent-color); } } &.flex-column { cursor: ns-resize; .line { height: calc(50% + 1px); border-bottom: 2px solid var(--accent-color); } } &:hover .line { visibility: visible; // Ignore the prefers-reduced-motion override, since we are not applying a true animation here, just a delay. transition-property: visibility !important; transition-delay: var(--animation-time-s) !important; } } .tile-node { border-radius: calc(var(--block-border-radius) + 2px); overflow: hidden; width: 100%; height: 100%; &.dragging { filter: blur(8px); } &.resizing { border: 1px solid var(--accent-color); backdrop-filter: blur(8px); } .tile-leaf { overflow: hidden; } .tile-preview-container { position: absolute; top: 10000px; white-space: nowrap !important; user-select: none; -webkit-user-select: none; .tile-preview { width: 100%; height: 100%; } } &:not(:only-child) .tile-leaf { padding: calc(var(--gap-size-px) / 2); } } --block-blur: 2px; .magnified-node-backdrop, .ephemeral-node-backdrop { position: absolute; top: 0; left: 0; width: 100%; height: 100%; backdrop-filter: blur(var(--block-blur)); } .magnified-node-backdrop { z-index: var(--zindex-layout-magnified-node-backdrop); } .ephemeral-node-backdrop { z-index: var(--zindex-layout-ephemeral-node-backdrop); } &.animate { .tile-node, .placeholder { transition-duration: var(--animation-time-s); transition-timing-function: linear; transition-property: transform, width, height, background-color; } } .tile-leaf, .overlay-leaf { height: 100%; width: 100%; } .placeholder { background-color: var(--accent-color); opacity: 0.5; border-radius: calc(var(--block-border-radius) + 2px); } } ================================================ FILE: frontend/layout/lib/types.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Atom, WritableAtom } from "jotai"; import { CSSProperties } from "react"; export enum NavigateDirection { Up = 0, Right = 1, Down = 2, Left = 3, } export function navigateDirectionToString(dir: NavigateDirection): string { switch (dir) { case NavigateDirection.Up: return "up"; case NavigateDirection.Right: return "right"; case NavigateDirection.Down: return "down"; case NavigateDirection.Left: return "left"; default: return "unknown"; } } export enum DropDirection { Top = 0, Right = 1, Bottom = 2, Left = 3, OuterTop = 4, OuterRight = 5, OuterBottom = 6, OuterLeft = 7, Center = 8, } export enum FlexDirection { Row = "row", Column = "column", } /** * Represents an operation to insert a node into a tree. */ export type MoveOperation = { /** * The index at which the node will be inserted in the parent. */ index: number; /** * The parent node. Undefined if inserting at root. */ parentId?: string; /** * Whether the node will be inserted at the root of the tree. */ insertAtRoot?: boolean; /** * The node to insert. */ node: LayoutNode; }; /** * Types of actions that modify the layout tree. */ export enum LayoutTreeActionType { ComputeMove = "computemove", Move = "move", Swap = "swap", SetPendingAction = "setpending", CommitPendingAction = "commitpending", ClearPendingAction = "clearpending", ResizeNode = "resize", InsertNode = "insert", InsertNodeAtIndex = "insertatindex", DeleteNode = "delete", FocusNode = "focus", MagnifyNodeToggle = "magnify", ClearTree = "clear", ReplaceNode = "replace", SplitHorizontal = "splithorizontal", SplitVertical = "splitvertical", } /** * Base class for actions that modify the layout tree. */ export interface LayoutTreeAction { type: LayoutTreeActionType; } /** * Action for computing a move operation and saving it as a pending action in the tree state. * * @see MoveOperation * @see LayoutTreeMoveNodeAction */ export interface LayoutTreeComputeMoveNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.ComputeMove; nodeId: string; nodeToMoveId: string; direction: DropDirection; } /** * Action for moving a node within the layout tree. * * @see MoveOperation */ export interface LayoutTreeMoveNodeAction extends LayoutTreeAction, MoveOperation { type: LayoutTreeActionType.Move; } /** * Action for swapping two nodes within the layout tree. * */ export interface LayoutTreeSwapNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.Swap; /** * The node that node2 will replace. */ node1Id: string; /** * The node that node1 will replace. */ node2Id: string; } interface InsertNodeOperation { /** * The node to insert. */ node: LayoutNode; /** * Whether the inserted node should be magnified. */ magnified: boolean; /** * Whether the inserted node should be focused. */ focused: boolean; } /** * Action for inserting a new node to the layout tree. * */ export interface LayoutTreeInsertNodeAction extends LayoutTreeAction, InsertNodeOperation { type: LayoutTreeActionType.InsertNode; } /** * Action for inserting a node into the layout tree at the specified index. */ export interface LayoutTreeInsertNodeAtIndexAction extends LayoutTreeAction, InsertNodeOperation { type: LayoutTreeActionType.InsertNodeAtIndex; /** * The array of indices to traverse when inserting the node. * The last index is the index within the parent node where the node should be inserted. */ indexArr: number[]; } /** * Action for deleting a node from the layout tree. */ export interface LayoutTreeDeleteNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.DeleteNode; nodeId: string; } /** * Action for setting the pendingAction field of the layout tree state. */ export interface LayoutTreeSetPendingAction extends LayoutTreeAction { type: LayoutTreeActionType.SetPendingAction; /** * The new value for the pending action field. */ action: LayoutTreeAction; } /** * Action for committing the action in the pendingAction field of the layout tree state. */ export interface LayoutTreeCommitPendingAction extends LayoutTreeAction { type: LayoutTreeActionType.CommitPendingAction; } /** * Action for clearing the pendingAction field from the layout tree state. */ export interface LayoutTreeClearPendingAction extends LayoutTreeAction { type: LayoutTreeActionType.ClearPendingAction; } // ReplaceNode: replace an existing node in place with a new one. export interface LayoutTreeReplaceNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.ReplaceNode; targetNodeId: string; newNode: LayoutNode; focused?: boolean; } // SplitHorizontal: split the current block horizontally. // The "position" field indicates whether the new node should be inserted before (to the left) // or after (to the right) of the target node. export interface LayoutTreeSplitHorizontalAction extends LayoutTreeAction { type: LayoutTreeActionType.SplitHorizontal; targetNodeId: string; newNode: LayoutNode; position: "before" | "after"; focused?: boolean; } // SplitVertical: similar to split horizontal but along the vertical axis. export interface LayoutTreeSplitVerticalAction extends LayoutTreeAction { type: LayoutTreeActionType.SplitVertical; targetNodeId: string; newNode: LayoutNode; position: "before" | "after"; focused?: boolean; } /** * An operation to resize a node. */ export interface ResizeNodeOperation { /** * The id of the node to resize. */ nodeId: string; /** * The new size for the node. */ size: number; } /** * Action for resizing a node from the layout tree. */ export interface LayoutTreeResizeNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.ResizeNode; /** * A list of node ids to update and their respective new sizes. */ resizeOperations: ResizeNodeOperation[]; } /** * Action for focusing a node from the layout tree. */ export interface LayoutTreeFocusNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.FocusNode; /** * The id of the node to focus; */ nodeId: string; } /** * Action for toggling magnification of a node from the layout tree. */ export interface LayoutTreeMagnifyNodeToggleAction extends LayoutTreeAction { type: LayoutTreeActionType.MagnifyNodeToggle; /** * The id of the node to maximize; */ nodeId: string; } /** * Action for clearing all nodes from the layout tree. */ export interface LayoutTreeClearTreeAction extends LayoutTreeAction { type: LayoutTreeActionType.ClearTree; } /** * Represents a single node in the layout tree. */ export interface LayoutNode { id: string; data?: TabLayoutData; children?: LayoutNode[]; flexDirection: FlexDirection; size: number; } export type LayoutTreeStateSetter = (value: LayoutState) => void; export type LayoutTreeState = { rootNode: LayoutNode; focusedNodeId?: string; magnifiedNodeId?: string; /** * A computed ordered list of leafs in the layout. This value is driven by the LayoutModel and should not be read when updated from the backend. */ leafOrder?: LeafOrderEntry[]; pendingBackendActions: LayoutActionData[]; }; export type WritableLayoutTreeStateAtom = WritableAtom<LayoutTreeState, [value: LayoutTreeState], void>; export type ContentRenderer = (nodeModel: NodeModel) => React.ReactNode; export type PreviewRenderer = (nodeModel: NodeModel) => React.ReactElement; export const DefaultNodeSize = 10; /** * contains callbacks and information about the contents (or styling) of of the TileLayout * nothing in here is specific to the TileLayout itself */ export interface TileLayoutContents { /** * The tabId with which this TileLayout is associated. */ tabId?: string; /** * The class name to use for the top-level div of the tile layout. */ className?: string; /** * The gap between tiles in a layout, in CSS pixels. */ gapSizePx?: number; /** * A callback that accepts the data from the leaf node and displays the leaf contents to the user. */ renderContent: ContentRenderer; /** * A callback that accepts the data from the leaf node and returns a preview that can be shown when the user drags a node. */ renderPreview?: PreviewRenderer; /** * A callback that is called when a node gets deleted from the LayoutTreeState. * @param data The contents of the node that was deleted. */ onNodeDelete?: (data: TabLayoutData) => Promise<void>; /** * A callback for getting the cursor point in reference to the current window. This removes Electron as a runtime dependency, allowing for better integration with Storybook. * @returns The cursor position relative to the current window. */ getCursorPoint?: () => Point; } export interface ResizeHandleProps { id: string; parentNodeId: string; parentIndex: number; centerPx: number; transform: CSSProperties; flexDirection: FlexDirection; } export interface LayoutNodeAdditionalProps { treeKey: string; transform?: CSSProperties; rect?: Dimensions; pixelToSizeRatio?: number; resizeHandles?: ResizeHandleProps[]; } export interface NodeModel { additionalProps: Atom<LayoutNodeAdditionalProps>; innerRect: Atom<CSSProperties>; blockNum: Atom<number>; numLeafs: Atom<number>; nodeId: string; blockId: string; addEphemeralNodeToLayout: () => void; animationTimeS: Atom<number>; isResizing: Atom<boolean>; isFocused: Atom<boolean>; isMagnified: Atom<boolean>; anyMagnified: Atom<boolean>; isEphemeral: Atom<boolean>; ready: Atom<boolean>; disablePointerEvents: Atom<boolean>; toggleMagnify: () => void; focusNode: () => void; onClose: () => void; dragHandleRef?: React.RefObject<HTMLDivElement>; displayContainerRef: React.RefObject<HTMLDivElement>; } /** * Result object returned by switchNodeFocusInDirection method. */ export interface NavigationResult { success: boolean; atLeft?: boolean; atTop?: boolean; atBottom?: boolean; atRight?: boolean; } ================================================ FILE: frontend/layout/lib/utils.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { CSSProperties } from "react"; import { XYCoord } from "react-dnd"; import { DropDirection, FlexDirection, NavigateDirection } from "./types"; export function reverseFlexDirection(flexDirection: FlexDirection): FlexDirection { return flexDirection === FlexDirection.Row ? FlexDirection.Column : FlexDirection.Row; } export function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord | null): DropDirection | undefined { // console.log("determineDropDirection", dimensions, offset); if (!offset || !dimensions) return undefined; const { width, height, left, top } = dimensions; let { x, y } = offset; x -= left; y -= top; // Lies outside of the box if (y < 0 || y > height || x < 0 || x > width) return undefined; // Determines if a drop point falls within the center fifth of the box, meaning we should return Center. const centerX1 = (2 * width) / 5; const centerX2 = (3 * width) / 5; const centerY1 = (2 * height) / 5; const centerY2 = (3 * height) / 5; if (x > centerX1 && x < centerX2 && y > centerY1 && y < centerY2) return DropDirection.Center; const diagonal1 = y * width - x * height; const diagonal2 = y * width + x * height - height * width; // Lies on diagonal if (diagonal1 == 0 || diagonal2 == 0) return undefined; let code = 0; if (diagonal2 > 0) { code += 1; } if (diagonal1 > 0) { code += 2; code = 5 - code; } // Determines whether a drop is close to an edge of the box, meaning drop direction should be OuterX, instead of X const xOuter1 = width / 5; const xOuter2 = width - width / 5; const yOuter1 = height / 5; const yOuter2 = height - height / 5; if (y < yOuter1 || y > yOuter2 || x < xOuter1 || x > xOuter2) { code += 4; } return code; } export function setTransform( { top, left, width, height }: Dimensions, setSize = true, roundVals = true, zIndex?: number | string ): CSSProperties { // Replace unitless items with px const topRounded = roundVals ? Math.floor(top) : top; const leftRounded = roundVals ? Math.floor(left) : left; const widthRounded = roundVals ? Math.ceil(width) : width; const heightRounded = roundVals ? Math.ceil(height) : height; const translate = `translate3d(${leftRounded}px,${topRounded}px, 0)`; return { top: 0, left: 0, transform: translate, width: setSize ? `${widthRounded}px` : undefined, height: setSize ? `${heightRounded}px` : undefined, position: "absolute", zIndex: zIndex, }; } export function getCenter(dimensions: Dimensions): Point { return { x: dimensions.left + dimensions.width / 2, y: dimensions.top + dimensions.height / 2, }; } export function navigateDirectionToOffset(direction: NavigateDirection): Point { switch (direction) { case NavigateDirection.Up: return { x: 0, y: -1 }; case NavigateDirection.Down: return { x: 0, y: 1 }; case NavigateDirection.Left: return { x: -1, y: 0 }; case NavigateDirection.Right: return { x: 1, y: 0 }; } } ================================================ FILE: frontend/layout/tests/layoutNode.test.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { assert, test } from "vitest"; import { addChildAt, addIntermediateNode, balanceNode, findNextInsertLocation, newLayoutNode } from "../lib/layoutNode"; import { FlexDirection, LayoutNode } from "../lib/types"; test("newLayoutNode", () => { assert.throws( () => newLayoutNode(FlexDirection.Column), "Invalid node", undefined, "calls to the constructor without data or children should fail" ); assert.throws( () => newLayoutNode(FlexDirection.Column, undefined, [], { blockId: "hello" }), "Invalid node", undefined, "calls to the constructor with both data and children should fail" ); assert.doesNotThrow( () => newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "hello" }), "Invalid node", undefined, "calls to the constructor with only data defined should succeed" ); assert.throws( () => newLayoutNode(FlexDirection.Column, undefined, [], undefined), "Invalid node", undefined, "calls to the constructor with empty children array should fail" ); assert.doesNotThrow( () => newLayoutNode( FlexDirection.Column, undefined, [newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "hello" })], undefined ), "Invalid node", undefined, "calls to the constructor with children array containing at least one child should succeed" ); }); test("addIntermediateNode", () => { let node1: LayoutNode = newLayoutNode(FlexDirection.Column, undefined, [ newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "hello" }), ]); assert(node1.children![0].data!.blockId === "hello", "node1 should have one child which should have data"); const intermediateNode1 = addIntermediateNode(node1); assert( node1.children !== undefined && node1.children.length === 1 && node1.children?.includes(intermediateNode1), "node1 should have a single child intermediateNode1" ); assert(intermediateNode1.flexDirection === FlexDirection.Row, "intermediateNode1 should have flexDirection Row"); assert( intermediateNode1.children![0].children![0].data!.blockId === "hello" && intermediateNode1.children![0].children![0].flexDirection === FlexDirection.Row, "intermediateNode1 should have a nested child which should have data and flexDirection Row" ); let node2: LayoutNode = newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "hello", }); const intermediateNode2 = addIntermediateNode(node2); assert( node2.children !== undefined && node2.data === undefined && node2.children.length === 1 && node2.children.includes(intermediateNode2), "node2 should have no data and a single child intermediateNode2" ); assert( intermediateNode2.data.blockId === "hello" && intermediateNode2.children === undefined, "intermediateNode2 should have no children and should have data matching the old value of node2" ); }); test("addChildAt - same flexDirection, no children", () => { let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }); let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2" }); addChildAt(node1, 1, node2); assert(node1.data === undefined, "node1 should have no data"); assert(node1.children!.length === 2, "node1 should have two children"); assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); assert(node1.children![1].id === node2.id, "node1's second child should be node2"); assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should now have flexDirection Column"); }); test("addChildAt - different flexDirection, no children", () => { let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }); let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2" }); addChildAt(node1, 1, node2); assert(node1.data === undefined, "node1 should have no data"); assert(node1.children!.length === 2, "node1 should have two children"); assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); assert(node1.children![0].data!.blockId === "node1", "node1's first child should have flexDirection Column"); assert(node1.children![1].id === node2.id, "node1's second child should be node2"); assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should have flexDirection Row"); }); test("addChildAt - same flexDirection, first node has children, second doesn't", () => { let node1 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }), ]); let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2" }); addChildAt(node1, 1, node2); assert(node1.data === undefined, "node1 should have no data"); assert(node1.children!.length === 2, "node1 should have two children"); assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); assert( node1.children![0].flexDirection === FlexDirection.Column, "node1's first child should have flexDirection Column" ); assert(node1.children![1].id === node2.id, "node1's second child should be node2"); assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should have flexDirection Column"); }); test("addChildAt - different flexDirection, first node has children, second doesn't", () => { let node1 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }), ]); let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2" }); addChildAt(node1, 1, node2); assert(node1.data === undefined, "node1 should have no data"); assert(node1.children!.length === 2, "node1 should have two children"); assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); assert(node1.children![1].id === node2.id, "node1's second child should be node2"); assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should now have flexDirection Column"); }); test("addChildAt - same flexDirection, first node has children, second has children", () => { let node1 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }), ]); let node2 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2" }), ]); addChildAt(node1, 1, node2); assert(node1.data === undefined, "node1 should have no data"); assert(node1.children!.length === 2, "node1 should have two children"); assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); assert( node1.children![0].flexDirection === FlexDirection.Column, "node1's first child should have flexDirection Column" ); assert(node1.children![1].id === node2.children![0].id, "node1's second child should be node2's child"); assert( node1.children![1].flexDirection === FlexDirection.Column, "node1's second child should have flexDirection Column" ); }); test("addChildAt - different flexDirection, first node has children, second has children", () => { let node1 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }), ]); let node2 = newLayoutNode(FlexDirection.Column, undefined, [ newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2" }), ]); addChildAt(node1, 1, node2); assert(node1.data === undefined, "node1 should have no data"); assert(node1.children!.length === 2, "node1 should have two children"); assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); assert( node1.children![0].flexDirection === FlexDirection.Column, "node1's first child should have flexDirection Column" ); assert(node1.children![1].id === node2.id, "node1's second child should be node2"); assert( node1.children![1].flexDirection === FlexDirection.Column, "node1's second child should have flexDirection Column" ); }); test("balanceNode - corrects flex directions", () => { let node1 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1Inner1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1Inner2" }), ]); const newNode1 = balanceNode(node1); assert(newNode1 !== undefined, "newNode1 should not be undefined"); node1 = newNode1; assert(node1.data === undefined, "node1 should have no data"); assert(node1.children![0].flexDirection !== node1.flexDirection); }); test("balanceNode - collapses nodes with single grandchild 1", () => { let node1 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, [ newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), ]), ]); const newNode1 = balanceNode(node1); assert(newNode1 !== undefined, "newNode1 should not be undefined"); node1 = newNode1; assert(node1.children === undefined, "node1 should have no children"); assert(node1.data!.blockId === "node1", "node1 should have data 'node1'"); }); test("balanceNode - collapses nodes with single grandchild 2", () => { let node2 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, [ newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2Inner1" }), newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2Inner2" }), ]), ]), ]); const newNode2 = balanceNode(node2); assert(newNode2 !== undefined, "newNode2 should not be undefined"); node2 = newNode2; assert(node2.children!.length === 2, "node2 should have two children"); assert(node2.children[0].data!.blockId === "node2Inner1", "node2's first child should have data 'node2Inner1'"); // assert(leafs.length === 2, "leafs should have two leafs"); // assert(leafs[0].data!.blockId === "node2Inner1", "leafs[0] should have data 'node2Inner1'"); // assert(leafs[1].data!.blockId === "node2Inner2", "leafs[1] should have data 'node2Inner2'"); }); test("balanceNode - collapses nodes with single grandchild 3", () => { let node3 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, [ newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node3" }), ]), ]), ]); const newNode3 = balanceNode(node3); assert(newNode3 !== undefined, "newNode3 should not be undefined"); node3 = newNode3; assert(node3.children === undefined, "node3 should have no children"); assert(node3.data!.blockId === "node3", "node3 should have data 'node3'"); }); test("balanceNode - collapses nodes with single grandchild 4", () => { let node4 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, [ newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Column, undefined, [ newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node4Inner1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node4Inner2" }), ]), ]), ]), ]); const newNode4 = balanceNode(node4); assert(newNode4 !== undefined, "newNode4 should not be undefined"); node4 = newNode4; assert(node4.children!.length === 1, "node4 should have one child"); assert(node4.children![0].children!.length === 2, "node4 should have two grandchildren"); assert( node4.children[0].children![0].data!.blockId === "node4Inner1", "node4's first child should have data 'node4Inner1'" ); }); test("findNextInsertLocation", () => { const node1 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), ]); const insertLoc1 = findNextInsertLocation(node1, 5); assert(insertLoc1.node.id === node1.id, "should insert into node1"); assert(insertLoc1.index === 4, "should insert into index 4 of node1"); const node2Inner5 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2Inner5" }); const node2 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), node2Inner5, ]); const insertLoc2 = findNextInsertLocation(node2, 5); assert(insertLoc2.node.id === node2Inner5.id, "should insert into node2Inner5"); assert(insertLoc2.index === 1, "should insert into index 1 of node2Inner1"); const node3Inner5 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), ]); const node3Inner4 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node3Inner4" }); const node3 = newLayoutNode(FlexDirection.Row, undefined, [ newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), node3Inner4, node3Inner5, ]); const insertLoc3 = findNextInsertLocation(node3, 5); assert(insertLoc3.node.id === node3Inner4.id, "should insert into node3Inner4"); assert(insertLoc3.index === 1, "should insert into index 1 of node3Inner4"); }); ================================================ FILE: frontend/layout/tests/layoutTree.test.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { assert, test } from "vitest"; import { newLayoutNode } from "../lib/layoutNode"; import { computeMoveNode, moveNode } from "../lib/layoutTree"; import { DropDirection, LayoutTreeActionType, LayoutTreeComputeMoveNodeAction, LayoutTreeMoveNodeAction, } from "../lib/types"; import { newLayoutTreeState } from "./model"; test("layoutTreeStateReducer - compute move", () => { const nodeA = newLayoutNode(undefined, undefined, undefined, { blockId: "nodeA" }); const node1 = newLayoutNode(undefined, undefined, undefined, { blockId: "node1" }); const node2 = newLayoutNode(undefined, undefined, undefined, { blockId: "node2" }); const treeState = newLayoutTreeState(newLayoutNode(undefined, undefined, [nodeA, node1, node2])); assert(treeState.rootNode.children!.length === 3, "root should have three children"); let pendingAction = computeMoveNode(treeState, { type: LayoutTreeActionType.ComputeMove, nodeId: treeState.rootNode.id, nodeToMoveId: node1.id, direction: DropDirection.Bottom, }); const insertOperation = pendingAction as LayoutTreeMoveNodeAction; assert(insertOperation.node === node1, "insert operation node should equal node1"); assert(!insertOperation.parentId, "insert operation parent should not be defined"); assert(insertOperation.index === 1, "insert operation index should equal 1"); assert(insertOperation.insertAtRoot, "insert operation insertAtRoot should be true"); moveNode(treeState, insertOperation); assert( treeState.rootNode.data === undefined && treeState.rootNode.children!.length === 3, "root node should still have three children" ); assert(treeState.rootNode.children![1].data!.blockId === "node1", "root's second child should be node1"); pendingAction = computeMoveNode(treeState, { type: LayoutTreeActionType.ComputeMove, nodeId: node1.id, nodeToMoveId: node2.id, direction: DropDirection.Bottom, }); const insertOperation2 = pendingAction as LayoutTreeMoveNodeAction; assert(insertOperation2.node === node2, "insert operation node should equal node2"); assert(insertOperation2.parentId === node1.id, "insert operation parent id should be node1 id"); assert(insertOperation2.index === 1, "insert operation index should equal 1"); assert(!insertOperation2.insertAtRoot, "insert operation insertAtRoot should be false"); moveNode(treeState, insertOperation2); assert( treeState.rootNode.data === undefined && (treeState.rootNode.children!.length as number) === 2, "root node should now have two children after node2 moved into node1" ); assert(treeState.rootNode.children![1].children!.length === 2, "root's second child should now have two children"); }); test("computeMove - noop action", () => { const nodeToMove = newLayoutNode(undefined, undefined, undefined, { blockId: "nodeToMove" }); const treeState = newLayoutTreeState( newLayoutNode(undefined, undefined, [ nodeToMove, newLayoutNode(undefined, undefined, undefined, { blockId: "otherNode" }), ]) ); let moveAction: LayoutTreeComputeMoveNodeAction = { type: LayoutTreeActionType.ComputeMove, nodeId: treeState.rootNode.id, nodeToMoveId: nodeToMove.id, direction: DropDirection.Left, }; let pendingAction = computeMoveNode(treeState, moveAction); assert(pendingAction === undefined, "inserting a node to the left of itself should not produce a pendingAction"); moveAction = { type: LayoutTreeActionType.ComputeMove, nodeId: treeState.rootNode.id, nodeToMoveId: nodeToMove.id, direction: DropDirection.Right, }; pendingAction = computeMoveNode(treeState, moveAction); assert(pendingAction === undefined, "inserting a node to the right of itself should not produce a pendingAction"); }); ================================================ FILE: frontend/layout/tests/model.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { LayoutNode, LayoutTreeState } from "../lib/types"; export function newLayoutTreeState(rootNode: LayoutNode): LayoutTreeState { return { rootNode, pendingBackendActions: [], }; } ================================================ FILE: frontend/layout/tests/utils.test.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { assert, test } from "vitest"; import { DropDirection, FlexDirection } from "../lib/types"; import { determineDropDirection, reverseFlexDirection } from "../lib/utils"; test("determineDropDirection", () => { const dimensions: Dimensions = { top: 0, left: 0, height: 5, width: 5, }; assert.equal( determineDropDirection(dimensions, { x: 2.5, y: 1.5, }), DropDirection.Top ); assert.equal( determineDropDirection(dimensions, { x: 2.5, y: 3.5, }), DropDirection.Bottom ); assert.equal( determineDropDirection(dimensions, { x: 3.5, y: 2.5, }), DropDirection.Right ); assert.equal( determineDropDirection(dimensions, { x: 1.5, y: 2.5, }), DropDirection.Left ); assert.equal( determineDropDirection(dimensions, { x: 2.5, y: 0.5, }), DropDirection.OuterTop ); assert.equal( determineDropDirection(dimensions, { x: 4.5, y: 2.5, }), DropDirection.OuterRight ); assert.equal( determineDropDirection(dimensions, { x: 2.5, y: 4.5, }), DropDirection.OuterBottom ); assert.equal( determineDropDirection(dimensions, { x: 0.5, y: 2.5, }), DropDirection.OuterLeft ); assert.equal( determineDropDirection(dimensions, { x: 2.5, y: 2.5, }), DropDirection.Center ); assert.equal( determineDropDirection(dimensions, { x: 2.51, y: 2.51, }), DropDirection.Center ); assert.equal( determineDropDirection(dimensions, { x: 1.5, y: 1.5, }), undefined ); }); test("reverseFlexDirection", () => { assert.equal(reverseFlexDirection(FlexDirection.Row), FlexDirection.Column); assert.equal(reverseFlexDirection(FlexDirection.Column), FlexDirection.Row); }); ================================================ FILE: frontend/preview/index.html ================================================ <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="color-scheme" content="dark" /> <title>Wave Preview Server
================================================ FILE: frontend/preview/mock/defaultconfig.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import mimetypesJson from "../../../pkg/wconfig/defaultconfig/mimetypes.json"; import presetsJson from "../../../pkg/wconfig/defaultconfig/presets.json"; import settingsJson from "../../../pkg/wconfig/defaultconfig/settings.json"; import termthemesJson from "../../../pkg/wconfig/defaultconfig/termthemes.json"; import waveaiJson from "../../../pkg/wconfig/defaultconfig/waveai.json"; import widgetsJson from "../../../pkg/wconfig/defaultconfig/widgets.json"; export const DefaultFullConfig: FullConfigType = { settings: settingsJson as SettingsType, mimetypes: mimetypesJson as unknown as { [key: string]: MimeTypeConfigType }, defaultwidgets: widgetsJson as unknown as { [key: string]: WidgetConfigType }, widgets: {}, presets: presetsJson as unknown as { [key: string]: MetaType }, termthemes: termthemesJson as unknown as { [key: string]: TermThemeType }, connections: {}, bookmarks: {}, waveai: waveaiJson as unknown as { [key: string]: AIModeConfigType }, configerrors: [], }; ================================================ FILE: frontend/preview/mock/mock-node-model.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import type { NodeModel } from "@/layout/index"; import { atom } from "jotai"; export type MockNodeModelOpts = { nodeId: string; blockId: string; innerRect?: { width: string; height: string }; numLeafs?: number; }; export function makeMockNodeModel(opts: MockNodeModelOpts): NodeModel { const isFocusedAtom = atom(true); const isMagnifiedAtom = atom(false); return { additionalProps: atom({} as any), innerRect: atom(opts.innerRect ?? { width: "1000px", height: "640px" }), blockNum: atom(1), numLeafs: atom(opts.numLeafs ?? 1), nodeId: opts.nodeId, blockId: opts.blockId, addEphemeralNodeToLayout: () => {}, animationTimeS: atom(0), isResizing: atom(false), isFocused: isFocusedAtom, isMagnified: isMagnifiedAtom, anyMagnified: atom((get) => get(isMagnifiedAtom)), isEphemeral: atom(false), ready: atom(true), disablePointerEvents: atom(false), toggleMagnify: () => { globalStore.set(isMagnifiedAtom, !globalStore.get(isMagnifiedAtom)); }, focusNode: () => { globalStore.set(isFocusedAtom, true); }, onClose: () => {}, dragHandleRef: { current: null }, displayContainerRef: { current: null }, }; } ================================================ FILE: frontend/preview/mock/mockfilesystem.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { arrayToBase64 } from "@/util/util"; const MockHomePath = "/Users/mike"; const MockDirMimeType = "directory"; const MockDirMode = 0o040755; const MockFileMode = 0o100644; const MockDirectoryChunkSize = 128; const MockFileChunkSize = 64 * 1024; const MockBaseModTime = Date.parse("2026-03-10T09:00:00.000Z"); const TinyPngBytes = Uint8Array.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x04, 0x00, 0x00, 0x00, 0xb5, 0x1c, 0x0c, 0x02, 0x00, 0x00, 0x00, 0x0b, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xfc, 0xff, 0x1f, 0x00, 0x03, 0x03, 0x01, 0xff, 0xa5, 0xf8, 0x8f, 0xb1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); const TinyJpegBytes = Uint8Array.from([ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0a, 0x07, 0x07, 0x06, 0x08, 0x0c, 0x0a, 0x0c, 0x0c, 0x0b, 0x0a, 0x0b, 0x0b, 0x0d, 0x0e, 0x12, 0x10, 0x0d, 0x0e, 0x11, 0x0e, 0x0b, 0x0b, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, 0x15, 0x15, 0x0c, 0x0f, 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xff, 0xc4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0xbf, 0xff, 0xd9, ]); type MockFsEntry = { path: string; dir: string; name: string; isdir: boolean; mimetype: string; modtime: number; mode: number; size: number; readonly?: boolean; supportsmkdir?: boolean; content?: Uint8Array; }; type MockFsEntryInput = { path: string; isdir?: boolean; mimetype?: string; readonly?: boolean; content?: string | Uint8Array; }; export type MockFilesystem = { homePath: string; fileCount: number; directoryCount: number; entryCount: number; fileInfo: (data: FileData) => Promise; fileRead: (data: FileData) => Promise; fileList: (data: FileListData) => Promise; fileJoin: (paths: string[]) => Promise; fileReadStream: (data: FileData) => AsyncGenerator; fileListStream: (data: FileListData) => AsyncGenerator; }; function normalizeMockPath(path: string, basePath = MockHomePath): string { if (path == null || path === "") { return basePath; } if (path.startsWith("wsh://")) { const url = new URL(path); path = url.pathname.replace(/^\/+/, "/"); } if (path === "~") { path = MockHomePath; } else if (path.startsWith("~/")) { path = MockHomePath + path.slice(1); } if (!path.startsWith("/")) { path = `${basePath}/${path}`; } const parts = path.split("/"); const resolvedParts: string[] = []; for (const part of parts) { if (!part || part === ".") { continue; } if (part === "..") { resolvedParts.pop(); continue; } resolvedParts.push(part); } const resolvedPath = "/" + resolvedParts.join("/"); return resolvedPath === "" ? "/" : resolvedPath; } function getDirName(path: string): string { if (path === "/") { return "/"; } const idx = path.lastIndexOf("/"); if (idx <= 0) { return "/"; } return path.slice(0, idx); } function getBaseName(path: string): string { if (path === "/") { return "/"; } const idx = path.lastIndexOf("/"); return idx < 0 ? path : path.slice(idx + 1); } function getMimeType(path: string, isdir: boolean): string { if (isdir) { return MockDirMimeType; } if (path.endsWith(".md")) { return "text/markdown"; } if (path.endsWith(".json")) { return "application/json"; } if (path.endsWith(".ts")) { return "text/typescript"; } if (path.endsWith(".tsx")) { return "text/tsx"; } if (path.endsWith(".js")) { return "text/javascript"; } if (path.endsWith(".txt") || path.endsWith(".log") || path.endsWith(".bashrc") || path.endsWith(".zprofile")) { return "text/plain"; } if (path.endsWith(".png")) { return "image/png"; } if (path.endsWith(".jpg") || path.endsWith(".jpeg")) { return "image/jpeg"; } if (path.endsWith(".pdf")) { return "application/pdf"; } if (path.endsWith(".zip")) { return "application/zip"; } if (path.endsWith(".dmg")) { return "application/x-apple-diskimage"; } if (path.endsWith(".svg")) { return "image/svg+xml"; } if (path.endsWith(".yaml") || path.endsWith(".yml")) { return "application/yaml"; } return "application/octet-stream"; } function makeContentBytes(content: string | Uint8Array): Uint8Array { if (content instanceof Uint8Array) { return content; } return new TextEncoder().encode(content); } function makeMockFsInput(path: string, content?: string | Uint8Array, mimetype?: string): MockFsEntryInput { return { path, content, mimetype }; } function createMockFilesystemEntries(): MockFsEntryInput[] { const entries: MockFsEntryInput[] = [ { path: "/", isdir: true }, { path: "/Users", isdir: true }, { path: MockHomePath, isdir: true }, { path: `${MockHomePath}/Desktop`, isdir: true }, { path: `${MockHomePath}/Documents`, isdir: true }, { path: `${MockHomePath}/Downloads`, isdir: true }, { path: `${MockHomePath}/Pictures`, isdir: true }, { path: `${MockHomePath}/Projects`, isdir: true }, { path: `${MockHomePath}/waveterm`, isdir: true }, { path: `${MockHomePath}/waveterm/docs`, isdir: true }, { path: `${MockHomePath}/waveterm/images`, isdir: true }, { path: `${MockHomePath}/.config`, isdir: true }, makeMockFsInput( `${MockHomePath}/.bashrc`, `export PATH="$HOME/bin:$PATH"\nalias gs="git status -sb"\nexport WAVETERM_THEME="midnight"\n`, "text/plain" ), makeMockFsInput(`${MockHomePath}/.gitconfig`), makeMockFsInput(`${MockHomePath}/.zprofile`), makeMockFsInput(`${MockHomePath}/todo.txt`), makeMockFsInput(`${MockHomePath}/notes.txt`), makeMockFsInput(`${MockHomePath}/shell-aliases`), makeMockFsInput(`${MockHomePath}/archive.log`), makeMockFsInput(`${MockHomePath}/session.txt`), makeMockFsInput(`${MockHomePath}/Desktop/launch-plan.md`), makeMockFsInput(`${MockHomePath}/Desktop/coffee.txt`), makeMockFsInput(`${MockHomePath}/Desktop/daily-standup.txt`), makeMockFsInput(`${MockHomePath}/Desktop/snippets.txt`), makeMockFsInput(`${MockHomePath}/Desktop/terminal-theme.png`), makeMockFsInput(`${MockHomePath}/Desktop/macos-shortcuts.txt`), makeMockFsInput(`${MockHomePath}/Desktop/bug-scrub.txt`), makeMockFsInput(`${MockHomePath}/Desktop/parking-receipt.pdf`), makeMockFsInput(`${MockHomePath}/Desktop/demo-script.md`), makeMockFsInput(`${MockHomePath}/Desktop/roadmap-draft.txt`), makeMockFsInput(`${MockHomePath}/Desktop/pairing-notes.txt`), makeMockFsInput(`${MockHomePath}/Desktop/wave-window.jpg`), makeMockFsInput( `${MockHomePath}/Documents/meeting-notes.md`, `# File Preview Notes\n\n- Build a richer preview mock environment.\n- Add a fake filesystem rooted at \`${MockHomePath}\`.\n- Make markdown previews resolve relative assets.\n`, "text/markdown" ), makeMockFsInput(`${MockHomePath}/Documents/architecture-overview.md`), makeMockFsInput(`${MockHomePath}/Documents/release-checklist.md`), makeMockFsInput(`${MockHomePath}/Documents/ideas.txt`), makeMockFsInput(`${MockHomePath}/Documents/customer-feedback.txt`), makeMockFsInput(`${MockHomePath}/Documents/cli-ux-notes.txt`), makeMockFsInput(`${MockHomePath}/Documents/migration-plan.md`), makeMockFsInput(`${MockHomePath}/Documents/design-review.md`), makeMockFsInput(`${MockHomePath}/Documents/ops-runbook.md`), makeMockFsInput(`${MockHomePath}/Documents/troubleshooting.txt`), makeMockFsInput(`${MockHomePath}/Documents/preview-fixtures.txt`), makeMockFsInput(`${MockHomePath}/Documents/backlog.txt`), makeMockFsInput(`${MockHomePath}/Documents/feature-flags.yaml`), makeMockFsInput(`${MockHomePath}/Documents/connections.csv`), makeMockFsInput(`${MockHomePath}/Documents/ssh-hosts.txt`), makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-01.md`), makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-05.md`), makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-09.md`), makeMockFsInput(`${MockHomePath}/Downloads/waveterm-nightly.dmg`), makeMockFsInput(`${MockHomePath}/Downloads/screenshot-pack.zip`), makeMockFsInput(`${MockHomePath}/Downloads/cli-reference.pdf`), makeMockFsInput(`${MockHomePath}/Downloads/ssh-cheatsheet.pdf`), makeMockFsInput(`${MockHomePath}/Downloads/perf-trace.json`), makeMockFsInput(`${MockHomePath}/Downloads/terminal-icons.zip`), makeMockFsInput(`${MockHomePath}/Downloads/demo-data.csv`), makeMockFsInput(`${MockHomePath}/Downloads/deploy-plan.txt`), makeMockFsInput(`${MockHomePath}/Downloads/customer-audio.m4a`), makeMockFsInput(`${MockHomePath}/Downloads/mock-shell-history.txt`), makeMockFsInput(`${MockHomePath}/Downloads/design-assets.zip`), makeMockFsInput(`${MockHomePath}/Downloads/old-preview-build.dmg`), makeMockFsInput(`${MockHomePath}/Downloads/testing-samples.tar`), makeMockFsInput(`${MockHomePath}/Downloads/workflow-failure.log`), makeMockFsInput(`${MockHomePath}/Downloads/team-photo.jpg`), makeMockFsInput(`${MockHomePath}/Downloads/preview-recording.mov`), makeMockFsInput(`${MockHomePath}/Downloads/standup-notes.txt`), makeMockFsInput(`${MockHomePath}/Downloads/metadata.json`), makeMockFsInput(`${MockHomePath}/Pictures/beach-sunrise.png`, TinyPngBytes, "image/png"), makeMockFsInput(`${MockHomePath}/Pictures/terminal-screenshot.jpg`, TinyJpegBytes, "image/jpeg"), makeMockFsInput(`${MockHomePath}/Pictures/diagram.png`), makeMockFsInput(`${MockHomePath}/Pictures/launch-party.jpg`), makeMockFsInput(`${MockHomePath}/Pictures/icon-sketch.png`), makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-01.png`), makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-02.png`), makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-03.png`), makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-04.png`), makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-05.png`), makeMockFsInput(`${MockHomePath}/Pictures/product-shot-01.jpg`), makeMockFsInput(`${MockHomePath}/Pictures/product-shot-02.jpg`), makeMockFsInput(`${MockHomePath}/Pictures/product-shot-03.jpg`), makeMockFsInput(`${MockHomePath}/Pictures/product-shot-04.jpg`), makeMockFsInput(`${MockHomePath}/Pictures/product-shot-05.jpg`), makeMockFsInput(`${MockHomePath}/Pictures/ui-concept.png`), makeMockFsInput(`${MockHomePath}/Projects/local.env`), makeMockFsInput(`${MockHomePath}/Projects/db-migration.sql`), makeMockFsInput(`${MockHomePath}/Projects/prompt-lab.txt`), makeMockFsInput(`${MockHomePath}/Projects/ui-spikes.tsx`), makeMockFsInput(`${MockHomePath}/Projects/file-browser.tsx`), makeMockFsInput(`${MockHomePath}/Projects/mock-data.json`), makeMockFsInput(`${MockHomePath}/Projects/preview-api.ts`), makeMockFsInput(`${MockHomePath}/Projects/bug-181.txt`), makeMockFsInput( `${MockHomePath}/waveterm/README.md`, `# Mock WaveTerm Repo\n\nThis fake repo exists only in the preview environment.\nIt gives file previews something realistic to browse.\n`, "text/markdown" ), makeMockFsInput(`${MockHomePath}/waveterm/package.json`), makeMockFsInput(`${MockHomePath}/waveterm/tsconfig.json`), makeMockFsInput(`${MockHomePath}/waveterm/Taskfile.yml`), makeMockFsInput(`${MockHomePath}/waveterm/preview-model.tsx`), makeMockFsInput(`${MockHomePath}/waveterm/mockwaveenv.ts`), makeMockFsInput(`${MockHomePath}/waveterm/vite.config.ts`), makeMockFsInput(`${MockHomePath}/waveterm/CHANGELOG.md`), makeMockFsInput( `${MockHomePath}/waveterm/docs/preview-notes.md`, `# Preview Mocking\n\nUse the preview server to iterate on file previews without Electron.\nRelative markdown assets should resolve through \`FileJoinCommand\`.\n`, "text/markdown" ), makeMockFsInput(`${MockHomePath}/waveterm/docs/filesystem-rpc.md`), makeMockFsInput(`${MockHomePath}/waveterm/docs/test-plan.md`), makeMockFsInput(`${MockHomePath}/waveterm/docs/connections.md`), makeMockFsInput(`${MockHomePath}/waveterm/docs/preview-gallery.md`), makeMockFsInput(`${MockHomePath}/waveterm/docs/release-notes.md`), makeMockFsInput(`${MockHomePath}/waveterm/images/wave-logo.png`, TinyPngBytes, "image/png"), makeMockFsInput(`${MockHomePath}/waveterm/images/hero.png`), makeMockFsInput(`${MockHomePath}/waveterm/images/avatar.jpg`), makeMockFsInput(`${MockHomePath}/waveterm/images/icon-16.png`), makeMockFsInput(`${MockHomePath}/waveterm/images/icon-32.png`), makeMockFsInput(`${MockHomePath}/waveterm/images/splash.jpg`), makeMockFsInput( `${MockHomePath}/.config/settings.json`, JSON.stringify( { "app:theme": "wave-dark", "preview:lastpath": `${MockHomePath}/Documents/meeting-notes.md`, "window:magnifiedblockopacity": 0.92, }, null, 2 ), "application/json" ), makeMockFsInput(`${MockHomePath}/.config/preview-cache.json`), makeMockFsInput(`${MockHomePath}/.config/recent-workspaces.json`), makeMockFsInput(`${MockHomePath}/.config/telemetry.log`), ]; return entries; } function buildEntries(): Map { const inputs = createMockFilesystemEntries(); const entries = new Map(); const ensureDir = (path: string) => { const normalizedPath = normalizeMockPath(path, "/"); if (entries.has(normalizedPath)) { return; } const dir = getDirName(normalizedPath); if (normalizedPath !== "/") { ensureDir(dir); } entries.set(normalizedPath, { path: normalizedPath, dir: normalizedPath === "/" ? "/" : dir, name: normalizedPath === "/" ? "/" : getBaseName(normalizedPath), isdir: true, mimetype: MockDirMimeType, modtime: MockBaseModTime + entries.size * 60000, mode: MockDirMode, size: 0, supportsmkdir: true, }); }; for (const input of inputs) { const normalizedPath = normalizeMockPath(input.path, "/"); const isdir = input.isdir ?? false; const dir = getDirName(normalizedPath); if (normalizedPath !== "/") { ensureDir(dir); } const content = input.content == null ? undefined : makeContentBytes(input.content); entries.set(normalizedPath, { path: normalizedPath, dir: normalizedPath === "/" ? "/" : dir, name: normalizedPath === "/" ? "/" : getBaseName(normalizedPath), isdir, mimetype: input.mimetype ?? getMimeType(normalizedPath, isdir), modtime: MockBaseModTime + entries.size * 60000, mode: isdir ? MockDirMode : MockFileMode, size: content?.byteLength ?? 0, readonly: input.readonly, supportsmkdir: isdir, content, }); } return entries; } function toFileInfo(entry: MockFsEntry): FileInfo { return { path: entry.path, dir: entry.dir, name: entry.name, size: entry.size, mode: entry.mode, modtime: entry.modtime, isdir: entry.isdir, supportsmkdir: entry.supportsmkdir, mimetype: entry.mimetype, readonly: entry.readonly, }; } function makeNotFoundInfo(path: string): FileInfo { const normalizedPath = normalizeMockPath(path); return { path: normalizedPath, dir: getDirName(normalizedPath), name: getBaseName(normalizedPath), notfound: true, supportsmkdir: true, }; } function sliceEntries(entries: FileInfo[], opts?: FileListOpts): FileInfo[] { let filteredEntries = entries; if (!opts?.all) { filteredEntries = filteredEntries.filter((entry) => entry.name != null && !entry.name.startsWith(".")); } const offset = Math.max(opts?.offset ?? 0, 0); const end = opts?.limit != null && opts.limit >= 0 ? offset + opts.limit : undefined; return filteredEntries.slice(offset, end); } function joinPaths(paths: string[]): string { if (paths.length === 0) { return MockHomePath; } let currentPath = normalizeMockPath(paths[0]); for (const part of paths.slice(1)) { currentPath = normalizeMockPath(part, currentPath); } return currentPath; } function getReadRange(data: FileData, size: number): { offset: number; end: number } { const offset = Math.max(data?.at?.offset ?? 0, 0); const end = data?.at?.size != null ? Math.min(offset + data.at.size, size) : size; return { offset, end: Math.max(offset, end) }; } export function makeMockFilesystem(): MockFilesystem { const entries = buildEntries(); const childrenByDir = new Map(); for (const entry of entries.values()) { if (entry.path === "/") { continue; } if (!childrenByDir.has(entry.dir)) { childrenByDir.set(entry.dir, []); } childrenByDir.get(entry.dir).push(entry); } for (const childEntries of childrenByDir.values()) { childEntries.sort((a, b) => { if (a.isdir !== b.isdir) { return a.isdir ? -1 : 1; } return a.name.localeCompare(b.name); }); } const getEntry = (path: string): MockFsEntry => { return entries.get(normalizeMockPath(path)); }; const fileInfo = async (data: FileData): Promise => { const entry = getEntry(data?.info?.path ?? MockHomePath); if (!entry) { return makeNotFoundInfo(data?.info?.path ?? MockHomePath); } return toFileInfo(entry); }; const fileRead = async (data: FileData): Promise => { const info = await fileInfo(data); if (info.notfound) { return { info }; } const entry = getEntry(info.path); if (entry.isdir) { const childEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child)); return { info, entries: childEntries }; } if (entry.content == null || entry.content.byteLength === 0) { return { info }; } const { offset, end } = getReadRange(data, entry.content.byteLength); return { info, data64: arrayToBase64(entry.content.slice(offset, end)), at: { offset, size: end - offset }, }; }; const fileList = async (data: FileListData): Promise => { const dirPath = normalizeMockPath(data?.path ?? MockHomePath); const entry = getEntry(dirPath); if (entry == null || !entry.isdir) { return []; } const dirEntries = (childrenByDir.get(dirPath) ?? []).map((child) => toFileInfo(child)); return sliceEntries(dirEntries, data?.opts); }; const fileJoin = async (paths: string[]): Promise => { const path = paths.length === 1 ? normalizeMockPath(paths[0]) : joinPaths(paths); const entry = getEntry(path); if (!entry) { return makeNotFoundInfo(path); } return toFileInfo(entry); }; const fileReadStream = async function* (data: FileData): AsyncGenerator { const info = await fileInfo(data); yield { info }; if (info.notfound) { return; } const entry = getEntry(info.path); if (entry.isdir) { const dirEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child)); for (let idx = 0; idx < dirEntries.length; idx += MockDirectoryChunkSize) { yield { entries: dirEntries.slice(idx, idx + MockDirectoryChunkSize) }; } return; } if (entry.content == null || entry.content.byteLength === 0) { return; } const { offset, end } = getReadRange(data, entry.content.byteLength); for (let currentOffset = offset; currentOffset < end; currentOffset += MockFileChunkSize) { const chunkEnd = Math.min(currentOffset + MockFileChunkSize, end); yield { data64: arrayToBase64(entry.content.slice(currentOffset, chunkEnd)), at: { offset: currentOffset, size: chunkEnd - currentOffset }, }; } }; const fileListStream = async function* (data: FileListData): AsyncGenerator { const fileInfos = await fileList(data); for (let idx = 0; idx < fileInfos.length; idx += MockDirectoryChunkSize) { yield { fileinfo: fileInfos.slice(idx, idx + MockDirectoryChunkSize) }; } }; const fileCount = Array.from(entries.values()).filter((entry) => !entry.isdir).length; const directoryCount = Array.from(entries.values()).filter((entry) => entry.isdir).length; return { homePath: MockHomePath, fileCount, directoryCount, entryCount: entries.size, fileInfo, fileRead, fileList, fileJoin, fileReadStream, fileListStream, }; } export const DefaultMockFilesystem = makeMockFilesystem(); ================================================ FILE: frontend/preview/mock/mockwaveenv.test.ts ================================================ import { base64ToArray, base64ToString } from "@/util/util"; import { describe, expect, it, vi } from "vitest"; import { DefaultMockFilesystem } from "./mockfilesystem"; const { showPreviewContextMenu } = vi.hoisted(() => ({ showPreviewContextMenu: vi.fn(), })); vi.mock("../preview-contextmenu", () => ({ showPreviewContextMenu, })); describe("makeMockWaveEnv", () => { it("uses the preview context menu by default", async () => { const { makeMockWaveEnv } = await import("./mockwaveenv"); const env = makeMockWaveEnv(); const menu = [{ label: "Open" }]; const event = { stopPropagation: vi.fn() } as any; env.showContextMenu(menu, event); expect(showPreviewContextMenu).toHaveBeenCalledWith(menu, event); }); it("provides a populated mock filesystem rooted at /Users/mike", () => { expect(DefaultMockFilesystem.homePath).toBe("/Users/mike"); expect(DefaultMockFilesystem.fileCount).toBeGreaterThanOrEqual(100); expect(DefaultMockFilesystem.directoryCount).toBeGreaterThanOrEqual(10); }); it("implements file info, read, list, and join commands", async () => { const { makeMockWaveEnv } = await import("./mockwaveenv"); const env = makeMockWaveEnv(); const bashrcInfo = await env.rpc.FileInfoCommand(null as any, { info: { path: "wsh://local//Users/mike/.bashrc" }, }); expect(bashrcInfo.path).toBe("/Users/mike/.bashrc"); expect(bashrcInfo.mimetype).toBe("text/plain"); const bashrcData = await env.rpc.FileReadCommand(null as any, { info: { path: "wsh://local//Users/mike/.bashrc" }, }); expect(base64ToString(bashrcData.data64)).toContain('alias gs="git status -sb"'); const visibleHomeEntries = await env.rpc.FileListCommand(null as any, { path: "/Users/mike", }); expect(visibleHomeEntries.some((entry) => entry.name === ".bashrc")).toBe(false); expect(visibleHomeEntries.some((entry) => entry.name === "waveterm")).toBe(true); const allHomeEntries = await env.rpc.FileListCommand(null as any, { path: "/Users/mike", opts: { all: true }, }); expect(allHomeEntries.some((entry) => entry.name === ".bashrc")).toBe(true); const dirRead = await env.rpc.FileReadCommand(null as any, { info: { path: "/Users/mike/waveterm" }, }); expect(dirRead.entries.some((entry) => entry.name === "docs" && entry.isdir)).toBe(true); const joined = await env.rpc.FileJoinCommand(null as any, [ "wsh://local//Users/mike/Documents", "../waveterm/docs", "preview-notes.md", ]); expect(joined.path).toBe("/Users/mike/waveterm/docs/preview-notes.md"); expect(joined.mimetype).toBe("text/markdown"); }); it("implements file list and read stream commands", async () => { const { makeMockWaveEnv } = await import("./mockwaveenv"); const env = makeMockWaveEnv(); const listPackets: CommandRemoteListEntriesRtnData[] = []; for await (const packet of env.rpc.FileListStreamCommand(null as any, { path: "/Users/mike", opts: { all: true, limit: 4 }, })) { listPackets.push(packet); } expect(listPackets).toHaveLength(1); expect(listPackets[0].fileinfo).toHaveLength(4); const readPackets: FileData[] = []; for await (const packet of env.rpc.FileReadStreamCommand(null as any, { info: { path: "/Users/mike/Pictures/beach-sunrise.png" }, })) { readPackets.push(packet); } expect(readPackets[0].info?.path).toBe("/Users/mike/Pictures/beach-sunrise.png"); const imageBytes = base64ToArray(readPackets[1].data64); expect(Array.from(imageBytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]); }); it("implements secrets commands with in-memory storage", async () => { const { makeMockWaveEnv } = await import("./mockwaveenv"); const env = makeMockWaveEnv({ platform: "linux" }); await env.rpc.SetSecretsCommand(null as any, { OPENAI_API_KEY: "sk-test", ANTHROPIC_API_KEY: "anthropic-test", } as any); expect(await env.rpc.GetSecretsLinuxStorageBackendCommand(null as any)).toBe("libsecret"); expect(await env.rpc.GetSecretsNamesCommand(null as any)).toEqual(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]); expect(await env.rpc.GetSecretsCommand(null as any, ["OPENAI_API_KEY", "MISSING_SECRET"])).toEqual({ OPENAI_API_KEY: "sk-test", }); await env.rpc.SetSecretsCommand(null as any, { OPENAI_API_KEY: null } as any); expect(await env.rpc.GetSecretsNamesCommand(null as any)).toEqual(["ANTHROPIC_API_KEY"]); expect(await env.rpc.GetSecretsCommand(null as any, ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"])).toEqual({ ANTHROPIC_API_KEY: "anthropic-test", }); }); }); ================================================ FILE: frontend/preview/mock/mockwaveenv.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { makeDefaultConnStatus } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { AllServiceTypes } from "@/app/store/services"; import { handleWaveEvent } from "@/app/store/wps"; import { RpcApiType } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil"; import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai"; import { showPreviewContextMenu } from "../preview-contextmenu"; import { MockSysinfoConnection } from "../previews/sysinfo.preview-util"; import { DefaultFullConfig } from "./defaultconfig"; import { DefaultMockFilesystem } from "./mockfilesystem"; import { previewElectronApi } from "./preview-electron-api"; export const PreviewTabId = crypto.randomUUID(); export const PreviewWindowId = crypto.randomUUID(); export const PreviewWorkspaceId = crypto.randomUUID(); export const PreviewClientId = crypto.randomUUID(); export const WebBlockId = crypto.randomUUID(); export const SysinfoBlockId = crypto.randomUUID(); // What works "out of the box" in the mock environment (no MockEnv overrides needed): // // RPC calls (handled in makeMockRpc): // - rpc.EventPublishCommand -- dispatches to handleWaveEvent(); works when the subscriber // is purely FE-based (registered via WPS on the frontend) // - rpc.GetMetaCommand -- reads .meta from the mock WOS atom for the given oref // - rpc.GetSecretsCommand -- reads secrets from an in-memory mock secret store // - rpc.GetSecretsLinuxStorageBackendCommand // returns "libsecret" on Linux previews and "" elsewhere // - rpc.GetSecretsNamesCommand -- lists secret names from the in-memory mock secret store // - rpc.SetMetaCommand -- writes .meta to the mock WOS atom (null values delete keys) // - rpc.SetConfigCommand -- merges settings into fullConfigAtom (null values delete keys) // - rpc.SetSecretsCommand -- writes/deletes secrets in the in-memory mock secret store // - rpc.UpdateTabNameCommand -- updates .name on the Tab WaveObj in the mock WOS // - rpc.UpdateWorkspaceTabIdsCommand -- updates .tabids on the Workspace WaveObj in the mock WOS // // Any other RPC call falls through to a console.log and resolves null. // Override specific calls via MockEnv.rpc (keys are Command method names, e.g. "GetMetaCommand"). // Override specific streaming calls via MockEnv.rpcStreaming (same key names, handler returns AsyncGenerator). // // Backend service calls (handled in callBackendService): // Any call falls through to a console.log and resolves null. // Override specific calls via MockEnv.services: { Service: { Method: impl } } // e.g. { "block": { "GetControllerStatus": (blockId) => myStatus } } export type RpcHandlerType = (...args: any[]) => Promise; export type RpcStreamHandlerType = (...args: any[]) => AsyncGenerator; export type RpcOverrides = { [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: RpcHandlerType; }; export type RpcStreamOverrides = { [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: RpcStreamHandlerType; }; type ServiceOverrides = { [Service: string]: { [Method: string]: (...args: any[]) => Promise; }; }; export type MockEnv = { isDev?: boolean; tabId?: string; platform?: NodeJS.Platform; settings?: Partial; rpc?: RpcOverrides; rpcStreaming?: RpcStreamOverrides; services?: ServiceOverrides; atoms?: Partial; electron?: Partial; createBlock?: WaveEnv["createBlock"]; showContextMenu?: WaveEnv["showContextMenu"]; connStatus?: Record; mockWaveObjs?: Record; }; export type MockWaveEnv = WaveEnv & { mockEnv: MockEnv; addRpcOverride: (command: K, handler: RpcHandlerType) => void; addRpcStreamOverride: (command: K, handler: RpcStreamHandlerType) => void; }; function mergeRecords(base: Record, overrides: Record): Record { if (base == null && overrides == null) { return undefined; } return { ...(base ?? {}), ...(overrides ?? {}) }; } export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { let mergedServices: ServiceOverrides; if (base.services != null || overrides.services != null) { mergedServices = {}; for (const svc of Object.keys(base.services ?? {})) { mergedServices[svc] = { ...(base.services[svc] ?? {}) }; } for (const svc of Object.keys(overrides.services ?? {})) { mergedServices[svc] = { ...(mergedServices[svc] ?? {}), ...(overrides.services[svc] ?? {}) }; } } return { isDev: overrides.isDev ?? base.isDev, tabId: overrides.tabId ?? base.tabId, platform: overrides.platform ?? base.platform, settings: mergeRecords(base.settings, overrides.settings), rpc: mergeRecords(base.rpc as any, overrides.rpc as any) as RpcOverrides, rpcStreaming: mergeRecords(base.rpcStreaming as any, overrides.rpcStreaming as any) as RpcStreamOverrides, services: mergedServices, atoms: overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined, electron: overrides.electron != null || base.electron != null ? { ...(base.electron ?? {}), ...(overrides.electron ?? {}) } : undefined, createBlock: overrides.createBlock ?? base.createBlock, showContextMenu: overrides.showContextMenu ?? base.showContextMenu, connStatus: mergeRecords(base.connStatus, overrides.connStatus), mockWaveObjs: mergeRecords(base.mockWaveObjs, overrides.mockWaveObjs), }; } function makeMockSettingsKeyAtom(settingsAtom: Atom): WaveEnv["getSettingsKeyAtom"] { const keyAtomCache = new Map>(); return (key: T) => { if (!keyAtomCache.has(key)) { keyAtomCache.set( key, atom((get) => get(settingsAtom)?.[key]) ); } return keyAtomCache.get(key) as Atom; }; } function makeMockGlobalAtoms( settingsOverrides: Partial, atomOverrides: Partial, tabId: string, getWaveObjectAtom: (oref: string) => PrimitiveAtom ): GlobalAtomsType { let fullConfig = DefaultFullConfig; if (settingsOverrides) { fullConfig = { ...DefaultFullConfig, settings: { ...DefaultFullConfig.settings, ...settingsOverrides }, }; } const fullConfigAtom = atom(fullConfig) as PrimitiveAtom; const settingsAtom = atom((get) => get(fullConfigAtom)?.settings ?? {}) as Atom; const workspaceIdAtom: Atom = atomOverrides?.workspaceId ?? (atom(null as string) as Atom); const workspaceAtom: Atom = atom((get) => { const wsId = get(workspaceIdAtom); if (wsId == null) { return null; } return get(getWaveObjectAtom("workspace:" + wsId)); }); const defaults: GlobalAtomsType = { builderId: atom(""), builderAppId: atom("") as any, uiContext: atom({ windowid: "", activetabid: tabId ?? "" } as UIContext), workspaceId: workspaceIdAtom, workspace: workspaceAtom, fullConfigAtom, waveaiModeConfigAtom: atom({}) as any, settingsAtom, hasCustomAIPresetsAtom: atom(false), hasConfigErrors: atom((get) => { const c = get(fullConfigAtom); return c?.configerrors != null && c.configerrors.length > 0; }), staticTabId: atom(tabId ?? ""), isFullScreen: atom(false) as any, zoomFactorAtom: atom(1.0) as any, controlShiftDelayAtom: atom(false) as any, prefersReducedMotionAtom: atom(false), documentHasFocus: atom(true) as any, updaterStatusAtom: atom("up-to-date" as UpdaterStatus) as any, modalOpen: atom(false) as any, allConnStatus: atom([] as ConnStatus[]), reinitVersion: atom(0) as any, waveAIRateLimitInfoAtom: atom(null) as any, }; if (!atomOverrides) { return defaults; } const merged = { ...defaults, ...atomOverrides }; if (!atomOverrides.workspace) { merged.workspace = workspaceAtom; } return merged; } type MockWosFns = { getWaveObjectAtom: (oref: string) => PrimitiveAtom; mockSetWaveObj: (oref: string, obj: T) => void; fullConfigAtom: PrimitiveAtom; platform: NodeJS.Platform; }; export function makeMockRpc( overrides: RpcOverrides, streamOverrides: RpcStreamOverrides, wos: MockWosFns ): { rpc: RpcApiType; setRpcHandler: (command: string, fn: RpcHandlerType) => void; setRpcStreamHandler: (command: string, fn: RpcStreamHandlerType) => void; } { const callDispatchMap = new Map Promise>(); const streamDispatchMap = new Map AsyncGenerator>(); const secrets = new Map(); const setCallHandler = (command: string, fn: (...args: any[]) => Promise) => { callDispatchMap.set(command, fn); }; const setStreamHandler = (command: string, fn: (...args: any[]) => AsyncGenerator) => { streamDispatchMap.set(command, fn); }; setCallHandler("eventpublish", async (_client, data: WaveEvent) => { console.log("[mock eventpublish]", data); handleWaveEvent(data); return null; }); setCallHandler("getmeta", async (_client, data: CommandGetMetaData) => { const objAtom = wos.getWaveObjectAtom(data.oref); const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType }; return current?.meta ?? {}; }); setCallHandler("setmeta", async (_client, data: CommandSetMetaData) => { const objAtom = wos.getWaveObjectAtom(data.oref); const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType }; const updatedMeta = { ...(current?.meta ?? {}) }; for (const [key, value] of Object.entries(data.meta)) { if (value === null) { delete updatedMeta[key]; } else { (updatedMeta as any)[key] = value; } } const updated = { ...current, meta: updatedMeta }; wos.mockSetWaveObj(data.oref, updated); return null; }); setCallHandler("updatetabname", async (_client, data: { args: [string, string] }) => { const [tabId, newName] = data.args; const tabORef = "tab:" + tabId; const objAtom = wos.getWaveObjectAtom(tabORef); const current = globalStore.get(objAtom) as Tab; const updated = { ...current, name: newName }; wos.mockSetWaveObj(tabORef, updated); return null; }); setCallHandler("setconfig", async (_client, data: SettingsType) => { const current = globalStore.get(wos.fullConfigAtom); const updatedSettings = { ...(current?.settings ?? {}) }; for (const [key, value] of Object.entries(data)) { if (value === null) { delete (updatedSettings as any)[key]; } else { (updatedSettings as any)[key] = value; } } globalStore.set(wos.fullConfigAtom, { ...current, settings: updatedSettings as SettingsType }); return null; }); setCallHandler("getsecretslinuxstoragebackend", async () => { if (wos.platform !== PlatformLinux) { return ""; } return "libsecret"; }); setCallHandler("getsecretsnames", async () => { return Array.from(secrets.keys()).sort(); }); setCallHandler("getsecrets", async (_client, data: string[]) => { const foundSecrets: Record = {}; for (const name of data ?? []) { const value = secrets.get(name); if (value != null) { foundSecrets[name] = value; } } return foundSecrets; }); setCallHandler("setsecrets", async (_client, data: Record) => { for (const [name, value] of Object.entries(data ?? {})) { if (value == null) { secrets.delete(name); continue; } secrets.set(name, value); } return null; }); setCallHandler("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => { const [workspaceId, tabIds] = data.args; const wsORef = "workspace:" + workspaceId; const objAtom = wos.getWaveObjectAtom(wsORef); const current = globalStore.get(objAtom) as Workspace; const updated = { ...current, tabids: tabIds }; wos.mockSetWaveObj(wsORef, updated); return null; }); setCallHandler("fileinfo", async (_client, data: FileData) => DefaultMockFilesystem.fileInfo(data)); setCallHandler("fileread", async (_client, data: FileData) => DefaultMockFilesystem.fileRead(data)); setCallHandler("filelist", async (_client, data: FileListData) => DefaultMockFilesystem.fileList(data)); setCallHandler("filejoin", async (_client, data: string[]) => DefaultMockFilesystem.fileJoin(data)); setStreamHandler("filereadstream", async function* (_client, data: FileData) { yield* DefaultMockFilesystem.fileReadStream(data); }); setStreamHandler("fileliststream", async function* (_client, data: FileListData) { yield* DefaultMockFilesystem.fileListStream(data); }); if (overrides) { for (const key of Object.keys(overrides) as (keyof RpcOverrides)[]) { const cmdName = key.slice(0, -"Command".length).toLowerCase(); setCallHandler(cmdName, overrides[key] as RpcHandlerType); } } if (streamOverrides) { for (const key of Object.keys(streamOverrides) as (keyof RpcStreamOverrides)[]) { const cmdName = key.slice(0, -"Command".length).toLowerCase(); setStreamHandler(cmdName, streamOverrides[key] as RpcStreamHandlerType); } } const rpc = new RpcApiType(); rpc.setMockRpcClient({ mockWshRpcCall(_client, command, data, _opts) { const fn = callDispatchMap.get(command); if (fn) { return fn(_client, data, _opts); } console.log("[mock rpc call]", command, data); return Promise.resolve(null); }, async *mockWshRpcStream(_client, command, data, _opts) { const streamFn = streamDispatchMap.get(command); if (streamFn) { yield* streamFn(_client, data, _opts); return; } const callFn = callDispatchMap.get(command); if (callFn) { yield await callFn(_client, data, _opts); return; } console.log("[mock rpc stream]", command, data); yield null; }, }); return { rpc, setRpcHandler: (command: string, fn: RpcHandlerType) => { const cmdName = command.endsWith("Command") ? command.slice(0, -"Command".length).toLowerCase() : command; setCallHandler(cmdName, fn); }, setRpcStreamHandler: (command: string, fn: RpcStreamHandlerType) => { const cmdName = command.endsWith("Command") ? command.slice(0, -"Command".length).toLowerCase() : command; setStreamHandler(cmdName, fn); }, }; } export function applyMockEnvOverrides(env: WaveEnv, newOverrides: MockEnv): MockWaveEnv { const existing = (env as MockWaveEnv).mockEnv; const merged = existing != null ? mergeMockEnv(existing, newOverrides) : newOverrides; return makeMockWaveEnv(merged); } export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { const overrides: MockEnv = mockEnv ?? {}; const tabId = overrides.tabId ?? PreviewTabId; const defaultMockWaveObjs: Record = { [`workspace:${PreviewWorkspaceId}`]: { otype: "workspace", oid: PreviewWorkspaceId, version: 1, name: "Preview Workspace", tabids: [PreviewTabId], activetabid: PreviewTabId, meta: {}, } as Workspace, [`tab:${PreviewTabId}`]: { otype: "tab", oid: PreviewTabId, version: 1, name: "Preview Tab", blockids: [WebBlockId, SysinfoBlockId], meta: {}, } as Tab, [`block:${WebBlockId}`]: { otype: "block", oid: WebBlockId, version: 1, meta: { view: "web", }, } as Block, [`block:${SysinfoBlockId}`]: { otype: "block", oid: SysinfoBlockId, version: 1, meta: { view: "sysinfo", connection: MockSysinfoConnection, "sysinfo:type": "CPU + Mem", "graph:numpoints": 90, }, } as Block, }; const defaultAtoms: Partial = { uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), staticTabId: atom(PreviewTabId), workspaceId: atom(PreviewWorkspaceId), }; const mergedOverrides: MockEnv = { ...overrides, tabId, mockWaveObjs: { ...defaultMockWaveObjs, ...(overrides.mockWaveObjs ?? {}) }, atoms: { ...defaultAtoms, ...(overrides.atoms ?? {}) }, }; const platform = mergedOverrides.platform ?? PlatformMacOS; const connStatusAtomCache = new Map>(); const waveObjectValueAtomCache = new Map>(); const waveObjectDerivedAtomCache = new Map>(); const blockMetaKeyAtomCache = new Map>(); const connConfigKeyAtomCache = new Map>(); const getWaveObjectAtom = (oref: string): PrimitiveAtom => { if (!waveObjectValueAtomCache.has(oref)) { const obj = (mergedOverrides.mockWaveObjs?.[oref] ?? null) as T; waveObjectValueAtomCache.set(oref, atom(obj) as PrimitiveAtom); } return waveObjectValueAtomCache.get(oref) as PrimitiveAtom; }; const atoms = makeMockGlobalAtoms( mergedOverrides.settings, mergedOverrides.atoms, mergedOverrides.tabId, getWaveObjectAtom ); const localHostDisplayNameAtom = atom((get) => { const configValue = get(atoms.settingsAtom)?.["conn:localhostdisplayname"]; if (configValue != null) { return configValue; } return "user@localhost"; }); const mockWosFns: MockWosFns = { getWaveObjectAtom, fullConfigAtom: atoms.fullConfigAtom, platform, mockSetWaveObj: (oref: string, obj: T) => { if (!waveObjectValueAtomCache.has(oref)) { waveObjectValueAtomCache.set(oref, atom(null as WaveObj)); } globalStore.set(waveObjectValueAtomCache.get(oref), obj); }, }; const { rpc, setRpcHandler, setRpcStreamHandler } = makeMockRpc(mergedOverrides.rpc, mergedOverrides.rpcStreaming, mockWosFns); const env = { isMock: true, mockEnv: mergedOverrides, electron: { ...previewElectronApi, getPlatform: () => platform, openExternal: (url: string) => { window.open(url, "_blank"); }, ...mergedOverrides.electron, }, rpc, atoms, getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom), platform, isDev: () => mergedOverrides.isDev ?? true, isWindows: () => platform === PlatformWindows, isMacOS: () => platform === PlatformMacOS, createBlock: mergedOverrides.createBlock ?? ((blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => { console.log("[mock createBlock]", blockDef, { magnified, ephemeral }); const newBlockId = crypto.randomUUID(); const newBlock: Block = { otype: "block", oid: newBlockId, version: 1, meta: blockDef.meta ?? {}, }; mockWosFns.mockSetWaveObj(`block:${newBlockId}`, newBlock); const tabORef = `tab:${tabId}`; const tabAtom = getWaveObjectAtom(tabORef); const currentTab = globalStore.get(tabAtom); if (currentTab != null) { mockWosFns.mockSetWaveObj(tabORef, { ...currentTab, blockids: [...(currentTab.blockids ?? []), newBlockId], }); } return Promise.resolve(newBlockId); }), showContextMenu: mergedOverrides.showContextMenu ?? showPreviewContextMenu, getLocalHostDisplayNameAtom: () => { return localHostDisplayNameAtom; }, getConnStatusAtom: (conn: string) => { if (!connStatusAtomCache.has(conn)) { const connStatus = mergedOverrides.connStatus?.[conn] ?? makeDefaultConnStatus(conn); connStatusAtomCache.set(conn, atom(connStatus)); } return connStatusAtomCache.get(conn); }, wos: { getWaveObjectAtom: mockWosFns.getWaveObjectAtom, getWaveObjectLoadingAtom: (oref: string) => { const cacheKey = oref + ":loading"; if (!waveObjectDerivedAtomCache.has(cacheKey)) { waveObjectDerivedAtomCache.set(cacheKey, atom(false)); } return waveObjectDerivedAtomCache.get(cacheKey) as Atom; }, isWaveObjectNullAtom: (oref: string) => { const cacheKey = oref + ":isnull"; if (!waveObjectDerivedAtomCache.has(cacheKey)) { waveObjectDerivedAtomCache.set( cacheKey, atom((get) => get(env.wos.getWaveObjectAtom(oref)) == null) ); } return waveObjectDerivedAtomCache.get(cacheKey) as Atom; }, useWaveObjectValue: (oref: string): [T, boolean] => { const objAtom = env.wos.getWaveObjectAtom(oref); return [useAtomValue(objAtom), false]; }, }, getBlockMetaKeyAtom: (blockId: string, key: T) => { const cacheKey = blockId + "#meta-" + key; if (!blockMetaKeyAtomCache.has(cacheKey)) { const metaAtom = atom((get) => { const blockORef = "block:" + blockId; const blockAtom = env.wos.getWaveObjectAtom(blockORef); const blockData = get(blockAtom); return blockData?.meta?.[key] as MetaType[T]; }); blockMetaKeyAtomCache.set(cacheKey, metaAtom); } return blockMetaKeyAtomCache.get(cacheKey) as Atom; }, getConnConfigKeyAtom: (connName: string, key: T) => { const cacheKey = connName + "#conn-" + key; if (!connConfigKeyAtomCache.has(cacheKey)) { const keyAtom = atom((get) => { const fullConfig = get(atoms.fullConfigAtom); return fullConfig.connections?.[connName]?.[key]; }); connConfigKeyAtomCache.set(cacheKey, keyAtom); } return connConfigKeyAtomCache.get(cacheKey) as Atom; }, services: null as any, callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => { const fn = mergedOverrides.services?.[service]?.[method]; if (fn) { return fn(...args); } console.log("[mock callBackendService]", service, method, args, noUIContext); return Promise.resolve(null); }, mockSetWaveObj: mockWosFns.mockSetWaveObj, mockModels: new Map(), addRpcOverride: (command: K, handler: RpcHandlerType) => { setRpcHandler(command as string, handler); }, addRpcStreamOverride: (command: K, handler: RpcStreamHandlerType) => { setRpcStreamHandler(command as string, handler); }, } as MockWaveEnv; env.services = Object.fromEntries( Object.entries(AllServiceTypes).map(([key, ServiceClass]) => [key, new ServiceClass(env)]) ) as any; return env; } ================================================ FILE: frontend/preview/mock/preview-electron-api.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 const previewElectronApi: ElectronApi = { getAuthKey: () => "", getIsDev: () => false, getCursorPoint: () => ({ x: 0, y: 0 }) as Electron.Point, getPlatform: () => "darwin", getEnv: (_varName: string) => "", getUserName: () => "", getHostName: () => "", getDataDir: () => "", getConfigDir: () => "", getHomeDir: () => "", getWebviewPreload: () => "", getAboutModalDetails: () => ({}) as AboutModalDetails, getZoomFactor: () => 1.0, showWorkspaceAppMenu: (_workspaceId: string) => {}, showBuilderAppMenu: (_builderId: string) => {}, showContextMenu: (_workspaceId: string, _menu: ElectronContextMenuItem[]) => {}, onContextMenuClick: (_callback: (id: string | null) => void) => {}, onNavigate: (_callback: (url: string) => void) => {}, onIframeNavigate: (_callback: (url: string) => void) => {}, downloadFile: (_path: string) => {}, openExternal: (_url: string) => {}, onFullScreenChange: (_callback: (isFullScreen: boolean) => void) => {}, onZoomFactorChange: (_callback: (zoomFactor: number) => void) => {}, onUpdaterStatusChange: (_callback: (status: UpdaterStatus) => void) => {}, getUpdaterStatus: () => "up-to-date", getUpdaterChannel: () => "", installAppUpdate: () => {}, onMenuItemAbout: (_callback: () => void) => {}, updateWindowControlsOverlay: (_rect: Dimensions) => {}, onReinjectKey: (_callback: (waveEvent: WaveKeyboardEvent) => void) => {}, setWebviewFocus: (_focusedId: number) => {}, registerGlobalWebviewKeys: (_keys: string[]) => {}, onControlShiftStateUpdate: (_callback: (state: boolean) => void) => {}, createWorkspace: () => {}, switchWorkspace: (_workspaceId: string) => {}, deleteWorkspace: (_workspaceId: string) => {}, setActiveTab: (_tabId: string) => {}, createTab: () => {}, closeTab: (_workspaceId: string, _tabId: string, _confirmClose: boolean) => Promise.resolve(false), setWindowInitStatus: (_status: "ready" | "wave-ready") => {}, onWaveInit: (_callback: (initOpts: WaveInitOpts) => void) => {}, onBuilderInit: (_callback: (initOpts: BuilderInitOpts) => void) => {}, sendLog: (_log: string) => {}, onQuicklook: (_filePath: string) => {}, openNativePath: (_filePath: string) => {}, captureScreenshot: (_rect: Electron.Rectangle) => Promise.resolve(""), setKeyboardChordMode: () => {}, clearWebviewStorage: (_webContentsId: number) => Promise.resolve(), setWaveAIOpen: (_isOpen: boolean) => {}, closeBuilderWindow: () => {}, incrementTermCommands: (_opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => {}, nativePaste: () => {}, openBuilder: (_appId?: string) => {}, setBuilderWindowAppId: (_appId: string) => {}, doRefresh: () => {}, saveTextFile: (_fileName: string, _content: string) => Promise.resolve(false), setIsActive: async () => {}, }; function installPreviewElectronApi() { (window as any).api = previewElectronApi; } export { installPreviewElectronApi, previewElectronApi }; ================================================ FILE: frontend/preview/mock/tabbar-mock.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import { useWaveEnv, WaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; import { applyMockEnvOverrides, MockWaveEnv } from "@/preview/mock/mockwaveenv"; import { PlatformMacOS } from "@/util/platformutil"; import { atom } from "jotai"; import React, { useMemo, useRef } from "react"; type PreviewTabEntry = { tabId: string; tabName: string; badges?: Badge[] | null; flagColor?: string | null; }; function badgeBlockId(tabId: string, badgeId: string): string { return `${tabId}-badge-${badgeId}`; } function makeTabWaveObj(tab: PreviewTabEntry): Tab { const blockids = (tab.badges ?? []).map((b) => badgeBlockId(tab.tabId, b.badgeid)); return { otype: "tab", oid: tab.tabId, version: 1, name: tab.tabName, blockids, meta: tab.flagColor ? { "tab:flagcolor": tab.flagColor } : {}, } as Tab; } function makeMockBadgeEvents(): BadgeEvent[] { const events: BadgeEvent[] = []; for (const tab of TabBarMockTabs) { for (const badge of tab.badges ?? []) { events.push({ oref: `block:${badgeBlockId(tab.tabId, badge.badgeid)}`, badge }); } } return events; } export const TabBarMockWorkspaceId = "preview-workspace-1"; export const TabBarMockTabs: PreviewTabEntry[] = [ { tabId: "preview-tab-1", tabName: "Terminal" }, { tabId: "preview-tab-2", tabName: "Build Logs", badges: [ { badgeid: "01958000-0000-7000-0000-000000000001", icon: "triangle-exclamation", color: "#f59e0b", priority: 2, }, ], }, { tabId: "preview-tab-3", tabName: "Deploy", badges: [ { badgeid: "01958000-0000-7000-0000-000000000002", icon: "circle-check", color: "#4ade80", priority: 3 }, ], flagColor: "#429dff", }, { tabId: "preview-tab-4", tabName: "A Very Long Tab Name To Show Truncation", badges: [ { badgeid: "01958000-0000-7000-0000-000000000003", icon: "bell", color: "#f87171", priority: 2 }, { badgeid: "01958000-0000-7000-0000-000000000004", icon: "circle-small", color: "#fbbf24", priority: 1 }, ], }, { tabId: "preview-tab-5", tabName: "Wave AI" }, { tabId: "preview-tab-6", tabName: "Preview", flagColor: "#bf55ec" }, ]; function makeMockWorkspace(tabIds: string[]): Workspace { return { otype: "workspace", oid: TabBarMockWorkspaceId, version: 1, name: "Preview Workspace", tabids: tabIds, activetabid: tabIds[1] ?? tabIds[0] ?? "", meta: {}, } as Workspace; } export function makeTabBarMockEnv( baseEnv: WaveEnv, envRef: React.RefObject, platform: NodeJS.Platform ): MockWaveEnv { const initialTabIds = TabBarMockTabs.map((t) => t.tabId); const mockWaveObjs: Record = { [`workspace:${TabBarMockWorkspaceId}`]: makeMockWorkspace(initialTabIds), }; for (const tab of TabBarMockTabs) { mockWaveObjs[`tab:${tab.tabId}`] = makeTabWaveObj(tab); } const env = applyMockEnvOverrides(baseEnv, { tabId: TabBarMockTabs[1].tabId, platform, mockWaveObjs, atoms: { workspaceId: atom(TabBarMockWorkspaceId), staticTabId: atom(TabBarMockTabs[1].tabId), }, rpc: { GetAllBadgesCommand: () => Promise.resolve(makeMockBadgeEvents()), }, electron: { createTab: () => { const e = envRef.current; if (e == null) return; const newTabId = `preview-tab-${crypto.randomUUID()}`; e.mockSetWaveObj(`tab:${newTabId}`, { otype: "tab", oid: newTabId, version: 1, name: "New Tab", blockids: [], meta: {}, } as Tab); const ws = globalStore.get(e.wos.getWaveObjectAtom(`workspace:${TabBarMockWorkspaceId}`)); e.mockSetWaveObj(`workspace:${TabBarMockWorkspaceId}`, { ...ws, tabids: [...(ws.tabids ?? []), newTabId], }); globalStore.set(e.atoms.staticTabId as any, newTabId); }, closeTab: (_workspaceId: string, tabId: string) => { const e = envRef.current; if (e == null) return Promise.resolve(false); const ws = globalStore.get(e.wos.getWaveObjectAtom(`workspace:${TabBarMockWorkspaceId}`)); const newTabIds = (ws.tabids ?? []).filter((id) => id !== tabId); if (newTabIds.length === 0) { return Promise.resolve(false); } e.mockSetWaveObj(`workspace:${TabBarMockWorkspaceId}`, { ...ws, tabids: newTabIds }); if (globalStore.get(e.atoms.staticTabId) === tabId) { globalStore.set(e.atoms.staticTabId as any, newTabIds[0]); } return Promise.resolve(true); }, setActiveTab: (tabId: string) => { const e = envRef.current; if (e == null) return; globalStore.set(e.atoms.staticTabId as any, tabId); }, showWorkspaceAppMenu: () => { console.log("[preview] showWorkspaceAppMenu"); }, }, }); envRef.current = env; return env; } type TabBarMockEnvProviderProps = { children: React.ReactNode; }; export function TabBarMockEnvProvider({ children }: TabBarMockEnvProviderProps) { const baseEnv = useWaveEnv(); const envRef = useRef(null); const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, PlatformMacOS), []); return {children}; } TabBarMockEnvProvider.displayName = "TabBarMockEnvProvider"; ================================================ FILE: frontend/preview/mock/use-rpc-override.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useWaveEnv } from "@/app/waveenv/waveenv"; import * as React from "react"; import { MockWaveEnv, RpcHandlerType, RpcOverrides, RpcStreamHandlerType, RpcStreamOverrides } from "./mockwaveenv"; export function useRpcOverride(command: K, handler: RpcHandlerType): void { const mockEnv = useWaveEnv() as MockWaveEnv; const registeredRef = React.useRef(false); if (!registeredRef.current) { registeredRef.current = true; mockEnv.addRpcOverride(command, handler); } } export function useRpcStreamOverride(command: K, handler: RpcStreamHandlerType): void { const mockEnv = useWaveEnv() as MockWaveEnv; const registeredRef = React.useRef(false); if (!registeredRef.current) { registeredRef.current = true; mockEnv.addRpcStreamOverride(command, handler); } } ================================================ FILE: frontend/preview/preview-contextmenu.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { autoUpdate, flip, FloatingPortal, offset, shift, type Placement, type VirtualElement, useFloating, } from "@floating-ui/react"; import { cn } from "@/util/util"; import { memo, useEffect, useMemo, useRef, useState } from "react"; type PreviewContextMenuState = { items: ContextMenuItem[]; x: number; y: number; }; type PreviewContextMenuPanelProps = { items: ContextMenuItem[]; point?: { x: number; y: number }; referenceElement?: HTMLElement; placement: Placement; depth: number; parentPath: number[]; openPath: number[]; setOpenPath: (path: number[]) => void; closeMenu: () => void; }; type PreviewContextMenuItemProps = { item: ContextMenuItem; itemPath: number[]; depth: number; parentPath: number[]; openPath: number[]; setOpenPath: (path: number[]) => void; closeMenu: () => void; }; let previewContextMenuListener: ((state: PreviewContextMenuState) => void) | null = null; const previewContextMenuItemIds = new WeakMap(); function makeVirtualElement(x: number, y: number): VirtualElement { return { getBoundingClientRect() { return { x, y, width: 0, height: 0, top: y, right: x, bottom: y, left: x, toJSON: () => ({}), } as DOMRect; }, }; } function isPathOpen(openPath: number[], path: number[]): boolean { if (path.length > openPath.length) { return false; } return path.every((segment, index) => openPath[index] === segment); } function getVisibleItems(items: ContextMenuItem[]): ContextMenuItem[] { return items.filter((item) => item.visible !== false); } function activateItem(item: ContextMenuItem, closeMenu: () => void): void { closeMenu(); item.click?.(); } function getPreviewContextMenuItemId(item: ContextMenuItem): string { const existingId = previewContextMenuItemIds.get(item); if (existingId != null) { return existingId; } const newId = crypto.randomUUID(); previewContextMenuItemIds.set(item, newId); return newId; } const PreviewContextMenuItem = memo( ({ item, itemPath, depth, parentPath, openPath, setOpenPath, closeMenu }: PreviewContextMenuItemProps) => { const rowRef = useRef(null); const submenuItems = getVisibleItems(item.submenu ?? []); const hasSubmenu = submenuItems.length > 0; const isDisabled = item.enabled === false; const isHeader = item.type === "header"; const isSeparator = item.type === "separator"; const isChecked = item.type === "checkbox" || item.type === "radio" ? item.checked === true : false; const isSubmenuOpen = hasSubmenu && isPathOpen(openPath, itemPath); if (isSeparator) { return
; } const handleMouseEnter = () => { if (hasSubmenu) { setOpenPath(itemPath); return; } setOpenPath(parentPath); }; const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); if (isDisabled || isHeader) { return; } if (hasSubmenu) { setOpenPath(itemPath); return; } activateItem(item, closeMenu); }; return ( <>
{isHeader ? ( {item.label} ) : ( <> {isChecked ? : null}
{item.label} {item.sublabel ? {item.sublabel} : null}
{hasSubmenu ? ( ) : null} )}
{hasSubmenu && isSubmenuOpen && rowRef.current != null ? ( ) : null} ); } ); PreviewContextMenuItem.displayName = "PreviewContextMenuItem"; const PreviewContextMenuPanel = memo( ({ items, point, referenceElement, placement, depth, parentPath, openPath, setOpenPath, closeMenu }: PreviewContextMenuPanelProps) => { const visibleItems = getVisibleItems(items); const virtualReference = useMemo(() => { if (point == null) { return null; } return makeVirtualElement(point.x, point.y); }, [point]); const { refs, floatingStyles } = useFloating({ open: true, placement, strategy: "fixed", whileElementsMounted: autoUpdate, middleware: [ offset(depth === 0 ? 4 : { mainAxis: -4, crossAxis: -4 }), flip({ padding: 8 }), shift({ padding: 8 }), ], }); useEffect(() => { if (referenceElement != null) { refs.setReference(referenceElement); return; } refs.setPositionReference(virtualReference); }, [referenceElement, refs, virtualReference]); if (visibleItems.length === 0) { return null; } return (
{visibleItems.map((item, index) => ( ))}
); } ); PreviewContextMenuPanel.displayName = "PreviewContextMenuPanel"; export function showPreviewContextMenu(menu: ContextMenuItem[], e: React.MouseEvent): void { e.stopPropagation(); e.preventDefault(); previewContextMenuListener?.({ items: menu, x: e.clientX, y: e.clientY, }); } export const PreviewContextMenu = memo(() => { const [menuState, setMenuState] = useState(null); const [openPath, setOpenPath] = useState([]); const portalRef = useRef(null); const closeMenu = () => { setMenuState(null); setOpenPath([]); }; useEffect(() => { previewContextMenuListener = (state) => { setMenuState(state); setOpenPath([]); }; return () => { previewContextMenuListener = null; }; }, []); useEffect(() => { if (menuState == null) { return; } const handlePointerDown = (event: PointerEvent) => { if (portalRef.current?.contains(event.target as Node)) { return; } closeMenu(); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { closeMenu(); } }; document.addEventListener("pointerdown", handlePointerDown, true); document.addEventListener("keydown", handleKeyDown); window.addEventListener("blur", closeMenu); window.addEventListener("resize", closeMenu); window.addEventListener("scroll", closeMenu, true); return () => { document.removeEventListener("pointerdown", handlePointerDown, true); document.removeEventListener("keydown", handleKeyDown); window.removeEventListener("blur", closeMenu); window.removeEventListener("resize", closeMenu); window.removeEventListener("scroll", closeMenu, true); }; }, [menuState]); if (menuState == null) { return null; } return (
); }); PreviewContextMenu.displayName = "PreviewContextMenu"; ================================================ FILE: frontend/preview/preview.css ================================================ /* Copyright 2026, Command Line Inc. SPDX-License-Identifier: Apache-2.0 */ /* Re-export the main tailwind setup, adding extra @source so Tailwind v4 scans the full frontend/app tree (the preview vite root is frontend/preview/, so the automatic scan would otherwise miss frontend/app/**). */ @import "../tailwindsetup.css"; @source "../app"; ================================================ FILE: frontend/preview/preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; import { ErrorBoundary } from "@/app/element/errorboundary"; import { getAtoms, initGlobalAtoms } from "@/app/store/global-atoms"; import { GlobalModel } from "@/app/store/global-model"; import { globalStore } from "@/app/store/jotaiStore"; import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; import { WaveEnvContext } from "@/app/waveenv/waveenv"; import { loadFonts } from "@/util/fontutil"; import { Provider } from "jotai"; import React, { lazy, Suspense, useRef } from "react"; import { createRoot } from "react-dom/client"; import { makeMockWaveEnv, PreviewClientId, PreviewTabId, PreviewWindowId } from "./mock/mockwaveenv"; import { installPreviewElectronApi } from "./mock/preview-electron-api"; import { PreviewContextMenu } from "./preview-contextmenu"; import "overlayscrollbars/overlayscrollbars.css"; import "../app/app.scss"; // preview.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports) // preview.css re-exports tailwindsetup.css and adds @source "../app" so Tailwind v4 scans frontend/app/** for class names import "./preview.css"; // Vite glob import — statically analyzed at build time, lazily loaded at runtime. // Each *.preview.tsx file is auto-discovered; its filename (minus the suffix) becomes the key. // Files may use a default export or any named export — the first export found is used as the component. const previewModules = import.meta.glob<{ default?: React.ComponentType; [key: string]: unknown }>( "./previews/*.preview.tsx" ); // Derive a human-readable key from the file path, e.g.: // "./previews/modal-about.preview.tsx" → "modal-about" function pathToKey(path: string): string { return path.replace(/^\.\/previews\//, "").replace(/\.preview\.tsx$/, ""); } // Build a map of key → lazy React component. // Each preview file is expected to have a default export that is the preview component. const previews: Record> = Object.fromEntries( Object.entries(previewModules).map(([path, loader]) => [ pathToKey(path), lazy(() => loader().then((mod) => ({ default: (mod.default ?? Object.values(mod)[0]) as React.ComponentType })) ), ]) ); function PreviewIndex() { return (

Wave Preview Server

Available previews:

{Object.keys(previews).map((name) => ( {name} ))}
); } function PreviewHeader({ previewName }: { previewName: string }) { return (
← index
{previewName}
); } function PreviewRoot() { const waveEnvRef = useRef(makeMockWaveEnv()); return ( ); } function PreviewApp() { const params = new URLSearchParams(window.location.search); const previewName = params.get("preview"); if (previewName) { const PreviewComponent = previews[previewName]; if (PreviewComponent) { return ( <>
); } return ( <>

Preview not found: {previewName}

← Back to index
); } return ; } function initPreview() { installPreviewElectronApi(); const initOpts = { tabId: PreviewTabId, windowId: PreviewWindowId, clientId: PreviewClientId, environment: "renderer", platform: "darwin", isPreview: true, } as GlobalInitOptions; initGlobalAtoms(initOpts); globalStore.set(getAtoms().fullConfigAtom, {} as FullConfigType); GlobalModel.getInstance().initialize(initOpts); loadFonts(); const container = document.getElementById("main")!; let root = (container as any).__reactRoot; if (!root) { root = createRoot(container); (container as any).__reactRoot = root; } root.render(); } initPreview(); ================================================ FILE: frontend/preview/previews/.gitkeep ================================================ ================================================ FILE: frontend/preview/previews/aifilediff.preview-util.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { stringToBase64 } from "@/util/util"; export const DefaultAiFileDiffChatId = "preview-aifilediff-chat"; export const DefaultAiFileDiffToolCallId = "preview-aifilediff-toolcall"; export const DefaultAiFileDiffFileName = "src/lib/greeting.ts"; export const DefaultAiFileDiffOriginal = `export function greet(name: string) { return "Hello " + name; } export function greetAll(names: string[]) { return names.map(greet).join("\\n"); } `; export const DefaultAiFileDiffModified = `export function greet(name: string) { const normalizedName = name.trim() || "friend"; return \`Hello, \${normalizedName}!\`; } export function greetAll(names: string[]) { return names.map(greet).join("\\n"); } `; export function makeMockAiFileDiffResponse( original = DefaultAiFileDiffOriginal, modified = DefaultAiFileDiffModified ): CommandWaveAIGetToolDiffRtnData { return { originalcontents64: stringToBase64(original), modifiedcontents64: stringToBase64(modified), }; } ================================================ FILE: frontend/preview/previews/aifilediff.preview.test.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { base64ToString } from "@/util/util"; import { describe, expect, it } from "vitest"; import { DefaultAiFileDiffModified, DefaultAiFileDiffOriginal, makeMockAiFileDiffResponse, } from "./aifilediff.preview-util"; describe("aifilediff preview helpers", () => { it("encodes the default diff content for the mock rpc response", () => { const response = makeMockAiFileDiffResponse(); expect(base64ToString(response.originalcontents64)).toBe(DefaultAiFileDiffOriginal); expect(base64ToString(response.modifiedcontents64)).toBe(DefaultAiFileDiffModified); }); it("accepts custom original and modified content", () => { const response = makeMockAiFileDiffResponse("before", "after"); expect(base64ToString(response.originalcontents64)).toBe("before"); expect(base64ToString(response.modifiedcontents64)).toBe("after"); }); }); ================================================ FILE: frontend/preview/previews/aifilediff.preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Block } from "@/app/block/block"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import * as React from "react"; import { makeMockNodeModel } from "../mock/mock-node-model"; import { useRpcOverride } from "../mock/use-rpc-override"; import { DefaultAiFileDiffChatId, DefaultAiFileDiffFileName, DefaultAiFileDiffToolCallId, makeMockAiFileDiffResponse, } from "./aifilediff.preview-util"; const PreviewNodeId = "preview-aifilediff-node"; export function AiFileDiffPreview() { const env = useWaveEnv(); const [blockId, setBlockId] = React.useState(null); useRpcOverride("WaveAIGetToolDiffCommand", async (_client, data) => { if (data.chatid !== DefaultAiFileDiffChatId || data.toolcallid !== DefaultAiFileDiffToolCallId) { return null; } return makeMockAiFileDiffResponse(); }); React.useEffect(() => { env.createBlock( { meta: { view: "aifilediff", file: DefaultAiFileDiffFileName, "aifilediff:chatid": DefaultAiFileDiffChatId, "aifilediff:toolcallid": DefaultAiFileDiffToolCallId, }, }, false, false ).then((id) => setBlockId(id)); }, []); const nodeModel = React.useMemo( () => (blockId != null ? makeMockNodeModel({ nodeId: PreviewNodeId, blockId }) : null), [blockId] ); if (blockId == null || nodeModel == null) { return null; } return (
full aifilediff block (mock WOS + mock WaveAI diff RPC)
); } ================================================ FILE: frontend/preview/previews/modal-about.preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { AboutModalV } from "@/app/modals/about"; export function AboutModalPreview() { return ( console.log("close")} /> ); } ================================================ FILE: frontend/preview/previews/onboarding.preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; import { InitPage, NoTelemetryStarPage } from "@/app/onboarding/onboarding"; import { OnboardingGradientBg } from "@/app/onboarding/onboarding-common"; import { DurableSessionPage } from "@/app/onboarding/onboarding-durable"; import { FilesPage, MagnifyBlocksPage, WaveAIPage } from "@/app/onboarding/onboarding-features"; import { StarAskPage } from "@/app/onboarding/onboarding-starask"; import { UpgradeMinorWelcomePage } from "@/app/onboarding/onboarding-upgrade-minor"; import { UpgradeOnboardingFooter, UpgradeOnboardingVersions } from "@/app/onboarding/onboarding-upgrade-patch"; function OnboardingModalWrapper({ width, children }: { width: string; children: React.ReactNode }) { return (
{children}
); } function OnboardingFeaturesV() { const noop = () => {}; return (
{}} />
); } function UpgradeOnboardingPatchV() { const noop = () => {}; return (
{UpgradeOnboardingVersions.map((version, idx) => { const hasPrev = idx > 0; const hasNext = idx < UpgradeOnboardingVersions.length - 1; return (
Wave {version.version} Update
{version.content()}
); })}
); } function UpgradeOnboardingMinorV() { const noop = () => {}; return ( ); } function StarAskV() { const noop = () => {}; return ( ); } export function OnboardingPreview() { return (
Onboarding features
Onboarding minor upgrade
Onboarding star ask
Onboarding patch updates
); } ================================================ FILE: frontend/preview/previews/sysinfo.preview-util.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export const DefaultSysinfoHistoryPoints = 140; export const MockSysinfoConnection = "local"; const MockMemoryTotal = 32; const MockCoreCount = 6; function clamp(value: number, minValue: number, maxValue: number): number { return Math.min(maxValue, Math.max(minValue, value)); } function round1(value: number): number { return Math.round(value * 10) / 10; } export function makeMockSysinfoEvent( ts: number, step: number, scope = MockSysinfoConnection ): Extract { const baseCpu = clamp(42 + 18 * Math.sin(step / 6) + 8 * Math.cos(step / 3.5), 8, 96); const memUsed = clamp(12 + 4 * Math.sin(step / 10) + 2 * Math.cos(step / 7), 6, MockMemoryTotal - 4); const memAvailable = clamp(MockMemoryTotal - memUsed + 1.5, 0, MockMemoryTotal); const values: Record = { cpu: round1(baseCpu), "mem:total": MockMemoryTotal, "mem:used": round1(memUsed), "mem:free": round1(MockMemoryTotal - memUsed), "mem:available": round1(memAvailable), }; for (let i = 0; i < MockCoreCount; i++) { const coreCpu = clamp(baseCpu + 10 * Math.sin(step / 4 + i) + i - 3, 2, 100); values[`cpu:${i}`] = round1(coreCpu); } return { event: "sysinfo", scopes: [scope], data: { ts, values, }, }; } export function makeMockSysinfoHistory( numPoints = DefaultSysinfoHistoryPoints, endTs = Date.now() ): Extract[] { const history: Extract[] = []; const startTs = endTs - (numPoints - 1) * 1000; for (let i = 0; i < numPoints; i++) { history.push(makeMockSysinfoEvent(startTs + i * 1000, i)); } return history; } ================================================ FILE: frontend/preview/previews/sysinfo.preview.test.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; import { DefaultSysinfoHistoryPoints, makeMockSysinfoEvent, makeMockSysinfoHistory } from "./sysinfo.preview-util"; describe("sysinfo preview helpers", () => { it("creates sysinfo events with the expected metrics", () => { const event = makeMockSysinfoEvent(1000, 3); expect(event.event).toBe("sysinfo"); expect(event.scopes).toEqual(["local"]); expect(event.data.ts).toBe(1000); expect(event.data.values.cpu).toBeGreaterThanOrEqual(0); expect(event.data.values.cpu).toBeLessThanOrEqual(100); expect(event.data.values["mem:used"]).toBeGreaterThan(0); expect(event.data.values["mem:total"]).toBeGreaterThan(event.data.values["mem:used"]); expect(event.data.values["cpu:0"]).toBeTypeOf("number"); }); it("creates evenly spaced sysinfo history", () => { const history = makeMockSysinfoHistory(4, 4000); expect(history).toHaveLength(4); expect(history.map((event) => event.data.ts)).toEqual([1000, 2000, 3000, 4000]); }); it("uses the default history length", () => { expect(makeMockSysinfoHistory()).toHaveLength(DefaultSysinfoHistoryPoints); }); }); ================================================ FILE: frontend/preview/previews/sysinfo.preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Block } from "@/app/block/block"; import { handleWaveEvent } from "@/app/store/wps"; import * as React from "react"; import { makeMockNodeModel } from "../mock/mock-node-model"; import { SysinfoBlockId } from "../mock/mockwaveenv"; import { useRpcOverride } from "../mock/use-rpc-override"; import { DefaultSysinfoHistoryPoints, makeMockSysinfoEvent, makeMockSysinfoHistory, MockSysinfoConnection, } from "./sysinfo.preview-util"; const PreviewNodeId = "preview-sysinfo-node"; export default function SysinfoPreview() { const historyRef = React.useRef(makeMockSysinfoHistory()); const nodeModel = React.useMemo( () => makeMockNodeModel({ nodeId: PreviewNodeId, blockId: SysinfoBlockId, innerRect: { width: "920px", height: "560px" }, numLeafs: 2 }), [] ); useRpcOverride("EventReadHistoryCommand", async (_client, data) => { if (data.event !== "sysinfo" || data.scope !== MockSysinfoConnection) { return []; } const maxItems = data.maxitems ?? historyRef.current.length; return historyRef.current.slice(-maxItems); }); React.useEffect(() => { let nextStep = historyRef.current.length; let nextTs = (historyRef.current[historyRef.current.length - 1]?.data?.ts ?? Date.now()) + 1000; const intervalId = window.setInterval(() => { const nextEvent = makeMockSysinfoEvent(nextTs, nextStep); historyRef.current = [...historyRef.current.slice(-(DefaultSysinfoHistoryPoints - 1)), nextEvent]; handleWaveEvent(nextEvent); nextStep++; nextTs += 1000; }, 1000); return () => { window.clearInterval(intervalId); }; }, []); return (
full sysinfo block (mock WOS + FE-only WPS events)
); } ================================================ FILE: frontend/preview/previews/tab.preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { TabV } from "@/app/tab/tab"; import { useEffect, useRef, useState } from "react"; const TAB_WIDTH = 130; const TAB_HEIGHT = 26; interface PreviewTabEntry { tabId: string; tabName: string; active: boolean; badges?: Badge[] | null; flagColor?: string | null; } const tabDefs: PreviewTabEntry[] = [ { tabId: "preview-tab-1", tabName: "Terminal", active: false }, { tabId: "preview-tab-2", tabName: "My Tab", active: true, badges: [ { badgeid: "b2", icon: "circle-check", color: "#4ade80", priority: 3 }, { badgeid: "b1", icon: "circle-small", color: "#fbbf24", priority: 1 }, { badgeid: "b3", icon: "circle-small", color: "red", priority: 1 }, ], }, { tabId: "preview-tab-2b", tabName: "My Tab 2", active: false, badges: [ { badgeid: "b2", icon: "bell", color: "#4ade80", priority: 3 }, { badgeid: "b1", icon: "circle-small", color: "red", priority: 1 }, ], }, { tabId: "preview-tab-3", tabName: "T3", active: false, flagColor: "#4ade80" }, { tabId: "preview-tab-4", tabName: "1 Badge", active: false, badges: [{ badgeid: "b1", icon: "circle-small", color: "#fbbf24", priority: 1 }], flagColor: "#fbbf24", }, { tabId: "preview-tab-5", tabName: "3 Badges", active: false, badges: [ { badgeid: "b1", icon: "circle-small", color: "#fbbf24", priority: 1 }, { badgeid: "b2", icon: "circle-check", color: "#4ade80", priority: 3 }, { badgeid: "b3", icon: "triangle-exclamation", color: "#f87171", priority: 2 }, { badgeid: "b4", icon: "bell", color: "#f87171", priority: 2 }, ], }, ]; export function TabPreview() { const [tabNames, setTabNames] = useState>( Object.fromEntries(tabDefs.map((t) => [t.tabId, t.tabName])) ); const [activeTabId, setActiveTabId] = useState(tabDefs.find((t) => t.active)?.tabId ?? tabDefs[0].tabId); const tabRefs = useRef>({}); // The real tabbar imperatively sets opacity: 1 and transform after calculating // tab positions. Tabs start at opacity: 0 in CSS, so we mirror that here. useEffect(() => { tabDefs.forEach((tab, index) => { const el = tabRefs.current[tab.tabId]; if (el) { el.style.opacity = "1"; el.style.transform = `translate3d(${index * TAB_WIDTH}px, 0, 0)`; } }); }, []); return (
{tabDefs.map((tab, index) => { const activeIndex = tabDefs.findIndex((t) => t.tabId === activeTabId); const isActive = tab.tabId === activeTabId; const showDivider = index !== 0 && !isActive && index !== activeIndex + 1; return ( { tabRefs.current[tab.tabId] = el; }} tabId={tab.tabId} tabName={tabNames[tab.tabId]} active={isActive} showDivider={showDivider} isDragging={false} tabWidth={TAB_WIDTH} isNew={false} badges={tab.badges ?? null} flagColor={tab.flagColor ?? null} onClick={() => setActiveTabId(tab.tabId)} onClose={() => console.log("close", tab.tabId)} onDragStart={() => {}} onContextMenu={() => {}} onRename={(newName) => { console.log("rename", tab.tabId, newName); setTabNames((prev) => ({ ...prev, [tab.tabId]: newName })); }} /> ); })}
); } ================================================ FILE: frontend/preview/previews/tabbar.preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { loadBadges, LoadBadgesEnv } from "@/app/store/badge"; import { TabBar } from "@/app/tab/tabbar"; import { TabBarEnv } from "@/app/tab/tabbarenv"; import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; import { makeTabBarMockEnv, TabBarMockWorkspaceId } from "@/preview/mock/tabbar-mock"; import { MockWaveEnv } from "@/preview/mock/mockwaveenv"; import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil"; import { useAtom, useAtomValue } from "jotai"; import { CSSProperties, useEffect, useMemo, useRef, useState } from "react"; const MockConfigErrors: ConfigError[] = [ { file: "~/.waveterm/config.json", err: 'unknown preset "bg@aurora"' }, { file: "~/.waveterm/settings.json", err: "invalid color for tab theme" }, ]; export function TabBarPreview() { const baseEnv = useWaveEnv(); const envRef = useRef(null); const [platform, setPlatform] = useState(PlatformMacOS); const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]); return ( ); } type TabBarPreviewInnerProps = { platform: NodeJS.Platform; setPlatform: (platform: NodeJS.Platform) => void; }; function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) { const env = useWaveEnv(); const loadBadgesEnv = useWaveEnv(); const [showConfigErrors, setShowConfigErrors] = useState(false); const [hideAiButton, setHideAiButton] = useState(false); const [showMenuBar, setShowMenuBar] = useState(false); const [isFullScreen, setIsFullScreen] = useAtom(env.atoms.isFullScreen); const [zoomFactor, setZoomFactor] = useAtom(env.atoms.zoomFactorAtom); const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom); const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom); const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${TabBarMockWorkspaceId}`)); useEffect(() => { loadBadges(loadBadgesEnv); }, []); useEffect(() => { setFullConfig((prev) => ({ ...(prev ?? ({} as FullConfigType)), settings: { ...(prev?.settings ?? {}), "app:hideaibutton": hideAiButton, "window:showmenubar": showMenuBar, }, configerrors: showConfigErrors ? MockConfigErrors : [], })); }, [hideAiButton, showMenuBar, setFullConfig, showConfigErrors]); return (
Double-click a tab name to rename it. Close/add buttons and drag reordering are fully functional.
0 ? 1 / zoomFactor : 1 } as CSSProperties} > {workspace != null && }
Tabs: {workspace?.tabids?.length ?? 0} · Config errors: {fullConfig?.configerrors?.length ?? 0}
); } TabBarPreviewInner.displayName = "TabBarPreviewInner"; ================================================ FILE: frontend/preview/previews/treeview.preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { TreeNodeData, TreeView } from "@/app/treeview/treeview"; import { useMemo, useState } from "react"; const RootId = "workspace:/"; const RootNode: TreeNodeData = { id: RootId, path: RootId, label: "workspace", isDirectory: true, childrenStatus: "unloaded", }; const DirectoryData: Record = { [RootId]: [ { id: "workspace:/src", path: "workspace:/src", label: "src", parentId: RootId, isDirectory: true }, { id: "workspace:/docs", path: "workspace:/docs", label: "docs", parentId: RootId, isDirectory: true }, { id: "workspace:/README.md", path: "workspace:/README.md", label: "README.md", parentId: RootId, isDirectory: false, mimeType: "text/markdown" }, { id: "workspace:/package.json", path: "workspace:/package.json", label: "package.json", parentId: RootId, isDirectory: false, mimeType: "application/json" }, ], "workspace:/src": [ { id: "workspace:/src/app", path: "workspace:/src/app", label: "app", parentId: "workspace:/src", isDirectory: true }, { id: "workspace:/src/styles", path: "workspace:/src/styles", label: "styles", parentId: "workspace:/src", isDirectory: true }, ...Array.from({ length: 200 }).map((_, idx) => ({ id: `workspace:/src/file-${idx.toString().padStart(3, "0")}.tsx`, path: `workspace:/src/file-${idx.toString().padStart(3, "0")}.tsx`, label: `file-${idx.toString().padStart(3, "0")}.tsx`, parentId: "workspace:/src", isDirectory: false, mimeType: "text/typescript", })), ], "workspace:/src/app": [ { id: "workspace:/src/app/main.tsx", path: "workspace:/src/app/main.tsx", label: "main.tsx", parentId: "workspace:/src/app", isDirectory: false, mimeType: "text/typescript" }, { id: "workspace:/src/app/router.ts", path: "workspace:/src/app/router.ts", label: "router.ts", parentId: "workspace:/src/app", isDirectory: false, mimeType: "text/typescript" }, ], "workspace:/src/styles": [ { id: "workspace:/src/styles/app.css", path: "workspace:/src/styles/app.css", label: "app.css", parentId: "workspace:/src/styles", isDirectory: false, mimeType: "text/css" }, ], "workspace:/docs": Array.from({ length: 25 }).map((_, idx) => ({ id: `workspace:/docs/page-${idx + 1}.md`, path: `workspace:/docs/page-${idx + 1}.md`, label: `page-${idx + 1}.md`, parentId: "workspace:/docs", isDirectory: false, mimeType: "text/markdown", })), }; export function TreeViewPreview() { const [width, setWidth] = useState(260); const [selection, setSelection] = useState(RootId); const initialNodes = useMemo(() => ({ [RootId]: RootNode }), []); return (
Tree width: {width}px
setWidth(Number(event.target.value))} className="mt-2 w-full cursor-pointer" />
Selection: {selection}
{ await new Promise((resolve) => setTimeout(resolve, 220)); const entries = DirectoryData[id] ?? []; return { nodes: entries.slice(0, limit), capped: entries.length > limit, totalKnown: entries.length, }; }} onOpenFile={(id) => { setSelection(`open:${id}`); }} onSelectionChange={(id) => { setSelection(id); }} />
); } ================================================ FILE: frontend/preview/previews/vtabbar.preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { loadBadges, LoadBadgesEnv } from "@/app/store/badge"; import { VTabBar } from "@/app/tab/vtabbar"; import { VTabBarEnv } from "@/app/tab/vtabbarenv"; import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; import { MockWaveEnv } from "@/preview/mock/mockwaveenv"; import { makeTabBarMockEnv, TabBarMockWorkspaceId } from "@/preview/mock/tabbar-mock"; import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil"; import { useAtom, useAtomValue } from "jotai"; import { useEffect, useMemo, useRef, useState } from "react"; export function VTabBarPreview() { const baseEnv = useWaveEnv(); const envRef = useRef(null); const [platform, setPlatform] = useState(PlatformMacOS); const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]); return ( ); } type VTabBarPreviewInnerProps = { platform: NodeJS.Platform; setPlatform: (platform: NodeJS.Platform) => void; }; function VTabBarPreviewInner({ platform, setPlatform }: VTabBarPreviewInnerProps) { const env = useWaveEnv(); const loadBadgesEnv = useWaveEnv(); const [hideAiButton, setHideAiButton] = useState(false); const [isFullScreen, setIsFullScreen] = useAtom(env.atoms.isFullScreen); const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom); const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom); const [width, setWidth] = useState(220); const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${TabBarMockWorkspaceId}`)); useEffect(() => { loadBadges(loadBadgesEnv); }, []); useEffect(() => { setFullConfig((prev) => ({ ...(prev ?? ({} as FullConfigType)), settings: { ...(prev?.settings ?? {}), "app:hideaibutton": hideAiButton, }, })); }, [hideAiButton, setFullConfig]); return (
{workspace != null && }
); } VTabBarPreviewInner.displayName = "VTabBarPreviewInner"; ================================================ FILE: frontend/preview/previews/web.preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { Block } from "@/app/block/block"; import * as React from "react"; import { makeMockNodeModel } from "../mock/mock-node-model"; import { WebBlockId } from "../mock/mockwaveenv"; const PreviewNodeId = "preview-web-node"; export function WebPreview() { const nodeModel = React.useMemo( () => makeMockNodeModel({ nodeId: PreviewNodeId, blockId: WebBlockId, innerRect: { width: "1040px", height: "620px" } }), [] ); return (
full web block using preview mock fallback
); } ================================================ FILE: frontend/preview/previews/widgets.preview.tsx ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useWaveEnv, WaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; import { Widgets } from "@/app/workspace/widgets"; import { atom, useAtom, useAtomValue } from "jotai"; import { useRef } from "react"; import { applyMockEnvOverrides } from "../mock/mockwaveenv"; const resizableHeightAtom = atom(250); const hasConfigErrorsAtom = atom(false); const isDevAtom = atom(true); const mockVersionAtom = atom(0); function makeMockApp(name: string, icon: string, iconcolor: string): AppInfo { return { appid: `local/${name.toLowerCase().replace(/\s+/g, "-")}`, modtime: 0, manifest: { appmeta: { title: name, shortdesc: "", icon, iconcolor }, configschema: {}, dataschema: {}, secrets: {}, }, }; } const mockApps: AppInfo[] = [ makeMockApp("Weather", "cloud-sun", "#60a5fa"), makeMockApp("Stocks", "chart-line", "#34d399"), makeMockApp("Notes", "note-sticky", "#fbbf24"), makeMockApp("Pomodoro", "clock", "#f87171"), makeMockApp("GitHub PRs", "code-pull-request", "#a78bfa"), makeMockApp("Server Monitor", "server", "#4ade80"), ]; const mockWidgets: { [key: string]: WidgetConfigType } = { "defwidget@term": { icon: "terminal", color: "#4ade80", label: "Terminal", description: "Open a terminal", "display:order": 0, blockdef: { meta: { view: "term", controller: "shell" } }, }, "defwidget@editor": { icon: "code", color: "#60a5fa", label: "Editor", description: "Open a code editor", "display:order": 1, blockdef: { meta: { view: "codeeditor" } }, }, "defwidget@web": { icon: "globe", color: "#f472b6", label: "Web", description: "Open a web browser", "display:order": 2, blockdef: { meta: { view: "web", url: "https://waveterm.dev" } }, }, "defwidget@ai": { icon: "sparkles", color: "#a78bfa", label: "AI", description: "Open Wave AI", "display:order": 3, blockdef: { meta: { view: "waveai" } }, }, "defwidget@files": { icon: "folder", color: "#fbbf24", label: "Files", description: "Open file browser", "display:order": 4, blockdef: { meta: { view: "preview", connection: "local" } }, }, "defwidget@sysinfo": { icon: "chart-line", color: "#34d399", label: "Sysinfo", description: "Open system info", "display:order": 5, blockdef: { meta: { view: "sysinfo" } }, }, }; const fullConfigAtom = atom({ settings: {}, widgets: mockWidgets } as unknown as FullConfigType); function makeWidgetsEnv( baseEnv: WaveEnv, isDev: boolean, hasCustomAIPresets: boolean, apps?: AppInfo[], atomOverrides?: Partial ) { return applyMockEnvOverrides(baseEnv, { isDev, rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) }, atoms: { fullConfigAtom, hasCustomAIPresetsAtom: atom(hasCustomAIPresets), ...atomOverrides, }, }); } function WidgetsScenario({ label, isDev = false, hasCustomAIPresets = true, height, apps, }: { label: string; isDev?: boolean; hasCustomAIPresets?: boolean; height?: number; apps?: AppInfo[]; }) { const baseEnv = useWaveEnv(); const envRef = useRef(null); if (envRef.current == null) { envRef.current = makeWidgetsEnv(baseEnv, isDev, hasCustomAIPresets, apps, { hasConfigErrors: hasConfigErrorsAtom, }); } return (
{label}
); } function WidgetsResizable({ isDev }: { isDev: boolean }) { const [height, setHeight] = useAtom(resizableHeightAtom); const baseEnv = useWaveEnv(); const envRef = useRef(null); if (envRef.current == null) { envRef.current = makeWidgetsEnv(baseEnv, isDev, true, mockApps, { hasConfigErrors: hasConfigErrorsAtom }); } return (
compact/supercompact — resizable (height: {height}px) setHeight(Number(e.target.value))} className="cursor-pointer" />
); } function PreviewControls() { const [hasConfigErrors, setHasConfigErrors] = useAtom(hasConfigErrorsAtom); const [isDev, setIsDev] = useAtom(isDevAtom); const [, setMockVersion] = useAtom(mockVersionAtom); function applyAndBump(fn: () => void) { fn(); setMockVersion((v) => v + 1); } return (
preview controls:
); } export function WidgetsPreview() { const isDev = useAtomValue(isDevAtom); const mockVersion = useAtomValue(mockVersionAtom); return (
); } ================================================ FILE: frontend/preview/vite.config.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react-swc"; import path from "path"; import { defineConfig } from "vite"; import svgr from "vite-plugin-svgr"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ root: __dirname, base: "./", // Serve the workspace-root public/ directory so Font Awesome and other // static assets (served by Electron in the real app) are available here too. publicDir: path.resolve(__dirname, "../../public"), plugins: [ tsconfigPaths(), svgr({ svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true }, include: "**/*.svg", }), react(), tailwindcss(), ], build: { minify: false, }, server: { port: 7007, }, }); ================================================ FILE: frontend/tailwindsetup.css ================================================ /* Copyright 2026, Command Line Inc. SPDX-License-Identifier: Apache-2.0 */ @import "tailwindcss"; @source "../node_modules/streamdown/dist/index.js"; @theme { --color-background: rgb(34, 34, 34); --color-foreground: #f7f7f7; --color-white: #f7f7f7; --color-primary: #f7f7f7; --color-muted-foreground: rgb(195, 200, 194); --color-secondary: rgb(195, 200, 194); --color-muted: rgb(140, 145, 140); --color-accent-50: rgb(236, 253, 232); --color-accent-100: rgb(209, 250, 202); --color-accent-200: rgb(167, 243, 168); --color-accent-300: rgb(110, 231, 133); --color-accent-400: rgb(88, 193, 66); /* main accent color */ --color-accent-500: rgb(63, 162, 51); --color-accent-600: rgb(47, 133, 47); --color-accent-700: rgb(34, 104, 43); --color-accent-800: rgb(22, 81, 35); --color-accent-900: rgb(15, 61, 29); --color-error: rgb(229, 77, 46); --color-warning: rgb(224, 185, 86); --color-success: rgb(78, 154, 6); --color-panel: rgba(31, 33, 31, 0.5); --color-hover: rgba(255, 255, 255, 0.1); --color-border: rgba(255, 255, 255, 0.16); --color-modalbg: #232323; --color-accentbg: rgba(88, 193, 66, 0.5); --color-hoverbg: rgba(255, 255, 255, 0.2); --color-highlightbg: rgba(255, 255, 255, 0.2); --color-accent: rgb(88, 193, 66); --color-accenthover: rgb(118, 223, 96); --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; --font-markdown: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; --text-xxs: 10px; --text-title: 18px; --text-default: 14px; --radius: 8px; /* ANSI Colors (Default Dark Palette) */ --ansi-black: #757575; --ansi-red: #cc685c; --ansi-green: #76c266; --ansi-yellow: #cbca9b; --ansi-blue: #85aacb; --ansi-magenta: #cc72ca; --ansi-cyan: #74a7cb; --ansi-white: #c1c1c1; --ansi-brightblack: #727272; --ansi-brightred: #cc9d97; --ansi-brightgreen: #a3dd97; --ansi-brightyellow: #cbcaaa; --ansi-brightblue: #9ab6cb; --ansi-brightmagenta: #cc8ecb; --ansi-brightcyan: #b7b8cb; --ansi-brightwhite: #f0f0f0; --container-w600: 600px; --container-w450: 450px; --container-w350: 350px; --container-xs: 300px; --container-xxs: 200px; --container-tiny: 120px; --z-window-drag: 100; } /* Applied when body.nohover is set — used to suppress hover effects during tab remount to prevent ghost-hover flicker */ @custom-variant nohover { body.nohover & { @slot; } } :root { --zoomfactor: 1; --zoomfactor-inv: 1; } /* Chart tooltip styling for sysinfo plots */ svg [aria-label="tip"] g path { color: var(--border-color); } /* Monaco editor scrollbar styling */ .monaco-editor .slider { background: rgba(255, 255, 255, 0.4); border-radius: 4px; transition: background 0.2s ease; } .monaco-editor .slider:hover { background: rgba(255, 255, 255, 0.6); } .ellipsis { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @keyframes float-up { 0% { transform: translate(-50%, 0); opacity: 1; } 100% { transform: translate(-50%, -40px); opacity: 0; } } ================================================ FILE: frontend/types/custom.d.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { WaveEnv } from "@/app/waveenv/waveenv"; import { type Placement } from "@floating-ui/react"; import type * as jotai from "jotai"; import type * as rxjs from "rxjs"; declare global { type GlobalAtomsType = { builderId: jotai.Atom; // readonly (for builder mode) builderAppId: jotai.PrimitiveAtom; // app being edited in builder mode uiContext: jotai.Atom; // driven from windowId, tabId workspaceId: jotai.Atom; // derived from window WOS object workspace: jotai.Atom; // driven from workspaceId via WOS fullConfigAtom: jotai.PrimitiveAtom; // driven from WOS, settings -- updated via WebSocket waveaiModeConfigAtom: jotai.PrimitiveAtom>; // resolved AI mode configs -- updated via WebSocket settingsAtom: jotai.Atom; // derrived from fullConfig hasCustomAIPresetsAtom: jotai.Atom; // derived from fullConfig hasConfigErrors: jotai.Atom; // derived from fullConfig staticTabId: jotai.Atom; isFullScreen: jotai.PrimitiveAtom; zoomFactorAtom: jotai.PrimitiveAtom; controlShiftDelayAtom: jotai.PrimitiveAtom; prefersReducedMotionAtom: jotai.Atom; documentHasFocus: jotai.PrimitiveAtom; updaterStatusAtom: jotai.PrimitiveAtom; modalOpen: jotai.PrimitiveAtom; allConnStatus: jotai.Atom; reinitVersion: jotai.PrimitiveAtom; waveAIRateLimitInfoAtom: jotai.PrimitiveAtom; }; type ThrottledValueAtom = jotai.WritableAtom], void>; type AtomWithThrottle = { currentValueAtom: jotai.Atom; throttledValueAtom: ThrottledValueAtom; }; type DebouncedValueAtom = jotai.WritableAtom], void>; type AtomWithDebounce = { currentValueAtom: jotai.Atom; debouncedValueAtom: DebouncedValueAtom; }; type SplitAtom = Atom[]>; type WritableSplitAtom = WritableAtom[], [SplitAtomAction], void>; type TabLayoutData = { blockId: string; }; type GlobalInitOptions = { tabId?: string; platform: NodeJS.Platform; windowId: string; clientId: string; environment: "electron" | "renderer"; primaryTabStartup?: boolean; builderId?: string; isPreview?: boolean; }; type WaveInitOpts = { tabId: string; clientId: string; windowId: string; activate: boolean; primaryTabStartup?: boolean; }; type BuilderInitOpts = { builderId: string; clientId: string; windowId: string; }; type ElectronApi = { getAuthKey(): string; // get-auth-key getIsDev(): boolean; // get-is-dev getCursorPoint: () => Electron.Point; // get-cursor-point getPlatform: () => NodeJS.Platform; // get-platform getEnv: (varName: string) => string; // get-env getUserName: () => string; // get-user-name getHostName: () => string; // get-host-name getDataDir: () => string; // get-data-dir getConfigDir: () => string; // get-config-dir getHomeDir: () => string; // get-home-dir getWebviewPreload: () => string; // get-webview-preload getAboutModalDetails: () => AboutModalDetails; // get-about-modal-details getZoomFactor: () => number; // get-zoom-factor showWorkspaceAppMenu: (workspaceId: string) => void; // workspace-appmenu-show showBuilderAppMenu: (builderId: string) => void; // builder-appmenu-show showContextMenu: (workspaceId: string, menu: ElectronContextMenuItem[]) => void; // contextmenu-show onContextMenuClick: (callback: (id: string | null) => void) => void; // contextmenu-click onNavigate: (callback: (url: string) => void) => void; onIframeNavigate: (callback: (url: string) => void) => void; downloadFile: (path: string) => void; // download openExternal: (url: string) => void; // open-external onFullScreenChange: (callback: (isFullScreen: boolean) => void) => void; // fullscreen-change onZoomFactorChange: (callback: (zoomFactor: number) => void) => void; // zoom-factor-change onUpdaterStatusChange: (callback: (status: UpdaterStatus) => void) => void; // app-update-status getUpdaterStatus: () => UpdaterStatus; // get-app-update-status getUpdaterChannel: () => string; // get-updater-channel installAppUpdate: () => void; // install-app-update onMenuItemAbout: (callback: () => void) => void; // menu-item-about updateWindowControlsOverlay: (rect: Dimensions) => void; // update-window-controls-overlay onReinjectKey: (callback: (waveEvent: WaveKeyboardEvent) => void) => void; // reinject-key setWebviewFocus: (focusedId: number) => void; // webview-focus, focusedId is the getWebContentsId of the webview registerGlobalWebviewKeys: (keys: string[]) => void; // register-global-webview-keys onControlShiftStateUpdate: (callback: (state: boolean) => void) => void; // control-shift-state-update createWorkspace: () => void; // create-workspace switchWorkspace: (workspaceId: string) => void; // switch-workspace deleteWorkspace: (workspaceId: string) => void; // delete-workspace setActiveTab: (tabId: string) => void; // set-active-tab createTab: () => void; // create-tab closeTab: (workspaceId: string, tabId: string, confirmClose: boolean) => Promise; // close-tab setWindowInitStatus: (status: "ready" | "wave-ready") => void; // set-window-init-status onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void; // wave-init onBuilderInit: (callback: (initOpts: BuilderInitOpts) => void) => void; // builder-init sendLog: (log: string) => void; // fe-log onQuicklook: (filePath: string) => void; // quicklook openNativePath(filePath: string): void; // open-native-path captureScreenshot(rect: Electron.Rectangle): Promise; // capture-screenshot setKeyboardChordMode: () => void; // set-keyboard-chord-mode clearWebviewStorage: (webContentsId: number) => Promise; // clear-webview-storage setWaveAIOpen: (isOpen: boolean) => void; // set-waveai-open closeBuilderWindow: () => void; // close-builder-window incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => void; // increment-term-commands nativePaste: () => void; // native-paste openBuilder: (appId?: string) => void; // open-builder setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid doRefresh: () => void; // do-refresh saveTextFile: (fileName: string, content: string) => Promise; // save-text-file setIsActive: () => Promise; // set-is-active }; type ElectronContextMenuItem = { id: string; // unique id, used for communication label: string; role?: string; // electron role (optional) type?: "separator" | "normal" | "submenu" | "checkbox" | "radio" | "header"; submenu?: ElectronContextMenuItem[]; checked?: boolean; visible?: boolean; enabled?: boolean; sublabel?: string; }; type ContextMenuItem = { label?: string; type?: "separator" | "normal" | "submenu" | "checkbox" | "radio" | "header"; role?: string; // electron role (optional) click?: () => void; // not required if role is set submenu?: ContextMenuItem[]; checked?: boolean; visible?: boolean; enabled?: boolean; sublabel?: string; }; type KeyPressDecl = { mods: { Cmd?: boolean; Option?: boolean; Shift?: boolean; Ctrl?: boolean; Alt?: boolean; Meta?: boolean; }; key: string; keyType: string; }; type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; type HeaderElem = | IconButtonDecl | ToggleIconButtonDecl | HeaderText | HeaderInput | HeaderDiv | HeaderTextButton | ConnectionButton | MenuButton; type IconButtonCommon = { icon: string | React.ReactNode; iconColor?: string; iconSpin?: boolean; className?: string; title?: string; disabled?: boolean; noAction?: boolean; }; type IconButtonDecl = IconButtonCommon & { elemtype: "iconbutton"; click?: (e: React.MouseEvent) => void; longClick?: (e: React.MouseEvent) => void; }; type ToggleIconButtonDecl = IconButtonCommon & { elemtype: "toggleiconbutton"; active: jotai.WritableAtom; }; type HeaderTextButton = { elemtype: "textbutton"; text: string; className?: string; title?: string; onClick?: (e: React.MouseEvent) => void; }; type HeaderText = { elemtype: "text"; text: string; ref?: React.RefObject; className?: string; noGrow?: boolean; onClick?: (e: React.MouseEvent) => void; }; type HeaderInput = { elemtype: "input"; value: string; className?: string; isDisabled?: boolean; ref?: React.RefObject; onChange?: (e: React.ChangeEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onFocus?: (e: React.FocusEvent) => void; onBlur?: (e: React.FocusEvent) => void; }; type HeaderDiv = { elemtype: "div"; className?: string; children: HeaderElem[]; onMouseOver?: (e: React.MouseEvent) => void; onMouseOut?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void; }; type ConnectionButton = { elemtype: "connectionbutton"; icon: string; text: string; iconColor: string; onClick?: (e: React.MouseEvent) => void; connected: boolean; }; type MenuItem = { label: string; icon?: string | React.ReactNode; subItems?: MenuItem[]; onClick?: (e: React.MouseEvent) => void; }; type MenuButtonProps = { items: MenuItem[]; className?: string; text: string; title?: string; menuPlacement?: Placement; }; type MenuButton = { elemtype: "menubutton"; } & MenuButtonProps; type SearchAtoms = { searchValue: PrimitiveAtom; resultsIndex: PrimitiveAtom; resultsCount: PrimitiveAtom; isOpen: PrimitiveAtom; focusInput: PrimitiveAtom; regex?: PrimitiveAtom; caseSensitive?: PrimitiveAtom; wholeWord?: PrimitiveAtom; }; declare type ViewComponentProps = { blockId: string; blockRef: React.RefObject; contentRef: React.RefObject; model: T; }; declare type ViewComponent = React.FC; type ViewModelInitType = { blockId: string; nodeModel: BlockNodeModel; tabModel: TabModel; waveEnv: WaveEnv; }; type ViewModelClass = new (initOpts: ViewModelInitType) => ViewModel; interface ViewModel { // The type of view, used for identifying and rendering the appropriate component. viewType: string; useTermHeader?: jotai.Atom; hideViewName?: jotai.Atom; // Icon representing the view, can be a string or an IconButton declaration. viewIcon?: jotai.Atom; // Display name for the view, used in UI headers. viewName?: jotai.Atom; // Optional header text or elements for the view. viewText?: jotai.Atom; termDurableStatus?: jotai.Atom; termConfigedDurable?: jotai.Atom; // Icon button displayed before the title in the header. preIconButton?: jotai.Atom; // Icon buttons displayed at the end of the block header. endIconButtons?: jotai.Atom; // Background styling metadata for the block. blockBg?: jotai.Atom; noHeader?: jotai.Atom; // Whether the block manages its own connection (e.g., for remote access). manageConnection?: jotai.Atom; // If true, filters out 'nowsh' connections (when managing connections) filterOutNowsh?: jotai.Atom; // If true, removes padding inside the block content area. noPadding?: jotai.Atom; // Atoms used for managing search functionality within the block. searchAtoms?: SearchAtoms; // The main view component associated with this ViewModel. viewComponent: ViewComponent; // Function to determine if this is a basic terminal block. isBasicTerm?: (getFn: jotai.Getter) => boolean; // Returns menu items for the settings dropdown. getSettingsMenuItems?: () => ContextMenuItem[]; // Attempts to give focus to the block, returning true if successful. giveFocus?: () => boolean; // Handles keydown events within the block. keyDownHandler?: (e: WaveKeyboardEvent) => boolean; // Cleans up resources when the block is disposed. dispose?: () => void; } type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing"; // jotai doesn't export this type :/ type Loadable = { state: "loading" } | { state: "hasData"; data: T } | { state: "hasError"; error: unknown }; interface Dimensions { width: number; height: number; left: number; top: number; } type TypeAheadModalType = { [key: string]: boolean }; interface AboutModalDetails { version: string; buildTime: number; } type BlockComponentModel = { openSwitchConnection?: () => void; viewModel: ViewModel; }; type ConnStatusType = "connected" | "connecting" | "disconnected" | "error" | "init"; interface SuggestionBaseItem { label: string; value: string; icon?: string | React.ReactNode; } interface SuggestionConnectionItem extends SuggestionBaseItem { status: ConnStatusType; iconColor: string; onSelect?: (_: string) => void; current?: boolean; } interface SuggestionConnectionScope { headerText?: string; items: SuggestionConnectionItem[]; } type SuggestionsType = SuggestionConnectionItem | SuggestionConnectionScope; type MarkdownResolveOpts = { connName: string; baseDir: string; }; interface AbstractWshClient { recvRpcMessage(msg: RpcMessage): void; } type ClientRpcEntry = { reqId: string; startTs: number; command: string; msgFn: (msg: RpcMessage) => void; }; type TimeSeriesMeta = { name?: string; color?: string; label?: string; maxy?: string | number; miny?: string | number; decimalPlaces?: number; }; interface SuggestionRequestContext { widgetid: string; reqnum: number; dispose?: boolean; } type SuggestionsFnType = (query: string, reqContext: SuggestionRequestContext) => Promise; type DraggedFile = { uri: string; absParent: string; relName: string; isDir: boolean; }; type ErrorButtonDef = { text: string; onClick: () => void; }; type ErrorMsg = { status: string; text: string; level?: "error" | "warning"; buttons?: Array; closeAction?: () => void; showDismiss?: boolean; }; type AIMessage = { messageid: string; parts: AIMessagePart[]; }; type AIMessagePart = | { type: "text"; text: string; } | { type: "file"; mimetype: string; // required filename?: string; data?: string; // base64 encoded data url?: string; size?: number; previewurl?: string; }; type AIModeConfigWithMode = { mode: string } & AIModeConfigType; } export {}; ================================================ FILE: frontend/types/gotypes.d.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // generated by cmd/generate/main-generatets.go declare global { // wshrpc.AIAttachedFile type AIAttachedFile = { name: string; type: string; size: number; data64: string; }; // wconfig.AIModeConfigType type AIModeConfigType = { "display:name": string; "display:order"?: number; "display:icon"?: string; "display:description"?: string; "ai:provider"?: string; "ai:apitype"?: string; "ai:model"?: string; "ai:thinkinglevel"?: string; "ai:verbosity"?: string; "ai:endpoint"?: string; "ai:proxyurl"?: string; "ai:azureapiversion"?: string; "ai:apitoken"?: string; "ai:apitokensecretname"?: string; "ai:azureresourcename"?: string; "ai:azuredeployment"?: string; "ai:capabilities"?: string[]; "ai:switchcompat"?: string[]; "waveai:cloud"?: boolean; "waveai:premium"?: boolean; }; // wconfig.AIModeConfigUpdate type AIModeConfigUpdate = { configs: {[key: string]: AIModeConfigType}; }; // wshrpc.ActivityDisplayType type ActivityDisplayType = { width: number; height: number; dpr: number; internal?: boolean; }; // wshrpc.ActivityUpdate type ActivityUpdate = { fgminutes?: number; activeminutes?: number; openminutes?: number; waveaifgminutes?: number; waveaiactiveminutes?: number; numtabs?: number; newtab?: number; numblocks?: number; numwindows?: number; numws?: number; numwsnamed?: number; numsshconn?: number; numwslconn?: number; nummagnify?: number; termcommandsrun?: number; numpanics?: number; numaireqs?: number; startup?: number; shutdown?: number; settabtheme?: number; buildtime?: string; displays?: ActivityDisplayType[]; renderers?: {[key: string]: number}; blocks?: {[key: string]: number}; wshcmds?: {[key: string]: number}; conn?: {[key: string]: number}; }; // wshrpc.AiMessageData type AiMessageData = { message?: string; }; // wshrpc.AppInfo type AppInfo = { appid: string; modtime: number; manifest?: AppManifest; }; // wshrpc.AppManifest type AppManifest = { appmeta: AppMeta; configschema: {[key: string]: any}; dataschema: {[key: string]: any}; secrets: {[key: string]: SecretMeta}; }; // wshrpc.AppMeta type AppMeta = { title: string; shortdesc: string; icon: string; iconcolor: string; }; // baseds.Badge type Badge = { badgeid: string; icon: string; color?: string; priority: number; pidlinked?: boolean; }; // baseds.BadgeEvent type BadgeEvent = { oref: string; clear?: boolean; clearall?: boolean; clearbyid?: string; badge?: Badge; }; // waveobj.Block type Block = WaveObj & { parentoref?: string; runtimeopts?: RuntimeOpts; stickers?: StickerType[]; subblockids?: string[]; jobid?: string; }; // blockcontroller.BlockControllerRuntimeStatus type BlockControllerRuntimeStatus = { blockid: string; version: number; shellprocstatus?: string; shellprocconnname?: string; shellprocexitcode: number; tsunamiport?: number; }; // waveobj.BlockDef type BlockDef = { files?: {[key: string]: FileDef}; meta?: MetaType; }; // wshrpc.BlockInfoData type BlockInfoData = { blockid: string; tabid: string; workspaceid: string; block: Block; files: WaveFileInfo[]; }; // wshrpc.BlockJobStatusData type BlockJobStatusData = { blockid: string; jobid: string; status?: null | "init" | "connected" | "disconnected" | "done"; versionts: number; donereason?: string; startuperror?: string; cmdexitts?: number; cmdexitcode?: number; cmdexitsignal?: string; }; // wshrpc.BlocksListEntry type BlocksListEntry = { windowid: string; workspaceid: string; tabid: string; blockid: string; meta: MetaType; }; // wshrpc.BlocksListRequest type BlocksListRequest = { windowid?: string; workspaceid?: string; }; // wshrpc.BuilderStatusData type BuilderStatusData = { status: string; port?: number; exitcode?: number; errormsg?: string; version: number; manifest?: AppManifest; secretbindings?: {[key: string]: string}; secretbindingscomplete: boolean; }; // waveobj.Client type Client = WaveObj & { windowids: string[]; tosagreed?: number; hasoldhistory?: boolean; tempoid?: string; installid?: string; }; // workspaceservice.CloseTabRtnType type CloseTabRtnType = { closewindow?: boolean; newactivetabid?: string; }; // wshrpc.CommandAuthenticateJobManagerData type CommandAuthenticateJobManagerData = { jobid: string; jobauthtoken: string; }; // wshrpc.CommandAuthenticateRtnData type CommandAuthenticateRtnData = { routeid: string; env?: {[key: string]: string}; initscripttext?: string; rpccontext?: RpcContext; }; // wshrpc.CommandAuthenticateToJobData type CommandAuthenticateToJobData = { jobaccesstoken: string; }; // wshrpc.CommandAuthenticateTokenData type CommandAuthenticateTokenData = { token: string; }; // wshrpc.CommandBadgeWatchPidData type CommandBadgeWatchPidData = { pid: number; oref: ORef; badgeid: string; }; // wshrpc.CommandBlockInputData type CommandBlockInputData = { blockid: string; inputdata64?: string; signame?: string; termsize?: TermSize; }; // wshrpc.CommandCaptureBlockScreenshotData type CommandCaptureBlockScreenshotData = { blockid: string; }; // wshrpc.CommandCheckGoVersionRtnData type CommandCheckGoVersionRtnData = { gostatus: string; gopath: string; goversion: string; errorstring?: string; }; // wshrpc.CommandConnServerInitData type CommandConnServerInitData = { clientid: string; }; // wshrpc.CommandControllerAppendOutputData type CommandControllerAppendOutputData = { blockid: string; data64: string; }; // wshrpc.CommandControllerResyncData type CommandControllerResyncData = { forcerestart?: boolean; tabid: string; blockid: string; rtopts?: RuntimeOpts; }; // wshrpc.CommandCreateBlockData type CommandCreateBlockData = { tabid: string; blockdef: BlockDef; rtopts?: RuntimeOpts; magnified?: boolean; ephemeral?: boolean; focused?: boolean; targetblockid?: string; targetaction?: string; }; // wshrpc.CommandCreateSubBlockData type CommandCreateSubBlockData = { parentblockid: string; blockdef: BlockDef; }; // wshrpc.CommandDebugTermData type CommandDebugTermData = { blockid: string; size: number; }; // wshrpc.CommandDebugTermRtnData type CommandDebugTermRtnData = { offset: number; data64: string; }; // wshrpc.CommandDeleteAppFileData type CommandDeleteAppFileData = { appid: string; filename: string; }; // wshrpc.CommandDeleteBlockData type CommandDeleteBlockData = { blockid: string; }; // wshrpc.CommandDeleteFileData type CommandDeleteFileData = { path: string; recursive: boolean; }; // wshrpc.CommandDisposeData type CommandDisposeData = { routeid: string; }; // wshrpc.CommandElectronDecryptData type CommandElectronDecryptData = { ciphertext: string; }; // wshrpc.CommandElectronDecryptRtnData type CommandElectronDecryptRtnData = { plaintext: string; storagebackend: string; }; // wshrpc.CommandElectronEncryptData type CommandElectronEncryptData = { plaintext: string; }; // wshrpc.CommandElectronEncryptRtnData type CommandElectronEncryptRtnData = { ciphertext: string; storagebackend: string; }; // wshrpc.CommandEventReadHistoryData type CommandEventReadHistoryData = { event: string; scope: string; maxitems: number; }; // wshrpc.CommandFileCopyData type CommandFileCopyData = { srcuri: string; desturi: string; opts?: FileCopyOpts; }; // wshrpc.CommandFileRestoreBackupData type CommandFileRestoreBackupData = { backupfilepath: string; restoretofilename: string; }; // wshrpc.CommandFileStreamData type CommandFileStreamData = { info: FileInfo; byterange?: string; streammeta: StreamMeta; }; // wshrpc.CommandGetMetaData type CommandGetMetaData = { oref: ORef; }; // wshrpc.CommandGetRTInfoData type CommandGetRTInfoData = { oref: ORef; }; // wshrpc.CommandGetTempDirData type CommandGetTempDirData = { filename?: string; }; // wshrpc.CommandGetWaveAIChatData type CommandGetWaveAIChatData = { chatid: string; }; // wshrpc.CommandJobCmdExitedData type CommandJobCmdExitedData = { jobid: string; exitcode?: number; exitsignal?: string; exiterr?: string; exitts?: number; }; // wshrpc.CommandJobConnectRtnData type CommandJobConnectRtnData = { seq: number; streamdone?: boolean; streamerror?: string; hasexited?: boolean; exitcode?: number; exitsignal?: string; exiterr?: string; }; // wshrpc.CommandJobControllerAttachJobData type CommandJobControllerAttachJobData = { jobid: string; blockid: string; }; // wshrpc.CommandJobControllerStartJobData type CommandJobControllerStartJobData = { connname: string; jobkind: string; cmd: string; args: string[]; env: {[key: string]: string}; termsize?: TermSize; }; // wshrpc.CommandJobInputData type CommandJobInputData = { jobid: string; inputsessionid?: string; seqnum?: number; inputdata64?: string; signame?: string; termsize?: TermSize; }; // wshrpc.CommandJobPrepareConnectData type CommandJobPrepareConnectData = { streammeta: StreamMeta; seq: number; termsize: TermSize; }; // wshrpc.CommandJobStartStreamData type CommandJobStartStreamData = object; // wshrpc.CommandListAllAppFilesData type CommandListAllAppFilesData = { appid: string; }; // wshrpc.CommandListAllAppFilesRtnData type CommandListAllAppFilesRtnData = { path: string; absolutepath: string; parentdir?: string; entries: DirEntryOut[]; entrycount: number; totalentries: number; truncated?: boolean; }; // wshrpc.CommandMakeDraftFromLocalData type CommandMakeDraftFromLocalData = { localappid: string; }; // wshrpc.CommandMakeDraftFromLocalRtnData type CommandMakeDraftFromLocalRtnData = { draftappid: string; }; // wshrpc.CommandMessageData type CommandMessageData = { message: string; }; // wshrpc.CommandPublishAppData type CommandPublishAppData = { appid: string; }; // wshrpc.CommandPublishAppRtnData type CommandPublishAppRtnData = { publishedappid: string; }; // wshrpc.CommandReadAppFileData type CommandReadAppFileData = { appid: string; filename: string; }; // wshrpc.CommandReadAppFileRtnData type CommandReadAppFileRtnData = { data64: string; notfound?: boolean; modts?: number; }; // wshrpc.CommandRemoteDisconnectFromJobManagerData type CommandRemoteDisconnectFromJobManagerData = { jobid: string; }; // wshrpc.CommandRemoteFileMultiInfoData type CommandRemoteFileMultiInfoData = { cwd: string; paths: string[]; }; // wshrpc.CommandRemoteFileStreamData type CommandRemoteFileStreamData = { path: string; byterange?: string; streammeta: StreamMeta; }; // wshrpc.CommandRemoteListEntriesData type CommandRemoteListEntriesData = { path: string; opts?: FileListOpts; }; // wshrpc.CommandRemoteListEntriesRtnData type CommandRemoteListEntriesRtnData = { fileinfo?: FileInfo[]; }; // wshrpc.CommandRemoteReconnectToJobManagerData type CommandRemoteReconnectToJobManagerData = { jobid: string; jobauthtoken: string; mainserverjwttoken: string; jobmanagerpid: number; jobmanagerstartts: number; }; // wshrpc.CommandRemoteReconnectToJobManagerRtnData type CommandRemoteReconnectToJobManagerRtnData = { success: boolean; jobmanagergone: boolean; error?: string; }; // wshrpc.CommandRemoteStartJobData type CommandRemoteStartJobData = { cmd: string; args: string[]; env: {[key: string]: string}; termsize: TermSize; streammeta?: StreamMeta; jobauthtoken: string; jobid: string; mainserverjwttoken: string; clientid: string; publickeybase64: string; }; // wshrpc.CommandRemoteStreamFileData type CommandRemoteStreamFileData = { path: string; byterange?: string; }; // wshrpc.CommandRemoteTerminateJobManagerData type CommandRemoteTerminateJobManagerData = { jobid: string; jobmanagerpid: number; jobmanagerstartts: number; }; // wshrpc.CommandRenameAppFileData type CommandRenameAppFileData = { appid: string; fromfilename: string; tofilename: string; }; // wshrpc.CommandResolveIdsData type CommandResolveIdsData = { blockid: string; ids: string[]; }; // wshrpc.CommandResolveIdsRtnData type CommandResolveIdsRtnData = { resolvedids: {[key: string]: ORef}; }; // wshrpc.CommandRestartBuilderAndWaitData type CommandRestartBuilderAndWaitData = { builderid: string; }; // wshrpc.CommandSetMetaData type CommandSetMetaData = { oref: ORef; meta: MetaType; }; // wshrpc.CommandSetRTInfoData type CommandSetRTInfoData = { oref: ORef; data: ObjRTInfo; delete?: boolean; }; // wshrpc.CommandStartBuilderData type CommandStartBuilderData = { builderid: string; }; // wshrpc.CommandStartJobData type CommandStartJobData = { cmd: string; args: string[]; env: {[key: string]: string}; termsize: TermSize; streammeta?: StreamMeta; }; // wshrpc.CommandStartJobRtnData type CommandStartJobRtnData = { cmdpid: number; cmdstartts: number; jobmanagerpid: number; jobmanagerstartts: number; }; // wshrpc.CommandStreamAckData type CommandStreamAckData = { id: string; seq: number; rwnd: number; fin?: boolean; delay?: number; cancel?: boolean; error?: string; }; // wshrpc.CommandStreamData type CommandStreamData = { id: string; seq: number; data64?: string; eof?: boolean; error?: string; }; // wshrpc.CommandTermGetScrollbackLinesData type CommandTermGetScrollbackLinesData = { linestart: number; lineend: number; lastcommand: boolean; }; // wshrpc.CommandTermGetScrollbackLinesRtnData type CommandTermGetScrollbackLinesRtnData = { totallines: number; linestart: number; lines: string[]; lastupdated: number; }; // wshrpc.CommandVarData type CommandVarData = { key: string; val?: string; remove?: boolean; zoneid: string; filename: string; }; // wshrpc.CommandVarResponseData type CommandVarResponseData = { key: string; val: string; exists: boolean; }; // wshrpc.CommandWaitForRouteData type CommandWaitForRouteData = { routeid: string; waitms: number; }; // wshrpc.CommandWaveAIAddContextData type CommandWaveAIAddContextData = { files?: AIAttachedFile[]; text?: string; submit?: boolean; newchat?: boolean; }; // wshrpc.CommandWaveAIGetToolDiffData type CommandWaveAIGetToolDiffData = { chatid: string; toolcallid: string; }; // wshrpc.CommandWaveAIGetToolDiffRtnData type CommandWaveAIGetToolDiffRtnData = { originalcontents64: string; modifiedcontents64: string; }; // wshrpc.CommandWaveAIToolApproveData type CommandWaveAIToolApproveData = { toolcallid: string; approval?: string; }; // wshrpc.CommandWaveFileReadStreamData type CommandWaveFileReadStreamData = { zoneid: string; name: string; streammeta: StreamMeta; }; // wshrpc.CommandWebSelectorData type CommandWebSelectorData = { workspaceid: string; blockid: string; tabid: string; selector: string; opts?: WebSelectorOpts; }; // wshrpc.CommandWriteAppFileData type CommandWriteAppFileData = { appid: string; filename: string; data64: string; }; // wshrpc.CommandWriteAppGoFileData type CommandWriteAppGoFileData = { appid: string; data64: string; }; // wshrpc.CommandWriteAppGoFileRtnData type CommandWriteAppGoFileRtnData = { data64: string; }; // wshrpc.CommandWriteAppSecretBindingsData type CommandWriteAppSecretBindingsData = { appid: string; bindings: {[key: string]: string}; }; // wshrpc.CommandWriteTempFileData type CommandWriteTempFileData = { filename: string; data64: string; }; // wconfig.ConfigError type ConfigError = { file: string; err: string; }; // wshrpc.ConnConfigRequest type ConnConfigRequest = { host: string; metamaptype: MetaType; }; // wshrpc.ConnExtData type ConnExtData = { connname: string; logblockid?: string; }; // wconfig.ConnKeywords type ConnKeywords = { "conn:wshenabled"?: boolean; "conn:askbeforewshinstall"?: boolean; "conn:wshpath"?: string; "conn:shellpath"?: string; "conn:ignoresshconfig"?: boolean; "display:hidden"?: boolean; "display:order"?: number; "term:*"?: boolean; "term:fontsize"?: number; "term:fontfamily"?: string; "term:theme"?: string; "term:durable"?: boolean; "cmd:env"?: {[key: string]: string}; "cmd:initscript"?: string; "cmd:initscript.sh"?: string; "cmd:initscript.bash"?: string; "cmd:initscript.zsh"?: string; "cmd:initscript.pwsh"?: string; "cmd:initscript.fish"?: string; "ssh:user"?: string; "ssh:hostname"?: string; "ssh:port"?: string; "ssh:identityfile"?: string[]; "ssh:passwordsecretname"?: string; "ssh:batchmode"?: boolean; "ssh:pubkeyauthentication"?: boolean; "ssh:passwordauthentication"?: boolean; "ssh:kbdinteractiveauthentication"?: boolean; "ssh:preferredauthentications"?: string[]; "ssh:addkeystoagent"?: boolean; "ssh:identityagent"?: string; "ssh:identitiesonly"?: boolean; "ssh:proxyjump"?: string[]; "ssh:userknownhostsfile"?: string[]; "ssh:globalknownhostsfile"?: string[]; }; // wshrpc.ConnRequest type ConnRequest = { host: string; keywords?: ConnKeywords; logblockid?: string; }; // wshrpc.ConnStatus type ConnStatus = { status: string; connhealthstatus?: string; wshenabled: boolean; connection: string; connected: boolean; hasconnected: boolean; activeconnnum: number; error?: string; wsherror?: string; nowshreason?: string; wshversion?: string; lastactivitybeforestalledtime?: number; keepalivesenttime?: number; }; // wshrpc.CpuDataRequest type CpuDataRequest = { id: string; count: number; }; // wshrpc.DirEntryOut type DirEntryOut = { name: string; dir?: boolean; symlink?: boolean; size?: number; mode: string; modified: string; modifiedtime: string; }; // vdom.DomRect type DomRect = { top: number; left: number; right: number; bottom: number; width: number; height: number; }; // wshrpc.FetchSuggestionsData type FetchSuggestionsData = { suggestiontype: string; query: string; widgetid: string; reqnum: number; "file:cwd"?: string; "file:dironly"?: boolean; "file:connection"?: string; }; // wshrpc.FetchSuggestionsResponse type FetchSuggestionsResponse = { reqnum: number; suggestions: SuggestionType[]; }; // wshrpc.FileCopyOpts type FileCopyOpts = { overwrite?: boolean; recursive?: boolean; merge?: boolean; timeout?: number; }; // wshrpc.FileData type FileData = { info?: FileInfo; data64?: string; entries?: FileInfo[]; at?: FileDataAt; }; // wshrpc.FileDataAt type FileDataAt = { offset: number; size?: number; }; // waveobj.FileDef type FileDef = { content?: string; meta?: {[key: string]: any}; }; // wshrpc.FileInfo type FileInfo = { path: string; dir?: string; name?: string; staterror?: string; notfound?: boolean; opts?: FileOpts; size?: number; meta?: {[key: string]: any}; mode?: number; modestr?: string; modtime?: number; isdir?: boolean; supportsmkdir?: boolean; mimetype?: string; readonly?: boolean; }; // wshrpc.FileListData type FileListData = { path: string; opts?: FileListOpts; }; // wshrpc.FileListOpts type FileListOpts = { all?: boolean; offset?: number; limit?: number; }; // wshrpc.FileOpts type FileOpts = { maxsize?: number; circular?: boolean; ijson?: boolean; ijsonbudget?: number; truncate?: boolean; append?: boolean; }; // wshrpc.FocusedBlockData type FocusedBlockData = { blockid: string; viewtype: string; controller: string; connname: string; blockmeta: MetaType; termjobstatus?: BlockJobStatusData; connstatus?: ConnStatus; termshellintegrationstatus?: string; termlastcommand?: string; }; // wconfig.FullConfigType type FullConfigType = { settings: SettingsType; mimetypes: {[key: string]: MimeTypeConfigType}; defaultwidgets: {[key: string]: WidgetConfigType}; widgets: {[key: string]: WidgetConfigType}; presets: {[key: string]: MetaType}; termthemes: {[key: string]: TermThemeType}; connections: {[key: string]: ConnKeywords}; bookmarks: {[key: string]: WebBookmark}; waveai: {[key: string]: AIModeConfigType}; configerrors: ConfigError[]; }; // waveobj.Job type Job = WaveObj & { connection: string; jobkind: string; cmd: string; cmdargs?: string[]; cmdenv?: {[key: string]: string}; jobauthtoken: string; attachedblockid?: string; waveversion?: string; terminateonreconnect?: boolean; jobmanagerstatus: string; jobmanagerdonereason?: string; jobmanagerstartuperror?: string; jobmanagerpid?: number; jobmanagerstartts?: number; cmdpid?: number; cmdstartts?: number; cmdtermsize: TermSize; cmdexitts?: number; cmdexitcode?: number; cmdexitsignal?: string; cmdexiterror?: string; streamdone?: boolean; streamerror?: string; }; // wshrpc.JobManagerStatusUpdate type JobManagerStatusUpdate = { jobid: string; jobmanagerstatus: string; }; // waveobj.LayoutActionData type LayoutActionData = { actiontype: string; actionid: string; blockid: string; nodesize?: number; indexarr?: number[]; focused: boolean; magnified: boolean; ephemeral: boolean; targetblockid?: string; position?: string; }; // waveobj.LayoutState type LayoutState = WaveObj & { rootnode?: any; magnifiednodeid?: string; focusednodeid?: string; leaforder?: LeafOrderEntry[]; pendingbackendactions?: LayoutActionData[]; }; // waveobj.LeafOrderEntry type LeafOrderEntry = { nodeid: string; blockid: string; }; // waveobj.MetaTSType type MetaType = { view?: string; controller?: string; file?: string; url?: string; pinnedurl?: string; connection?: string; edit?: boolean; history?: string[]; "history:forward"?: string[]; "display:name"?: string; "display:order"?: number; icon?: string; "icon:color"?: string; "frame:*"?: boolean; frame?: boolean; "frame:bordercolor"?: string; "frame:activebordercolor"?: string; "frame:title"?: string; "frame:icon"?: string; "frame:text"?: string; "cmd:*"?: boolean; cmd?: string; "cmd:interactive"?: boolean; "cmd:login"?: boolean; "cmd:persistent"?: boolean; "cmd:runonstart"?: boolean; "cmd:clearonstart"?: boolean; "cmd:runonce"?: boolean; "cmd:closeonexit"?: boolean; "cmd:closeonexitforce"?: boolean; "cmd:closeonexitdelay"?: number; "cmd:nowsh"?: boolean; "cmd:args"?: string[]; "cmd:shell"?: boolean; "cmd:allowconnchange"?: boolean; "cmd:jwt"?: boolean; "cmd:env"?: {[key: string]: string}; "cmd:cwd"?: string; "cmd:initscript"?: string; "cmd:initscript.sh"?: string; "cmd:initscript.bash"?: string; "cmd:initscript.zsh"?: string; "cmd:initscript.pwsh"?: string; "cmd:initscript.fish"?: string; "ai:*"?: boolean; "ai:preset"?: string; "ai:apitype"?: string; "ai:baseurl"?: string; "ai:apitoken"?: string; "ai:name"?: string; "ai:model"?: string; "ai:orgid"?: string; "ai:apiversion"?: string; "ai:maxtokens"?: number; "ai:timeoutms"?: number; "aifilediff:chatid"?: string; "aifilediff:toolcallid"?: string; "editor:*"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; "editor:wordwrap"?: boolean; "editor:fontsize"?: number; "graph:*"?: boolean; "graph:numpoints"?: number; "graph:metrics"?: string[]; "sysinfo:type"?: string; "tab:flagcolor"?: string; "bg:*"?: boolean; bg?: string; "bg:opacity"?: number; "bg:blendmode"?: string; "bg:bordercolor"?: string; "bg:activebordercolor"?: string; "layout:vtabbarwidth"?: number; "waveai:panelopen"?: boolean; "waveai:panelwidth"?: number; "waveai:model"?: string; "waveai:chatid"?: string; "waveai:widgetcontext"?: boolean; "term:*"?: boolean; "term:fontsize"?: number; "term:fontfamily"?: string; "term:mode"?: string; "term:theme"?: string; "term:localshellpath"?: string; "term:localshellopts"?: string[]; "term:scrollback"?: number; "term:vdomblockid"?: string; "term:vdomtoolbarblockid"?: string; "term:transparency"?: number; "term:allowbracketedpaste"?: boolean; "term:shiftenternewline"?: boolean; "term:macoptionismeta"?: boolean; "term:cursor"?: string; "term:cursorblink"?: boolean; "term:conndebug"?: string; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; "term:osc52"?: string; "term:durable"?: boolean; "web:zoom"?: number; "web:hidenav"?: boolean; "web:partition"?: string; "web:useragenttype"?: string; "markdown:fontsize"?: number; "markdown:fixedfontsize"?: number; "tsunami:*"?: boolean; "tsunami:sdkreplacepath"?: string; "tsunami:apppath"?: string; "tsunami:appid"?: string; "tsunami:scaffoldpath"?: string; "tsunami:env"?: {[key: string]: string}; "vdom:*"?: boolean; "vdom:initialized"?: boolean; "vdom:correlationid"?: string; "vdom:route"?: string; "vdom:persist"?: boolean; "onboarding:githubstar"?: boolean; "onboarding:lastversion"?: string; count?: number; }; // tsgenmeta.MethodMeta type MethodMeta = { Desc: string; ArgNames: string[]; ReturnDesc: string; }; // wconfig.MimeTypeConfigType type MimeTypeConfigType = { icon: string; color: string; }; // waveobj.ORef type ORef = string; // waveobj.ObjRTInfo type ObjRTInfo = { "tsunami:appmeta"?: AppMeta; "tsunami:schemas"?: any; "shell:hascurcwd"?: boolean; "shell:state"?: string; "shell:type"?: string; "shell:version"?: string; "shell:uname"?: string; "shell:integration"?: boolean; "shell:omz"?: boolean; "shell:comp"?: string; "shell:inputempty"?: boolean; "shell:lastcmd"?: string; "shell:lastcmdexitcode"?: number; "builder:layout"?: {[key: string]: number}; "builder:appid"?: string; "builder:env"?: {[key: string]: string}; "waveai:chatid"?: string; "waveai:mode"?: string; "waveai:maxoutputtokens"?: number; }; // wshrpc.PathCommandData type PathCommandData = { pathtype: string; open: boolean; openexternal: boolean; tabid: string; }; // waveobj.Point type Point = { x: number; y: number; }; // uctypes.RateLimitInfo type RateLimitInfo = { req: number; reqlimit: number; preq: number; preqlimit: number; resetepoch: number; unknown?: boolean; }; // wshrpc.RemoteInfo type RemoteInfo = { clientarch: string; clientos: string; clientversion: string; shell: string; homedir: string; }; // wshrpc.RestartBuilderAndWaitResult type RestartBuilderAndWaitResult = { success: boolean; errormessage?: string; buildoutput: string; }; // wshrpc.RpcContext type RpcContext = { sockname?: string; routeid: string; procroute?: boolean; blockid?: string; conn?: string; isrouter?: boolean; }; // wshutil.RpcMessage type RpcMessage = { command?: string; reqid?: string; resid?: string; timeout?: number; route?: string; source?: string; cont?: boolean; cancel?: boolean; error?: string; datatype?: string; data?: any; }; // wshrpc.RpcOpts type RpcOpts = { timeout?: number; noresponse?: boolean; route?: string; }; // waveobj.RuntimeOpts type RuntimeOpts = { termsize?: TermSize; winsize?: WinSize; }; // wshrpc.SecretMeta type SecretMeta = { desc: string; optional: boolean; }; // wconfig.SettingsType type SettingsType = { "app:*"?: boolean; "app:globalhotkey"?: string; "app:dismissarchitecturewarning"?: boolean; "app:defaultnewblock"?: string; "app:showoverlayblocknums"?: boolean; "app:ctrlvpaste"?: boolean; "app:confirmquit"?: boolean; "app:hideaibutton"?: boolean; "app:disablectrlshiftarrows"?: boolean; "app:disablectrlshiftdisplay"?: boolean; "app:focusfollowscursor"?: string; "app:tabbar"?: string; "feature:waveappbuilder"?: boolean; "ai:*"?: boolean; "ai:preset"?: string; "ai:apitype"?: string; "ai:baseurl"?: string; "ai:apitoken"?: string; "ai:name"?: string; "ai:model"?: string; "ai:orgid"?: string; "ai:apiversion"?: string; "ai:maxtokens"?: number; "ai:timeoutms"?: number; "ai:proxyurl"?: string; "ai:fontsize"?: number; "ai:fixedfontsize"?: number; "waveai:showcloudmodes"?: boolean; "waveai:defaultmode"?: string; "term:*"?: boolean; "term:fontsize"?: number; "term:fontfamily"?: string; "term:theme"?: string; "term:disablewebgl"?: boolean; "term:localshellpath"?: string; "term:localshellopts"?: string[]; "term:gitbashpath"?: string; "term:scrollback"?: number; "term:copyonselect"?: boolean; "term:transparency"?: number; "term:allowbracketedpaste"?: boolean; "term:shiftenternewline"?: boolean; "term:macoptionismeta"?: boolean; "term:cursor"?: string; "term:cursorblink"?: boolean; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; "term:osc52"?: string; "term:durable"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; "editor:wordwrap"?: boolean; "editor:fontsize"?: number; "editor:inlinediff"?: boolean; "web:*"?: boolean; "web:openlinksinternally"?: boolean; "web:defaulturl"?: string; "web:defaultsearch"?: string; "autoupdate:*"?: boolean; "autoupdate:enabled"?: boolean; "autoupdate:intervalms"?: number; "autoupdate:installonquit"?: boolean; "autoupdate:channel"?: string; "markdown:fontsize"?: number; "markdown:fixedfontsize"?: number; "preview:showhiddenfiles"?: boolean; "preview:defaultsort"?: string; "tab:preset"?: string; "tab:confirmclose"?: boolean; "widget:*"?: boolean; "widget:showhelp"?: boolean; "window:*"?: boolean; "window:fullscreenonlaunch"?: boolean; "window:transparent"?: boolean; "window:blur"?: boolean; "window:opacity"?: number; "window:bgcolor"?: string; "window:reducedmotion"?: boolean; "window:tilegapsize"?: number; "window:showmenubar"?: boolean; "window:nativetitlebar"?: boolean; "window:disablehardwareacceleration"?: boolean; "window:maxtabcachesize"?: number; "window:magnifiedblockopacity"?: number; "window:magnifiedblocksize"?: number; "window:magnifiedblockblurprimarypx"?: number; "window:magnifiedblockblursecondarypx"?: number; "window:confirmclose"?: boolean; "window:savelastwindow"?: boolean; "window:dimensions"?: string; "window:zoom"?: number; "telemetry:*"?: boolean; "telemetry:enabled"?: boolean; "conn:*"?: boolean; "conn:askbeforewshinstall"?: boolean; "conn:wshenabled"?: boolean; "conn:localhostdisplayname"?: string; "debug:*"?: boolean; "debug:pprofport"?: number; "debug:pprofmemprofilerate"?: number; "debug:webglstatus"?: boolean; "tsunami:*"?: boolean; "tsunami:scaffoldpath"?: string; "tsunami:sdkreplacepath"?: string; "tsunami:sdkversion"?: string; "tsunami:gopath"?: string; }; // waveobj.StickerClickOptsType type StickerClickOptsType = { sendinput?: string; createblock?: BlockDef; }; // waveobj.StickerDisplayOptsType type StickerDisplayOptsType = { icon: string; imgsrc: string; svgblob?: string; }; // waveobj.StickerType type StickerType = { stickertype: string; style: {[key: string]: any}; clickopts?: StickerClickOptsType; display: StickerDisplayOptsType; }; // wshrpc.StreamMeta type StreamMeta = { id: string; rwnd: number; readerrouteid: string; writerrouteid: string; }; // wps.SubscriptionRequest type SubscriptionRequest = { event: string; scopes?: string[]; allscopes?: boolean; }; // wshrpc.SuggestionType type SuggestionType = { type: string; suggestionid: string; display: string; subtext?: string; icon?: string; iconcolor?: string; iconsrc?: string; matchpos?: number[]; submatchpos?: number[]; score?: number; "file:mimetype"?: string; "file:path"?: string; "file:name"?: string; "url:url"?: string; }; // telemetrydata.TEvent type TEvent = { uuid?: string; ts?: number; tslocal?: string; event: string; props: TEventProps; }; // telemetrydata.TEventProps type TEventProps = { "client:arch"?: string; "client:version"?: string; "client:initial_version"?: string; "client:buildtime"?: string; "client:osrelease"?: string; "client:isdev"?: boolean; "client:packagetype"?: string; "client:macos"?: string; "cohort:month"?: string; "cohort:isoweek"?: string; "autoupdate:channel"?: string; "autoupdate:enabled"?: boolean; "localshell:type"?: string; "localshell:version"?: string; "loc:countrycode"?: string; "loc:regioncode"?: string; "settings:customwidgets"?: number; "settings:customaipresets"?: number; "settings:customsettings"?: number; "settings:customaimodes"?: number; "settings:secretscount"?: number; "settings:transparent"?: boolean; "activity:activeminutes"?: number; "activity:fgminutes"?: number; "activity:openminutes"?: number; "activity:waveaiactiveminutes"?: number; "activity:waveaifgminutes"?: number; "activity:termcommandsrun"?: number; "activity:termcommands:remote"?: number; "activity:termcommands:durable"?: number; "activity:termcommands:wsl"?: number; "app:firstday"?: boolean; "app:firstlaunch"?: boolean; "action:initiator"?: "keyboard" | "mouse"; "action:type"?: string; "debug:panictype"?: string; "block:view"?: string; "block:controller"?: string; "ai:backendtype"?: string; "ai:local"?: boolean; "wsh:cmd"?: string; "wsh:haderror"?: boolean; "conn:conntype"?: string; "conn:wsherrorcode"?: string; "conn:errorcode"?: string; "conn:suberrorcode"?: string; "conn:contexterror"?: boolean; "onboarding:feature"?: "waveai" | "durable" | "magnify" | "wsh"; "onboarding:version"?: string; "onboarding:githubstar"?: "already" | "star" | "later"; "onboarding:page"?: string; "display:height"?: number; "display:width"?: number; "display:dpr"?: number; "display:count"?: number; "display:all"?: any; "count:blocks"?: number; "count:tabs"?: number; "count:windows"?: number; "count:workspaces"?: number; "count:sshconn"?: number; "count:wslconn"?: number; "count:jobs"?: number; "count:jobsconnected"?: number; "count:views"?: {[key: string]: number}; "waveai:apitype"?: string; "waveai:model"?: string; "waveai:chatid"?: string; "waveai:stepnum"?: number; "waveai:inputtokens"?: number; "waveai:outputtokens"?: number; "waveai:nativewebsearchcount"?: number; "waveai:requestcount"?: number; "waveai:toolusecount"?: number; "waveai:tooluseerrorcount"?: number; "waveai:tooldetail"?: {[key: string]: number}; "waveai:premiumreq"?: number; "waveai:proxyreq"?: number; "waveai:haderror"?: boolean; "waveai:imagecount"?: number; "waveai:pdfcount"?: number; "waveai:textdoccount"?: number; "waveai:textlen"?: number; "waveai:firstbytems"?: number; "waveai:requestdurms"?: number; "waveai:widgetaccess"?: boolean; "waveai:thinkinglevel"?: string; "waveai:mode"?: string; "waveai:provider"?: string; "waveai:islocal"?: boolean; "waveai:feedback"?: "good" | "bad"; "waveai:action"?: string; "job:donereason"?: string; "job:kind"?: string; $set?: TEventUserProps; $set_once?: TEventUserProps; }; // telemetrydata.TEventUserProps type TEventUserProps = { "client:arch"?: string; "client:version"?: string; "client:initial_version"?: string; "client:buildtime"?: string; "client:osrelease"?: string; "client:isdev"?: boolean; "client:packagetype"?: string; "client:macos"?: string; "cohort:month"?: string; "cohort:isoweek"?: string; "autoupdate:channel"?: string; "autoupdate:enabled"?: boolean; "localshell:type"?: string; "localshell:version"?: string; "loc:countrycode"?: string; "loc:regioncode"?: string; "settings:customwidgets"?: number; "settings:customaipresets"?: number; "settings:customsettings"?: number; "settings:customaimodes"?: number; "settings:secretscount"?: number; "settings:transparent"?: boolean; }; // waveobj.Tab type Tab = WaveObj & { name: string; layoutstate: string; blockids: string[]; }; // waveobj.TermSize type TermSize = { rows: number; cols: number; }; // wconfig.TermThemeType type TermThemeType = { "display:name": string; "display:order": number; black: string; red: string; green: string; yellow: string; blue: string; magenta: string; cyan: string; white: string; brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string; brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string; gray: string; cmdtext: string; foreground: string; selectionBackground: string; background: string; cursor: string; }; // wshrpc.TimeSeriesData type TimeSeriesData = { ts: number; values: {[key: string]: number}; }; // uctypes.UIChat type UIChat = { chatid: string; apitype: string; model: string; apiversion: string; messages: UIMessage[]; }; // waveobj.UIContext type UIContext = { windowid: string; activetabid: string; }; // uctypes.UIMessage type UIMessage = { id: string; role: string; metadata?: any; parts?: UIMessagePart[]; }; // uctypes.UIMessagePart type UIMessagePart = { type: string; text?: string; state?: string; toolCallId?: string; input?: any; output?: any; errorText?: string; providerExecuted?: boolean; sourceId?: string; url?: string; title?: string; filename?: string; mediaType?: string; id?: string; data?: any; providerMetadata?: {[key: string]: any}; }; // userinput.UserInputRequest type UserInputRequest = { requestid: string; querytext: string; responsetype: string; title: string; markdown: boolean; timeoutms: number; checkboxmsg: string; publictext: boolean; oklabel?: string; cancellabel?: string; }; // userinput.UserInputResponse type UserInputResponse = { type: string; requestid: string; text?: string; confirm?: boolean; errormsg?: string; checkboxstat?: boolean; }; // vdom.VDomAsyncInitiationRequest type VDomAsyncInitiationRequest = { type: "asyncinitiationrequest"; ts: number; blockid?: string; }; // vdom.VDomBackendOpts type VDomBackendOpts = { closeonctrlc?: boolean; globalkeyboardevents?: boolean; globalstyles?: boolean; }; // vdom.VDomBackendUpdate type VDomBackendUpdate = { type: "backendupdate"; ts: number; blockid: string; opts?: VDomBackendOpts; haswork?: boolean; renderupdates?: VDomRenderUpdate[]; transferelems?: VDomTransferElem[]; statesync?: VDomStateSync[]; refoperations?: VDomRefOperation[]; messages?: VDomMessage[]; }; // vdom.VDomBinding type VDomBinding = { type: "binding"; bind: string; }; // vdom.VDomCreateContext type VDomCreateContext = { type: "createcontext"; ts: number; meta?: MetaType; target?: VDomTarget; persist?: boolean; }; // vdom.VDomElem type VDomElem = { waveid?: string; tag: string; props?: {[key: string]: any}; children?: VDomElem[]; text?: string; }; // vdom.VDomEvent type VDomEvent = { waveid: string; eventtype: string; globaleventtype?: string; targetvalue?: string; targetchecked?: boolean; targetname?: string; targetid?: string; keydata?: WaveKeyboardEvent; mousedata?: WavePointerData; }; // vdom.VDomFrontendUpdate type VDomFrontendUpdate = { type: "frontendupdate"; ts: number; blockid: string; correlationid?: string; dispose?: boolean; resync?: boolean; rendercontext?: VDomRenderContext; events?: VDomEvent[]; statesync?: VDomStateSync[]; refupdates?: VDomRefUpdate[]; messages?: VDomMessage[]; }; // vdom.VDomFunc type VDomFunc = { type: "func"; stoppropagation?: boolean; preventdefault?: boolean; globalevent?: string; #keys?: string[]; }; // vdom.VDomMessage type VDomMessage = { messagetype: string; message: string; stacktrace?: string; params?: any[]; }; // vdom.VDomRef type VDomRef = { type: "ref"; refid: string; trackposition?: boolean; position?: VDomRefPosition; hascurrent?: boolean; }; // vdom.VDomRefOperation type VDomRefOperation = { refid: string; op: string; params?: any[]; outputref?: string; }; // vdom.VDomRefPosition type VDomRefPosition = { offsetheight: number; offsetwidth: number; scrollheight: number; scrollwidth: number; scrolltop: number; boundingclientrect: DomRect; }; // vdom.VDomRefUpdate type VDomRefUpdate = { refid: string; hascurrent: boolean; position?: VDomRefPosition; }; // vdom.VDomRenderContext type VDomRenderContext = { blockid: string; focused: boolean; width: number; height: number; rootrefid: string; background?: boolean; }; // vdom.VDomRenderUpdate type VDomRenderUpdate = { updatetype: "root"|"append"|"replace"|"remove"|"insert"; waveid?: string; vdomwaveid?: string; vdom?: VDomElem; index?: number; }; // vdom.VDomStateSync type VDomStateSync = { atom: string; value: any; }; // vdom.VDomTarget type VDomTarget = { newblock?: boolean; magnified?: boolean; toolbar?: VDomTargetToolbar; }; // vdom.VDomTargetToolbar type VDomTargetToolbar = { toolbar: boolean; height?: string; }; // vdom.VDomTransferElem type VDomTransferElem = { waveid?: string; tag: string; props?: {[key: string]: any}; children?: string[]; text?: string; }; // wshrpc.VDomUrlRequestData type VDomUrlRequestData = { method: string; url: string; headers: {[key: string]: string}; body?: string; }; // wshrpc.VDomUrlRequestResponse type VDomUrlRequestResponse = { statuscode?: number; headers?: {[key: string]: string}; body?: string; }; type WSCommandType = { wscommand: string; } & ( WSRpcCommand ); // eventbus.WSEventType type WSEventType = { eventtype: string; oref?: string; data: any; }; // wps.WSFileEventData type WSFileEventData = { zoneid: string; filename: string; fileop: string; data64: string; }; // webcmd.WSRpcCommand type WSRpcCommand = { wscommand: "rpc"; message: RpcMessage; }; // wconfig.WatcherUpdate type WatcherUpdate = { fullconfig: FullConfigType; }; // wshrpc.WaveAIOptsType type WaveAIOptsType = { model: string; apitype?: string; apitoken: string; orgid?: string; apiversion?: string; baseurl?: string; proxyurl?: string; maxtokens?: number; maxchoices?: number; timeoutms?: number; }; // wshrpc.WaveAIPacketType type WaveAIPacketType = { type: string; model?: string; created?: number; finish_reason?: string; usage?: WaveAIUsageType; index?: number; text?: string; error?: string; }; // wshrpc.WaveAIPromptMessageType type WaveAIPromptMessageType = { role: string; content: string; name?: string; }; // wshrpc.WaveAIStreamRequest type WaveAIStreamRequest = { clientid?: string; opts: WaveAIOptsType; prompt: WaveAIPromptMessageType[]; }; // wshrpc.WaveAIUsageType type WaveAIUsageType = { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number; }; // filestore.WaveFile type WaveFile = { zoneid: string; name: string; opts: FileOpts; createdts: number; size: number; modts: number; meta: {[key: string]: any}; }; // wshrpc.WaveFileInfo type WaveFileInfo = { zoneid: string; name: string; opts: FileOpts; createdts: number; size: number; modts: number; meta: {[key: string]: any}; }; // wshrpc.WaveInfoData type WaveInfoData = { version: string; clientid: string; buildtime: string; configdir: string; datadir: string; }; // vdom.WaveKeyboardEvent type WaveKeyboardEvent = { type: "keydown"|"keyup"|"keypress"|"unknown"; key: string; code: string; repeat?: boolean; location?: number; shift?: boolean; control?: boolean; alt?: boolean; meta?: boolean; cmd?: boolean; option?: boolean; }; // wshrpc.WaveNotificationOptions type WaveNotificationOptions = { title?: string; body?: string; silent?: boolean; }; // waveobj.WaveObj type WaveObj = { otype: string; oid: string; version: number; meta: MetaType; }; // waveobj.WaveObjUpdate type WaveObjUpdate = { updatetype: string; otype: string; oid: string; obj?: WaveObj; }; // vdom.WavePointerData type WavePointerData = { button: number; buttons: number; clientx?: number; clienty?: number; pagex?: number; pagey?: number; screenx?: number; screeny?: number; movementx?: number; movementy?: number; shift?: boolean; control?: boolean; alt?: boolean; meta?: boolean; cmd?: boolean; option?: boolean; }; // waveobj.Window type WaveWindow = WaveObj & { workspaceid: string; isnew?: boolean; pos: Point; winsize: WinSize; lastfocusts: number; }; // wconfig.WebBookmark type WebBookmark = { url: string; title?: string; icon?: string; iconcolor?: string; iconurl?: string; "display:order"?: number; }; // service.WebCallType type WebCallType = { service: string; method: string; uicontext?: UIContext; args: any[]; }; // service.WebReturnType type WebReturnType = { success?: boolean; error?: string; data?: any; updates?: WaveObjUpdate[]; }; // wshrpc.WebSelectorOpts type WebSelectorOpts = { all?: boolean; inner?: boolean; }; // wconfig.WidgetConfigType type WidgetConfigType = { "display:order"?: number; "display:hidden"?: boolean; icon?: string; color?: string; label?: string; description?: string; workspaces?: string[]; magnified?: boolean; blockdef: BlockDef; }; // waveobj.WinSize type WinSize = { width: number; height: number; }; // waveobj.Workspace type Workspace = WaveObj & { name?: string; icon?: string; color?: string; tabids: string[]; activetabid: string; }; // wshrpc.WorkspaceInfoData type WorkspaceInfoData = { windowid: string; workspacedata: Workspace; }; // waveobj.WorkspaceListEntry type WorkspaceListEntry = { workspaceid: string; windowid: string; }; // wshrpc.WshServerCommandMeta type WshServerCommandMeta = { commandtype: string; }; } export {} ================================================ FILE: frontend/types/jsx.d.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 /// ================================================ FILE: frontend/types/media.d.ts ================================================ // this comes from vite/client.d.ts // removed the CSS types for easier VSCode "Go to Definition" support. // CSS modules type CSSModuleClasses = { readonly [key: string]: string }; declare module "*.module.css" { const classes: CSSModuleClasses; export default classes; } declare module "*.module.scss" { const classes: CSSModuleClasses; export default classes; } declare module "*.module.sass" { const classes: CSSModuleClasses; export default classes; } declare module "*.module.less" { const classes: CSSModuleClasses; export default classes; } declare module "*.module.styl" { const classes: CSSModuleClasses; export default classes; } declare module "*.module.stylus" { const classes: CSSModuleClasses; export default classes; } declare module "*.module.pcss" { const classes: CSSModuleClasses; export default classes; } declare module "*.module.sss" { const classes: CSSModuleClasses; export default classes; } // Built-in asset types // see `src/node/constants.ts` // images declare module "*.apng" { const src: string; export default src; } declare module "*.bmp" { const src: string; export default src; } declare module "*.png" { const src: string; export default src; } declare module "*.jpg" { const src: string; export default src; } declare module "*.jpeg" { const src: string; export default src; } declare module "*.jfif" { const src: string; export default src; } declare module "*.pjpeg" { const src: string; export default src; } declare module "*.pjp" { const src: string; export default src; } declare module "*.gif" { const src: string; export default src; } declare module "*.svg" { const src: string; export default src; } declare module "*.ico" { const src: string; export default src; } declare module "*.webp" { const src: string; export default src; } declare module "*.avif" { const src: string; export default src; } declare module "*.cur" { const src: string; export default src; } declare module "*.jxl" { const src: string; export default src; } // media declare module "*.mp4" { const src: string; export default src; } declare module "*.webm" { const src: string; export default src; } declare module "*.ogg" { const src: string; export default src; } declare module "*.mp3" { const src: string; export default src; } declare module "*.wav" { const src: string; export default src; } declare module "*.flac" { const src: string; export default src; } declare module "*.aac" { const src: string; export default src; } declare module "*.opus" { const src: string; export default src; } declare module "*.mov" { const src: string; export default src; } declare module "*.m4a" { const src: string; export default src; } declare module "*.vtt" { const src: string; export default src; } // fonts declare module "*.woff" { const src: string; export default src; } declare module "*.woff2" { const src: string; export default src; } declare module "*.eot" { const src: string; export default src; } declare module "*.ttf" { const src: string; export default src; } declare module "*.otf" { const src: string; export default src; } // other declare module "*.webmanifest" { const src: string; export default src; } declare module "*.pdf" { const src: string; export default src; } declare module "*.txt" { const src: string; export default src; } // wasm?init declare module "*.wasm?init" { const initWasm: (options?: WebAssembly.Imports) => Promise; export default initWasm; } // web worker declare module "*?worker" { const workerConstructor: { new (options?: { name?: string }): Worker; }; export default workerConstructor; } declare module "*?worker&inline" { const workerConstructor: { new (options?: { name?: string }): Worker; }; export default workerConstructor; } declare module "*?worker&url" { const src: string; export default src; } declare module "*?sharedworker" { const sharedWorkerConstructor: { new (options?: { name?: string }): SharedWorker; }; export default sharedWorkerConstructor; } declare module "*?sharedworker&inline" { const sharedWorkerConstructor: { new (options?: { name?: string }): SharedWorker; }; export default sharedWorkerConstructor; } declare module "*?sharedworker&url" { const src: string; export default src; } declare module "*?raw" { const src: string; export default src; } declare module "*?url" { const src: string; export default src; } declare module "*?inline" { const src: string; export default src; } declare module "*?no-inline" { const src: string; export default src; } declare module "*?url&inline" { const src: string; export default src; } declare module "*?url&no-inline" { const src: string; export default src; } declare interface VitePreloadErrorEvent extends Event { payload: Error; } declare interface WindowEventMap { "vite:preloadError": VitePreloadErrorEvent; } // import.meta.glob — provided by Vite at build time interface ImportMeta { glob>( pattern: string | string[], options?: { eager?: boolean; import?: string; query?: string | Record } ): Record Promise>; glob>( pattern: string | string[], options: { eager: true; import?: string; query?: string | Record } ): Record; } ================================================ FILE: frontend/types/vite-env.d.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 /// ================================================ FILE: frontend/types/waveevent.d.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // generated by cmd/generate/main-generatets.go declare global { // wps.WaveEvent type WaveEventName = | "blockclose" | "connchange" | "sysinfo" | "controllerstatus" | "builderstatus" | "builderoutput" | "waveobj:update" | "blockfile" | "config" | "userinput" | "route:down" | "route:up" | "workspace:update" | "waveai:ratelimit" | "waveapp:appgoupdated" | "tsunami:updatemeta" | "waveai:modeconfig" | "block:jobstatus" | "badge" ; type WaveEvent = { event: WaveEventName; scopes?: string[]; sender?: string; persist?: number; data?: unknown; } & ( { event: "blockclose"; data?: string; } | { event: "connchange"; data?: ConnStatus; } | { event: "sysinfo"; data?: TimeSeriesData; } | { event: "controllerstatus"; data?: BlockControllerRuntimeStatus; } | { event: "builderstatus"; data?: BuilderStatusData; } | { event: "builderoutput"; data?: {[key: string]: any}; } | { event: "waveobj:update"; data?: WaveObjUpdate; } | { event: "blockfile"; data?: WSFileEventData; } | { event: "config"; data?: WatcherUpdate; } | { event: "userinput"; data?: UserInputRequest; } | { event: "route:down"; data?: null; } | { event: "route:up"; data?: null; } | { event: "workspace:update"; data?: null; } | { event: "waveai:ratelimit"; data?: RateLimitInfo; } | { event: "waveapp:appgoupdated"; data?: null; } | { event: "tsunami:updatemeta"; data?: AppMeta; } | { event: "waveai:modeconfig"; data?: AIModeConfigUpdate; } | { event: "block:jobstatus"; data?: BlockJobStatusData; } | { event: "badge"; data?: BadgeEvent; } ); } export {} ================================================ FILE: frontend/util/color-validator.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { validateCssColor } from "./color-validator"; describe("validateCssColor", () => { beforeEach(() => { vi.stubGlobal("CSS", { supports: (_property: string, value: string) => { return [ "red", "#aabbcc", "#aabbccdd", "rgb(255, 0, 0)", "rgba(255, 0, 0, 0.5)", "hsl(120 100% 50%)", "transparent", "currentColor", ].includes(value); }, }); }); afterEach(() => { vi.unstubAllGlobals(); }); it("returns type for supported CSS color formats", () => { expect(validateCssColor("red")).toBe("keyword"); expect(validateCssColor("#aabbcc")).toBe("hex"); expect(validateCssColor("#aabbccdd")).toBe("hex8"); expect(validateCssColor("rgb(255, 0, 0)")).toBe("rgb"); expect(validateCssColor("rgba(255, 0, 0, 0.5)")).toBe("rgba"); expect(validateCssColor("hsl(120 100% 50%)")).toBe("hsl"); expect(validateCssColor("transparent")).toBe("transparent"); expect(validateCssColor("currentColor")).toBe("currentcolor"); }); it("throws for invalid CSS colors", () => { expect(() => validateCssColor(":not-a-color:")).toThrow("Invalid CSS color"); expect(() => validateCssColor("#12")).toThrow("Invalid CSS color"); expect(() => validateCssColor("rgb(255, 0)")).toThrow("Invalid CSS color"); }); }); ================================================ FILE: frontend/util/color-validator.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 const HexColorRegex = /^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i; const FunctionalColorRegex = /^([a-z-]+)\(/i; const NamedColorRegex = /^[a-z]+$/i; function isValidCssColor(color: string): boolean { if (typeof CSS == "undefined" || typeof CSS.supports != "function") { return false; } return CSS.supports("color", color); } function getCssColorType(color: string): string { const normalizedColor = color.toLowerCase(); if (HexColorRegex.test(normalizedColor)) { if (normalizedColor.length === 4) { return "hex3"; } if (normalizedColor.length === 5) { return "hex4"; } if (normalizedColor.length === 9) { return "hex8"; } return "hex"; } if (normalizedColor === "transparent") { return "transparent"; } if (normalizedColor === "currentcolor") { return "currentcolor"; } const functionMatch = normalizedColor.match(FunctionalColorRegex); if (functionMatch) { return functionMatch[1]; } if (NamedColorRegex.test(normalizedColor)) { return "keyword"; } return "color"; } export function validateCssColor(color: string): string { if (typeof color != "string") { throw new Error(`Invalid CSS color: ${String(color)}`); } const normalizedColor = color.trim(); if (normalizedColor === "" || !isValidCssColor(normalizedColor)) { throw new Error(`Invalid CSS color: ${color}`); } return getCssColorType(normalizedColor); } ================================================ FILE: frontend/util/endpoints.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { isPreviewWindow } from "@/app/store/windowtype"; import { getEnv } from "./getenv"; import { lazy } from "./util"; export const WebServerEndpointVarName = "WAVE_SERVER_WEB_ENDPOINT"; export const WSServerEndpointVarName = "WAVE_SERVER_WS_ENDPOINT"; export const getWebServerEndpoint = lazy(() => { if (isPreviewWindow()) return null; return `http://${getEnv(WebServerEndpointVarName)}`; }); export const getWSServerEndpoint = lazy(() => { if (isPreviewWindow()) return null; return `ws://${getEnv(WSServerEndpointVarName)}`; }); ================================================ FILE: frontend/util/fetchutil.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // Utility to abstract the fetch function so the Electron net module can be used when available. let net: Electron.Net; if (typeof window === "undefined") { try { import("electron").then(({ net: electronNet }) => (net = electronNet)); } catch (e) { // do nothing } } export function fetch(input: string | GlobalRequest | URL, init?: RequestInit): Promise { if (net) { return net.fetch(input.toString(), init); } else { return globalThis.fetch(input, init); } } ================================================ FILE: frontend/util/focusutil.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0s import * as util from "./util"; export function findBlockId(element: HTMLElement): string | null { let current: HTMLElement = element; while (current) { if (current.hasAttribute("data-blockid")) { return current.getAttribute("data-blockid"); } current = current.parentElement; } return null; } export function getElemAsStr(elem: EventTarget) { if (elem == null) { return "null"; } if (!(elem instanceof HTMLElement)) { if (elem instanceof Text) { elem = elem.parentElement; } if (!(elem instanceof HTMLElement)) { return "unknown"; } } const blockId = findBlockId(elem); let rtn = elem.tagName.toLowerCase(); if (!util.isBlank(elem.id)) { rtn += "#" + elem.id; } if (!util.isBlank(elem.className)) { rtn += "." + elem.className; } if (blockId != null) { rtn += ` [${blockId.substring(0, 8)}]`; } return rtn; } export function hasSelection() { const sel = document.getSelection(); return sel && sel.rangeCount > 0 && !sel.isCollapsed; } export function focusedBlockId(): string { const focused = document.activeElement; if (focused instanceof HTMLElement) { const blockId = findBlockId(focused); if (blockId) { return blockId; } } const sel = document.getSelection(); if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { let anchor = sel.anchorNode; if (anchor instanceof Text) { anchor = anchor.parentElement; } if (anchor instanceof HTMLElement) { const blockId = findBlockId(anchor); if (blockId) { return blockId; } } } return null; } ================================================ FILE: frontend/util/fontutil.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 let isJetBrainsMonoLoaded = false; let isHackFontLoaded = false; let isHackNerdFontLoaded = false; let isInterFontLoaded = false; function addToFontFaceSet(fontFaceSet: FontFaceSet, fontFace: FontFace) { // any cast to work around typing issue (fontFaceSet as any).add(fontFace); } function loadJetBrainsMonoFont() { if (isJetBrainsMonoLoaded) { return; } isJetBrainsMonoLoaded = true; const jbmFontNormal = new FontFace("JetBrains Mono", "url('fonts/jetbrains-mono-v13-latin-regular.woff2')", { style: "normal", weight: "400", }); const jbmFont200 = new FontFace("JetBrains Mono", "url('fonts/jetbrains-mono-v13-latin-200.woff2')", { style: "normal", weight: "200", }); const jbmFont700 = new FontFace("JetBrains Mono", "url('fonts/jetbrains-mono-v13-latin-700.woff2')", { style: "normal", weight: "700", }); addToFontFaceSet(document.fonts, jbmFontNormal); addToFontFaceSet(document.fonts, jbmFont200); addToFontFaceSet(document.fonts, jbmFont700); jbmFontNormal.load(); jbmFont200.load(); jbmFont700.load(); } function loadHackNerdFont() { if (isHackNerdFontLoaded) { return; } isHackFontLoaded = true; const hackRegular = new FontFace("Hack", "url('fonts/hacknerdmono-regular.ttf')", { style: "normal", weight: "400", }); const hackBold = new FontFace("Hack", "url('fonts/hacknerdmono-bold.ttf')", { style: "normal", weight: "700", }); const hackItalic = new FontFace("Hack", "url('fonts/hacknerdmono-italic.ttf')", { style: "italic", weight: "400", }); const hackBoldItalic = new FontFace("Hack", "url('fonts/hacknerdmono-bolditalic.ttf')", { style: "italic", weight: "700", }); addToFontFaceSet(document.fonts, hackRegular); addToFontFaceSet(document.fonts, hackBold); addToFontFaceSet(document.fonts, hackItalic); addToFontFaceSet(document.fonts, hackBoldItalic); hackRegular.load(); hackBold.load(); hackItalic.load(); hackBoldItalic.load(); } function loadInterFont() { if (isInterFontLoaded) { return; } isInterFontLoaded = true; const interFont = new FontFace("Inter", "url('fonts/inter-variable.woff2')", { style: "normal", weight: "100 900", }); addToFontFaceSet(document.fonts, interFont); interFont.load(); } function loadFonts() { loadInterFont(); loadJetBrainsMonoFont(); loadHackNerdFont(); } export { loadFonts }; ================================================ FILE: frontend/util/getenv.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 function getWindow(): Window { return globalThis.window; } function getProcess(): NodeJS.Process { return globalThis.process; } function getApi(): ElectronApi { return (window as any).api; } /** * Gets an environment variable from the host process, either directly or via IPC if called from the browser. * @param paramName The name of the environment variable to attempt to retrieve. * @returns The value of the environment variable or null if not present. */ export function getEnv(paramName: string): string { const win = getWindow(); if (win != null) { return getApi().getEnv(paramName); } const proc = getProcess(); if (proc != null) { return proc.env[paramName]; } return null; } ================================================ FILE: frontend/util/historyutil.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as util from "@/util/util"; const MaxHistory = 20; // this needs to be fixed for windows function getParentDirectory(path: string): string { if (util.isBlank(path) == null) { // this not great, ideally we'd never be passed a null path return "/"; } if (path == "/") { return "/"; } const splitPath = path.split("/"); splitPath.pop(); if (splitPath.length == 1 && splitPath[0] == "") { return "/"; } const newPath = splitPath.join("/"); return newPath; } function goHistoryBack(curValKey: "url" | "file", curVal: string, meta: MetaType, backToParent: boolean): MetaType { const rtnMeta: MetaType = {}; const history = (meta?.history ?? []).slice(); const historyForward = (meta?.["history:forward"] ?? []).slice(); if (history == null || history.length == 0) { if (backToParent) { const parentDir = getParentDirectory(curVal); if (parentDir == curVal) { return null; } historyForward.unshift(curVal); while (historyForward.length > MaxHistory) { historyForward.pop(); } return { [curValKey]: parentDir, "history:forward": historyForward }; } else { return null; } } const lastVal = history.pop(); historyForward.unshift(curVal); return { [curValKey]: lastVal, history: history, "history:forward": historyForward }; } function goHistoryForward(curValKey: "url" | "file", curVal: string, meta: MetaType): MetaType { const rtnMeta: MetaType = {}; let history = (meta?.history ?? []).slice(); const historyForward = (meta?.["history:forward"] ?? []).slice(); if (historyForward == null || historyForward.length == 0) { return null; } const lastVal = historyForward.shift(); history.push(curVal); if (history.length > MaxHistory) { history.shift(); } return { [curValKey]: lastVal, history: history, "history:forward": historyForward }; } function goHistory(curValKey: "url" | "file", curVal: string, newVal: string, meta: MetaType): MetaType { const rtnMeta: MetaType = {}; const history = (meta?.history ?? []).slice(); history.push(curVal); if (history.length > MaxHistory) { history.shift(); } return { [curValKey]: newVal, history: history, "history:forward": [] }; } export { getParentDirectory, goHistory, goHistoryBack, goHistoryForward }; ================================================ FILE: frontend/util/ijson.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // ijson values are regular JSON values: string, number, boolean, null, object, array // path is an array of strings and numbers type PathType = (string | number)[]; const simplePathStrRe = /^[a-zA-Z_][a-zA-Z0-9_]*$/; function formatPath(path: PathType): string { if (path.length == 0) { return "$"; } let pathStr = "$"; for (const pathPart of path) { if (typeof pathPart === "string") { if (simplePathStrRe.test(pathPart)) { pathStr += "." + pathPart; } else { pathStr += "[" + JSON.stringify(pathPart) + "]"; } } else if (typeof pathPart === "number") { pathStr += "[" + pathPart + "]"; } else { pathStr += ".*"; } } return pathStr; } function isArray(obj: any): boolean { return obj != null && Array.isArray(obj); } function isObject(obj: any): boolean { return obj != null && obj instanceof Object && !isArray(obj); } function getPath(obj: any, path: PathType): any { let cur = obj; for (const pathPart of path) { if (cur == null) { return null; } if (typeof pathPart === "string") { if (isObject(cur)) { cur = cur[pathPart]; } else { return null; } } else if (typeof pathPart === "number") { if (isArray(cur)) { cur = cur[pathPart]; } else { return null; } } else { throw new Error("Invalid path part: " + pathPart); } } return cur; } type SetPathOpts = { force?: boolean; remove?: boolean; combinefn?: (oldVal: any, newVal: any, opts: SetPathOpts) => any; }; function combineFn_arrayAppend(oldVal: any, newVal: any, opts: SetPathOpts): any { if (oldVal == null) { return [newVal]; } if (!isArray(oldVal) && !opts.force) { throw new Error("Cannot append to non-array: " + oldVal); } if (!isArray(oldVal)) { return [newVal]; } oldVal.push(newVal); return oldVal; } function checkPath(path: PathType): boolean { if (!isArray(path)) { return false; } for (const pathPart of path) { if (typeof pathPart !== "string" && typeof pathPart !== "number") { return false; } } return true; } function setPath(obj: any, path: PathType, value: any, opts: SetPathOpts) { if (opts == null) { opts = {}; } if (opts.remove && value != null) { throw new Error("Cannot set value and remove at the same time"); } if (path == null) { path = []; } if (!checkPath(path)) { throw new Error("Invalid path: " + formatPath(path)); } return setPathInternal(obj, path, value, opts); } function isEmpty(obj: any): boolean { if (obj == null) { return true; } if (isArray(obj)) { return obj.length == 0; } if (isObject(obj)) { for (const _ in obj) { return false; } return true; } return false; } function removeFromArr(arr: any[], idx: number): any[] { console.log("removefromarray", arr, idx); if (idx >= arr.length) { return arr; } if (idx == arr.length - 1) { arr.pop(); if (arr.length == 0) { return null; } return arr; } arr[idx] = null; return arr; } function setPathInternal(obj: any, path: PathType, value: any, opts: SetPathOpts): any { if (path.length == 0) { if (opts.combinefn != null) { return opts.combinefn(obj, value, opts); } return value; } const pathPart = path[0]; if (typeof pathPart === "string") { if (obj == null) { if (opts.remove) { return null; } obj = {}; } if (!isObject(obj)) { if (opts.force) { obj = {}; } else { throw new Error("Cannot set path on non-object: " + obj); } } if (opts.remove && path.length == 1) { delete obj[pathPart]; if (isEmpty(obj)) { return null; } return obj; } const newVal = setPathInternal(obj[pathPart], path.slice(1), value, opts); if (opts.remove && newVal == null) { delete obj[pathPart]; if (isEmpty(obj)) { return null; } return obj; } obj[pathPart] = newVal; return obj; } else if (typeof pathPart === "number") { if (pathPart < 0 || !Number.isInteger(pathPart)) { throw new Error("Invalid path part: " + pathPart); } if (obj == null) { if (opts.remove) { return null; } obj = []; } if (!isArray(obj)) { if (opts.force) { obj = []; } else { throw new Error("Cannot set path on non-array: " + obj); } } if (opts.remove && path.length == 1) { return removeFromArr(obj, pathPart); } const newVal = setPathInternal(obj[pathPart], path.slice(1), value, opts); if (opts.remove && newVal == null) { return removeFromArr(obj, pathPart); } obj[pathPart] = newVal; return obj; } else { throw new Error("Invalid path part: " + pathPart); } } function getCommandPath(command: object): PathType { if (command["path"] == null) { return []; } return command["path"]; } function applyCommand(data: any, command: any): any { if (command == null) { throw new Error("Invalid command (null)"); } if (!isObject(command)) { throw new Error("Invalid command (not an object): " + command); } const commandType = command.type; if (commandType == null) { throw new Error("Invalid command (no type): " + command); } const path = getCommandPath(command); if (!checkPath(path)) { throw new Error("Invalid command path: " + formatPath(path)); } switch (commandType) { case "set": return setPath(data, path, command.value, null); case "del": return setPath(data, path, null, { remove: true }); case "append": return setPath(data, path, command.value, { combinefn: combineFn_arrayAppend }); default: throw new Error("Invalid command type: " + commandType); } } export { applyCommand, combineFn_arrayAppend, getPath, setPath }; export type { PathType, SetPathOpts }; ================================================ FILE: frontend/util/isdev.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { getEnv } from "./getenv"; import { lazy } from "./util"; export const WaveDevVarName = "WAVETERM_DEV"; export const WaveDevViteVarName = "WAVETERM_DEV_VITE"; /** * Determines whether the current app instance is a development build. * @returns True if the current app instance is a development build. */ export const isDev = lazy(() => !!getEnv(WaveDevVarName)); /** * Determines whether the current app instance is running via the Vite dev server. * @returns True if the app is running via the Vite dev server. */ export const isDevVite = lazy(() => !!getEnv(WaveDevViteVarName)); ================================================ FILE: frontend/util/keyutil.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as util from "./util"; const KeyTypeCodeRegex = /c{(.*)}/; const KeyTypeKey = "key"; const KeyTypeCode = "code"; let PLATFORM: NodeJS.Platform = "darwin"; const PlatformMacOS = "darwin"; function setKeyUtilPlatform(platform: NodeJS.Platform) { PLATFORM = platform; } function getKeyUtilPlatform(): NodeJS.Platform { return PLATFORM; } function keydownWrapper( fn: (waveEvent: WaveKeyboardEvent) => boolean ): (event: KeyboardEvent | React.KeyboardEvent) => void { return (event: KeyboardEvent | React.KeyboardEvent) => { const waveEvent = adaptFromReactOrNativeKeyEvent(event); const rtnVal = fn(waveEvent); if (rtnVal) { event.preventDefault(); event.stopPropagation(); } }; } function waveEventToKeyDesc(waveEvent: WaveKeyboardEvent): string { let keyDesc: string[] = []; if (waveEvent.cmd) { keyDesc.push("Cmd"); } if (waveEvent.option) { keyDesc.push("Option"); } if (waveEvent.meta) { keyDesc.push("Meta"); } if (waveEvent.control) { keyDesc.push("Ctrl"); } if (waveEvent.shift) { keyDesc.push("Shift"); } if (waveEvent.key != null && waveEvent.key != "") { if (waveEvent.key == " ") { keyDesc.push("Space"); } else { keyDesc.push(waveEvent.key); } } else { keyDesc.push("c{" + waveEvent.code + "}"); } return keyDesc.join(":"); } function parseKey(key: string): { key: string; type: string } { let regexMatch = key.match(KeyTypeCodeRegex); if (regexMatch != null && regexMatch.length > 1) { let code = regexMatch[1]; return { key: code, type: KeyTypeCode }; } else if (regexMatch != null) { console.log("error: regexMatch is not null yet there is no captured group: ", regexMatch, key); } return { key: key, type: KeyTypeKey }; } function parseKeyDescription(keyDescription: string): KeyPressDecl { let rtn = { key: "", mods: {} } as KeyPressDecl; let keys = keyDescription.replace(/[()]/g, "").split(":"); for (let key of keys) { if (key == "Cmd") { if (PLATFORM == PlatformMacOS) { rtn.mods.Meta = true; } else { rtn.mods.Alt = true; } rtn.mods.Cmd = true; } else if (key == "Shift") { rtn.mods.Shift = true; } else if (key == "Ctrl") { rtn.mods.Ctrl = true; } else if (key == "Option") { if (PLATFORM == PlatformMacOS) { rtn.mods.Alt = true; } else { rtn.mods.Meta = true; } rtn.mods.Option = true; } else if (key == "Alt") { if (PLATFORM == PlatformMacOS) { rtn.mods.Option = true; } else { rtn.mods.Cmd = true; } rtn.mods.Alt = true; } else if (key == "Meta") { if (PLATFORM == PlatformMacOS) { rtn.mods.Cmd = true; } else { rtn.mods.Option = true; } rtn.mods.Meta = true; } else { let { key: parsedKey, type: keyType } = parseKey(key); rtn.key = parsedKey; rtn.keyType = keyType; if (rtn.keyType == KeyTypeKey && key.length == 1) { // check for if key is upper case // TODO what about unicode upper case? if (/[A-Z]/.test(key.charAt(0))) { // this key is an upper case A - Z - we should apply the shift key, even if it wasn't specified rtn.mods.Shift = true; } else if (key == " ") { rtn.key = "Space"; // we allow " " and "Space" to be mapped to Space key } } } } return rtn; } function notMod(keyPressMod: boolean, eventMod: boolean) { return (keyPressMod && !eventMod) || (eventMod && !keyPressMod); } function isCharacterKeyEvent(event: WaveKeyboardEvent): boolean { if (event.alt || event.meta || event.control) { return false; } return util.countGraphemes(event.key) == 1; } const inputKeyMap = new Map([ ["Backspace", true], ["Delete", true], ["Enter", true], ["Space", true], ["Tab", true], ["ArrowLeft", true], ["ArrowRight", true], ["ArrowUp", true], ["ArrowDown", true], ["Home", true], ["End", true], ["PageUp", true], ["PageDown", true], ["Cmd:a", true], ["Cmd:c", true], ["Cmd:v", true], ["Cmd:x", true], ["Cmd:z", true], ["Cmd:Shift:z", true], ["Cmd:ArrowLeft", true], ["Cmd:ArrowRight", true], ["Cmd:Backspace", true], ["Cmd:Delete", true], ["Shift:ArrowLeft", true], ["Shift:ArrowRight", true], ["Shift:ArrowUp", true], ["Shift:ArrowDown", true], ["Shift:Home", true], ["Shift:End", true], ["Cmd:Shift:ArrowLeft", true], ["Cmd:Shift:ArrowRight", true], ["Cmd:Shift:ArrowUp", true], ["Cmd:Shift:ArrowDown", true], ]); function isInputEvent(event: WaveKeyboardEvent): boolean { if (isCharacterKeyEvent(event)) { return true; } for (let key of inputKeyMap.keys()) { if (checkKeyPressed(event, key)) { return true; } } } function checkKeyPressed(event: WaveKeyboardEvent, keyDescription: string): boolean { let keyPress = parseKeyDescription(keyDescription); if (notMod(keyPress.mods.Option, event.option)) { return false; } if (notMod(keyPress.mods.Cmd, event.cmd)) { return false; } if (notMod(keyPress.mods.Shift, event.shift)) { return false; } if (notMod(keyPress.mods.Ctrl, event.control)) { return false; } if (notMod(keyPress.mods.Alt, event.alt)) { return false; } if (notMod(keyPress.mods.Meta, event.meta)) { return false; } let eventKey = ""; let descKey = keyPress.key; if (keyPress.keyType == KeyTypeCode) { eventKey = event.code; } if (keyPress.keyType == KeyTypeKey) { eventKey = event.key; if (eventKey != null && eventKey.length == 1 && /[A-Z]/.test(eventKey.charAt(0))) { // key is upper case A-Z, this means shift is applied, we want to allow // "Shift:e" as well as "Shift:E" or "E" eventKey = eventKey.toLocaleLowerCase(); descKey = descKey.toLocaleLowerCase(); } else if (eventKey == " ") { eventKey = "Space"; // a space key is shown as " ", we want users to be able to set space key as "Space" or " ", whichever they prefer } } if (descKey != eventKey) { return false; } return true; } function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): WaveKeyboardEvent { let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent; rtn.control = event.ctrlKey; rtn.shift = event.shiftKey; rtn.cmd = PLATFORM == PlatformMacOS ? event.metaKey : event.altKey; rtn.option = PLATFORM == PlatformMacOS ? event.altKey : event.metaKey; rtn.meta = event.metaKey; rtn.alt = event.altKey; rtn.code = event.code; rtn.key = event.key; rtn.location = event.location; (rtn as any).nativeEvent = event; if (event.type == "keydown" || event.type == "keyup" || event.type == "keypress") { rtn.type = event.type; } else { rtn.type = "unknown"; } rtn.repeat = event.repeat; return rtn; } function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent; if (event.type == "keyUp") { rtn.type = "keyup"; } else if (event.type == "keyDown") { rtn.type = "keydown"; } else { rtn.type = "unknown"; } rtn.control = event.control; rtn.cmd = PLATFORM == PlatformMacOS ? event.meta : event.alt; rtn.option = PLATFORM == PlatformMacOS ? event.alt : event.meta; rtn.meta = event.meta; rtn.alt = event.alt; rtn.shift = event.shift; rtn.repeat = event.isAutoRepeat; rtn.location = event.location; rtn.code = event.code; rtn.key = event.key; return rtn; } const keyMap = { Enter: "\r", Backspace: "\x7f", Tab: "\t", Escape: "\x1b", ArrowUp: "\x1b[A", ArrowDown: "\x1b[B", ArrowRight: "\x1b[C", ArrowLeft: "\x1b[D", Insert: "\x1b[2~", Delete: "\x1b[3~", Home: "\x1b[1~", End: "\x1b[4~", PageUp: "\x1b[5~", PageDown: "\x1b[6~", }; function keyboardEventToASCII(event: WaveKeyboardEvent): string { // check modifiers // if no modifiers are set, just send the key if (!event.alt && !event.control && !event.meta) { if (event.key == null || event.key == "") { return ""; } if (keyMap[event.key] != null) { return keyMap[event.key]; } if (event.key.length == 1) { return event.key; } else { console.log("not sending keyboard event", event.key, event); } } // if meta or alt is set, there is no ASCII representation if (event.meta || event.alt) { return ""; } // if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value if (event.control) { if ( (event.key.length === 1 && event.key >= "A" && event.key <= "Z") || (event.key >= "a" && event.key <= "z") ) { const key = event.key.toUpperCase(); return String.fromCharCode(key.charCodeAt(0) - 64); } } return ""; } export { adaptFromElectronKeyEvent, adaptFromReactOrNativeKeyEvent, checkKeyPressed, getKeyUtilPlatform, isCharacterKeyEvent, isInputEvent, keyboardEventToASCII, keydownWrapper, parseKeyDescription, setKeyUtilPlatform, waveEventToKeyDesc, }; ================================================ FILE: frontend/util/platformutil.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export const PlatformMacOS = "darwin"; export const PlatformWindows = "win32"; export const PlatformLinux = "linux"; export let PLATFORM: NodeJS.Platform = PlatformMacOS; export let MacOSVersion: string = null; export function setPlatform(platform: NodeJS.Platform) { PLATFORM = platform; } export function setMacOSVersion(version: string) { MacOSVersion = version; } export function isMacOSTahoeOrLater(): boolean { if (!isMacOS() || MacOSVersion == null) { return false; } const major = parseInt(MacOSVersion.split(".")[0], 10); return major >= 16; } export function isMacOS(): boolean { return PLATFORM == PlatformMacOS; } export function isWindows(): boolean { return PLATFORM == PlatformWindows; } export function makeNativeLabel(isDirectory: boolean) { let managerName: string; if (!isDirectory) { managerName = "Default Application"; } else if (PLATFORM === PlatformMacOS) { managerName = "Finder"; } else if (PLATFORM == PlatformWindows) { managerName = "Explorer"; } else { managerName = "File Manager"; } let fileAction: string; if (isDirectory) { fileAction = "Reveal"; } else { fileAction = "Open File"; } return `${fileAction} in ${managerName}`; } ================================================ FILE: frontend/util/previewutil.ts ================================================ import { createBlock, getApi } from "@/app/store/global"; import { makeNativeLabel } from "./platformutil"; import { fireAndForget } from "./util"; import { formatRemoteUri } from "./waveutil"; export function addOpenMenuItems(menu: ContextMenuItem[], conn: string, finfo: FileInfo): ContextMenuItem[] { if (!finfo) { return menu; } menu.push({ type: "separator", }); if (!conn) { // TODO: resolve correct host path if connection is WSL // if the entry is a directory, reveal it in the file manager, if the entry is a file, reveal its parent directory menu.push({ label: makeNativeLabel(true), click: () => { getApi().openNativePath(finfo.isdir ? finfo.path : finfo.dir); }, }); // if the entry is a file, open it in the default application if (!finfo.isdir) { menu.push({ label: makeNativeLabel(false), click: () => { getApi().openNativePath(finfo.path); }, }); } } else { menu.push({ label: "Download File", click: () => { const remoteUri = formatRemoteUri(finfo.path, conn); getApi().downloadFile(remoteUri); }, }); } menu.push({ type: "separator", }); if (!finfo.isdir) { menu.push({ label: "Open Preview in New Block", click: () => fireAndForget(async () => { const blockDef: BlockDef = { meta: { view: "preview", file: finfo.path, connection: conn, }, }; await createBlock(blockDef); }), }); } menu.push({ label: "Open Terminal Here", click: () => { const termBlockDef: BlockDef = { meta: { controller: "shell", view: "term", "cmd:cwd": finfo.isdir ? finfo.path : finfo.dir, connection: conn, }, }; fireAndForget(() => createBlock(termBlockDef)); }, }); return menu; } ================================================ FILE: frontend/util/sharedconst.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export const CHORD_TIMEOUT = 2000; ================================================ FILE: frontend/util/util.ts ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0s import base64 from "base64-js"; import clsx, { type ClassValue } from "clsx"; import { Atom, atom, Getter, SetStateAction, Setter, useAtomValue } from "jotai"; import { twMerge } from "tailwind-merge"; import { debounce, throttle } from "throttle-debounce"; const prevValueCache = new WeakMap(); // stores a previous value for a deep equal comparison (used with the deepCompareReturnPrev function) function isBlank(str: string): boolean { return str == null || str == ""; } function isLocalConnName(connName: string): boolean { if (isBlank(connName)) { return true; } return connName === "local" || connName.startsWith("local:"); } function isWslConnName(connName: string): boolean { return connName != null && connName.startsWith("wsl://"); } function isSshConnName(connName: string): boolean { return !isLocalConnName(connName) && !isWslConnName(connName); } function base64ToString(b64: string): string { if (b64 == null) { return null; } if (b64 == "") { return ""; } const stringBytes = base64.toByteArray(b64); return new TextDecoder().decode(stringBytes); } function stringToBase64(input: string): string { const stringBytes = new TextEncoder().encode(input); return base64.fromByteArray(stringBytes); } function arrayToBase64(input: Uint8Array): string { return base64.fromByteArray(input); } function base64ToArray(b64: string): Uint8Array { const cleanB64 = b64.replace(/\s+/g, ""); return base64.toByteArray(cleanB64); } function base64ToArrayBuffer(b64: string): ArrayBuffer { const cleanB64 = b64.replace(/\s+/g, ""); const u8 = base64.toByteArray(cleanB64); // Uint8Array // Force a plain ArrayBuffer slice (no SharedArrayBuffer, no offset issues) return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer; } function boundNumber(num: number, min: number, max: number): number { if (num == null || typeof num != "number" || isNaN(num)) { return null; } return Math.min(Math.max(num, min), max); } // key must be a suitable weakmap key. pass the new value // it will return the prevValue (for object equality) if the new value is deep equal to the prev value function deepCompareReturnPrev(key: any, newValue: any): any { if (key == null) { return newValue; } const previousValue = prevValueCache.get(key); if (previousValue !== undefined && JSON.stringify(newValue) === JSON.stringify(previousValue)) { return previousValue; } prevValueCache.set(key, newValue); return newValue; } // works for json-like objects (arrays, objects, strings, numbers, booleans) function jsonDeepEqual(v1: any, v2: any): boolean { if (v1 === v2) { return true; } if (typeof v1 !== typeof v2) { return false; } if ((v1 == null && v2 != null) || (v1 != null && v2 == null)) { return false; } if (typeof v1 === "object") { if (Array.isArray(v1) && Array.isArray(v2)) { if (v1.length !== v2.length) { return false; } for (let i = 0; i < v1.length; i++) { if (!jsonDeepEqual(v1[i], v2[i])) { return false; } } return true; } else { const keys1 = Object.keys(v1); const keys2 = Object.keys(v2); if (keys1.length !== keys2.length) { return false; } for (const key of keys1) { if (!jsonDeepEqual(v1[key], v2[key])) { return false; } } return true; } } return false; } function makeIconClass(icon: string, fw: boolean, opts?: { spin?: boolean; defaultIcon?: string }): string { if (isBlank(icon)) { if (opts?.defaultIcon != null) { return makeIconClass(opts.defaultIcon, fw, { spin: opts?.spin }); } return null; } let animation: string | null = null; let hasFwModifier = false; while (icon.match(/\+(spin|beat|fade|fw)$/)) { const modifierMatch = icon.match(/\+(spin|beat|fade|fw)$/); if (modifierMatch) { const modifier = modifierMatch[1]; if (modifier === "fw") { hasFwModifier = true; } else { animation = modifier; } icon = icon.replace(/\+(spin|beat|fade|fw)$/, ""); } } let baseClass: string; if (icon.match(/^(solid@)?[a-z0-9-]+$/)) { icon = icon.replace(/^solid@/, ""); baseClass = `fa fa-solid fa-${icon}`; } else if (icon.match(/^regular@[a-z0-9-]+$/)) { icon = icon.replace(/^regular@/, ""); baseClass = `fa fa-sharp fa-regular fa-${icon}`; } else if (icon.match(/^brands@[a-z0-9-]+$/)) { icon = icon.replace(/^brands@/, ""); baseClass = `fa fa-brands fa-${icon}`; } else if (icon.match(/^custom@[a-z0-9-]+$/)) { icon = icon.replace(/^custom@/, ""); baseClass = `fa fa-kit fa-${icon}`; } else { if (opts?.defaultIcon != null) { return makeIconClass(opts.defaultIcon, fw, { spin: opts?.spin }); } return null; } const shouldAddFw = fw || hasFwModifier; const hasSpin = animation === "spin" || opts?.spin; const animationClass = animation && animation !== "spin" ? `fa-${animation}` : null; return clsx(baseClass, shouldAddFw ? "fa-fw" : null, hasSpin ? "fa-spin" : null, animationClass); } /** * A wrapper function for running a promise and catching any errors * @param f The promise to run */ function fireAndForget(f: () => Promise) { f()?.catch((e) => { console.log("fireAndForget error", e); }); } const promiseWeakMap = new WeakMap, ResolvedValue>(); type ResolvedValue = { pending: boolean; error: any; value: T; }; // returns the value, pending state, and error of a promise function getPromiseState(promise: Promise): [T, boolean, any] { if (promise == null) { return [null, false, null]; } if (promiseWeakMap.has(promise)) { const value = promiseWeakMap.get(promise); return [value.value, value.pending, value.error]; } const value: ResolvedValue = { pending: true, error: null, value: null, }; promise.then( (result) => { value.pending = false; value.error = null; value.value = result; }, (error) => { value.pending = false; value.error = error; } ); promiseWeakMap.set(promise, value); return [value.value, value.pending, value.error]; } // returns the value of a promise, or a default value if the promise is still pending (or had an error) function getPromiseValue(promise: Promise, def: T): T { const [value, pending, error] = getPromiseState(promise); if (pending || error) { return def; } return value; } function jotaiLoadableValue(value: Loadable, def: T): T { if (value.state === "hasData") { return value.data; } return def; } const NullAtom = atom(null); function useAtomValueSafe(atom: Atom | Atom>): T { if (atom == null) { return useAtomValue(NullAtom) as T; } return useAtomValue(atom); } /** * Simple wrapper function that lazily evaluates the provided function and caches its result for future calls. * @param callback The function to lazily run. * @returns The result of the function. */ const lazy = any>(callback: T) => { let res: ReturnType; let processed = false; return (...args: Parameters): ReturnType => { if (processed) return res; res = callback(...args); processed = true; return res; }; }; /** * Generates an external link by appending the given URL to the "https://extern?" endpoint. * * @param {string} url - The URL to be encoded and appended to the external link. * @return {string} The generated external link. */ function makeExternLink(url: string): string { return "https://extern?" + encodeURIComponent(url); } function atomWithThrottle(initialValue: T, delayMilliseconds = 500): AtomWithThrottle { // DO NOT EXPORT currentValueAtom as using this atom to set state can cause // inconsistent state between currentValueAtom and throttledValueAtom const _currentValueAtom = atom(initialValue); const throttledValueAtom = atom(initialValue, (get, set, update: SetStateAction) => { const prevValue = get(_currentValueAtom); const nextValue = typeof update === "function" ? (update as (prev: T) => T)(prevValue) : update; set(_currentValueAtom, nextValue); throttleUpdate(get, set); }); const throttleUpdate = throttle(delayMilliseconds, (get: Getter, set: Setter) => { const curVal = get(_currentValueAtom); set(throttledValueAtom, curVal); }); return { currentValueAtom: atom((get) => get(_currentValueAtom)), throttledValueAtom, }; } function atomWithDebounce(initialValue: T, delayMilliseconds = 500): AtomWithDebounce { // DO NOT EXPORT currentValueAtom as using this atom to set state can cause // inconsistent state between currentValueAtom and debouncedValueAtom const _currentValueAtom = atom(initialValue); const debouncedValueAtom = atom(initialValue, (get, set, update: SetStateAction) => { const prevValue = get(_currentValueAtom); const nextValue = typeof update === "function" ? (update as (prev: T) => T)(prevValue) : update; set(_currentValueAtom, nextValue); debounceUpdate(get, set); }); const debounceUpdate = debounce(delayMilliseconds, (get: Getter, set: Setter) => { const curVal = get(_currentValueAtom); set(debouncedValueAtom, curVal); }); return { currentValueAtom: atom((get) => get(_currentValueAtom)), debouncedValueAtom, }; } function getPrefixedSettings(settings: SettingsType, prefix: string): SettingsType { const rtn: SettingsType = {}; if (settings == null || isBlank(prefix)) { return rtn; } for (const key in settings) { if (key == prefix || key.startsWith(prefix + ":")) { rtn[key] = settings[key]; } } return rtn; } function countGraphemes(str: string): number { if (str == null) { return 0; } // this exists (need to hack TS to get it to not show an error) const seg = new (Intl as any).Segmenter(undefined, { granularity: "grapheme" }); return Array.from(seg.segment(str)).length; } function makeConnRoute(conn: string): string { if (isBlank(conn)) { return "conn:local"; } return "conn:" + conn; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function mergeMeta(meta: MetaType, metaUpdate: MetaType, prefix?: string): MetaType { const rtn: MetaType = {}; // Helper function to check if a key matches the prefix criteria const shouldIncludeKey = (key: string): boolean => { if (prefix === undefined) { return true; } if (prefix === "") { return !key.includes(":"); } return key.startsWith(prefix + ":"); }; // Copy original meta (only keys matching prefix criteria) for (const [k, v] of Object.entries(meta)) { if (shouldIncludeKey(k)) { rtn[k] = v; } } // Deal with "section:*" keys (only if they match prefix criteria) for (const k of Object.keys(metaUpdate)) { if (!k.endsWith(":*")) { continue; } if (!metaUpdate[k]) { continue; } const sectionPrefix = k.slice(0, -2); // Remove ':*' suffix if (sectionPrefix === "") { continue; } // Only process if this section matches our prefix criteria if (!shouldIncludeKey(sectionPrefix)) { continue; } // Delete "[sectionPrefix]" and all keys that start with "[sectionPrefix]:" const prefixColon = sectionPrefix + ":"; for (const k2 of Object.keys(rtn)) { if (k2 === sectionPrefix || k2.startsWith(prefixColon)) { delete rtn[k2]; } } } // Deal with regular keys (only if they match prefix criteria) for (const [k, v] of Object.entries(metaUpdate)) { if (!shouldIncludeKey(k)) { continue; } if (k.endsWith(":*")) { continue; } if (v === null || v === undefined) { delete rtn[k]; continue; } rtn[k] = v; } return rtn; } function escapeBytes(str: string): string { return str.replace(/[\s\S]/g, (ch) => { const code = ch.charCodeAt(0); switch (ch) { case "\n": return "\\n"; case "\r": return "\\r"; case "\t": return "\\t"; case "\b": return "\\b"; case "\f": return "\\f"; } if (code === 0x1b) return "\\x1b"; // escape if (code < 0x20 || code === 0x7f) return `\\x${code.toString(16).padStart(2, "0")}`; return ch; }); } function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } type ParsedDataUrl = { mimeType: string; buffer: Uint8Array; }; function parseDataUrl(dataUrl: string): ParsedDataUrl { if (!dataUrl.startsWith("data:")) throw new Error("Invalid data URL"); const [header, data] = dataUrl.split(",", 2); if (data === undefined) throw new Error("Invalid data URL: missing data"); const meta = header.slice(5); let mimeType = "text/plain;charset=US-ASCII"; const parts = meta.split(";"); if (parts[0]) mimeType = parts[0]; const isBase64 = parts.some((p) => p.toLowerCase() === "base64"); let buffer: Uint8Array; if (isBase64) { buffer = base64ToArray(data); } else { // assume text const decoded = decodeURIComponent(data); buffer = new TextEncoder().encode(decoded); } return { mimeType, buffer }; } function formatRelativeTime(timestamp: number): string { if (!timestamp) { return "never"; } const now = Date.now(); const diffInSeconds = Math.floor((now - timestamp) / 1000); const diffInMinutes = Math.floor(diffInSeconds / 60); const diffInHours = Math.floor(diffInMinutes / 60); const diffInDays = Math.floor(diffInHours / 24); if (diffInMinutes <= 0) { return "Just now"; } else if (diffInMinutes < 60) { return `${diffInMinutes} min${diffInMinutes !== 1 ? "s" : ""} ago`; } else if (diffInHours < 24) { return `${diffInHours} hr${diffInHours !== 1 ? "s" : ""} ago`; } else if (diffInDays < 7) { return `${diffInDays} day${diffInDays !== 1 ? "s" : ""} ago`; } else { return new Date(timestamp).toLocaleDateString(); } } /** * Sort objects by display:order (ascending) and display:name (alphabetically) * @param a First object to compare * @param b Second object to compare * @returns Comparison result for Array.sort() */ function sortByDisplayOrder(a: T, b: T): number { const orderDiff = (a["display:order"] || 0) - (b["display:order"] || 0); if (orderDiff !== 0) return orderDiff; return (a["display:name"] || "").localeCompare(b["display:name"] || ""); } export { arrayToBase64, atomWithDebounce, atomWithThrottle, base64ToArray, base64ToArrayBuffer, base64ToString, boundNumber, cn, countGraphemes, deepCompareReturnPrev, escapeBytes, fireAndForget, formatRelativeTime, getPrefixedSettings, getPromiseState, getPromiseValue, isBlank, isLocalConnName, isSshConnName, isWslConnName, jotaiLoadableValue, jsonDeepEqual, lazy, makeConnRoute, makeExternLink, makeIconClass, mergeMeta, NullAtom, parseDataUrl, sleep, sortByDisplayOrder, stringToBase64, useAtomValueSafe, }; ================================================ FILE: frontend/util/waveutil.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0s import { getWebServerEndpoint } from "@/util/endpoints"; import { boundNumber, isBlank } from "@/util/util"; import { generate as generateCSS, parse as parseCSS, walk as walkCSS } from "css-tree"; function encodeFileURL(file: string) { const webEndpoint = getWebServerEndpoint(); const fileUri = formatRemoteUri(file, "local"); const rtn = webEndpoint + `/wave/stream-file?path=${encodeURIComponent(fileUri)}&no404=1`; return rtn; } export function processBackgroundUrls(cssText: string): string { if (isBlank(cssText)) { return null; } cssText = cssText.trim(); if (cssText.endsWith(";")) { cssText = cssText.slice(0, -1); } const attrRe = /^background(-image)?\s*:\s*/i; cssText = cssText.replace(attrRe, ""); const ast = parseCSS("background: " + cssText, { context: "declaration", }); let hasUnsafeUrl = false; walkCSS(ast, { visit: "Url", enter(node) { const originalUrl = node.value.trim(); if ( originalUrl.startsWith("http:") || originalUrl.startsWith("https:") || originalUrl.startsWith("data:") ) { return; } // allow file:/// urls (if they are absolute) if (originalUrl.startsWith("file://")) { const path = originalUrl.slice(7); if (!path.startsWith("/")) { console.log(`Invalid background, contains a non-absolute file URL: ${originalUrl}`); hasUnsafeUrl = true; return; } const newUrl = encodeFileURL(path); node.value = newUrl; return; } // allow absolute paths if (originalUrl.startsWith("/") || originalUrl.startsWith("~/") || /^[a-zA-Z]:(\/|\\)/.test(originalUrl)) { const newUrl = encodeFileURL(originalUrl); node.value = newUrl; return; } hasUnsafeUrl = true; console.log(`Invalid background, contains an unsafe URL scheme: ${originalUrl}`); }, }); if (hasUnsafeUrl) { return null; } const rtnStyle = generateCSS(ast); if (rtnStyle == null) { return null; } return rtnStyle.replace(/^background:\s*/, ""); } export function computeBgStyleFromMeta(meta: MetaType, defaultOpacity: number = null): React.CSSProperties { const bgAttr = meta?.["bg"]; if (isBlank(bgAttr)) { return null; } try { const processedBg = processBackgroundUrls(bgAttr); const rtn: React.CSSProperties = {}; rtn.background = processedBg; rtn.opacity = boundNumber(meta["bg:opacity"], 0, 1) ?? defaultOpacity; if (!isBlank(meta?.["bg:blendmode"])) { rtn.backgroundBlendMode = meta["bg:blendmode"]; } return rtn; } catch (e) { console.error("error processing background", e); return null; } } export function formatRemoteUri(path: string, connection: string): string { connection = connection ?? "local"; return `wsh://${connection}/${path}`; } ================================================ FILE: frontend/util/wsutil.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { WebSocket as NodeWebSocketType } from "ws"; let NodeWebSocket: typeof NodeWebSocketType = null; if (typeof window === "undefined") { // Necessary to avoid issues with Rollup: https://github.com/websockets/ws/issues/2057 import("ws") .then((ws) => (NodeWebSocket = ws.default)) .catch((e) => { console.log("Error importing 'ws':", e); }); } type ComboWebSocket = NodeWebSocketType | WebSocket; function newWebSocket(url: string, headers: { [key: string]: string }): ComboWebSocket { if (NodeWebSocket) { return new NodeWebSocket(url, { headers }); } else { return new WebSocket(url); } } export { newWebSocket }; export type { ComboWebSocket as WebSocket }; ================================================ FILE: frontend/wave.ts ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { App } from "@/app/app"; import { loadMonaco } from "@/app/monaco/monaco-env"; import { loadBadges } from "@/app/store/badge"; import { GlobalModel } from "@/app/store/global-model"; import { globalRefocus, registerBuilderGlobalKeys, registerControlShiftStateUpdateHandler, registerElectronReinjectKeyHandler, registerGlobalKeys, } from "@/app/store/keymodel"; import { modalsModel } from "@/app/store/modalmodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeBuilderRouteId, makeTabRouteId } from "@/app/store/wshrouter"; import { initWshrpc, TabRpcClient } from "@/app/store/wshrpcutil"; import { BuilderApp } from "@/builder/builder-app"; import { getLayoutModelForStaticTab } from "@/layout/index"; import { countersClear, countersPrint } from "@/store/counters"; import { atoms, getApi, globalStore, initGlobal, initGlobalWaveEventSubs, loadConnStatus, subscribeToConnEvents, } from "@/store/global"; import { activeTabIdAtom } from "@/store/tab-model"; import * as WOS from "@/store/wos"; import { loadFonts } from "@/util/fontutil"; import { setKeyUtilPlatform } from "@/util/keyutil"; import { isMacOS, setMacOSVersion } from "@/util/platformutil"; import { createElement } from "react"; import { createRoot } from "react-dom/client"; const platform = getApi().getPlatform(); document.title = `Wave Terminal`; let savedInitOpts: WaveInitOpts = null; (window as any).WOS = WOS; (window as any).globalStore = globalStore; (window as any).globalAtoms = atoms; (window as any).RpcApi = RpcApi; (window as any).isFullScreen = false; (window as any).countersPrint = countersPrint; (window as any).countersClear = countersClear; (window as any).getLayoutModelForStaticTab = getLayoutModelForStaticTab; (window as any).modalsModel = modalsModel; function updateZoomFactor(zoomFactor: number) { console.log("update zoomfactor", zoomFactor); document.documentElement.style.setProperty("--zoomfactor", String(zoomFactor)); document.documentElement.style.setProperty("--zoomfactor-inv", String(1 / zoomFactor)); } async function initBare() { getApi().sendLog("Init Bare"); document.body.style.visibility = "hidden"; document.body.style.opacity = "0"; document.body.classList.add("is-transparent"); getApi().onWaveInit(initWaveWrap); getApi().onBuilderInit(initBuilderWrap); setKeyUtilPlatform(platform); loadFonts(); updateZoomFactor(getApi().getZoomFactor()); getApi().onZoomFactorChange((zoomFactor) => { updateZoomFactor(zoomFactor); }); document.fonts.ready.then(() => { console.log("Init Bare Done"); getApi().setWindowInitStatus("ready"); }); } document.addEventListener("DOMContentLoaded", initBare); async function initWaveWrap(initOpts: WaveInitOpts) { try { if (savedInitOpts) { await reinitWave(); return; } savedInitOpts = initOpts; await initWave(initOpts); } catch (e) { getApi().sendLog("Error in initWave " + e.message + "\n" + e.stack); console.error("Error in initWave", e); } finally { document.body.style.visibility = null; document.body.style.opacity = null; document.body.classList.remove("is-transparent"); } } async function reinitWave() { console.log("Reinit Wave"); getApi().sendLog("Reinit Wave"); // We use this hack to prevent a flicker of the previously-hovered tab when this view was last active. document.body.classList.add("nohover"); requestAnimationFrame(() => setTimeout(() => { document.body.classList.remove("nohover"); }, 100) ); await WOS.reloadWaveObject(WOS.makeORef("client", savedInitOpts.clientId)); const waveWindow = await WOS.reloadWaveObject(WOS.makeORef("window", savedInitOpts.windowId)); const ws = await WOS.reloadWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); const initialTab = await WOS.reloadWaveObject(WOS.makeORef("tab", savedInitOpts.tabId)); await WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)); reloadAllWorkspaceTabs(ws); document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change getApi().setWindowInitStatus("wave-ready"); globalStore.set(atoms.reinitVersion, globalStore.get(atoms.reinitVersion) + 1); globalStore.set(atoms.updaterStatusAtom, getApi().getUpdaterStatus()); setTimeout(() => { globalRefocus(); }, 50); } function reloadAllWorkspaceTabs(ws: Workspace) { if (ws == null || !ws.tabids?.length) { return; } ws.tabids?.forEach((tabid) => { WOS.reloadWaveObject(WOS.makeORef("tab", tabid)); }); } function loadAllWorkspaceTabs(ws: Workspace) { if (ws == null || !ws.tabids?.length) { return; } ws.tabids?.forEach((tabid) => { WOS.getObjectValue(WOS.makeORef("tab", tabid)); }); } async function initWave(initOpts: WaveInitOpts) { getApi().sendLog("Init Wave " + JSON.stringify(initOpts)); const globalInitOpts: GlobalInitOptions = { tabId: initOpts.tabId, clientId: initOpts.clientId, windowId: initOpts.windowId, platform, environment: "renderer", primaryTabStartup: initOpts.primaryTabStartup, }; console.log("Wave Init", globalInitOpts); globalStore.set(activeTabIdAtom, initOpts.tabId); await GlobalModel.getInstance().initialize(globalInitOpts); initGlobal(globalInitOpts); (window as any).globalAtoms = atoms; // Init WPS event handlers const globalWS = initWshrpc(makeTabRouteId(initOpts.tabId)); (window as any).globalWS = globalWS; (window as any).TabRpcClient = TabRpcClient; // ensures client/window/workspace are loaded into the cache before rendering try { await loadConnStatus(); await loadBadges(); initGlobalWaveEventSubs(initOpts); subscribeToConnEvents(); if (isMacOS()) { const macOSVersion = await RpcApi.MacOSVersionCommand(TabRpcClient); setMacOSVersion(macOSVersion); } const [_client, waveWindow, initialTab] = await Promise.all([ WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)), WOS.loadAndPinWaveObject(WOS.makeORef("window", initOpts.windowId)), WOS.loadAndPinWaveObject(WOS.makeORef("tab", initOpts.tabId)), ]); const [ws, _layoutState] = await Promise.all([ WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)), WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)), ]); loadAllWorkspaceTabs(ws); WOS.wpsSubscribeToObject(WOS.makeORef("workspace", waveWindow.workspaceid)); document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change } catch (e) { console.error("Failed initialization error", e); getApi().sendLog("Error in initialization (wave.ts, loading required objects) " + e.message + "\n" + e.stack); } registerGlobalKeys(); registerElectronReinjectKeyHandler(); registerControlShiftStateUpdateHandler(); await loadMonaco(); const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient); console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); const waveaiModeConfig = await RpcApi.GetWaveAIModeConfigCommand(TabRpcClient); globalStore.set(atoms.waveaiModeConfigAtom, waveaiModeConfig.configs); console.log("Wave First Render"); let firstRenderResolveFn: () => void = null; const firstRenderPromise = new Promise((resolve) => { firstRenderResolveFn = resolve; }); const reactElem = createElement(App, { onFirstRender: firstRenderResolveFn }, null); const elem = document.getElementById("main"); const root = createRoot(elem); root.render(reactElem); await firstRenderPromise; console.log("Wave First Render Done"); getApi().setWindowInitStatus("wave-ready"); } async function initBuilderWrap(initOpts: BuilderInitOpts) { try { await initBuilder(initOpts); } catch (e) { getApi().sendLog("Error in initBuilder " + e.message + "\n" + e.stack); console.error("Error in initBuilder", e); } finally { document.body.style.visibility = null; document.body.style.opacity = null; document.body.classList.remove("is-transparent"); } } async function initBuilder(initOpts: BuilderInitOpts) { getApi().sendLog("Init Builder " + JSON.stringify(initOpts)); const globalInitOpts: GlobalInitOptions = { clientId: initOpts.clientId, windowId: initOpts.windowId, platform, environment: "renderer", builderId: initOpts.builderId, }; console.log("Tsunami Builder Init", globalInitOpts); await GlobalModel.getInstance().initialize(globalInitOpts); initGlobal(globalInitOpts); (window as any).globalAtoms = atoms; const globalWS = initWshrpc(makeBuilderRouteId(initOpts.builderId)); (window as any).globalWS = globalWS; (window as any).TabRpcClient = TabRpcClient; await loadConnStatus(); let appIdToUse: string = null; try { const oref = WOS.makeORef("builder", initOpts.builderId); const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref }); if (rtInfo && rtInfo["builder:appid"]) { appIdToUse = rtInfo["builder:appid"]; } } catch (e) { console.log("Could not load saved builder appId from rtinfo:", e); } document.title = appIdToUse ? `WaveApp Builder (${appIdToUse})` : "WaveApp Builder"; globalStore.set(atoms.builderAppId, appIdToUse); const _client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)); registerBuilderGlobalKeys(); registerElectronReinjectKeyHandler(); await loadMonaco(); const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient); console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); const waveaiModeConfig = await RpcApi.GetWaveAIModeConfigCommand(TabRpcClient); globalStore.set(atoms.waveaiModeConfigAtom, waveaiModeConfig.configs); console.log("Tsunami Builder First Render"); let firstRenderResolveFn: () => void = null; const firstRenderPromise = new Promise((resolve) => { firstRenderResolveFn = resolve; }); const reactElem = createElement(BuilderApp, { initOpts, onFirstRender: firstRenderResolveFn }, null); const elem = document.getElementById("main"); const root = createRoot(elem); root.render(reactElem); await firstRenderPromise; console.log("Tsunami Builder First Render Done"); } ================================================ FILE: go.mod ================================================ module github.com/wavetermdev/waveterm go 1.25.6 require ( github.com/Microsoft/go-winio v0.6.2 github.com/alexflint/go-filemutex v1.3.0 github.com/creack/pty v1.1.24 github.com/emirpasic/gods v1.18.1 github.com/fsnotify/fsnotify v1.9.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/generative-ai-go v0.20.1 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 github.com/invopop/jsonschema v0.13.0 github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 github.com/junegunn/fzf v0.65.2 github.com/kevinburke/ssh_config v1.2.0 github.com/launchdarkly/eventsource v1.11.0 github.com/mattn/go-sqlite3 v1.14.34 github.com/mitchellh/mapstructure v1.5.0 github.com/sashabaranov/go-openai v1.41.2 github.com/sawka/txwrap v0.2.0 github.com/shirou/gopsutil/v4 v4.26.2 github.com/skeema/knownhosts v1.3.1 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.10.2 github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b github.com/wavetermdev/htmltoken v0.2.0 github.com/wavetermdev/waveterm/tsunami v0.12.3 golang.org/x/crypto v0.49.0 golang.org/x/mod v0.33.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 golang.org/x/term v0.41.0 google.golang.org/api v0.271.0 ) require ( cloud.google.com/go v0.121.6 // indirect cloud.google.com/go/ai v0.8.0 // indirect cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/kevinburke/ssh_config => github.com/wavetermdev/ssh_config v0.0.0-20241219203747-6409e4292f34 replace github.com/creack/pty => github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b replace github.com/wavetermdev/waveterm/tsunami => ./tsunami ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/0xrawsec/golang-utils v1.3.2 h1:ww4jrtHRSnX9xrGzJYbalx5nXoZewy4zPxiY+ubJgtg= github.com/0xrawsec/golang-utils v1.3.2/go.mod h1:m7AzHXgdSAkFCD9tWWsApxNVxMlyy7anpPVOyT/yM7E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUymZXdOcM= github.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/generative-ai-go v0.20.1 h1:6dEIujpgN2V0PgLhr6c/M1ynRdc7ARtiIDPFzj45uNQ= github.com/google/generative-ai-go v0.20.1/go.mod h1:TjOnZJmZKzarWbjUJgy+r3Ee7HGBRVLhOIgupnwR4Bg= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/junegunn/fzf v0.65.2 h1:Uz6Qey1K4JoGNMskYlwRDnGuCEu/sAh+NxQ4YdX3yn0= github.com/junegunn/fzf v0.65.2/go.mod h1:0PctWYfS0aCfyLFEIUjtE+PIXD2UFKaHgbIHiECG7Bo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/launchdarkly/eventsource v1.11.0 h1:aAdvh2XmtXA17QsRFL0XKHURMqhxg7J+CceQmhSzBas= github.com/launchdarkly/eventsource v1.11.0/go.mod h1:dU+rZxkPOlGPsyJPpiDqiepAcFwIITDUClY9+A6RrMw= github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1zshl1ZWa0/oR+8bvg= github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b h1:cLGKfKb1uk0hxI0Q8L83UAJPpeJ+gSpn3cCU/tjd3eg= github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b/go.mod h1:KO+FcPtyLAiRC0hJwreJVvfwc7vnNz77UxBTIGHdPVk= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94= github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 h1:XQpsQG5lqRJlx4mUVHcJvyyc1rdTI9nHvwrdfcuy8aM= github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117/go.mod h1:mx0TjbqsaDD9DUT5gA1s3hw47U6RIbbIBfvGzR85K0g= github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b h1:wFBKF5k5xbJQU8bYgcSoQ/ScvmYyq6KHUabAuVUjOWM= github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b/go.mod h1:N1CYNinssZru+ikvYTgVbVeSi21thHUTCoJ9xMvWe+s= github.com/wavetermdev/htmltoken v0.2.0 h1:sFVPPemlDv7/jg7n4Hx1AEF2m9MVAFjFpELWfhi/DlM= github.com/wavetermdev/htmltoken v0.2.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= github.com/wavetermdev/ssh_config v0.0.0-20241219203747-6409e4292f34 h1:I8VZVTZEXhnzfN7jB9a7TZYpzNO48sCUWMRXHM9XWSA= github.com/wavetermdev/ssh_config v0.0.0-20241219203747-6409e4292f34/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: index.html ================================================ Wave
================================================ FILE: package.json ================================================ { "name": "waveterm", "author": { "name": "Command Line Inc", "email": "info@commandline.dev" }, "productName": "Wave", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", "version": "0.14.3", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" }, "private": true, "main": "./dist/main/index.js", "type": "module", "browserslist": [ "Chrome >= 128" ], "scripts": { "dev": "electron-vite dev", "start": "electron-vite preview", "build:dev": "electron-vite build --mode development", "build:prod": "electron-vite build --mode production", "coverage": "vitest run --coverage", "test": "vitest", "postinstall": "node ./postinstall.cjs" }, "devDependencies": { "@eslint/js": "^9.39", "@rollup/plugin-node-resolve": "^16.0.3", "@tailwindcss/vite": "^4.2.1", "@types/color": "^4.2.0", "@types/css-tree": "^2", "@types/debug": "^4", "@types/node": "^22.13.17", "@types/papaparse": "^5", "@types/pngjs": "^6.0.5", "@types/prop-types": "^15", "@types/react": "19", "@types/react-dom": "19", "@types/semver": "^7", "@types/shell-quote": "^1", "@types/sprintf-js": "^1", "@types/throttle-debounce": "^5", "@types/tinycolor2": "^1", "@types/ws": "^8", "@vitejs/plugin-react-swc": "4.2.3", "@vitest/coverage-istanbul": "^3.0.9", "electron": "^41.0.2", "electron-builder": "^26.8", "electron-vite": "^5.0", "eslint": "^9.39", "eslint-config-prettier": "^10.1.8", "globals": "^17.4.0", "node-abi": "^4.26.0", "postcss": "^8.5.8", "prettier": "^3.8.1", "prettier-plugin-jsdoc": "^1.8.0", "prettier-plugin-organize-imports": "^4.3.0", "sass": "1.91.0", "tailwindcss": "^4.2.1", "tailwindcss-animate": "^1.0.7", "ts-node": "^10.9.2", "tslib": "^2.8.1", "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56", "vite": "^6.4.1", "vite-plugin-image-optimizer": "^2.0.3", "vite-plugin-svgr": "^4.5.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.9" }, "dependencies": { "@ai-sdk/react": "^2.0.104", "@floating-ui/react": "^0.27.16", "@observablehq/plot": "^0.6.17", "@react-hook/resize-observer": "^2.0.2", "@table-nav/core": "^0.0.7", "@table-nav/react": "^0.0.7", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-serialize": "^0.13.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "ai": "^5.0.92", "base64-js": "^1.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "color": "^4.2.3", "colord": "^2.9.3", "css-tree": "^3.1.0", "dayjs": "^1.11.19", "debug": "^4.4.3", "electron-updater": "^6.6", "env-paths": "^3.0.0", "fast-average-color": "^9.5.0", "htl": "^0.3.1", "html-to-image": "^1.11.13", "immer": "^10.1.1", "jotai": "2.9.3", "mermaid": "^11.12.3", "monaco-editor": "^0.55.1", "monaco-yaml": "^5.4.0", "overlayscrollbars": "^2.14.0", "overlayscrollbars-react": "^0.5.6", "papaparse": "^5.5.3", "parse-srcset": "^1.0.2", "pngjs": "^7.0.0", "prop-types": "^15.8.1", "qs": "^6.15.0", "react": "^19.2.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.0", "react-frame-component": "^5.2.7", "react-markdown": "^9.0.3", "react-resizable-panels": "^3.0.6", "react-zoom-pan-pinch": "^3.7.0", "recharts": "^2.15.4", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "remark-flexible-toc": "^1.2.4", "remark-gfm": "^4.0.1", "remark-github-blockquote-alert": "^1.3.1", "rxjs": "^7.8.2", "semver": "^7.7.3", "shell-quote": "^1.8.3", "shiki": "^3.22.0", "sprintf-js": "^1.1.3", "streamdown": "^1.6.10", "tailwind-merge": "^3.5.0", "throttle-debounce": "^5.0.2", "tinycolor2": "^1.6.0", "unist-util-visit": "^5.1.0", "use-device-pixel-ratio": "^1.1.2", "uuid": "^13.0.0", "winston": "^3.19.0", "ws": "^8.19.0", "yaml": "^2.7.1" }, "packageManager": "npm@10.9.2", "workspaces": [ "docs", "tsunami/frontend" ] } ================================================ FILE: pkg/aiusechat/aiutil/aiutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiutil import ( "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/web/sse" ) // ExtractXmlAttribute extracts an attribute value from an XML-like tag. // Expects double-quoted strings where internal quotes are encoded as ". // Returns the unquoted value and true if found, or empty string and false if not found or invalid. func ExtractXmlAttribute(tag, attrName string) (string, bool) { attrStart := strings.Index(tag, attrName+"=") if attrStart == -1 { return "", false } pos := attrStart + len(attrName+"=") start := strings.Index(tag[pos:], `"`) if start == -1 { return "", false } start += pos end := strings.Index(tag[start+1:], `"`) if end == -1 { return "", false } end += start + 1 quotedValue := tag[start : end+1] value, err := strconv.Unquote(quotedValue) if err != nil { return "", false } value = strings.ReplaceAll(value, """, `"`) return value, true } // GenerateDeterministicSuffix creates an 8-character hash from input strings func GenerateDeterministicSuffix(inputs ...string) string { hasher := sha256.New() for _, input := range inputs { hasher.Write([]byte(input)) } hash := hasher.Sum(nil) return hex.EncodeToString(hash)[:8] } // ExtractImageUrl extracts an image URL from either URL field (http/https/data) or raw Data func ExtractImageUrl(data []byte, url, mimeType string) (string, error) { if url != "" { if !strings.HasPrefix(url, "data:") && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { return "", fmt.Errorf("unsupported URL protocol in file part: %s", url) } return url, nil } if len(data) > 0 { base64Data := base64.StdEncoding.EncodeToString(data) return fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data), nil } return "", fmt.Errorf("file part missing both url and data") } // ExtractTextData extracts text data from either Data field or URL field (data: URLs only) func ExtractTextData(data []byte, url string) ([]byte, error) { if len(data) > 0 { return data, nil } if url != "" { if strings.HasPrefix(url, "data:") { _, decodedData, err := utilfn.DecodeDataURL(url) if err != nil { return nil, fmt.Errorf("failed to decode data URL for text/plain file: %w", err) } return decodedData, nil } return nil, fmt.Errorf("dropping text/plain file with URL (must be fetched and converted to data)") } return nil, fmt.Errorf("text/plain file part missing data") } // FormatAttachedTextFile formats a text file attachment with proper encoding and deterministic suffix func FormatAttachedTextFile(fileName string, textContent []byte) string { if fileName == "" { fileName = "untitled.txt" } encodedFileName := strings.ReplaceAll(fileName, `"`, """) quotedFileName := strconv.Quote(encodedFileName) textStr := string(textContent) deterministicSuffix := GenerateDeterministicSuffix(textStr, fileName) return fmt.Sprintf("\n%s\n", deterministicSuffix, quotedFileName, textStr, deterministicSuffix) } // FormatAttachedDirectoryListing formats a directory listing attachment with proper encoding and deterministic suffix func FormatAttachedDirectoryListing(directoryName, jsonContent string) string { if directoryName == "" { directoryName = "unnamed-directory" } encodedDirName := strings.ReplaceAll(directoryName, `"`, """) quotedDirName := strconv.Quote(encodedDirName) deterministicSuffix := GenerateDeterministicSuffix(jsonContent, directoryName) return fmt.Sprintf("\n%s\n", deterministicSuffix, quotedDirName, jsonContent, deterministicSuffix) } // ConvertDataUserFile converts OpenAI attached file/directory blocks to UIMessagePart // Returns (found, part) where found indicates if the prefix was matched, // and part is the converted UIMessagePart (can be nil if parsing failed) func ConvertDataUserFile(blockText string) (bool, *uctypes.UIMessagePart) { if strings.HasPrefix(blockText, " len(prefix) { if model[len(prefix)] >= '0' && model[len(prefix)] <= '9' { return true } } return false } // GeminiSupportsImageToolResults returns true if the model supports multimodal function responses (images in tool results) // This is only supported by Gemini 3 Pro and later models func GeminiSupportsImageToolResults(model string) bool { m := strings.ToLower(model) return strings.Contains(m, "gemini-3") || strings.Contains(m, "gemini-4") } // CreateToolUseData creates a UIMessageDataToolUse from tool call information func CreateToolUseData(toolCallID, toolName string, arguments string, chatOpts uctypes.WaveChatOpts) uctypes.UIMessageDataToolUse { toolUseData := uctypes.UIMessageDataToolUse{ ToolCallId: toolCallID, ToolName: toolName, Status: uctypes.ToolUseStatusPending, } toolDef := chatOpts.GetToolDefinition(toolName) if toolDef == nil { toolUseData.Status = uctypes.ToolUseStatusError toolUseData.ErrorMessage = "tool not found" return toolUseData } var parsedArgs any if err := json.Unmarshal([]byte(arguments), &parsedArgs); err != nil { toolUseData.Status = uctypes.ToolUseStatusError toolUseData.ErrorMessage = fmt.Sprintf("failed to parse tool arguments: %v", err) return toolUseData } if toolDef.ToolCallDesc != nil { toolUseData.ToolDesc = toolDef.ToolCallDesc(parsedArgs, nil, nil) } if toolDef.ToolApproval != nil { toolUseData.Approval = toolDef.ToolApproval(parsedArgs) } if chatOpts.TabId != "" { if argsMap, ok := parsedArgs.(map[string]any); ok { if widgetId, ok := argsMap["widget_id"].(string); ok && widgetId != "" { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() fullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, chatOpts.TabId, widgetId) if err == nil { toolUseData.BlockId = fullBlockId } } } } return toolUseData } // SendToolProgress sends tool progress updates via SSE if the tool has a progress descriptor func SendToolProgress(toolCallID, toolName string, jsonData []byte, chatOpts uctypes.WaveChatOpts, sseHandler *sse.SSEHandlerCh, usePartialParse bool) { toolDef := chatOpts.GetToolDefinition(toolName) if toolDef == nil || toolDef.ToolProgressDesc == nil { return } var parsedJSON any var err error if usePartialParse { parsedJSON, err = utilfn.ParsePartialJson(jsonData) } else { err = json.Unmarshal(jsonData, &parsedJSON) } if err != nil { return } statusLines, err := toolDef.ToolProgressDesc(parsedJSON) if err != nil { return } progressData := &uctypes.UIMessageDataToolProgress{ ToolCallId: toolCallID, ToolName: toolName, StatusLines: statusLines, } _ = sseHandler.AiMsgData("data-toolprogress", "progress-"+toolCallID, progressData) } ================================================ FILE: pkg/aiusechat/anthropic/anthropic-backend.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package anthropic import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" "github.com/google/uuid" "github.com/launchdarkly/eventsource" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/web/sse" ) const ( AnthropicDefaultAPIVersion = "2023-06-01" AnthropicDefaultMaxTokens = 4096 AnthropicThinkingBudget = 1024 AnthropicMinThinkingBudget = 1024 ProviderMetadataThinkingSignatureKey = "anthropic:signature" ) // ---------- Anthropic wire types (subset) ---------- // Derived from anthropic-messages-api.md and anthropic-streaming.md. :contentReference[oaicite:6]{index=6} :contentReference[oaicite:7]{index=7} type anthropicChatMessage struct { MessageId string `json:"messageid"` // internal field for idempotency (cannot send to anthropic) Usage *anthropicUsageType `json:"usage,omitempty"` // internal field (cannot send to anthropic) Role string `json:"role"` Content []anthropicMessageContentBlock `json:"content"` } func (m *anthropicChatMessage) GetMessageId() string { return m.MessageId } func (m *anthropicChatMessage) GetRole() string { return m.Role } func (m *anthropicChatMessage) GetUsage() *uctypes.AIUsage { if m.Usage == nil { return nil } return &uctypes.AIUsage{ APIType: uctypes.APIType_AnthropicMessages, Model: m.Usage.Model, InputTokens: m.Usage.InputTokens, OutputTokens: m.Usage.OutputTokens, NativeWebSearchCount: m.Usage.NativeWebSearchCount, } } type anthropicInputMessage struct { Role string `json:"role"` Content []anthropicMessageContentBlock `json:"content"` } type anthropicMessageContentBlock struct { // text, image, document, tool_use, tool_result, thinking, redacted_thinking, // server_tool_use, web_search_tool_result, code_execution_tool_result, // mcp_tool_use, mcp_tool_result, container_upload, search_result, web_search_result Type string `json:"type"` CacheControl *anthropicCacheControl `json:"cache_control,omitempty"` // Text content Text string `json:"text,omitempty"` // not going to support citations now // Citations []anthropicCitation `json:"citations,omitempty"` // Image+File content Source *anthropicSource `json:"source,omitempty"` SourcePreviewUrl string `json:"sourcepreviewurl,omitempty"` // internal field (cannot marshal to API, must be stripped) // Document content Title string `json:"title,omitempty"` Context string `json:"context,omitempty"` // Tool use content ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input interface{} `json:"input,omitempty"` ToolUseDisplayName string `json:"toolusedisplayname,omitempty"` // internal field (cannot marshal to API, must be stripped) ToolUseShortDescription string `json:"tooluseshortdescription,omitempty"` // internal field (cannot marshal to API, must be stripped) ToolUseData *uctypes.UIMessageDataToolUse `json:"toolusedata,omitempty"` // internal field (cannot marshal to API, must be stripped) // Tool result content ToolUseID string `json:"tool_use_id,omitempty"` IsError bool `json:"is_error,omitempty"` Content interface{} `json:"content,omitempty"` // string or []blocks for tool results // Thinking content (extended thinking feature) Thinking string `json:"thinking,omitempty"` Signature string `json:"signature,omitempty"` // Server tool use/MCP (web search, code execution, MCP tools) ServerName string `json:"server_name,omitempty"` // Container upload FileID string `json:"file_id,omitempty"` // Web search result (for responses) URL string `json:"url,omitempty"` EncryptedContent string `json:"encrypted_content,omitempty"` PageAge string `json:"page_age,omitempty"` // Code execution results ReturnCode int `json:"return_code,omitempty"` Stdout string `json:"stdout,omitempty"` Stderr string `json:"stderr,omitempty"` } type anthropicSource struct { Type string `json:"type"` // "base64", "url", "file", "text", "content" Data string `json:"data,omitempty"` MediaType string `json:"media_type,omitempty"` // MIME type URL string `json:"url,omitempty"` // URL reference FileID string `json:"file_id,omitempty"` // file upload ID Text string `json:"text,omitempty"` // plain text (documents only) Content interface{} `json:"content,omitempty"` // content blocks (documents only) FileName string `json:"filename,omitempty"` // internal field (cannot marshal to API, must be stripped) Size int `json:"size,omitempty"` // internal field (cannot marshal to API, must be stripped) } func (s *anthropicSource) Clean() *anthropicSource { if s == nil { return nil } rtn := *s rtn.FileName = "" rtn.Size = 0 return &rtn } func (b *anthropicMessageContentBlock) Clean() *anthropicMessageContentBlock { if b == nil { return nil } rtn := *b rtn.SourcePreviewUrl = "" rtn.ToolUseDisplayName = "" rtn.ToolUseShortDescription = "" rtn.ToolUseData = nil if rtn.Source != nil { rtn.Source = rtn.Source.Clean() } return &rtn } type anthropicCitation struct { Type string `json:"type"` CitedText string `json:"cited_text"` DocumentIndex int `json:"document_index,omitempty"` DocumentTitle string `json:"document_title,omitempty"` StartCharIndex int `json:"start_char_index,omitempty"` EndCharIndex int `json:"end_char_index,omitempty"` // ... other citation type fields } type anthropicStreamRequest struct { Model string `json:"model"` Messages []anthropicInputMessage `json:"messages"` MaxTokens int `json:"max_tokens"` Stream bool `json:"stream"` System []anthropicMessageContentBlock `json:"system,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` Tools []any `json:"tools,omitempty"` // *uctypes.ToolDefinition or *anthropicWebSearchTool Thinking *anthropicThinkingOpts `json:"thinking,omitempty"` } type anthropicWebSearchTool struct { Type string `json:"type"` // "web_search_20250305" Name string `json:"name"` // "web_search" } type anthropicCacheControl struct { Type string `json:"type"` // "ephemeral" TTL string `json:"ttl"` // "5m" or "1h" } type anthropicMessageObj struct { ID string `json:"id"` Model string `json:"model"` StopReason *string `json:"stop_reason"` StopSequence *string `json:"stop_sequence"` } type anthropicContentBlockType struct { Type string `json:"type"` Text string `json:"text,omitempty"` Thinking string `json:"thinking,omitempty"` ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input json.RawMessage `json:"input,omitempty"` } type anthropicDeltaType struct { Type string `json:"type"` Text string `json:"text,omitempty"` // text_delta.text Thinking string `json:"thinking,omitempty"` // thinking_delta.thinking PartialJSON string `json:"partial_json,omitempty"` Signature string `json:"signature,omitempty"` StopReason *string `json:"stop_reason,omitempty"` // message_delta.delta.stop_reason StopSeq *string `json:"stop_sequence,omitempty"` // message_delta.delta.stop_sequence } type anthropicCacheCreationType struct { Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens,omitempty"` // default: 0 Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens,omitempty"` // default: 0 } type anthropicServerToolUseType struct { WebFetchRequests int `json:"web_fetch_requests,omitempty"` // default: 0 WebSearchRequests int `json:"web_search_requests,omitempty"` // default: 0 } type anthropicUsageType struct { InputTokens int `json:"input_tokens,omitempty"` // cumulative OutputTokens int `json:"output_tokens,omitempty"` // cumulative CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` // internal fields for Wave use (not sent to API) Model string `json:"model,omitempty"` NativeWebSearchCount int `json:"nativewebsearchcount,omitempty"` // for reference, but we dont keep thsese up to date or track them CacheCreation *anthropicCacheCreationType `json:"cache_creation,omitempty"` // breakdown of cached tokens by TTL ServerToolUse *anthropicServerToolUseType `json:"server_tool_use,omitempty"` // server tool requests ServiceTier *string `json:"service_tier,omitempty"` // standard, priority, or batch } type anthropicErrorType struct { Type string `json:"type"` Message string `json:"message"` } type anthropicHTTPErrorResponse struct { Type string `json:"type"` Error anthropicErrorType `json:"error"` } type anthropicFullStreamEvent struct { Type string `json:"type"` Message *anthropicMessageObj `json:"message,omitempty"` Index *int `json:"index,omitempty"` ContentBlock *anthropicContentBlockType `json:"content_block,omitempty"` Delta *anthropicDeltaType `json:"delta,omitempty"` Usage *anthropicUsageType `json:"usage,omitempty"` Error *anthropicErrorType `json:"error,omitempty"` } type anthropicThinkingOpts struct { Type string `json:"type"` BudgetTokens int `json:"budget_tokens"` } // ---------- per-index content block bookkeeping ---------- type blockKind int const ( blockText blockKind = iota blockThinking blockToolUse ) type blockState struct { kind blockKind // For text/reasoning: local SSE id localID string // Content block being built for rtnMessage contentBlock *anthropicMessageContentBlock // For tool_use: toolCallID string // Anthropic tool_use.id toolName string accumJSON *partialJSON // accumulator for input_json_delta } // partialJSON is a minimal, allocation-friendly accumulator for Anthropic // input_json_delta (concat, then parse once on content_block_stop). :contentReference[oaicite:8]{index=8} type partialJSON struct { buf bytes.Buffer } type streamingState struct { blockMap map[int]*blockState toolCalls []uctypes.WaveToolCall stopFromDelta string msgID string model string stepStarted bool rtnMessage *anthropicChatMessage usage *anthropicUsageType chatOpts uctypes.WaveChatOpts webSearchCount int } func (p *partialJSON) Write(s string) { // The stream may send empty "" chunks; ignore if zero-length if s == "" { return } p.buf.WriteString(s) } func (p *partialJSON) Bytes() []byte { return p.buf.Bytes() } func (p *partialJSON) FinalObject() (json.RawMessage, error) { raw := p.buf.Bytes() // If empty, treat as "{}" if len(bytes.TrimSpace(raw)) == 0 { return json.RawMessage(`{}`), nil } // The accumulated content should be a valid JSON object string; parse it. var v interface{} if err := json.Unmarshal(raw, &v); err != nil { return nil, fmt.Errorf("invalid accumulated tool input JSON: %w", err) } // Ensure it's an object per Anthropic contract switch v.(type) { case map[string]interface{}: return json.RawMessage(raw), nil default: return nil, fmt.Errorf("tool input is not an object") } } // sanitizeHostnameInError removes the Wave cloud hostname from error messages func sanitizeHostnameInError(err error) error { if err == nil { return nil } errStr := err.Error() parsedURL, parseErr := url.Parse(uctypes.DefaultAIEndpoint) if parseErr == nil && parsedURL.Host != "" && strings.Contains(errStr, parsedURL.Host) { errStr = strings.ReplaceAll(errStr, uctypes.DefaultAIEndpoint, "AI service") errStr = strings.ReplaceAll(errStr, parsedURL.Host, "host") } return fmt.Errorf("%s", errStr) } // makeThinkingOpts creates thinking options based on level and max tokens func makeThinkingOpts(thinkingLevel string, maxTokens int) *anthropicThinkingOpts { if thinkingLevel != uctypes.ThinkingLevelMedium && thinkingLevel != uctypes.ThinkingLevelHigh { return nil } maxThinkingBudget := int(float64(maxTokens) * 0.75) // If 75% of maxTokens is less than minimum, disable thinking if maxThinkingBudget < AnthropicMinThinkingBudget { return nil } // Use the smaller of our default budget or 75% of maxTokens thinkingBudget := AnthropicThinkingBudget if thinkingBudget > maxThinkingBudget { thinkingBudget = maxThinkingBudget } return &anthropicThinkingOpts{ Type: "enabled", BudgetTokens: thinkingBudget, } } // ---------- Public entrypoint ---------- // // Mapping rules recap (Anthropic → AI‑SDK): // - message_start → AiMsgStart + AiMsgStartStep // - content_block_start(type=text) → AiMsgTextStart; text_delta → AiMsgTextDelta; content_block_stop → AiMsgTextEnd // - content_block_start(type=thinking) → AiMsgReasoningStart; thinking_delta → AiMsgReasoningDelta; stop → AiMsgReasoningEnd // - content_block_start(type=tool_use) → AiMsgToolInputStart; input_json_delta → AiMsgToolInputDelta; stop → AiMsgToolInputAvailable // - If final stop_reason == "tool_use": emit AiMsgFinishStep and return StopReason{Kind:ToolUse, ...} WITHOUT AiMsgFinish // - If message_stop with stop_reason == "end_turn" or nil: emit AiMsgFinish then [DONE] // - On Anthropic error event: AiMsgError and return StopKindError. :contentReference[oaicite:9]{index=9} :contentReference[oaicite:10]{index=10} // parseAnthropicHTTPError parses Anthropic API HTTP error responses func parseAnthropicHTTPError(resp *http.Response) error { slurp, _ := io.ReadAll(resp.Body) // Try to parse as Anthropic error format first var eresp anthropicHTTPErrorResponse if err := json.Unmarshal(slurp, &eresp); err == nil && eresp.Error.Message != "" { return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, eresp.Error.Message)) } // Try to parse as proxy error format var proxyErr uctypes.ProxyErrorResponse if err := json.Unmarshal(slurp, &proxyErr); err == nil && !proxyErr.Success && proxyErr.Error != "" { return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, proxyErr.Error)) } // Fall back to truncated raw response msg := utilfn.TruncateString(strings.TrimSpace(string(slurp)), 120) if msg == "" { msg = "unknown error" } return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, msg)) } func RunAnthropicChatStep( ctx context.Context, sse *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, *anthropicChatMessage, *uctypes.RateLimitInfo, error) { if sse == nil { return nil, nil, nil, errors.New("sse handler is nil") } // Get chat from store chat := chatstore.DefaultChatStore.Get(chatOpts.ChatId) if chat == nil { return nil, nil, nil, fmt.Errorf("chat not found: %s", chatOpts.ChatId) } // Validate that chatOpts.Config match the chat's stored configuration if chat.APIType != chatOpts.Config.APIType { return nil, nil, nil, fmt.Errorf("API type mismatch: chat has %s, chatOpts has %s", chat.APIType, chatOpts.Config.APIType) } if !uctypes.AreModelsCompatible(chat.APIType, chat.Model, chatOpts.Config.Model) { return nil, nil, nil, fmt.Errorf("model mismatch: chat has %s, chatOpts has %s", chat.Model, chatOpts.Config.Model) } if chat.APIVersion != chatOpts.Config.APIVersion { return nil, nil, nil, fmt.Errorf("API version mismatch: chat has %s, chatOpts has %s", chat.APIVersion, chatOpts.Config.APIVersion) } // Context with timeout if provided. if chatOpts.Config.TimeoutMs > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(chatOpts.Config.TimeoutMs)*time.Millisecond) defer cancel() } // Validate continuation if provided if cont != nil { if !uctypes.AreModelsCompatible(chat.APIType, chatOpts.Config.Model, cont.Model) { return nil, nil, nil, fmt.Errorf("cannot continue with a different model, model:%q, cont-model:%q", chatOpts.Config.Model, cont.Model) } } // Convert GenAIMessages to anthropicInputMessages var anthropicMsgs []anthropicInputMessage for _, genMsg := range chat.NativeMessages { // Cast to anthropicChatMessage chatMsg, ok := genMsg.(*anthropicChatMessage) if !ok { return nil, nil, nil, fmt.Errorf("expected anthropicChatMessage, got %T", genMsg) } // Convert to anthropicInputMessage with copied content contentCopy := make([]anthropicMessageContentBlock, len(chatMsg.Content)) copy(contentCopy, chatMsg.Content) inputMsg := anthropicInputMessage{ Role: chatMsg.Role, Content: contentCopy, } anthropicMsgs = append(anthropicMsgs, inputMsg) } req, err := buildAnthropicHTTPRequest(ctx, anthropicMsgs, chatOpts) if err != nil { return nil, nil, nil, err } httpClient, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL) if err != nil { return nil, nil, nil, err } resp, err := httpClient.Do(req) if err != nil { return nil, nil, nil, sanitizeHostnameInError(err) } defer resp.Body.Close() // Parse rate limit info from header if present (do this before error check) rateLimitInfo := uctypes.ParseRateLimitHeader(resp.Header.Get("X-Wave-RateLimit")) ct := resp.Header.Get("Content-Type") if resp.StatusCode != http.StatusOK || !strings.HasPrefix(ct, "text/event-stream") { // Handle 429 rate limit with special logic if resp.StatusCode == http.StatusTooManyRequests && rateLimitInfo != nil { if rateLimitInfo.PReq == 0 && rateLimitInfo.Req > 0 { // Premium requests exhausted, but regular requests available stopReason := &uctypes.WaveStopReason{ Kind: uctypes.StopKindPremiumRateLimit, } return stopReason, nil, rateLimitInfo, nil } if rateLimitInfo.Req == 0 { // All requests exhausted stopReason := &uctypes.WaveStopReason{ Kind: uctypes.StopKindRateLimit, } return stopReason, nil, rateLimitInfo, nil } } return nil, nil, rateLimitInfo, parseAnthropicHTTPError(resp) } // At this point we have a valid SSE stream, so setup SSE handling // From here on, errors must be returned through the SSE stream if cont == nil { sse.SetupSSE() } // Use eventsource decoder for proper SSE parsing decoder := eventsource.NewDecoder(resp.Body) stopReason, rtnMessage := handleAnthropicStreamingResp(ctx, sse, decoder, cont, chatOpts) return stopReason, rtnMessage, rateLimitInfo, nil } // handleAnthropicStreamingResp processes the SSE stream after HTTP setup is complete func handleAnthropicStreamingResp( ctx context.Context, sse *sse.SSEHandlerCh, decoder *eventsource.Decoder, cont *uctypes.WaveContinueResponse, chatOpts uctypes.WaveChatOpts, ) (*uctypes.WaveStopReason, *anthropicChatMessage) { // Per-response state state := &streamingState{ blockMap: map[int]*blockState{}, rtnMessage: &anthropicChatMessage{ MessageId: uuid.New().String(), Role: "assistant", Content: []anthropicMessageContentBlock{}, }, chatOpts: chatOpts, } var rtnStopReason *uctypes.WaveStopReason // Ensure step is closed on error/cancellation defer func() { // Set usage in the returned message if state.usage != nil { state.usage.Model = state.model if state.webSearchCount > 0 { state.usage.NativeWebSearchCount = state.webSearchCount } state.rtnMessage.Usage = state.usage } if !state.stepStarted { return } _ = sse.AiMsgFinishStep() if rtnStopReason == nil || rtnStopReason.Kind != uctypes.StopKindToolUse { _ = sse.AiMsgFinish("", nil) } }() // SSE event processing loop for { // Check for context cancellation if err := ctx.Err(); err != nil { _ = sse.AiMsgError("request cancelled") return &uctypes.WaveStopReason{ Kind: uctypes.StopKindCanceled, ErrorType: "cancelled", ErrorText: "request cancelled", }, state.rtnMessage } event, err := decoder.Decode() if err != nil { if errors.Is(err, io.EOF) { // Normal end of stream break } if sse.Err() != nil { return &uctypes.WaveStopReason{ Kind: uctypes.StopKindCanceled, ErrorType: "client_disconnect", ErrorText: "client disconnected", }, extractPartialTextFromState(state) } // transport error mid-stream _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{ Kind: uctypes.StopKindError, ErrorType: "stream", ErrorText: err.Error(), }, state.rtnMessage } if stop, ret := handleAnthropicEvent(event, sse, state, cont); ret != nil { // Either error or message_stop triggered return rtnStopReason = ret return ret, state.rtnMessage } else { // maybe updated final stop reason (from message_delta) if stop != nil && *stop != "" { state.stopFromDelta = *stop } } } // EOF - let defer handle cleanup rtnStopReason = &uctypes.WaveStopReason{ Kind: uctypes.StopKindDone, RawReason: state.stopFromDelta, } return rtnStopReason, state.rtnMessage } func extractPartialTextFromState(state *streamingState) *anthropicChatMessage { var content []anthropicMessageContentBlock for _, block := range state.rtnMessage.Content { if block.Type == "text" && block.Text != "" { content = append(content, block) } } var partialIdx []int for idx, st := range state.blockMap { if st.kind == blockText && st.contentBlock != nil && st.contentBlock.Text != "" { partialIdx = append(partialIdx, idx) } } sort.Ints(partialIdx) for _, idx := range partialIdx { st := state.blockMap[idx] if st.kind == blockText && st.contentBlock != nil && st.contentBlock.Text != "" { content = append(content, *st.contentBlock) } } if len(content) == 0 { return nil } return &anthropicChatMessage{ MessageId: state.rtnMessage.MessageId, Role: "assistant", Content: content, Usage: state.rtnMessage.Usage, } } // handleAnthropicEvent processes one SSE event block. It may emit SSE parts // and/or return a StopReason when the stream is complete. // // Return tuple: // - stopFromDelta: a *string with stop reason when message_delta updates stop_reason // - final: a *StopReason to return immediately (e.g., after message_stop or error) // // Event model: anthropic-streaming.md. :contentReference[oaicite:16]{index=16} func handleAnthropicEvent( event eventsource.Event, sse *sse.SSEHandlerCh, state *streamingState, cont *uctypes.WaveContinueResponse, ) (stopFromDelta *string, final *uctypes.WaveStopReason) { if err := sse.Err(); err != nil { return nil, &uctypes.WaveStopReason{ Kind: uctypes.StopKindCanceled, ErrorType: "client_disconnect", ErrorText: "client disconnected", } } eventName := event.Event() data := event.Data() switch eventName { case "ping": return nil, nil // ignore case "error": // Example: data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}} :contentReference[oaicite:17]{index=17} var ev anthropicFullStreamEvent if jerr := json.Unmarshal([]byte(data), &ev); jerr != nil { err := fmt.Errorf("error event decode: %w", jerr) _ = sse.AiMsgError(err.Error()) return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()} } msg := "unknown error" etype := "error" if ev.Error != nil { msg = ev.Error.Message etype = ev.Error.Type } _ = sse.AiMsgError(msg) return nil, &uctypes.WaveStopReason{ Kind: uctypes.StopKindError, ErrorType: etype, ErrorText: msg, } case "message_start": var ev anthropicFullStreamEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()} } if ev.Message != nil { state.msgID = ev.Message.ID state.model = ev.Message.Model } // Initialize usage from message_start event if ev.Usage != nil { state.usage = ev.Usage } if cont == nil { _ = sse.AiMsgStart(state.msgID) } _ = sse.AiMsgStartStep() state.stepStarted = true return nil, nil case "content_block_start": var ev anthropicFullStreamEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()} } if ev.Index == nil || ev.ContentBlock == nil { return nil, nil } idx := *ev.Index switch ev.ContentBlock.Type { case "text": id := uuid.New().String() state.blockMap[idx] = &blockState{ kind: blockText, localID: id, contentBlock: &anthropicMessageContentBlock{ Type: "text", Text: "", }, } _ = sse.AiMsgTextStart(id) case "thinking": id := uuid.New().String() state.blockMap[idx] = &blockState{ kind: blockThinking, localID: id, contentBlock: &anthropicMessageContentBlock{ Type: "thinking", Thinking: "", }, } _ = sse.AiMsgReasoningStart(id) case "tool_use": tcID := ev.ContentBlock.ID tName := ev.ContentBlock.Name st := &blockState{ kind: blockToolUse, toolCallID: tcID, toolName: tName, accumJSON: &partialJSON{}, } state.blockMap[idx] = st _ = sse.AiMsgToolInputStart(tcID, tName) case "server_tool_use": if ev.ContentBlock.Name == "web_search" { state.webSearchCount++ } default: // ignore other block types gracefully per Anthropic guidance :contentReference[oaicite:18]{index=18} } return nil, nil case "content_block_delta": var ev anthropicFullStreamEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()} } if ev.Index == nil || ev.Delta == nil { return nil, nil } st := state.blockMap[*ev.Index] if st == nil { return nil, nil } switch ev.Delta.Type { case "text_delta": if st.kind == blockText { _ = sse.AiMsgTextDelta(st.localID, ev.Delta.Text) // Accumulate text in the content block if st.contentBlock != nil { st.contentBlock.Text += ev.Delta.Text } } case "thinking_delta": if st.kind == blockThinking { _ = sse.AiMsgReasoningDelta(st.localID, ev.Delta.Thinking) // Accumulate thinking content in the content block if st.contentBlock != nil { st.contentBlock.Thinking += ev.Delta.Thinking } } case "input_json_delta": if st.kind == blockToolUse { st.accumJSON.Write(ev.Delta.PartialJSON) _ = sse.AiMsgToolInputDelta(st.toolCallID, ev.Delta.PartialJSON) aiutil.SendToolProgress(st.toolCallID, st.toolName, st.accumJSON.Bytes(), state.chatOpts, sse, true) } case "signature_delta": // Accumulate signature for thinking blocks if st.kind == blockThinking && st.contentBlock != nil { st.contentBlock.Signature += ev.Delta.Signature } default: // ignore unknown deltas gracefully. :contentReference[oaicite:20]{index=20} } return nil, nil case "content_block_stop": var ev anthropicFullStreamEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()} } if ev.Index == nil { return nil, nil } st := state.blockMap[*ev.Index] if st == nil { return nil, nil } switch st.kind { case blockText: _ = sse.AiMsgTextEnd(st.localID) // Add completed text block to rtnMessage if st.contentBlock != nil { state.rtnMessage.Content = append(state.rtnMessage.Content, *st.contentBlock) } case blockThinking: _ = sse.AiMsgReasoningEnd(st.localID) // Add completed thinking block to rtnMessage if st.contentBlock != nil { state.rtnMessage.Content = append(state.rtnMessage.Content, *st.contentBlock) } case blockToolUse: raw, jerr := st.accumJSON.FinalObject() if jerr != nil { _ = sse.AiMsgError(jerr.Error()) return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "parse", ErrorText: jerr.Error()} } var input any if len(raw) > 0 { jerr = json.Unmarshal(raw, &input) if jerr != nil { _ = sse.AiMsgError(jerr.Error()) return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "parse", ErrorText: jerr.Error()} } } _ = sse.AiMsgToolInputAvailable(st.toolCallID, st.toolName, raw) aiutil.SendToolProgress(st.toolCallID, st.toolName, raw, state.chatOpts, sse, false) state.toolCalls = append(state.toolCalls, uctypes.WaveToolCall{ ID: st.toolCallID, Name: st.toolName, Input: input, }) // Add completed tool_use block to rtnMessage toolUseBlock := anthropicMessageContentBlock{ Type: "tool_use", ID: st.toolCallID, Name: st.toolName, Input: input, } state.rtnMessage.Content = append(state.rtnMessage.Content, toolUseBlock) } // extractPartialTextFromState reads blockMap for still-in-flight content, so remove completed blocks // once they have been appended to rtnMessage.Content to avoid duplicate text on disconnect. delete(state.blockMap, *ev.Index) return nil, nil case "message_delta": var ev anthropicFullStreamEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()} } if ev.Delta != nil && ev.Delta.StopReason != nil { stopFromDelta = ev.Delta.StopReason } // Update cumulative usage from message_delta event if ev.Usage != nil { if state.usage == nil { state.usage = &anthropicUsageType{} } // Update the fields we track (cumulative values) if ev.Usage.InputTokens > 0 { state.usage.InputTokens = ev.Usage.InputTokens } if ev.Usage.OutputTokens > 0 { state.usage.OutputTokens = ev.Usage.OutputTokens } if ev.Usage.CacheCreationInputTokens > 0 { state.usage.CacheCreationInputTokens = ev.Usage.CacheCreationInputTokens } if ev.Usage.CacheReadInputTokens > 0 { state.usage.CacheReadInputTokens = ev.Usage.CacheReadInputTokens } } return stopFromDelta, nil case "message_stop": // Decide finalization based on last known stop_reason. // If we didn't capture it in message_delta, treat as end_turn. reason := "end_turn" if state.stopFromDelta != "" { reason = state.stopFromDelta } switch reason { case "tool_use": return nil, &uctypes.WaveStopReason{ Kind: uctypes.StopKindToolUse, RawReason: reason, ToolCalls: state.toolCalls, } case "max_tokens": return nil, &uctypes.WaveStopReason{ Kind: uctypes.StopKindMaxTokens, RawReason: reason, } case "refusal": return nil, &uctypes.WaveStopReason{ Kind: uctypes.StopKindContent, RawReason: reason, } case "pause_turn": return nil, &uctypes.WaveStopReason{ Kind: uctypes.StopKindPauseTurn, RawReason: reason, } default: // end_turn, stop_sequence (treat as end of this call) return nil, &uctypes.WaveStopReason{ Kind: uctypes.StopKindDone, RawReason: reason, } } default: logutil.DevPrintf("unknown anthropic event type: %s", eventName) return nil, nil } } ================================================ FILE: pkg/aiusechat/anthropic/anthropic-backend_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package anthropic import ( "testing" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" ) func TestConvertPartsToAnthropicBlocks_TextOnly(t *testing.T) { parts := []uctypes.UIMessagePart{ {Type: "text", Text: "Hello world"}, {Type: "text", Text: "Default text"}, } blocks, err := convertPartsToAnthropicBlocks(parts, "user") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(blocks) != 2 { t.Fatalf("expected 2 blocks, got %d", len(blocks)) } // Check first block block1 := blocks[0] if block1.Type != "text" { t.Errorf("expected type 'text', got %v", block1.Type) } if block1.Text != "Hello world" { t.Errorf("expected text 'Hello world', got %v", block1.Text) } // Check second block (empty type defaults to text) block2 := blocks[1] if block2.Type != "text" { t.Errorf("expected type 'text', got %v", block2.Type) } if block2.Text != "Default text" { t.Errorf("expected text 'Default text', got %v", block2.Text) } } func TestConvertPartsToAnthropicBlocks_SkipsUnknownTypes(t *testing.T) { parts := []uctypes.UIMessagePart{ {Type: "text", Text: "Valid text"}, {Type: "unknown_type", Text: "Should be skipped"}, {Type: "text", Text: "Another valid text"}, } blocks, err := convertPartsToAnthropicBlocks(parts, "user") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(blocks) != 2 { t.Fatalf("expected 2 blocks (unknown type skipped), got %d", len(blocks)) } block1 := blocks[0] if block1.Text != "Valid text" { t.Errorf("expected first text 'Valid text', got %v", block1.Text) } block2 := blocks[1] if block2.Text != "Another valid text" { t.Errorf("expected second text 'Another valid text', got %v", block2.Text) } } func TestGetFunctionCallInputByToolCallId(t *testing.T) { toolData := &uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusPending} chat := uctypes.AIChat{ NativeMessages: []uctypes.GenAIMessage{ &anthropicChatMessage{ MessageId: "m1", Role: "assistant", Content: []anthropicMessageContentBlock{ {Type: "tool_use", ID: "call-1", Name: "read_file", Input: map[string]interface{}{"path": "/tmp/a"}, ToolUseData: toolData}, }, }, }, } fnCall := GetFunctionCallInputByToolCallId(chat, "call-1") if fnCall == nil { t.Fatalf("expected function call input") } if fnCall.CallId != "call-1" || fnCall.Name != "read_file" { t.Fatalf("unexpected function call input: %#v", fnCall) } if fnCall.Arguments != "{\"path\":\"/tmp/a\"}" { t.Fatalf("unexpected arguments: %s", fnCall.Arguments) } if fnCall.ToolUseData == nil || fnCall.ToolUseData.ToolCallId != "call-1" { t.Fatalf("expected tool use data") } } func TestUpdateAndRemoveToolUseCall(t *testing.T) { chatID := "anthropic-test-tooluse" chatstore.DefaultChatStore.Delete(chatID) defer chatstore.DefaultChatStore.Delete(chatID) aiOpts := &uctypes.AIOptsType{ APIType: uctypes.APIType_AnthropicMessages, Model: "claude-sonnet-4-5", APIVersion: AnthropicDefaultAPIVersion, } msg := &anthropicChatMessage{ MessageId: "m1", Role: "assistant", Content: []anthropicMessageContentBlock{ {Type: "text", Text: "start"}, {Type: "tool_use", ID: "call-1", Name: "read_file", Input: map[string]interface{}{"path": "/tmp/a"}}, }, } if err := chatstore.DefaultChatStore.PostMessage(chatID, aiOpts, msg); err != nil { t.Fatalf("failed to seed chat: %v", err) } newData := uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusCompleted} if err := UpdateToolUseData(chatID, "call-1", newData); err != nil { t.Fatalf("update failed: %v", err) } chat := chatstore.DefaultChatStore.Get(chatID) updated := chat.NativeMessages[0].(*anthropicChatMessage) if updated.Content[1].ToolUseData == nil || updated.Content[1].ToolUseData.Status != uctypes.ToolUseStatusCompleted { t.Fatalf("tool use data not updated") } if err := RemoveToolUseCall(chatID, "call-1"); err != nil { t.Fatalf("remove failed: %v", err) } chat = chatstore.DefaultChatStore.Get(chatID) updated = chat.NativeMessages[0].(*anthropicChatMessage) if len(updated.Content) != 1 || updated.Content[0].Type != "text" { t.Fatalf("expected tool_use block removed, got %#v", updated.Content) } } func TestConvertToUIMessageIncludesToolUseData(t *testing.T) { msg := &anthropicChatMessage{ MessageId: "m1", Role: "assistant", Content: []anthropicMessageContentBlock{ { Type: "tool_use", ID: "call-1", Name: "read_file", Input: map[string]interface{}{"path": "/tmp/a"}, ToolUseData: &uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusPending}, }, }, } ui := msg.ConvertToUIMessage() if ui == nil || len(ui.Parts) != 2 { t.Fatalf("expected tool and data-tooluse parts, got %#v", ui) } if ui.Parts[0].Type != "tool-read_file" || ui.Parts[1].Type != "data-tooluse" { t.Fatalf("unexpected part types: %#v", ui.Parts) } } ================================================ FILE: pkg/aiusechat/anthropic/anthropic-convertmessage.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package anthropic import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "log" "net/http" "regexp" "slices" "strings" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" ) // these conversions are based off the anthropic spec // and the aiprompts/aisdk-uimessage-type.md doc (v5) // buildAnthropicHTTPRequest creates a complete HTTP request for the Anthropic API func buildAnthropicHTTPRequest(ctx context.Context, msgs []anthropicInputMessage, chatOpts uctypes.WaveChatOpts) (*http.Request, error) { opts := chatOpts.Config if opts.Model == "" { return nil, errors.New("ai:model is required") } if chatOpts.ClientId == "" { return nil, errors.New("chatOpts.ClientId is required") } // Set defaults endpoint := opts.Endpoint if endpoint == "" { return nil, errors.New("ai:endpoint is required") } maxTokens := opts.MaxTokens if maxTokens <= 0 { maxTokens = AnthropicDefaultMaxTokens } // Convert messages to clear FileName fields from Source blocks convertedMsgs := make([]anthropicInputMessage, len(msgs)) for i, msg := range msgs { convertedMsgs[i] = convertMessageForAPI(msg) } // inject chatOpts.TabState as a "text" block at the END of the LAST "user" message found (append to Content) if chatOpts.TabState != "" { // Find the last "user" message for i := len(convertedMsgs) - 1; i >= 0; i-- { if convertedMsgs[i].Role == "user" { // Create a text block with the TabState content tabStateBlock := anthropicMessageContentBlock{ Type: "text", Text: chatOpts.TabState, } // Append to the Content of this message convertedMsgs[i].Content = append(convertedMsgs[i].Content, tabStateBlock) break } } } // inject chatOpts.PlatformInfo, AppStaticFiles, and AppGoFile as "text" blocks at the END of the LAST "user" message found (append to Content) if chatOpts.PlatformInfo != "" || chatOpts.AppStaticFiles != "" || chatOpts.AppGoFile != "" { // Find the last "user" message for i := len(convertedMsgs) - 1; i >= 0; i-- { if convertedMsgs[i].Role == "user" { if chatOpts.PlatformInfo != "" { platformInfoBlock := anthropicMessageContentBlock{ Type: "text", Text: "\n" + chatOpts.PlatformInfo + "\n", } convertedMsgs[i].Content = append(convertedMsgs[i].Content, platformInfoBlock) } if chatOpts.AppStaticFiles != "" { appStaticFilesBlock := anthropicMessageContentBlock{ Type: "text", Text: "\n" + chatOpts.AppStaticFiles + "\n", } convertedMsgs[i].Content = append(convertedMsgs[i].Content, appStaticFilesBlock) } if chatOpts.AppGoFile != "" { appGoFileBlock := anthropicMessageContentBlock{ Type: "text", Text: "\n" + chatOpts.AppGoFile + "\n", } convertedMsgs[i].Content = append(convertedMsgs[i].Content, appGoFileBlock) } break } } } // Build request body reqBody := &anthropicStreamRequest{ Model: opts.Model, MaxTokens: maxTokens, Stream: true, Messages: convertedMsgs, } // Add system prompt if provided if len(chatOpts.SystemPrompt) > 0 { systemBlocks := make([]anthropicMessageContentBlock, len(chatOpts.SystemPrompt)) for i, prompt := range chatOpts.SystemPrompt { systemBlocks[i] = anthropicMessageContentBlock{ Type: "text", Text: prompt, } } reqBody.System = systemBlocks } for _, tool := range chatOpts.Tools { cleanedTool := tool.Clean() reqBody.Tools = append(reqBody.Tools, cleanedTool) } for _, tool := range chatOpts.TabTools { cleanedTool := tool.Clean() reqBody.Tools = append(reqBody.Tools, cleanedTool) } if chatOpts.AllowNativeWebSearch { reqBody.Tools = append(reqBody.Tools, &anthropicWebSearchTool{Type: "web_search_20250305", Name: "web_search"}) } // Enable extended thinking based on level reqBody.Thinking = makeThinkingOpts(opts.ThinkingLevel, maxTokens) // pretty print json of anthropicMsgs if jsonStr, err := utilfn.MarshalIndentNoHTMLString(convertedMsgs, "", " "); err == nil { var toolNames []string for _, tool := range chatOpts.Tools { toolNames = append(toolNames, tool.Name) } for _, tool := range chatOpts.TabTools { toolNames = append(toolNames, tool.Name) } if chatOpts.AllowNativeWebSearch { toolNames = append(toolNames, "web_search[server]") } logutil.DevPrintf("tools: %s\n", strings.Join(toolNames, ", ")) logutil.DevPrintf("anthropicMsgs JSON:\n%s", jsonStr) logutil.DevPrintf("has-api-key: %v\n", opts.APIToken != "") } var buf bytes.Buffer encoder := json.NewEncoder(&buf) encoder.SetEscapeHTML(false) err := encoder.Encode(reqBody) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf) if err != nil { return nil, err } req.Header.Set("content-type", "application/json") if opts.APIToken != "" { req.Header.Set("x-api-key", opts.APIToken) } req.Header.Set("anthropic-version", AnthropicDefaultAPIVersion) req.Header.Set("accept", "text/event-stream") // Only send Wave-specific headers when using Wave provider if opts.Provider == uctypes.AIProvider_Wave { if chatOpts.ClientId != "" { req.Header.Set("X-Wave-ClientId", chatOpts.ClientId) } if chatOpts.ChatId != "" { req.Header.Set("X-Wave-ChatId", chatOpts.ChatId) } req.Header.Set("X-Wave-Version", wavebase.WaveVersion) req.Header.Set("X-Wave-APIType", uctypes.APIType_AnthropicMessages) req.Header.Set("X-Wave-RequestType", chatOpts.GetWaveRequestType()) } return req, nil } // convertToolUsePart converts a tool-* type UIMessagePart to an Anthropic tool_use or tool_result block func convertToolUsePart(p uctypes.UIMessagePart) (*anthropicMessageContentBlock, error) { // Sanity check that this is actually a tool-* type if !strings.HasPrefix(p.Type, "tool-") { return nil, fmt.Errorf("convertToolUsePart expects 'tool-*' type, got '%s'", p.Type) } // Extract tool name from type field (format: "tool-{name}") toolName := strings.TrimPrefix(p.Type, "tool-") if toolName == "" { return nil, fmt.Errorf("tool name is empty (type was '%s')", p.Type) } if len(toolName) > 200 { return nil, fmt.Errorf("tool name exceeds 200 character limit: %d characters", len(toolName)) } if p.ToolCallID == "" { return nil, fmt.Errorf("tool call ID is required but missing") } // Validate ToolCallID charset (must match ^[a-zA-Z0-9_-]+$) validIDPattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) if !validIDPattern.MatchString(p.ToolCallID) { return nil, fmt.Errorf("tool call ID contains invalid characters (must be alphanumeric, underscore, or dash): %s", p.ToolCallID) } // Handle different states if p.State == "input-streaming" || p.State == "input-available" { // These states represent tool calls (tool_use blocks) // Anthropic expects an object for input, never nil input := p.Input if input == nil { input = map[string]interface{}{} } else { // Validate that input is an object (map), not string/array if _, ok := input.(map[string]interface{}); !ok { return nil, fmt.Errorf("tool input must be an object/map, got %T", input) } } return &anthropicMessageContentBlock{ Type: "tool_use", ID: p.ToolCallID, Name: toolName, Input: input, }, nil } else if p.State == "output-available" { // This state represents successful tool execution result (tool_result block) var content interface{} if p.Output != nil { // Try to convert output to string if it's not already if outputStr, ok := p.Output.(string); ok { content = outputStr } else { // If it's not a string, marshal it to JSON outputBytes, err := json.Marshal(p.Output) if err != nil { return nil, fmt.Errorf("failed to marshal tool output: %w", err) } content = string(outputBytes) } } else { content = "" } return &anthropicMessageContentBlock{ Type: "tool_result", ToolUseID: p.ToolCallID, Content: content, }, nil } else if p.State == "output-error" { // This state represents failed tool execution (tool_result block with error) errorContent := p.ErrorText if errorContent == "" { errorContent = "Tool execution failed" } return &anthropicMessageContentBlock{ Type: "tool_result", ToolUseID: p.ToolCallID, Content: errorContent, IsError: true, }, nil } else { return nil, fmt.Errorf("invalid tool part state '%s' (must be 'input-streaming', 'input-available', 'output-available', or 'output-error')", p.State) } } // convertPartToAnthropicBlocks converts a single UIMessagePart to one or more Anthropic content blocks func convertPartToAnthropicBlocks(p uctypes.UIMessagePart, role string, blockIndex int) ([]anthropicMessageContentBlock, error) { if p.Type == "text" { return []anthropicMessageContentBlock{{ Type: "text", Text: p.Text, }}, nil } else if p.Type == "reasoning" { // Check if we have a signature in provider metadata signature, hasSignature := p.ProviderMetadata[ProviderMetadataThinkingSignatureKey] if !hasSignature { return nil, fmt.Errorf("reasoning part requires signature in provider metadata key '%s'", ProviderMetadataThinkingSignatureKey) } signatureStr, ok := signature.(string) if !ok { return nil, fmt.Errorf("reasoning part signature must be a string, got %T", signature) } return []anthropicMessageContentBlock{{ Type: "thinking", Thinking: p.Text, Signature: signatureStr, }}, nil } else if p.Type == "source-url" || p.Type == "source-document" { // no longer convert citations return nil, nil } else if p.Type == "step-start" { // Omit step-start parts from Anthropic return nil, nil } else if strings.HasPrefix(p.Type, "data-") { // Omit data-* parts from Anthropic return nil, nil } else if p.Type == "file" { // Anthropic expects files in user messages if role != "user" { return nil, fmt.Errorf("dropping file part in %s message (files should be in user messages)", role) } block, err := convertFileUIMessagePart(p) if err != nil { return nil, err } return []anthropicMessageContentBlock{*block}, nil } else if strings.HasPrefix(p.Type, "tool-") { block, err := convertToolUsePart(p) if err != nil { return nil, err } return []anthropicMessageContentBlock{*block}, nil } else { // Skip unknown part types return nil, fmt.Errorf("dropping unknown part type '%s'", p.Type) } } // convertPartsToAnthropicBlocks converts UseChatMessagePart array to Anthropic content blocks with role-based validation func convertPartsToAnthropicBlocks(parts []uctypes.UIMessagePart, role string) ([]anthropicMessageContentBlock, error) { var blocks []anthropicMessageContentBlock for _, p := range parts { partBlocks, err := convertPartToAnthropicBlocks(p, role, len(blocks)) if err != nil { log.Printf("anthropic: %v", err) continue } blocks = append(blocks, partBlocks...) } return blocks, nil } // convertFileUIMessagePart converts a file part to Anthropic image or document block format func convertFileUIMessagePart(p uctypes.UIMessagePart) (*anthropicMessageContentBlock, error) { if p.Type != "file" { return nil, fmt.Errorf("convertFileUIMessagePart expects 'file' type, got '%s'", p.Type) } if p.URL == "" { return nil, errors.New("file part missing url") } if p.MediaType == "" { return nil, errors.New("file part missing mediaType") } // Validate URL protocol - only allow data:, http:, https: if !strings.HasPrefix(p.URL, "data:") && !strings.HasPrefix(p.URL, "http://") && !strings.HasPrefix(p.URL, "https://") { return nil, fmt.Errorf("unsupported URL protocol in file part: %s", p.URL) } // Branch on mediaType first to determine block type and constraints switch { case strings.HasPrefix(p.MediaType, "image/"): // image/* (jpeg, png, gif, webp) → Anthropic image block if strings.HasPrefix(p.URL, "data:") { // Data URL → base64 source parts := strings.SplitN(p.URL, ",", 2) if len(parts) != 2 { return nil, errors.New("invalid data URL format") } return &anthropicMessageContentBlock{ Type: "image", Source: &anthropicSource{ Type: "base64", Data: parts[1], MediaType: p.MediaType, }, }, nil } else { // HTTP/HTTPS URL → url source (no media_type for image URLs) return &anthropicMessageContentBlock{ Type: "image", Source: &anthropicSource{ Type: "url", URL: p.URL, }, }, nil } case p.MediaType == "application/pdf": // application/pdf → Anthropic document block if strings.HasPrefix(p.URL, "data:") { // Data URL → base64 source parts := strings.SplitN(p.URL, ",", 2) if len(parts) != 2 { return nil, errors.New("invalid data URL format") } return &anthropicMessageContentBlock{ Type: "document", Source: &anthropicSource{ Type: "base64", Data: parts[1], MediaType: p.MediaType, }, }, nil } else { // HTTP/HTTPS URL → url source (no media_type for URL sources) return &anthropicMessageContentBlock{ Type: "document", Source: &anthropicSource{ Type: "url", URL: p.URL, }, }, nil } case p.MediaType == "text/plain": // text/plain → Anthropic document block, but NO URL form supported if strings.HasPrefix(p.URL, "data:") { // Data URL → decode base64 data and return as document with PlainTextSource parts := strings.SplitN(p.URL, ",", 2) if len(parts) != 2 { return nil, errors.New("invalid data URL format") } // Decode base64 data textData, err := base64.StdEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("failed to decode base64 data: %w", err) } return &anthropicMessageContentBlock{ Type: "document", Source: &anthropicSource{ Type: "text", Data: string(textData), MediaType: "text/plain", }, }, nil } else { // HTTP/HTTPS URL → not supported inline, would need to fetch return nil, fmt.Errorf("dropping text/plain file with URL (must be fetched and converted to base64 or uploaded to Files API)") } default: // Other media types → not supported inline, must upload and use file_id return nil, fmt.Errorf("dropping file with unsupported media type '%s' (must be uploaded to Files API and sent as file_id)", p.MediaType) } } // convertAIMessageToAnthropicChatMessage converts an AIMessage to anthropicChatMessage // These messages are ALWAYS role "user" func ConvertAIMessageToAnthropicChatMessage(aiMsg uctypes.AIMessage) (*anthropicChatMessage, error) { if err := aiMsg.Validate(); err != nil { return nil, fmt.Errorf("invalid AIMessage: %w", err) } var contentBlocks []anthropicMessageContentBlock for i, part := range aiMsg.Parts { switch part.Type { case uctypes.AIMessagePartTypeText: if part.Text == "" { return nil, fmt.Errorf("part %d: text type requires non-empty text field", i) } contentBlocks = append(contentBlocks, anthropicMessageContentBlock{ Type: "text", Text: part.Text, }) case uctypes.AIMessagePartTypeFile: block, err := convertFileAIMessagePart(part) if err != nil { return nil, fmt.Errorf("part %d: %w", i, err) } contentBlocks = append(contentBlocks, *block) default: return nil, fmt.Errorf("part %d: unsupported part type '%s'", i, part.Type) } } return &anthropicChatMessage{ MessageId: aiMsg.MessageId, Role: "user", Content: contentBlocks, }, nil } // hasInlineData checks if the part has data available for inline use (either Data field or data URL) func hasInlineData(part uctypes.AIMessagePart) bool { hasData := len(part.Data) > 0 hasURL := part.URL != "" && strings.HasPrefix(part.URL, "data:") return hasData || hasURL } // extractBase64Data extracts base64 data from either the Data field or a data URL func extractBase64Data(part uctypes.AIMessagePart) (string, error) { hasData := len(part.Data) > 0 hasURL := part.URL != "" if hasData { // Raw data → base64 encode return base64.StdEncoding.EncodeToString(part.Data), nil } else if hasURL && strings.HasPrefix(part.URL, "data:") { // Data URL → check format and extract/encode data appropriately parts := strings.SplitN(part.URL, ",", 2) if len(parts) != 2 { return "", errors.New("invalid data URL format") } header := parts[0] data := parts[1] // Check if it's already base64 encoded: data:mediatype;base64, if strings.Contains(header, ";base64") { // Already base64 encoded return data, nil } else { // Raw data that needs base64 encoding: data:mediatype, return base64.StdEncoding.EncodeToString([]byte(data)), nil } } return "", errors.New("no data available for base64 extraction") } // convertFileAIMessagePart converts a file AIMessagePart to anthropicMessageContentBlock func convertFileAIMessagePart(part uctypes.AIMessagePart) (*anthropicMessageContentBlock, error) { if part.Type != uctypes.AIMessagePartTypeFile { return nil, fmt.Errorf("convertFileAIMessagePart expects 'file' type, got '%s'", part.Type) } if err := part.Validate(); err != nil { return nil, err } // Validate URL protocol if URL is provided - only allow data:, http:, https: if part.URL != "" { if !strings.HasPrefix(part.URL, "data:") && !strings.HasPrefix(part.URL, "http://") && !strings.HasPrefix(part.URL, "https://") { return nil, fmt.Errorf("unsupported URL protocol in file part: %s", part.URL) } } // Branch on mimetype to determine block type and constraints switch { case strings.HasPrefix(part.MimeType, "image/"): // image/* (jpeg, png, gif, webp) → Anthropic image block if hasInlineData(part) { // Data available → use base64 source base64Data, err := extractBase64Data(part) if err != nil { return nil, err } return &anthropicMessageContentBlock{ Type: "image", Source: &anthropicSource{ Type: "base64", Data: base64Data, MediaType: part.MimeType, FileName: part.FileName, }, SourcePreviewUrl: part.PreviewUrl, }, nil } else { // HTTP/HTTPS URL → url source (no media_type for image URLs) return &anthropicMessageContentBlock{ Type: "image", Source: &anthropicSource{ Type: "url", URL: part.URL, FileName: part.FileName, }, SourcePreviewUrl: part.PreviewUrl, }, nil } case part.MimeType == "application/pdf": // application/pdf → Anthropic document block if hasInlineData(part) { // Data available → use base64 source base64Data, err := extractBase64Data(part) if err != nil { return nil, err } return &anthropicMessageContentBlock{ Type: "document", Source: &anthropicSource{ Type: "base64", Data: base64Data, MediaType: part.MimeType, FileName: part.FileName, }, SourcePreviewUrl: part.PreviewUrl, }, nil } else { // HTTP/HTTPS URL → url source (no media_type for URL sources) return &anthropicMessageContentBlock{ Type: "document", Source: &anthropicSource{ Type: "url", URL: part.URL, FileName: part.FileName, }, SourcePreviewUrl: part.PreviewUrl, }, nil } case part.MimeType == "text/plain": // text/plain → Anthropic document block, but NO URL form supported if hasInlineData(part) { var textData string if len(part.Data) > 0 { // Raw data → convert to string directly textData = string(part.Data) } else { // Data URL → extract base64 data and decode back to string base64Data, err := extractBase64Data(part) if err != nil { return nil, err } decoded, err := base64.StdEncoding.DecodeString(base64Data) if err != nil { return nil, fmt.Errorf("failed to decode base64 data: %w", err) } textData = string(decoded) } return &anthropicMessageContentBlock{ Type: "document", Source: &anthropicSource{ Type: "text", Data: textData, MediaType: part.MimeType, FileName: part.FileName, }, }, nil } else { // HTTP/HTTPS URL → not supported inline, would need to fetch return nil, fmt.Errorf("text/plain file with URL not supported (must be fetched and converted to base64 or uploaded to Files API)") } default: // Other media types → not supported inline, must upload and use file_id return nil, fmt.Errorf("unsupported media type '%s' (must be uploaded to Files API and sent as file_id)", part.MimeType) } } // ConvertToUIMessage converts an anthropicChatMessage to a UIMessage func (m *anthropicChatMessage) ConvertToUIMessage() *uctypes.UIMessage { var parts []uctypes.UIMessagePart // Iterate over all content blocks for _, block := range m.Content { switch block.Type { case "text": // Convert text blocks to UIMessagePart parts = append(parts, uctypes.UIMessagePart{ Type: "text", Text: block.Text, }) case "image": // Convert image blocks to data-userfile UIMessagePart (only for user role) if m.Role == "user" && block.Source != nil { parts = append(parts, uctypes.UIMessagePart{ Type: "data-userfile", Data: uctypes.UIMessageDataUserFile{ FileName: block.Source.FileName, Size: block.Source.Size, MimeType: block.Source.MediaType, PreviewUrl: block.SourcePreviewUrl, }, }) } case "document": // Convert document blocks to data-userfile UIMessagePart (only for user role) if m.Role == "user" && block.Source != nil { parts = append(parts, uctypes.UIMessagePart{ Type: "data-userfile", Data: uctypes.UIMessageDataUserFile{ FileName: block.Source.FileName, Size: block.Source.Size, MimeType: block.Source.MediaType, PreviewUrl: block.SourcePreviewUrl, }, }) } case "tool_use": // Convert tool_use blocks to tool UIMessagePart with input-available state if block.Name != "" && block.ID != "" { parts = append(parts, uctypes.UIMessagePart{ Type: "tool-" + block.Name, State: "input-available", ToolCallID: block.ID, Input: block.Input, }) if block.ToolUseData != nil { parts = append(parts, uctypes.UIMessagePart{ Type: "data-tooluse", ID: block.ID, Data: *block.ToolUseData, }) } } default: // For now, skip all other types (will implement later) continue } } if len(parts) == 0 { return nil } return &uctypes.UIMessage{ ID: m.MessageId, Role: m.Role, Parts: parts, } } // convertMessageForAPI creates a copy of the anthropicInputMessage with internal fields stripped from content blocks func convertMessageForAPI(msg anthropicInputMessage) anthropicInputMessage { // Create a copy of the message converted := anthropicInputMessage{ Role: msg.Role, Content: make([]anthropicMessageContentBlock, len(msg.Content)), } // Copy each content block and clean it (strips internal fields) for i, block := range msg.Content { converted.Content[i] = *block.Clean() } return converted } // ConvertToolResultsToAnthropicChatMessage converts AIToolResult slice to anthropicChatMessage func ConvertToolResultsToAnthropicChatMessage(toolResults []uctypes.AIToolResult) (*anthropicChatMessage, error) { if len(toolResults) == 0 { return nil, errors.New("toolResults cannot be empty") } var contentBlocks []anthropicMessageContentBlock for _, result := range toolResults { if result.ToolUseID == "" { return nil, fmt.Errorf("tool result missing ToolUseID") } var content interface{} var isError bool if result.ErrorText != "" { content = result.ErrorText isError = true } else { // Check if text looks like an image data URL if strings.HasPrefix(result.Text, "data:image/") { // Parse the data URL to extract media type and base64 data parts := strings.SplitN(result.Text, ",", 2) if len(parts) == 2 { // Extract media type from "data:image/png;base64" mediaTypePart := strings.TrimPrefix(parts[0], "data:") mediaType := strings.Split(mediaTypePart, ";")[0] // Create content as array with image block content = []anthropicMessageContentBlock{ { Type: "image", Source: &anthropicSource{ Type: "base64", Data: parts[1], MediaType: mediaType, }, }, } isError = false } else { // Failed to parse data URL content = "failed to parse image data URL" isError = true } } else { content = result.Text isError = false } } contentBlocks = append(contentBlocks, anthropicMessageContentBlock{ Type: "tool_result", ToolUseID: result.ToolUseID, Content: content, IsError: isError, }) } return &anthropicChatMessage{ MessageId: uuid.New().String(), Role: "user", Content: contentBlocks, }, nil } // ConvertAIChatToUIChat converts an AIChat to a UIChat for Anthropic func ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { if aiChat.APIType != uctypes.APIType_AnthropicMessages { return nil, fmt.Errorf("APIType must be '%s', got '%s'", uctypes.APIType_AnthropicMessages, aiChat.APIType) } uiMessages := make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages)) for i, nativeMsg := range aiChat.NativeMessages { anthropicMsg, ok := nativeMsg.(*anthropicChatMessage) if !ok { return nil, fmt.Errorf("message %d: expected *anthropicChatMessage, got %T", i, nativeMsg) } uiMsg := anthropicMsg.ConvertToUIMessage() if uiMsg != nil { uiMessages = append(uiMessages, *uiMsg) } } return &uctypes.UIChat{ ChatId: aiChat.ChatId, APIType: aiChat.APIType, Model: aiChat.Model, APIVersion: aiChat.APIVersion, Messages: uiMessages, }, nil } func GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { for _, genMsg := range aiChat.NativeMessages { chatMsg, ok := genMsg.(*anthropicChatMessage) if !ok { continue } for _, block := range chatMsg.Content { if block.Type != "tool_use" || block.ID != toolCallId { continue } argsInput := block.Input if argsInput == nil { argsInput = map[string]interface{}{} } argsBytes, err := json.Marshal(argsInput) if err != nil { continue } return &uctypes.AIFunctionCallInput{ CallId: block.ID, Name: block.Name, Arguments: string(argsBytes), ToolUseData: block.ToolUseData, } } } return nil } func UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error { chat := chatstore.DefaultChatStore.Get(chatId) if chat == nil { return fmt.Errorf("chat not found: %s", chatId) } for _, genMsg := range chat.NativeMessages { chatMsg, ok := genMsg.(*anthropicChatMessage) if !ok { continue } for i, block := range chatMsg.Content { if block.Type != "tool_use" || block.ID != toolCallId { continue } updatedMsg := &anthropicChatMessage{ MessageId: chatMsg.MessageId, Usage: chatMsg.Usage, Role: chatMsg.Role, Content: slices.Clone(chatMsg.Content), } updatedMsg.Content[i].ToolUseData = &toolUseData aiOpts := &uctypes.AIOptsType{ APIType: chat.APIType, Model: chat.Model, APIVersion: chat.APIVersion, } return chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg) } } return fmt.Errorf("tool call with ID %s not found in chat %s", toolCallId, chatId) } func RemoveToolUseCall(chatId string, toolCallId string) error { chat := chatstore.DefaultChatStore.Get(chatId) if chat == nil { return fmt.Errorf("chat not found: %s", chatId) } for _, genMsg := range chat.NativeMessages { chatMsg, ok := genMsg.(*anthropicChatMessage) if !ok { continue } for i, block := range chatMsg.Content { if block.Type != "tool_use" || block.ID != toolCallId { continue } updatedMsg := &anthropicChatMessage{ MessageId: chatMsg.MessageId, Usage: chatMsg.Usage, Role: chatMsg.Role, Content: slices.Delete(slices.Clone(chatMsg.Content), i, i+1), } if len(updatedMsg.Content) == 0 { chatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId) } else { aiOpts := &uctypes.AIOptsType{ APIType: chat.APIType, Model: chat.Model, APIVersion: chat.APIVersion, } if err := chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg); err != nil { return err } } return nil } } return nil } ================================================ FILE: pkg/aiusechat/chatstore/chatstore.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package chatstore import ( "fmt" "slices" "sync" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" ) type ChatStore struct { lock sync.Mutex chats map[string]*uctypes.AIChat } var DefaultChatStore = &ChatStore{ chats: make(map[string]*uctypes.AIChat), } func (cs *ChatStore) Get(chatId string) *uctypes.AIChat { cs.lock.Lock() defer cs.lock.Unlock() chat := cs.chats[chatId] if chat == nil { return nil } // Copy the chat to prevent concurrent access issues copyChat := &uctypes.AIChat{ ChatId: chat.ChatId, APIType: chat.APIType, Model: chat.Model, APIVersion: chat.APIVersion, NativeMessages: make([]uctypes.GenAIMessage, len(chat.NativeMessages)), } copy(copyChat.NativeMessages, chat.NativeMessages) return copyChat } func (cs *ChatStore) Delete(chatId string) { cs.lock.Lock() defer cs.lock.Unlock() delete(cs.chats, chatId) } func (cs *ChatStore) CountUserMessages(chatId string) int { cs.lock.Lock() defer cs.lock.Unlock() chat := cs.chats[chatId] if chat == nil { return 0 } count := 0 for _, msg := range chat.NativeMessages { if msg.GetRole() == "user" { count++ } } return count } func (cs *ChatStore) PostMessage(chatId string, aiOpts *uctypes.AIOptsType, message uctypes.GenAIMessage) error { cs.lock.Lock() defer cs.lock.Unlock() chat := cs.chats[chatId] if chat == nil { // Create new chat chat = &uctypes.AIChat{ ChatId: chatId, APIType: aiOpts.APIType, Model: aiOpts.Model, APIVersion: aiOpts.APIVersion, NativeMessages: make([]uctypes.GenAIMessage, 0), } cs.chats[chatId] = chat } else { // Verify that the AI options match if chat.APIType != aiOpts.APIType { return fmt.Errorf("API type mismatch: expected %s, got %s (must start a new chat)", chat.APIType, aiOpts.APIType) } if !uctypes.AreModelsCompatible(chat.APIType, chat.Model, aiOpts.Model) { return fmt.Errorf("model mismatch: expected %s, got %s (must start a new chat)", chat.Model, aiOpts.Model) } if chat.APIVersion != aiOpts.APIVersion { return fmt.Errorf("API version mismatch: expected %s, got %s (must start a new chat)", chat.APIVersion, aiOpts.APIVersion) } } // Check for existing message with same ID (idempotency) messageId := message.GetMessageId() for i, existingMessage := range chat.NativeMessages { if existingMessage.GetMessageId() == messageId { // Replace existing message with same ID chat.NativeMessages[i] = message return nil } } // Append the new message if no duplicate found chat.NativeMessages = append(chat.NativeMessages, message) return nil } func (cs *ChatStore) RemoveMessage(chatId string, messageId string) bool { cs.lock.Lock() defer cs.lock.Unlock() chat := cs.chats[chatId] if chat == nil { return false } initialLen := len(chat.NativeMessages) chat.NativeMessages = slices.DeleteFunc(chat.NativeMessages, func(msg uctypes.GenAIMessage) bool { return msg.GetMessageId() == messageId }) return len(chat.NativeMessages) < initialLen } ================================================ FILE: pkg/aiusechat/gemini/doc.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // Package gemini implements the Google Gemini backend for WaveTerm's AI chat system. // // This package provides a complete implementation of the UseChatBackend interface // for Google's Gemini API, including: // - Streaming chat responses via Server-Sent Events (SSE) // - Function calling (tool use) support // - Multi-modal input support (text, images, PDFs) // - Proper message conversion and state management // // # API Type // // The Gemini backend uses the API type constant: // uctypes.APIType_GoogleGemini = "google-gemini" // // # Supported Features // // - Text messages // - Image uploads (JPEG, PNG, etc.) - inline base64 encoding // - PDF document uploads - inline base64 encoding // - Text file attachments // - Directory listings // - Function/tool calling with structured arguments // - Streaming responses with real-time token delivery // // # Usage // // The backend is automatically registered and can be obtained via: // // backend, err := aiusechat.GetBackendByAPIType(uctypes.APIType_GoogleGemini) // // To use the Gemini API, you need: // 1. A Google AI API key // 2. Configure the chat with APIType_GoogleGemini // 3. Set the Model (e.g., "gemini-2.0-flash-exp") // 4. Provide the API key in the Config.APIToken field // // # Configuration Example // // chatOpts := uctypes.WaveChatOpts{ // ChatId: "my-chat-id", // ClientId: "my-client-id", // Config: uctypes.AIOptsType{ // APIType: uctypes.APIType_GoogleGemini, // Model: "gemini-2.0-flash-exp", // APIToken: "your-google-api-key", // MaxTokens: 8192, // Capabilities: []string{ // uctypes.AICapabilityTools, // uctypes.AICapabilityImages, // uctypes.AICapabilityPdfs, // }, // }, // Tools: []uctypes.ToolDefinition{...}, // SystemPrompt: []string{"You are a helpful assistant."}, // } // // # Message Format // // The Gemini backend uses the GeminiChatMessage type internally, which stores: // - MessageId: Unique identifier for idempotency // - Role: "user" or "model" (model is Gemini's term for assistant) // - Parts: Array of message parts (text, inline data, function calls/responses) // - Usage: Token usage metadata // // # Function Calling // // Function calling is supported via Gemini's native function calling feature: // - Tools are converted to Gemini's FunctionDeclaration format // - Function calls are streamed with real-time argument updates // - Function responses are sent back as user messages with FunctionResponse parts // // # API Endpoint // // By default, the backend uses: // https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent // // You can override this by setting Config.BaseURL. // // # Error Handling // // The backend properly handles: // - Content blocking/safety filters // - Token limit errors // - Network errors // - Malformed responses // - Context cancellation // // All errors are properly propagated through the SSE stream. // // # Limitations // // - File uploads must be provided as base64-encoded inline data // - Images and PDFs use inline data, not file upload URIs // - Multi-turn conversations require proper role alternation (user/model) // - Some advanced Gemini features like caching are not yet implemented package gemini ================================================ FILE: pkg/aiusechat/gemini/gemini-backend.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package gemini import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "strings" "time" "github.com/google/uuid" "github.com/launchdarkly/eventsource" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/web/sse" ) // ensureAltSse ensures the ?alt=sse query parameter is set on the endpoint func ensureAltSse(endpoint string) (string, error) { parsedURL, err := url.Parse(endpoint) if err != nil { return "", fmt.Errorf("invalid ai:endpoint URL: %w", err) } query := parsedURL.Query() if query.Get("alt") != "sse" { query.Set("alt", "sse") parsedURL.RawQuery = query.Encode() return parsedURL.String(), nil } return endpoint, nil } // appendPartToLastUserMessage appends a text part to the last user message in the contents slice func appendPartToLastUserMessage(contents []GeminiContent, text string) { for i := len(contents) - 1; i >= 0; i-- { if contents[i].Role == "user" { contents[i].Parts = append(contents[i].Parts, GeminiMessagePart{ Text: text, }) break } } } // buildGeminiHTTPRequest creates an HTTP request for the Gemini API func buildGeminiHTTPRequest(ctx context.Context, contents []GeminiContent, chatOpts uctypes.WaveChatOpts) (*http.Request, error) { opts := chatOpts.Config if opts.Model == "" { return nil, errors.New("ai:model is required") } if opts.APIToken == "" { return nil, errors.New("ai:apitoken is required") } if opts.Endpoint == "" { return nil, errors.New("ai:endpoint is required") } maxTokens := opts.MaxTokens if maxTokens <= 0 { maxTokens = GeminiDefaultMaxTokens } // Build request body reqBody := &GeminiRequest{ Contents: contents, GenerationConfig: &GeminiGenerationConfig{ MaxOutputTokens: int32(maxTokens), Temperature: 0.7, // Default temperature }, } // Map thinking level for Gemini 3+ models if opts.ThinkingLevel != "" && strings.Contains(opts.Model, "gemini-3") { geminiThinkingLevel := "high" if opts.ThinkingLevel == uctypes.ThinkingLevelLow { geminiThinkingLevel = "low" } reqBody.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ ThinkingLevel: geminiThinkingLevel, } } // Add system instruction if provided if len(chatOpts.SystemPrompt) > 0 { systemText := strings.Join(chatOpts.SystemPrompt, "\n\n") reqBody.SystemInstruction = &GeminiContent{ Parts: []GeminiMessagePart{ {Text: systemText}, }, } } // Add tools if provided var allTools []uctypes.ToolDefinition allTools = append(allTools, chatOpts.Tools...) allTools = append(allTools, chatOpts.TabTools...) if len(allTools) > 0 { var functionDeclarations []GeminiFunctionDeclaration for _, tool := range allTools { // Only include tools whose capabilities are met if !tool.HasRequiredCapabilities(opts.Capabilities) { continue } functionDeclarations = append(functionDeclarations, ConvertToolDefinitionToGemini(tool)) } if len(functionDeclarations) > 0 { reqBody.Tools = []GeminiTool{ {FunctionDeclarations: functionDeclarations}, } reqBody.ToolConfig = &GeminiToolConfig{ FunctionCallingConfig: &GeminiFunctionCallingConfig{ Mode: "AUTO", }, } } } // Injected data - append to last user message as separate parts if chatOpts.TabState != "" { appendPartToLastUserMessage(reqBody.Contents, chatOpts.TabState) } if chatOpts.PlatformInfo != "" { appendPartToLastUserMessage(reqBody.Contents, "\n"+chatOpts.PlatformInfo+"\n") } if chatOpts.AppStaticFiles != "" { appendPartToLastUserMessage(reqBody.Contents, "\n"+chatOpts.AppStaticFiles+"\n") } if chatOpts.AppGoFile != "" { appendPartToLastUserMessage(reqBody.Contents, "\n"+chatOpts.AppGoFile+"\n") } if wavebase.IsDevMode() { var toolNames []string for _, tool := range allTools { toolNames = append(toolNames, tool.Name) } log.Printf("gemini: model %s, messages: %d, tools: %s\n", opts.Model, len(contents), strings.Join(toolNames, ",")) } // Encode request body buf, err := aiutil.JsonEncodeRequestBody(reqBody) if err != nil { return nil, err } // Build URL endpoint, err := ensureAltSse(opts.Endpoint) if err != nil { return nil, err } // Create HTTP request req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf) if err != nil { return nil, err } // Set headers req.Header.Set("Content-Type", "application/json") req.Header.Set("x-goog-api-key", opts.APIToken) return req, nil } // RunGeminiChatStep executes a chat step using the Gemini API func RunGeminiChatStep( ctx context.Context, sseHandler *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, *GeminiChatMessage, *uctypes.RateLimitInfo, error) { if sseHandler == nil { return nil, nil, nil, errors.New("sse handler is nil") } // Get chat from store chat := chatstore.DefaultChatStore.Get(chatOpts.ChatId) if chat == nil { return nil, nil, nil, fmt.Errorf("chat not found: %s", chatOpts.ChatId) } // Validate that chatOpts.Config match the chat's stored configuration if chat.APIType != chatOpts.Config.APIType { return nil, nil, nil, fmt.Errorf("API type mismatch: chat has %s, chatOpts has %s", chat.APIType, chatOpts.Config.APIType) } if chat.Model != chatOpts.Config.Model { return nil, nil, nil, fmt.Errorf("model mismatch: chat has %s, chatOpts has %s", chat.Model, chatOpts.Config.Model) } // Context with timeout if provided if chatOpts.Config.TimeoutMs > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(chatOpts.Config.TimeoutMs)*time.Millisecond) defer cancel() } // Convert GenAIMessages to Gemini contents var contents []GeminiContent for _, genMsg := range chat.NativeMessages { chatMsg, ok := genMsg.(*GeminiChatMessage) if !ok { return nil, nil, nil, fmt.Errorf("expected GeminiChatMessage, got %T", genMsg) } content := GeminiContent{ Role: chatMsg.Role, Parts: make([]GeminiMessagePart, len(chatMsg.Parts)), } for i, part := range chatMsg.Parts { content.Parts[i] = *part.Clean() } contents = append(contents, content) } req, err := buildGeminiHTTPRequest(ctx, contents, chatOpts) if err != nil { return nil, nil, nil, err } httpClient, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL) if err != nil { return nil, nil, nil, err } resp, err := httpClient.Do(req) if err != nil { return nil, nil, nil, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) // Try to parse as Gemini error var geminiErr GeminiErrorResponse if err := json.Unmarshal(bodyBytes, &geminiErr); err == nil && geminiErr.Error != nil { return nil, nil, nil, fmt.Errorf("Gemini API error (%d): %s", geminiErr.Error.Code, geminiErr.Error.Message) } return nil, nil, nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, utilfn.TruncateString(string(bodyBytes), 120)) } // Setup SSE if this is a new request (not a continuation) if cont == nil { if err := sseHandler.SetupSSE(); err != nil { return nil, nil, nil, fmt.Errorf("failed to setup SSE: %w", err) } } // Stream processing stopReason, assistantMsg, err := processGeminiStream(ctx, resp.Body, sseHandler, chatOpts, cont) if err != nil { return nil, nil, nil, err } return stopReason, assistantMsg, nil, nil } // processGeminiStream handles the streaming response from Gemini func processGeminiStream( ctx context.Context, body io.Reader, sseHandler *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, *GeminiChatMessage, error) { msgID := uuid.New().String() textID := uuid.New().String() textStarted := false var textBuilder strings.Builder var textThoughtSignature string var finishReason string var functionCalls []GeminiMessagePart var usageMetadata *GeminiUsageMetadata if cont == nil { _ = sseHandler.AiMsgStart(msgID) } _ = sseHandler.AiMsgStartStep() decoder := eventsource.NewDecoder(body) for { if err := ctx.Err(); err != nil { _ = sseHandler.AiMsgError("request cancelled") return &uctypes.WaveStopReason{ Kind: uctypes.StopKindCanceled, ErrorType: "cancelled", ErrorText: "request cancelled", }, nil, err } event, err := decoder.Decode() if err != nil { if errors.Is(err, io.EOF) { break } if sseHandler.Err() != nil { partialMsg := extractPartialGeminiMessage(msgID, textBuilder.String()) return &uctypes.WaveStopReason{ Kind: uctypes.StopKindCanceled, ErrorType: "client_disconnect", ErrorText: "client disconnected", }, partialMsg, nil } _ = sseHandler.AiMsgError(fmt.Sprintf("stream decode error: %v", err)) return &uctypes.WaveStopReason{ Kind: uctypes.StopKindError, ErrorType: "stream", ErrorText: err.Error(), }, nil, fmt.Errorf("stream decode error: %w", err) } data := event.Data() if data == "" { continue } // Parse the JSON response var chunk GeminiStreamResponse if err := json.Unmarshal([]byte(data), &chunk); err != nil { log.Printf("gemini: failed to parse chunk: %v\n", err) continue } // Check for prompt feedback (blocking) if chunk.PromptFeedback != nil && chunk.PromptFeedback.BlockReason != "" { errorMsg := fmt.Sprintf("Content blocked: %s", chunk.PromptFeedback.BlockReason) _ = sseHandler.AiMsgError(errorMsg) return &uctypes.WaveStopReason{ Kind: uctypes.StopKindContent, ErrorType: "blocked", ErrorText: errorMsg, }, nil, fmt.Errorf("%s", errorMsg) } // Store usage metadata if present if chunk.UsageMetadata != nil { usageMetadata = chunk.UsageMetadata } // Log grounding metadata (web search queries) if chunk.GroundingMetadata != nil && len(chunk.GroundingMetadata.WebSearchQueries) > 0 { if wavebase.IsDevMode() { log.Printf("gemini: web search queries executed: %v\n", chunk.GroundingMetadata.WebSearchQueries) } } // Process candidates if len(chunk.Candidates) == 0 { continue } candidate := chunk.Candidates[0] // Log candidate grounding metadata if present if candidate.GroundingMetadata != nil && len(candidate.GroundingMetadata.WebSearchQueries) > 0 { if wavebase.IsDevMode() { log.Printf("gemini: candidate web search queries: %v\n", candidate.GroundingMetadata.WebSearchQueries) } } // Store finish reason if candidate.FinishReason != "" { finishReason = candidate.FinishReason } if candidate.Content == nil { continue } // Process content parts for _, part := range candidate.Content.Parts { if part.Text != "" { if !textStarted { _ = sseHandler.AiMsgTextStart(textID) textStarted = true } textBuilder.WriteString(part.Text) _ = sseHandler.AiMsgTextDelta(textID, part.Text) if part.ThoughtSignature != "" { textThoughtSignature = part.ThoughtSignature } } if part.FunctionCall != nil { toolCallId := uuid.New().String() argsBytes, _ := json.Marshal(part.FunctionCall.Args) aiutil.SendToolProgress(toolCallId, part.FunctionCall.Name, argsBytes, chatOpts, sseHandler, false) // Preserve thought_signature exactly as received from API // It can be at part level, FunctionCall level, or both functionCalls = append(functionCalls, GeminiMessagePart{ FunctionCall: part.FunctionCall, ThoughtSignature: part.ThoughtSignature, ToolUseData: &uctypes.UIMessageDataToolUse{ ToolCallId: toolCallId, ToolName: part.FunctionCall.Name, }, }) } } } // Determine stop reason stopKind := uctypes.StopKindDone switch finishReason { case "MAX_TOKENS": stopKind = uctypes.StopKindMaxTokens case "SAFETY": stopKind = uctypes.StopKindContent case "RECITATION": stopKind = uctypes.StopKindContent } // Build assistant message var parts []GeminiMessagePart if textBuilder.Len() > 0 { parts = append(parts, GeminiMessagePart{ Text: textBuilder.String(), ThoughtSignature: textThoughtSignature, }) } parts = append(parts, functionCalls...) // Set usage metadata model if usageMetadata != nil { usageMetadata.Model = chatOpts.Config.Model } assistantMsg := &GeminiChatMessage{ MessageId: msgID, Role: "model", Parts: parts, Usage: usageMetadata, } // Build tool calls for stop reason var waveToolCalls []uctypes.WaveToolCall if len(functionCalls) > 0 { stopKind = uctypes.StopKindToolUse for _, fcPart := range functionCalls { if fcPart.FunctionCall != nil && fcPart.ToolUseData != nil { waveToolCalls = append(waveToolCalls, uctypes.WaveToolCall{ ID: fcPart.ToolUseData.ToolCallId, Name: fcPart.FunctionCall.Name, Input: fcPart.FunctionCall.Args, ToolUseData: fcPart.ToolUseData, }) } } } stopReason := &uctypes.WaveStopReason{ Kind: stopKind, RawReason: finishReason, ToolCalls: waveToolCalls, } if textStarted { _ = sseHandler.AiMsgTextEnd(textID) } _ = sseHandler.AiMsgFinishStep() if stopKind != uctypes.StopKindToolUse { _ = sseHandler.AiMsgFinish(finishReason, nil) } return stopReason, assistantMsg, nil } func extractPartialGeminiMessage(msgID string, text string) *GeminiChatMessage { if text == "" { return nil } return &GeminiChatMessage{ MessageId: msgID, Role: "model", Parts: []GeminiMessagePart{ { Text: text, }, }, } } ================================================ FILE: pkg/aiusechat/gemini/gemini-convertmessage.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package gemini import ( "encoding/base64" "encoding/json" "fmt" "log" "slices" "strings" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) // cleanSchemaForGemini removes fields from JSON Schema that Gemini doesn't accept // Gemini uses a strict subset of JSON Schema and rejects fields like $schema, units, title, etc. func cleanSchemaForGemini(schema map[string]any) map[string]any { if schema == nil { return nil } cleaned := make(map[string]any) // Fields that Gemini accepts in the root schema allowedRootFields := map[string]bool{ "type": true, "properties": true, "required": true, "description": true, "items": true, "enum": true, "format": true, "minimum": true, "maximum": true, "pattern": true, "default": true, } for key, value := range schema { if !allowedRootFields[key] { // Skip fields like $schema, title, units, definitions, $ref, etc. continue } // Recursively clean nested schemas switch key { case "properties": if props, ok := value.(map[string]any); ok { cleanedProps := make(map[string]any) for propName, propValue := range props { if propSchema, ok := propValue.(map[string]any); ok { cleanedProps[propName] = cleanSchemaForGemini(propSchema) } else { // Preserve non-map property values cleanedProps[propName] = propValue } } cleaned[key] = cleanedProps } case "items": if items, ok := value.(map[string]any); ok { cleaned[key] = cleanSchemaForGemini(items) } else { cleaned[key] = value } default: cleaned[key] = value } } return cleaned } // ConvertToolDefinitionToGemini converts a Wave ToolDefinition to Gemini format func ConvertToolDefinitionToGemini(tool uctypes.ToolDefinition) GeminiFunctionDeclaration { // Clean the schema to remove fields that Gemini doesn't accept cleanedSchema := cleanSchemaForGemini(tool.InputSchema) return GeminiFunctionDeclaration{ Name: tool.Name, Description: tool.Description, Parameters: cleanedSchema, } } // convertFileAIMessagePart converts a file AIMessagePart to Gemini format func convertFileAIMessagePart(part uctypes.AIMessagePart) (*GeminiMessagePart, error) { if part.Type != uctypes.AIMessagePartTypeFile { return nil, fmt.Errorf("convertFileAIMessagePart expects 'file' type, got '%s'", part.Type) } if part.MimeType == "" { return nil, fmt.Errorf("file part missing mimetype") } // Handle different file types switch { case strings.HasPrefix(part.MimeType, "image/"): // For images, we need base64 data var base64Data string if len(part.Data) > 0 { base64Data = base64.StdEncoding.EncodeToString(part.Data) } else if part.URL != "" { // If URL is provided, it should be a data URL if strings.HasPrefix(part.URL, "data:") { // Extract base64 data from data URL parts := strings.SplitN(part.URL, ",", 2) if len(parts) == 2 { base64Data = parts[1] } else { return nil, fmt.Errorf("invalid data URL format") } } else { return nil, fmt.Errorf("dropping image with non-data URL (must be fetched and converted to base64)") } } else { return nil, fmt.Errorf("image file part missing data") } return &GeminiMessagePart{ InlineData: &GeminiInlineData{ MimeType: part.MimeType, Data: base64Data, }, FileName: part.FileName, PreviewUrl: part.PreviewUrl, }, nil case part.MimeType == "application/pdf": // Handle PDFs - Gemini supports base64 data for PDFs if len(part.Data) == 0 { if part.URL != "" { return nil, fmt.Errorf("dropping PDF with URL (must be fetched and converted to base64 data)") } return nil, fmt.Errorf("PDF file part missing data") } // Convert raw data to base64 base64Data := base64.StdEncoding.EncodeToString(part.Data) return &GeminiMessagePart{ InlineData: &GeminiInlineData{ MimeType: "application/pdf", Data: base64Data, }, FileName: part.FileName, PreviewUrl: part.PreviewUrl, }, nil case part.MimeType == "text/plain": textData, err := aiutil.ExtractTextData(part.Data, part.URL) if err != nil { return nil, err } formattedText := aiutil.FormatAttachedTextFile(part.FileName, textData) return &GeminiMessagePart{ Text: formattedText, }, nil case part.MimeType == "directory": var jsonContent string if len(part.Data) > 0 { jsonContent = string(part.Data) } else { return nil, fmt.Errorf("directory listing part missing data") } formattedText := aiutil.FormatAttachedDirectoryListing(part.FileName, jsonContent) return &GeminiMessagePart{ Text: formattedText, }, nil default: return nil, fmt.Errorf("dropping file with unsupported mimetype '%s' (Gemini supports images, PDFs, text/plain, and directories)", part.MimeType) } } // ConvertAIMessageToGeminiChatMessage converts an AIMessage to GeminiChatMessage // These messages are ALWAYS role "user" func ConvertAIMessageToGeminiChatMessage(aiMsg uctypes.AIMessage) (*GeminiChatMessage, error) { if err := aiMsg.Validate(); err != nil { return nil, fmt.Errorf("invalid AIMessage: %w", err) } var parts []GeminiMessagePart for i, part := range aiMsg.Parts { switch part.Type { case uctypes.AIMessagePartTypeText: if part.Text == "" { return nil, fmt.Errorf("part %d: text type requires non-empty text field", i) } parts = append(parts, GeminiMessagePart{ Text: part.Text, }) case uctypes.AIMessagePartTypeFile: geminiPart, err := convertFileAIMessagePart(part) if err != nil { log.Printf("gemini: %v", err) continue } parts = append(parts, *geminiPart) default: // Drop unknown part types log.Printf("gemini: dropping unknown part type '%s'", part.Type) continue } } return &GeminiChatMessage{ MessageId: aiMsg.MessageId, Role: "user", Parts: parts, }, nil } // ConvertToolResultsToGeminiChatMessage converts AIToolResult slice to GeminiChatMessage func ConvertToolResultsToGeminiChatMessage(toolResults []uctypes.AIToolResult) (*GeminiChatMessage, error) { if len(toolResults) == 0 { return nil, fmt.Errorf("toolResults cannot be empty") } var parts []GeminiMessagePart for _, result := range toolResults { if result.ToolUseID == "" { return nil, fmt.Errorf("tool result missing ToolUseID") } response := make(map[string]any) var nestedParts []GeminiMessagePart if result.ErrorText != "" { response["ok"] = false response["error"] = result.ErrorText } else if strings.HasPrefix(result.Text, "data:") { mimeType, base64Data, err := utilfn.DecodeDataURL(result.Text) if err != nil { log.Printf("gemini: failed to decode data URL in tool result: %v\n", err) response["ok"] = false response["error"] = fmt.Sprintf("failed to decode data URL: %v", err) } else if strings.HasPrefix(mimeType, "image/") { // For image data URLs, use multimodal function response (Gemini 3 Pro+) displayName := fmt.Sprintf("result_%s.%s", result.ToolUseID[:8], strings.TrimPrefix(mimeType, "image/")) response["ok"] = true response["image"] = map[string]string{"$ref": displayName} // Add the image data as a nested part nestedParts = append(nestedParts, GeminiMessagePart{ InlineData: &GeminiInlineData{ MimeType: mimeType, Data: base64.StdEncoding.EncodeToString(base64Data), DisplayName: displayName, }, }) } else { log.Printf("gemini: unsupported data URL mimetype in tool result: %s\n", mimeType) response["ok"] = false response["error"] = fmt.Sprintf("unsupported data URL mimetype: %s", mimeType) } } else { response["ok"] = true response["result"] = result.Text } parts = append(parts, GeminiMessagePart{ FunctionResponse: &GeminiFunctionResponse{ Name: result.ToolName, Response: response, Parts: nestedParts, }, }) } return &GeminiChatMessage{ MessageId: uuid.New().String(), Role: "user", // Function responses are sent as user messages Parts: parts, }, nil } // convertContentPartToUIPart converts a Gemini content part to UIMessagePart func convertContentPartToUIPart(part GeminiMessagePart, role string) []uctypes.UIMessagePart { var uiParts []uctypes.UIMessagePart if part.Text != "" { if found, dataPart := aiutil.ConvertDataUserFile(part.Text); found { if dataPart != nil { uiParts = append(uiParts, *dataPart) } } else { uiParts = append(uiParts, uctypes.UIMessagePart{ Type: "text", Text: part.Text, }) } } if part.InlineData != nil && role == "user" { // Show uploaded files in user messages var mimeType string if strings.HasPrefix(part.InlineData.MimeType, "image/") { mimeType = "image/*" } else { mimeType = part.InlineData.MimeType } uiParts = append(uiParts, uctypes.UIMessagePart{ Type: "data-userfile", Data: uctypes.UIMessageDataUserFile{ FileName: part.FileName, MimeType: mimeType, PreviewUrl: part.PreviewUrl, }, }) } // Tool use parts are handled separately by the backend if part.ToolUseData != nil { uiParts = append(uiParts, uctypes.UIMessagePart{ Type: "data-tooluse", ID: part.ToolUseData.ToolCallId, Data: *part.ToolUseData, }) } return uiParts } // convertToUIMessage converts a GeminiChatMessage to a UIMessage func (m *GeminiChatMessage) convertToUIMessage() *uctypes.UIMessage { var parts []uctypes.UIMessagePart for _, part := range m.Parts { // Skip function responses - they're not shown in UI if part.FunctionResponse != nil { continue } partUIParts := convertContentPartToUIPart(part, m.Role) parts = append(parts, partUIParts...) } if len(parts) == 0 { return nil } // Convert Gemini role to standard role role := m.Role if role == "model" { role = "assistant" } return &uctypes.UIMessage{ ID: m.MessageId, Role: role, Parts: parts, } } // ConvertAIChatToUIChat converts an AIChat to a UIChat for Gemini func ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { if aiChat.APIType != uctypes.APIType_GoogleGemini { return nil, fmt.Errorf("APIType must be '%s', got '%s'", uctypes.APIType_GoogleGemini, aiChat.APIType) } uiMessages := make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages)) for i, nativeMsg := range aiChat.NativeMessages { geminiMsg, ok := nativeMsg.(*GeminiChatMessage) if !ok { return nil, fmt.Errorf("message %d: expected *GeminiChatMessage, got %T", i, nativeMsg) } uiMsg := geminiMsg.convertToUIMessage() if uiMsg != nil { uiMessages = append(uiMessages, *uiMsg) } } return &uctypes.UIChat{ ChatId: aiChat.ChatId, APIType: aiChat.APIType, Model: aiChat.Model, APIVersion: aiChat.APIVersion, Messages: uiMessages, }, nil } // GetFunctionCallInputByToolCallId returns the function call input associated with the given tool call ID func GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { for _, nativeMsg := range aiChat.NativeMessages { geminiMsg, ok := nativeMsg.(*GeminiChatMessage) if !ok { continue } for _, part := range geminiMsg.Parts { if part.FunctionCall != nil && part.ToolUseData != nil && part.ToolUseData.ToolCallId == toolCallId { // Convert args map to JSON string argsBytes, err := json.Marshal(part.FunctionCall.Args) if err != nil { log.Printf("gemini: error marshaling function call args: %v", err) continue } return &uctypes.AIFunctionCallInput{ CallId: toolCallId, Name: part.FunctionCall.Name, Arguments: string(argsBytes), ToolUseData: part.ToolUseData, } } } } return nil } // UpdateToolUseData updates the tool use data for a specific tool call in the chat func UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error { chat := chatstore.DefaultChatStore.Get(chatId) if chat == nil { return fmt.Errorf("chat not found: %s", chatId) } for _, genMsg := range chat.NativeMessages { chatMsg, ok := genMsg.(*GeminiChatMessage) if !ok { continue } for i, part := range chatMsg.Parts { if part.FunctionCall != nil && part.ToolUseData != nil && part.ToolUseData.ToolCallId == toolCallId { // Update the message with new tool use data updatedMsg := &GeminiChatMessage{ MessageId: chatMsg.MessageId, Role: chatMsg.Role, Parts: make([]GeminiMessagePart, len(chatMsg.Parts)), Usage: chatMsg.Usage, } copy(updatedMsg.Parts, chatMsg.Parts) updatedMsg.Parts[i].ToolUseData = &toolUseData aiOpts := &uctypes.AIOptsType{ APIType: chat.APIType, Model: chat.Model, APIVersion: chat.APIVersion, } return chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg) } } } return fmt.Errorf("tool call with ID %s not found in chat %s", toolCallId, chatId) } func RemoveToolUseCall(chatId string, toolCallId string) error { chat := chatstore.DefaultChatStore.Get(chatId) if chat == nil { return fmt.Errorf("chat not found: %s", chatId) } for _, genMsg := range chat.NativeMessages { chatMsg, ok := genMsg.(*GeminiChatMessage) if !ok { continue } partIndex := -1 for i, part := range chatMsg.Parts { if part.FunctionCall != nil && part.ToolUseData != nil && part.ToolUseData.ToolCallId == toolCallId { partIndex = i break } } if partIndex == -1 { continue } updatedMsg := &GeminiChatMessage{ MessageId: chatMsg.MessageId, Role: chatMsg.Role, Parts: slices.Delete(slices.Clone(chatMsg.Parts), partIndex, partIndex+1), Usage: chatMsg.Usage, } if len(updatedMsg.Parts) == 0 { chatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId) } else { aiOpts := &uctypes.AIOptsType{ APIType: chat.APIType, Model: chat.Model, APIVersion: chat.APIVersion, } if err := chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg); err != nil { return err } } return nil } return nil } ================================================ FILE: pkg/aiusechat/gemini/gemini-types.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package gemini import ( "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" ) const ( GeminiDefaultMaxTokens = 8192 ) // GeminiChatMessage represents a stored chat message for Gemini backend type GeminiChatMessage struct { MessageId string `json:"messageid"` Role string `json:"role"` // "user", "model" Parts []GeminiMessagePart `json:"parts"` Usage *GeminiUsageMetadata `json:"usage,omitempty"` } func (m *GeminiChatMessage) GetMessageId() string { return m.MessageId } func (m *GeminiChatMessage) GetRole() string { return m.Role } func (m *GeminiChatMessage) GetUsage() *uctypes.AIUsage { if m.Usage == nil { return nil } return &uctypes.AIUsage{ APIType: uctypes.APIType_GoogleGemini, Model: m.Usage.Model, InputTokens: m.Usage.PromptTokenCount, OutputTokens: m.Usage.CandidatesTokenCount, } } // GeminiMessagePart represents different types of content in a message type GeminiMessagePart struct { // Text part Text string `json:"text,omitempty"` // Inline data (images, PDFs, etc.) InlineData *GeminiInlineData `json:"inlineData,omitempty"` // File data (for uploaded files) FileData *GeminiFileData `json:"fileData,omitempty"` // Function call (assistant calling a tool) FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"` // Function response (result of tool execution) FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"` // Thought signature (for thinking models - applies to text and function calls) ThoughtSignature string `json:"thoughtSignature,omitempty"` // Internal fields (not sent to API) PreviewUrl string `json:"previewurl,omitempty"` // internal field FileName string `json:"filename,omitempty"` // internal field ToolUseData *uctypes.UIMessageDataToolUse `json:"toolusedata,omitempty"` // internal field } // Clean removes internal fields before sending to API func (p *GeminiMessagePart) Clean() *GeminiMessagePart { if p == nil { return nil } cleaned := *p cleaned.PreviewUrl = "" cleaned.FileName = "" cleaned.ToolUseData = nil return &cleaned } // GeminiInlineData represents inline binary data type GeminiInlineData struct { MimeType string `json:"mimeType"` Data string `json:"data"` // base64 encoded DisplayName string `json:"displayName,omitempty"` // for multimodal function responses } // GeminiFileData represents uploaded file reference type GeminiFileData struct { MimeType string `json:"mimeType"` FileUri string `json:"fileUri"` // gs:// URI from file upload DisplayName string `json:"displayName,omitempty"` // for multimodal function responses } // GeminiFunctionCall represents a function call from the model type GeminiFunctionCall struct { Name string `json:"name"` Args map[string]any `json:"args,omitempty"` } // GeminiFunctionResponse represents a function execution result type GeminiFunctionResponse struct { Name string `json:"name"` Response map[string]any `json:"response"` Parts []GeminiMessagePart `json:"parts,omitempty"` // nested parts for multimodal content (Gemini 3 Pro and later) } // GeminiUsageMetadata represents token usage type GeminiUsageMetadata struct { Model string `json:"model,omitempty"` // internal field PromptTokenCount int `json:"promptTokenCount"` CachedContentTokenCount int `json:"cachedContentTokenCount,omitempty"` CandidatesTokenCount int `json:"candidatesTokenCount"` TotalTokenCount int `json:"totalTokenCount"` } // GeminiThinkingConfig represents thinking configuration for Gemini 3+ models type GeminiThinkingConfig struct { ThinkingLevel string `json:"thinkingLevel,omitempty"` // "low" or "high" } // GeminiGenerationConfig represents generation parameters type GeminiGenerationConfig struct { Temperature float32 `json:"temperature,omitempty"` TopP float32 `json:"topP,omitempty"` TopK int32 `json:"topK,omitempty"` CandidateCount int32 `json:"candidateCount,omitempty"` MaxOutputTokens int32 `json:"maxOutputTokens,omitempty"` StopSequences []string `json:"stopSequences,omitempty"` ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"` // for Gemini 3+ models } // GeminiTool represents a function tool definition type GeminiTool struct { FunctionDeclarations []GeminiFunctionDeclaration `json:"functionDeclarations,omitempty"` GoogleSearch *GeminiGoogleSearch `json:"googleSearch,omitempty"` } // GeminiGoogleSearch represents Google Search configuration (empty for default) type GeminiGoogleSearch struct{} // GeminiFunctionDeclaration represents a function schema type GeminiFunctionDeclaration struct { Name string `json:"name"` Description string `json:"description"` Parameters map[string]any `json:"parameters,omitempty"` } // GeminiToolConfig represents tool choice configuration type GeminiToolConfig struct { FunctionCallingConfig *GeminiFunctionCallingConfig `json:"functionCallingConfig,omitempty"` } // GeminiFunctionCallingConfig represents function calling configuration type GeminiFunctionCallingConfig struct { Mode string `json:"mode,omitempty"` // "AUTO", "ANY", "NONE" } // GeminiContent represents a content message for the API type GeminiContent struct { Role string `json:"role,omitempty"` Parts []GeminiMessagePart `json:"parts"` } // Clean removes internal fields from all parts func (c *GeminiContent) Clean() *GeminiContent { if c == nil { return nil } cleaned := &GeminiContent{ Role: c.Role, Parts: make([]GeminiMessagePart, len(c.Parts)), } for i, part := range c.Parts { cleaned.Parts[i] = *part.Clean() } return cleaned } // GeminiRequest represents a request to the Gemini API type GeminiRequest struct { Contents []GeminiContent `json:"contents"` SystemInstruction *GeminiContent `json:"systemInstruction,omitempty"` GenerationConfig *GeminiGenerationConfig `json:"generationConfig,omitempty"` Tools []GeminiTool `json:"tools,omitempty"` ToolConfig *GeminiToolConfig `json:"toolConfig,omitempty"` } // GeminiStreamResponse represents a streaming response chunk type GeminiStreamResponse struct { Candidates []GeminiCandidate `json:"candidates,omitempty"` PromptFeedback *GeminiPromptFeedback `json:"promptFeedback,omitempty"` UsageMetadata *GeminiUsageMetadata `json:"usageMetadata,omitempty"` GroundingMetadata *GeminiGroundingMetadata `json:"groundingMetadata,omitempty"` } // GeminiCandidate represents a candidate response type GeminiCandidate struct { Content *GeminiContent `json:"content,omitempty"` FinishReason string `json:"finishReason,omitempty"` Index int `json:"index,omitempty"` SafetyRatings []GeminiSafetyRating `json:"safetyRatings,omitempty"` GroundingMetadata *GeminiGroundingMetadata `json:"groundingMetadata,omitempty"` } // GeminiSafetyRating represents a safety rating type GeminiSafetyRating struct { Category string `json:"category"` Probability string `json:"probability"` } // GeminiPromptFeedback represents feedback about the prompt type GeminiPromptFeedback struct { BlockReason string `json:"blockReason,omitempty"` SafetyRatings []GeminiSafetyRating `json:"safetyRatings,omitempty"` } // GeminiErrorResponse represents an error response type GeminiErrorResponse struct { Error *GeminiError `json:"error,omitempty"` } // GeminiError represents an error type GeminiError struct { Code int `json:"code"` Message string `json:"message"` Status string `json:"status,omitempty"` } // GeminiGroundingMetadata represents grounding metadata with web search results type GeminiGroundingMetadata struct { WebSearchQueries []string `json:"webSearchQueries,omitempty"` } ================================================ FILE: pkg/aiusechat/google/doc.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // Package google provides Google Generative AI integration for WaveTerm. // // This package implements file summarization using Google's Gemini models. // Unlike other AI provider implementations in the aiusechat package, this // package does NOT implement full SSE streaming. It uses a simple // request-response API for file summarization. // // # Supported File Types // // The package supports the same file types as defined in wshcmd-ai.go: // - Images (PNG, JPEG, etc.): up to 7MB // - PDFs: up to 5MB // - Text files: up to 200KB // // Binary files are rejected unless they are recognized as images or PDFs. // // # Usage // // To summarize a file: // // ctx := context.Background() // summary, usage, err := google.SummarizeFile(ctx, "/path/to/file.txt", google.SummarizeOpts{ // APIKey: "YOUR_API_KEY", // Mode: google.ModeQuickSummary, // }) // if err != nil { // log.Fatal(err) // } // fmt.Println("Summary:", summary) // fmt.Printf("Tokens used: %d\n", usage.TotalTokenCount) // // # Configuration // // The summarization behavior can be customized by modifying the constants: // - SummarizeModel: The Gemini model to use (default: "gemini-2.5-flash-lite") // - SummarizePrompt: The prompt sent to the model // - GoogleAPIURL: The base URL for the API (for reference, not currently used by the SDK) package google ================================================ FILE: pkg/aiusechat/google/google-summarize.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package google import ( "context" "fmt" "net/http" "os" "strings" "github.com/google/generative-ai-go/genai" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "google.golang.org/api/option" ) const ( // GoogleAPIURL is the base URL for the Google Generative AI API GoogleAPIURL = "https://generativelanguage.googleapis.com" // SummarizeModel is the model used for file summarization SummarizeModel = "gemini-2.5-flash-lite" // Mode constants ModeQuickSummary = "quick" ModeUseful = "useful" ModePublicCode = "publiccode" ModeHTMLContent = "htmlcontent" ModeHTMLFull = "htmlfull" // SummarizePrompt is the default prompt used for file summarization SummarizePrompt = "Please provide a concise summary of this file. Include the main topics, key points, and any notable information." // QuickSummaryPrompt is the prompt for quick file summaries QuickSummaryPrompt = `Summarize the following file for another AI agent that is deciding which files to read. If the content is HTML or web page markup, ignore layout elements such as headers, footers, sidebars, navigation menus, cookie banners, pop-ups, ads, and search boxes. Focus only on the visible main content that describes the page’s subject or purpose. Keep the summary extremely concise — one or two sentences at most. Explain what the file appears to be and its main purpose or contents. If it's code, mention the language and what it implements (e.g., a CLI, library, test, or config). Avoid speculation or verbose explanations. Do not include markdown, bullets, or formatting — just a plain text summary.` // UsefulSummaryPrompt is the prompt for useful file summaries with more detail UsefulSummaryPrompt = `You are summarizing a single file so that another AI agent can understand its purpose and structure. If the content is HTML or web page markup, ignore layout elements such as headers, footers, sidebars, navigation menus, cookie banners, pop-ups, ads, and search boxes. Focus only on the visible main content that describes the page’s subject or purpose. Start with a short overview (2–4 sentences) describing the overall purpose of the file. If the file is large (more than about 150 lines) or has multiple major sections or functions, then briefly summarize each major section (1–2 sentences per section) and include an approximate line range in parentheses like "(lines 80–220)". Keep section summaries extremely concise — only include the most important parts or entry points. If it's code, mention key functions or classes and what they do. If it's documentation, describe key topics or sections. If it's a data or config file, summarize the structure and purpose of the values. Never produce more text than would fit comfortably on one screen (roughly under 200 words total). Plain text only — no lists, no markdown, no JSON.` // PublicCodeSummaryPrompt is the prompt for public API summaries PublicCodeSummaryPrompt = `You are summarizing a SINGLE source file to expose its PUBLIC API to another AI client. GOAL Produce a compact, header-like listing of all PUBLIC symbols callers would use. OUTPUT FORMAT (plain text only; no bullets/markdown/JSON): 1) Public data structures required by public functions (types/structs/interfaces/enums/const groups): (lines A–B) 2) Public functions/methods in order of appearance: (lines A–B) RULES - PUBLIC means exported/externally visible for the language (Go: capitalized; Java/C#/TS: public; Rust: pub; Python: not underscore-prefixed, etc.). - Include ALL public functions/methods. - Include public data structures ONLY if referenced by any public function OR commonly constructed/consumed by callers. - For multi-line declarations, emit a single-line canonical form by collapsing internal whitespace while preserving tokens and order. - The one-line comment is either a compressed docstring or, if absent, a concise inferred purpose (≤ 20 words). - Include approximate line ranges as "(lines A–B)". - Skip private helpers, tests, examples, and internal-only constants. - Preserve generics/annotations/modifiers as they appear (e.g., type params, async, const, noexcept). - No preface or epilogue text—just the listing. EXAMPLE STYLE (illustrative; use the target language's comment syntax): // Configuration for the proxy (lines 10–42) type ProxyConfig struct { ... } // Creates and configures a new proxy instance (lines 60–92) func NewProxy(cfg ProxyConfig) (*Proxy, error) // Handles a single HTTP request (lines 95–168) func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)` // HTMLContentPrompt is the prompt for converting HTML to content-focused Markdown HTMLContentPrompt = `Convert the following stripped HTML into clean Markdown for READING CONTENT ONLY. - Output Markdown ONLY (no explanations, no JSON, no code fences). - Keep document title as a single H1 if present (from or first <h1>). - Preserve headings (h1–h6), paragraphs, strong/emphasis, inline code. - Convert <a> to [text](absolute_url). If href is relative, resolve against BASE_URL: {{BASE_URL}}. Do not output javascript:void links. - Preserve lists (ul/ol, nested), blockquotes, and code blocks (<pre><code>) as fenced code (include language if obvious). - Convert tables to Markdown tables; keep header row; include up to 50 data rows, then append "… (more rows)". - Keep images ONLY if alt text is descriptive; render as ![alt](absolute_url). Skip tracking pixels and decorative images. - Discard navigation, site header/footer, sidebars, cookie banners, search bars, newsletter/signup, social share, repetitive link clouds, and legal boilerplate unless they are the ONLY content. - Preserve in-page structure order; do not invent content; do not summarize prose—extract faithfully. - Normalize whitespace, collapse repeated blank lines to one. ` // HTMLFullPrompt is the prompt for converting HTML to navigation-focused Markdown HTMLFullPrompt = `Convert the following stripped HTML into Markdown optimized for SITE NAVIGATION. - Output Markdown ONLY (no explanations, no JSON, no code fences). - Start with a top-level title (from <title> or first <h1>) as H1. - Include primary navigation as a section "## Navigation" with bullet lists of top-level links (use visible link text; dedupe exact duplicates). - Include secondary nav/footer links under "## Footer Links". - Then extract the main page content as Markdown (headings, paragraphs, lists, blockquotes, code blocks). - Convert <a> to [text](absolute_url). If href is relative, resolve against BASE_URL: {{BASE_URL}}. - Convert tables to Markdown tables; keep header + up to 50 rows, then "… (more rows)". - Keep images with meaningful alt as ![alt](absolute_url); otherwise skip. - Preserve order as it appears in the page; do not summarize prose—extract faithfully. - Normalize whitespace; collapse repeated blank lines.` ) // SummarizeOpts contains options for file summarization type SummarizeOpts struct { APIKey string Mode string } // GoogleUsage represents token usage information from Google's Generative AI API type GoogleUsage struct { PromptTokenCount int32 `json:"prompt_token_count"` CachedContentTokenCount int32 `json:"cached_content_token_count"` CandidatesTokenCount int32 `json:"candidates_token_count"` TotalTokenCount int32 `json:"total_token_count"` } func detectMimeType(data []byte) string { mimeType := http.DetectContentType(data) return strings.Split(mimeType, ";")[0] } func getMaxFileSize(mimeType, mode string) (int, string) { if mimeType == "application/pdf" { return 5 * 1024 * 1024, "5MB" } if strings.HasPrefix(mimeType, "image/") { return 7 * 1024 * 1024, "7MB" } if mode == ModeHTMLContent || mode == ModeHTMLFull { return 500 * 1024, "500KB" } return 200 * 1024, "200KB" } // SummarizeFile reads a file and generates a summary using Google's Generative AI. // It supports images, PDFs, and text files based on the limits defined in wshcmd-ai.go. // Returns the summary text, usage information, and any error encountered. func SummarizeFile(ctx context.Context, filename string, opts SummarizeOpts) (string, *GoogleUsage, error) { if opts.Mode == "" { return "", nil, fmt.Errorf("mode is required") } // Read the file data, err := os.ReadFile(filename) if err != nil { return "", nil, fmt.Errorf("reading file: %w", err) } // Detect MIME type mimeType := detectMimeType(data) isPDF := mimeType == "application/pdf" isImage := strings.HasPrefix(mimeType, "image/") if !isPDF && !isImage { mimeType = "text/plain" if utilfn.ContainsBinaryData(data) { return "", nil, fmt.Errorf("file contains binary data and cannot be summarized") } } // Validate file size maxSize, sizeStr := getMaxFileSize(mimeType, opts.Mode) if len(data) > maxSize { return "", nil, fmt.Errorf("file exceeds maximum size of %s for %s files", sizeStr, mimeType) } // Create client client, err := genai.NewClient(ctx, option.WithAPIKey(opts.APIKey)) if err != nil { return "", nil, fmt.Errorf("creating Google AI client: %w", err) } defer client.Close() // Create model model := client.GenerativeModel(SummarizeModel) // Select prompt based on mode var prompt string switch opts.Mode { case ModeQuickSummary: prompt = QuickSummaryPrompt case ModeUseful: prompt = UsefulSummaryPrompt case ModePublicCode: prompt = PublicCodeSummaryPrompt case ModeHTMLContent: prompt = HTMLContentPrompt case ModeHTMLFull: prompt = HTMLFullPrompt default: prompt = SummarizePrompt } // Prepare the content parts var parts []genai.Part // Add the prompt parts = append(parts, genai.Text(prompt)) // Add the file content based on type if isImage { // For images, use Blob parts = append(parts, genai.Blob{ MIMEType: mimeType, Data: data, }) } else if isPDF { // For PDFs, use Blob parts = append(parts, genai.Blob{ MIMEType: mimeType, Data: data, }) } else { // For text files, convert to string parts = append(parts, genai.Text(string(data))) } // Generate content resp, err := model.GenerateContent(ctx, parts...) if err != nil { return "", nil, fmt.Errorf("generating content: %w", err) } // Check if we got any candidates if len(resp.Candidates) == 0 { return "", nil, fmt.Errorf("no response candidates returned") } // Extract the text from the first candidate candidate := resp.Candidates[0] if candidate.Content == nil || len(candidate.Content.Parts) == 0 { return "", nil, fmt.Errorf("no content in response") } var summary strings.Builder for _, part := range candidate.Content.Parts { if textPart, ok := part.(genai.Text); ok { summary.WriteString(string(textPart)) } } // Convert usage metadata var usage *GoogleUsage if resp.UsageMetadata != nil { usage = &GoogleUsage{ PromptTokenCount: resp.UsageMetadata.PromptTokenCount, CachedContentTokenCount: resp.UsageMetadata.CachedContentTokenCount, CandidatesTokenCount: resp.UsageMetadata.CandidatesTokenCount, TotalTokenCount: resp.UsageMetadata.TotalTokenCount, } } return summary.String(), usage, nil } ================================================ FILE: pkg/aiusechat/google/google-summarize_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package google import ( "context" "os" "path/filepath" "testing" "time" ) func TestDetectMimeType(t *testing.T) { tests := []struct { name string data []byte expected string }{ { name: "plain text", data: []byte("Hello, World!"), expected: "text/plain", }, { name: "empty file", data: []byte{}, expected: "text/plain", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := detectMimeType(tt.data) if !containsMimeType(result, tt.expected) { t.Errorf("detectMimeType() = %v, want to contain %v", result, tt.expected) } }) } } func containsMimeType(got, want string) bool { // DetectContentType may return variations like "text/plain; charset=utf-8" return got == want || (want == "text/plain" && got == "text/plain; charset=utf-8") } func TestSummarizeFile_FileNotFound(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _, err := SummarizeFile(ctx, "/nonexistent/file.txt", SummarizeOpts{ APIKey: "fake-api-key", Mode: ModeQuickSummary, }) if err == nil { t.Error("SummarizeFile() expected error for nonexistent file, got nil") } } func TestSummarizeFile_BinaryFile(t *testing.T) { // Create a temporary binary file tmpDir := t.TempDir() binFile := filepath.Join(tmpDir, "test.bin") // Create binary data (not text, image, or PDF) binaryData := []byte{0x00, 0x01, 0x02, 0x03, 0x7F, 0x80, 0xFF} if err := os.WriteFile(binFile, binaryData, 0644); err != nil { t.Fatalf("Failed to create test file: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _, err := SummarizeFile(ctx, binFile, SummarizeOpts{ APIKey: "fake-api-key", Mode: ModeQuickSummary, }) if err == nil { t.Error("SummarizeFile() expected error for binary file, got nil") } if err != nil && !containsString(err.Error(), "binary data") { t.Errorf("SummarizeFile() error = %v, want error containing 'binary data'", err) } } func TestSummarizeFile_FileTooLarge(t *testing.T) { // Create a temporary text file that exceeds the limit tmpDir := t.TempDir() textFile := filepath.Join(tmpDir, "large.txt") // Create a file larger than 200KB (text file limit) largeData := make([]byte, 201*1024) for i := range largeData { largeData[i] = 'a' } if err := os.WriteFile(textFile, largeData, 0644); err != nil { t.Fatalf("Failed to create test file: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _, err := SummarizeFile(ctx, textFile, SummarizeOpts{ APIKey: "fake-api-key", Mode: ModeQuickSummary, }) if err == nil { t.Error("SummarizeFile() expected error for file too large, got nil") } if err != nil && !containsString(err.Error(), "exceeds maximum size") { t.Errorf("SummarizeFile() error = %v, want error containing 'exceeds maximum size'", err) } } func containsString(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && stringContains(s, substr))) } func stringContains(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // Note: We don't test the actual API call without a real API key // Integration tests would require setting GOOGLE_API_KEY environment variable ================================================ FILE: pkg/aiusechat/openai/openai-backend.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package openai import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/google/uuid" "github.com/launchdarkly/eventsource" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/web/sse" ) // sanitizeHostnameInError removes the Wave cloud hostname from error messages func sanitizeHostnameInError(err error) error { if err == nil { return nil } errStr := err.Error() parsedURL, parseErr := url.Parse(uctypes.DefaultAIEndpoint) if parseErr == nil && parsedURL.Host != "" { if strings.Contains(errStr, parsedURL.Host) { errStr = strings.ReplaceAll(errStr, uctypes.DefaultAIEndpoint, "AI service") errStr = strings.ReplaceAll(errStr, parsedURL.Host, "host") } } return fmt.Errorf("%s", errStr) } // ---------- OpenAI wire types (subset) ---------- type OpenAIChatMessage struct { MessageId string `json:"messageid"` // internal field for idempotency (cannot send to openai) Message *OpenAIMessage `json:"message,omitempty"` FunctionCall *OpenAIFunctionCallInput `json:"functioncall,omitempty"` FunctionCallOutput *OpenAIFunctionCallOutputInput `json:"functioncalloutput,omitempty"` Usage *OpenAIUsage } type OpenAIMessage struct { Role string `json:"role"` Content []OpenAIMessageContent `json:"content"` } type OpenAIFunctionCallInput struct { Type string `json:"type"` // Required: The type of the function tool call. Always function_call CallId string `json:"call_id"` // Required: The unique ID of the function tool call generated by the model Name string `json:"name"` // Required: The name of the function to run Arguments string `json:"arguments"` // Required: A JSON string of the arguments to pass to the function Status string `json:"status,omitempty"` // Optional: The status of the item. One of in_progress, completed, or incomplete ToolUseData *uctypes.UIMessageDataToolUse `json:"toolusedata,omitempty"` // Internal field for UI tool use data (must be cleaned before sending to API) // removed the "id" field (optional to send back in inputs) } type OpenAIFunctionCallOutputInput struct { Type string `json:"type"` // Required: The type of the function tool call output. Always function_call_output CallId string `json:"call_id"` // Required: The unique ID of the function tool call generated by the model Output interface{} `json:"output"` // Required: Text, image, or file output of the function tool call // removed "status" field (not required for inputs) // removed the "id" field (optional to send back in inputs) } type OpenAIFunctionCallErrorOutput struct { Ok string `json:"ok"` Error string `json:"error"` } type OpenAIMessageContent struct { Type string `json:"type"` // "input_text", "output_text", "input_image", "input_file", "function_call" Text string `json:"text,omitempty"` ImageUrl string `json:"image_url,omitempty"` PreviewUrl string `json:"previewurl,omitempty"` // internal field for 128x128 webp data url (cannot send to API) Filename string `json:"filename,omitempty"` FileData string `json:"file_data,omitempty"` // for Tools (type will be "function_call") Arguments any `json:"arguments,omitempty"` CallId string `json:"call_id,omitempty"` Name string `json:"name,omitempty"` } func (c *OpenAIMessageContent) clean() *OpenAIMessageContent { if c.PreviewUrl == "" && (c.Type != "input_image" || c.Filename == "") { return c } rtn := *c rtn.PreviewUrl = "" if c.Type == "input_image" { rtn.Filename = "" } return &rtn } func (m *OpenAIMessage) cleanAndCopy() *OpenAIMessage { rtn := &OpenAIMessage{Role: m.Role} rtn.Content = make([]OpenAIMessageContent, len(m.Content)) for idx, block := range m.Content { cleaned := block.clean() rtn.Content[idx] = *cleaned } return rtn } func (f *OpenAIFunctionCallInput) clean() *OpenAIFunctionCallInput { if f.ToolUseData == nil { return f } rtn := *f rtn.ToolUseData = nil return &rtn } type openAIErrorResponse struct { Error openAIErrorType `json:"error"` } type openAIErrorType struct { Message string `json:"message"` Type string `json:"type"` Code string `json:"code"` } func (m *OpenAIChatMessage) GetMessageId() string { return m.MessageId } func (m *OpenAIChatMessage) GetRole() string { if m.Message != nil { return m.Message.Role } return "" } func (m *OpenAIChatMessage) GetUsage() *uctypes.AIUsage { if m.Usage == nil { return nil } return &uctypes.AIUsage{ APIType: uctypes.APIType_OpenAIResponses, Model: m.Usage.Model, InputTokens: m.Usage.InputTokens, OutputTokens: m.Usage.OutputTokens, NativeWebSearchCount: m.Usage.NativeWebSearchCount, } } // ---------- OpenAI SSE Event Types ---------- type openaiResponseCreatedEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` Response openaiResponse `json:"response"` } type openaiResponseInProgressEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` Response openaiResponse `json:"response"` } type openaiResponseOutputItemAddedEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` OutputIndex int `json:"output_index"` Item openaiOutputItem `json:"item"` } type openaiResponseOutputItemDoneEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` OutputIndex int `json:"output_index"` Item openaiOutputItem `json:"item"` } type openaiResponseContentPartAddedEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` ItemId string `json:"item_id"` OutputIndex int `json:"output_index"` ContentIndex int `json:"content_index"` Part OpenAIMessageContent `json:"part"` } type openaiResponseOutputTextDeltaEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` ItemId string `json:"item_id"` OutputIndex int `json:"output_index"` ContentIndex int `json:"content_index"` Delta string `json:"delta"` Logprobs []string `json:"logprobs"` Obfuscation string `json:"obfuscation"` } type openaiResponseOutputTextDoneEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` ItemId string `json:"item_id"` OutputIndex int `json:"output_index"` ContentIndex int `json:"content_index"` Text string `json:"text"` Logprobs []string `json:"logprobs"` } type openaiResponseContentPartDoneEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` ItemId string `json:"item_id"` OutputIndex int `json:"output_index"` ContentIndex int `json:"content_index"` Part OpenAIMessageContent `json:"part"` } type openaiResponseCompletedEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` Response openaiResponse `json:"response"` } type openaiResponseFunctionCallArgumentsDeltaEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` ItemId string `json:"item_id"` OutputIndex int `json:"output_index"` Delta string `json:"delta"` } type openaiResponseFunctionCallArgumentsDoneEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` ItemId string `json:"item_id"` OutputIndex int `json:"output_index"` Arguments string `json:"arguments"` } type openaiResponseReasoningSummaryPartAddedEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` ItemId string `json:"item_id"` OutputIndex int `json:"output_index"` SummaryIndex int `json:"summary_index"` Part openaiReasoningSummaryPart `json:"part"` } type openaiResponseReasoningSummaryPartDoneEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` ItemId string `json:"item_id"` OutputIndex int `json:"output_index"` SummaryIndex int `json:"summary_index"` Part openaiReasoningSummaryPart `json:"part"` } type openaiReasoningSummaryPart struct { Type string `json:"type"` Text string `json:"text"` } type openaiResponseReasoningSummaryTextDeltaEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` ItemId string `json:"item_id"` OutputIndex int `json:"output_index"` SummaryIndex int `json:"summary_index"` Delta string `json:"delta"` } type openaiResponseReasoningSummaryTextDoneEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number"` ItemId string `json:"item_id"` OutputIndex int `json:"output_index"` SummaryIndex int `json:"summary_index"` Text string `json:"text"` } // ---------- OpenAI Response Structure Types ---------- type openaiResponse struct { Id string `json:"id"` Object string `json:"object"` CreatedAt int64 `json:"created_at"` Status string `json:"status"` Background bool `json:"background"` Error *openaiError `json:"error"` IncompleteDetails *openaiIncompleteInfo `json:"incomplete_details"` Instructions *string `json:"instructions"` MaxOutputTokens *int `json:"max_output_tokens"` MaxToolCalls *int `json:"max_tool_calls"` Model string `json:"model"` Output []openaiOutputItem `json:"output"` ParallelToolCalls bool `json:"parallel_tool_calls"` PreviousResponseId *string `json:"previous_response_id"` PromptCacheKey *string `json:"prompt_cache_key"` Reasoning openaiReasoning `json:"reasoning"` SafetyIdentifier *string `json:"safety_identifier"` ServiceTier string `json:"service_tier"` Store bool `json:"store"` Temperature float64 `json:"temperature"` Text openaiTextConfig `json:"text"` ToolChoice string `json:"tool_choice"` Tools []OpenAIRequestTool `json:"tools"` TopLogprobs int `json:"top_logprobs"` TopP float64 `json:"top_p"` Truncation string `json:"truncation"` Usage *OpenAIUsage `json:"usage"` User *string `json:"user"` Metadata map[string]interface{} `json:"metadata"` } type openaiOutputItem struct { Id string `json:"id"` Type string `json:"type"` Status string `json:"status,omitempty"` Content []OpenAIMessageContent `json:"content,omitempty"` Role string `json:"role,omitempty"` Summary []openaiReasoningSummaryPart `json:"summary,omitempty"` // tools (type="function_call") Name string `json:"name,omitempty"` CallId string `json:"call_id,omitempty"` Arguments string `json:"arguments,omitempty"` } type openaiReasoning struct { Effort string `json:"effort"` Summary *string `json:"summary"` } type openaiTextConfig struct { Format openaiTextFormat `json:"format"` Verbosity string `json:"verbosity"` } type openaiTextFormat struct { Type string `json:"type"` } type OpenAIUsage struct { InputTokens int `json:"input_tokens,omitempty"` OutputTokens int `json:"output_tokens,omitempty"` TotalTokens int `json:"total_tokens,omitempty"` InputTokensDetails *openaiInputTokensDetails `json:"input_tokens_details,omitempty"` OutputTokensDetails *openaiOutputTokensDetails `json:"output_tokens_details,omitempty"` Model string `json:"model,omitempty"` // internal field (not from OpenAI API) NativeWebSearchCount int `json:"nativewebsearchcount,omitempty"` // internal field (not from OpenAI API) } type openaiInputTokensDetails struct { CachedTokens int `json:"cached_tokens"` } type openaiOutputTokensDetails struct { ReasoningTokens int `json:"reasoning_tokens"` } type openaiError struct { // Error details - can be expanded later } type openaiIncompleteInfo struct { Reason string `json:"reason"` } // ---------- OpenAI streaming state types ---------- type openaiBlockKind int const ( openaiBlockText openaiBlockKind = iota openaiBlockReasoning openaiBlockToolUse ) type openaiBlockState struct { kind openaiBlockKind localID string // For SSE streaming to UI toolCallID string // For function calls toolName string // For function calls summaryCount int // For reasoning: number of summary parts seen partialJSON []byte // For function calls: accumulated JSON arguments accumulatedText string // For text blocks: accumulated text content } type openaiStreamingState struct { blockMap map[string]*openaiBlockState // Use item_id as key for UI streaming msgID string model string stepStarted bool chatOpts uctypes.WaveChatOpts webSearchCount int } // ---------- Public entrypoint ---------- func UpdateToolUseData(chatId string, callId string, newToolUseData uctypes.UIMessageDataToolUse) error { chat := chatstore.DefaultChatStore.Get(chatId) if chat == nil { return fmt.Errorf("chat not found: %s", chatId) } for _, genMsg := range chat.NativeMessages { chatMsg, ok := genMsg.(*OpenAIChatMessage) if !ok { continue } if chatMsg.FunctionCall != nil && chatMsg.FunctionCall.CallId == callId { updatedMsg := *chatMsg updatedFunctionCall := *chatMsg.FunctionCall updatedFunctionCall.ToolUseData = &newToolUseData updatedMsg.FunctionCall = &updatedFunctionCall aiOpts := &uctypes.AIOptsType{ APIType: chat.APIType, Model: chat.Model, APIVersion: chat.APIVersion, } return chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, &updatedMsg) } } return fmt.Errorf("function call with callId %s not found in chat %s", callId, chatId) } func RemoveToolUseCall(chatId string, callId string) error { chat := chatstore.DefaultChatStore.Get(chatId) if chat == nil { return fmt.Errorf("chat not found: %s", chatId) } for _, genMsg := range chat.NativeMessages { chatMsg, ok := genMsg.(*OpenAIChatMessage) if !ok { continue } if chatMsg.FunctionCall != nil && chatMsg.FunctionCall.CallId == callId { chatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId) return nil } } return nil } func RunOpenAIChatStep( ctx context.Context, sse *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, []*OpenAIChatMessage, *uctypes.RateLimitInfo, error) { if sse == nil { return nil, nil, nil, errors.New("sse handler is nil") } // Get chat from store chat := chatstore.DefaultChatStore.Get(chatOpts.ChatId) if chat == nil { return nil, nil, nil, fmt.Errorf("chat not found: %s", chatOpts.ChatId) } // Validate that chatOpts.Config match the chat's stored configuration if chat.APIType != chatOpts.Config.APIType { return nil, nil, nil, fmt.Errorf("API type mismatch: chat has %s, chatOpts has %s", chat.APIType, chatOpts.Config.APIType) } if !uctypes.AreModelsCompatible(chat.APIType, chat.Model, chatOpts.Config.Model) { return nil, nil, nil, fmt.Errorf("model mismatch: chat has %s, chatOpts has %s", chat.Model, chatOpts.Config.Model) } if chat.APIVersion != chatOpts.Config.APIVersion { return nil, nil, nil, fmt.Errorf("API version mismatch: chat has %s, chatOpts has %s", chat.APIVersion, chatOpts.Config.APIVersion) } // Context with timeout if provided. if chatOpts.Config.TimeoutMs > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(chatOpts.Config.TimeoutMs)*time.Millisecond) defer cancel() } // Validate continuation if provided if cont != nil { if !uctypes.AreModelsCompatible(chat.APIType, chatOpts.Config.Model, cont.Model) { return nil, nil, nil, fmt.Errorf("cannot continue with a different model, model:%q, cont-model:%q", chatOpts.Config.Model, cont.Model) } } // Convert GenAIMessages to input objects (OpenAIMessage or OpenAIFunctionCallInput) var inputs []any for _, genMsg := range chat.NativeMessages { // Cast to OpenAIChatMessage chatMsg, ok := genMsg.(*OpenAIChatMessage) if !ok { return nil, nil, nil, fmt.Errorf("expected OpenAIChatMessage, got %T", genMsg) } // Convert to appropriate input type based on what's populated if chatMsg.Message != nil { // Clean message to remove preview URLs cleanedMsg := chatMsg.Message.cleanAndCopy() inputs = append(inputs, *cleanedMsg) } else if chatMsg.FunctionCall != nil { cleanedFunctionCall := chatMsg.FunctionCall.clean() inputs = append(inputs, *cleanedFunctionCall) } else if chatMsg.FunctionCallOutput != nil { inputs = append(inputs, *chatMsg.FunctionCallOutput) } } req, err := buildOpenAIHTTPRequest(ctx, inputs, chatOpts, cont) if err != nil { return nil, nil, nil, err } httpClient, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL) if err != nil { return nil, nil, nil, err } resp, err := httpClient.Do(req) if err != nil { return nil, nil, nil, sanitizeHostnameInError(err) } defer resp.Body.Close() // Parse rate limit info from header if present (do this before error check) rateLimitInfo := uctypes.ParseRateLimitHeader(resp.Header.Get("X-Wave-RateLimit")) ct := resp.Header.Get("Content-Type") if resp.StatusCode != http.StatusOK || !strings.HasPrefix(ct, "text/event-stream") { // Handle 429 rate limit with special logic if resp.StatusCode == http.StatusTooManyRequests && rateLimitInfo != nil { if rateLimitInfo.PReq == 0 && rateLimitInfo.Req > 0 { // Premium requests exhausted, but regular requests available stopReason := &uctypes.WaveStopReason{ Kind: uctypes.StopKindPremiumRateLimit, } return stopReason, nil, rateLimitInfo, nil } if rateLimitInfo.Req == 0 { // All requests exhausted stopReason := &uctypes.WaveStopReason{ Kind: uctypes.StopKindRateLimit, } return stopReason, nil, rateLimitInfo, nil } } return nil, nil, rateLimitInfo, parseOpenAIHTTPError(resp) } // At this point we have a valid SSE stream, so setup SSE handling // From here on, errors must be returned through the SSE stream if cont == nil { sse.SetupSSE() } // Use eventsource decoder for proper SSE parsing decoder := eventsource.NewDecoder(resp.Body) stopReason, rtnMessages := handleOpenAIStreamingResp(ctx, sse, decoder, cont, chatOpts) return stopReason, rtnMessages, rateLimitInfo, nil } // parseOpenAIHTTPError parses OpenAI API HTTP error responses func parseOpenAIHTTPError(resp *http.Response) error { body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("openai %s: failed to read error response: %v", resp.Status, err) } logutil.DevPrintf("openai full error: %s\n", body) // Try to parse as OpenAI error format first var errorResp openAIErrorResponse if err := json.Unmarshal(body, &errorResp); err == nil && errorResp.Error.Message != "" { return fmt.Errorf("openai %s: %s", resp.Status, errorResp.Error.Message) } // Try to parse as proxy error format var proxyErr uctypes.ProxyErrorResponse if err := json.Unmarshal(body, &proxyErr); err == nil && !proxyErr.Success && proxyErr.Error != "" { return fmt.Errorf("openai %s: %s", resp.Status, proxyErr.Error) } return fmt.Errorf("openai %s: %s", resp.Status, utilfn.TruncateString(string(body), 120)) } // handleOpenAIStreamingResp handles the OpenAI SSE streaming response func handleOpenAIStreamingResp(ctx context.Context, sse *sse.SSEHandlerCh, decoder *eventsource.Decoder, cont *uctypes.WaveContinueResponse, chatOpts uctypes.WaveChatOpts) (*uctypes.WaveStopReason, []*OpenAIChatMessage) { // Per-response state state := &openaiStreamingState{ blockMap: map[string]*openaiBlockState{}, chatOpts: chatOpts, } var rtnStopReason *uctypes.WaveStopReason var rtnMessages []*OpenAIChatMessage // Ensure step is closed on error/cancellation defer func() { if !state.stepStarted { return } _ = sse.AiMsgFinishStep() if rtnStopReason == nil || rtnStopReason.Kind != uctypes.StopKindToolUse { _ = sse.AiMsgFinish("", nil) } }() // SSE event processing loop for { event, err := decoder.Decode() if err != nil { if errors.Is(err, io.EOF) { // EOF without proper completion - protocol error _ = sse.AiMsgError("stream ended unexpectedly without completion") return &uctypes.WaveStopReason{ Kind: uctypes.StopKindError, ErrorType: "protocol", ErrorText: "stream ended unexpectedly without completion", }, rtnMessages } // Check if client disconnected if sse.Err() != nil { // SSE connection broken (client stopped/disconnected) partialMessages := extractPartialTextFromState(state) if partialMessages != nil { rtnMessages = append(rtnMessages, partialMessages...) } return &uctypes.WaveStopReason{ Kind: uctypes.StopKindCanceled, ErrorType: "client_disconnect", ErrorText: "client disconnected", }, rtnMessages } // transport error mid-stream _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{ Kind: uctypes.StopKindError, ErrorType: "stream", ErrorText: err.Error(), }, rtnMessages } if finalStopReason, finalMessages := handleOpenAIEvent(event, sse, state, cont); finalStopReason != nil { rtnStopReason = finalStopReason if finalMessages != nil { rtnMessages = finalMessages } else if finalStopReason.Kind == uctypes.StopKindCanceled { partialMessages := extractPartialTextFromState(state) if partialMessages != nil { rtnMessages = append(rtnMessages, partialMessages...) } } return finalStopReason, rtnMessages } } // unreachable } // extractPartialTextFromState extracts accumulated text from streaming state when client disconnects func extractPartialTextFromState(state *openaiStreamingState) []*OpenAIChatMessage { var textContent []OpenAIMessageContent for _, blockState := range state.blockMap { if blockState.kind == openaiBlockText && blockState.accumulatedText != "" { textContent = append(textContent, OpenAIMessageContent{ Type: "output_text", Text: blockState.accumulatedText, }) } } if len(textContent) == 0 { return nil } assistantMessage := &OpenAIChatMessage{ MessageId: uuid.New().String(), Message: &OpenAIMessage{ Role: "assistant", Content: textContent, }, } return []*OpenAIChatMessage{assistantMessage} } // handleOpenAIEvent processes one SSE event block. It may emit SSE parts // and/or return a StopReason and final message when the stream is complete. // // Return tuple: // - final: a *StopReason to return immediately (e.g., after response.completed or error) // - message: a *OpenAIChatMessage when response is completed func handleOpenAIEvent( event eventsource.Event, sse *sse.SSEHandlerCh, state *openaiStreamingState, cont *uctypes.WaveContinueResponse, ) (final *uctypes.WaveStopReason, messages []*OpenAIChatMessage) { if err := sse.Err(); err != nil { return &uctypes.WaveStopReason{ Kind: uctypes.StopKindCanceled, ErrorType: "client_disconnect", ErrorText: "client disconnected", }, nil } eventName := event.Event() data := event.Data() switch eventName { case "response.created": var ev openaiResponseCreatedEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } state.msgID = ev.Response.Id state.model = ev.Response.Model if cont == nil { _ = sse.AiMsgStart(state.msgID) } return nil, nil case "response.in_progress": // Start the step on in_progress if !state.stepStarted { _ = sse.AiMsgStartStep() state.stepStarted = true } return nil, nil case "response.output_item.added": var ev openaiResponseOutputItemAddedEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } switch ev.Item.Type { case "reasoning": // Create reasoning block - emit start immediately id := uuid.New().String() state.blockMap[ev.Item.Id] = &openaiBlockState{ kind: openaiBlockReasoning, localID: id, summaryCount: 0, } _ = sse.AiMsgReasoningStart(id) case "message": // Message item - content parts will be handled in streaming events case "function_call": // Track function call info and notify frontend id := uuid.New().String() state.blockMap[ev.Item.Id] = &openaiBlockState{ kind: openaiBlockToolUse, localID: id, toolCallID: ev.Item.CallId, toolName: ev.Item.Name, } // no longer send tool inputs to FE // _ = sse.AiMsgToolInputStart(ev.Item.CallId, ev.Item.Name) } return nil, nil case "response.output_item.done": var ev openaiResponseOutputItemDoneEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } if st := state.blockMap[ev.Item.Id]; st != nil { switch st.kind { case openaiBlockReasoning: _ = sse.AiMsgReasoningEnd(st.localID) case openaiBlockToolUse: // Tool input completion notification was already sent in function_call_arguments.done // This just marks the end of the tool item itself } } return nil, nil case "response.content_part.added": var ev openaiResponseContentPartAddedEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } switch ev.Part.Type { case "output_text": // Handle text content for UI streaming only id := uuid.New().String() state.blockMap[ev.ItemId] = &openaiBlockState{ kind: openaiBlockText, localID: id, } _ = sse.AiMsgTextStart(id) } return nil, nil case "response.output_text.delta": var ev openaiResponseOutputTextDeltaEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockText { st.accumulatedText += ev.Delta _ = sse.AiMsgTextDelta(st.localID, ev.Delta) } return nil, nil case "response.output_text.done": return nil, nil case "response.content_part.done": var ev openaiResponseContentPartDoneEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockText { _ = sse.AiMsgTextEnd(st.localID) } return nil, nil case "response.completed", "response.failed", "response.incomplete": var ev openaiResponseCompletedEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } // Handle error case if ev.Response.Error != nil { errorMsg := "OpenAI API error" _ = sse.AiMsgError(errorMsg) return &uctypes.WaveStopReason{ Kind: uctypes.StopKindError, ErrorType: "api", ErrorText: errorMsg, }, nil } // Handle incomplete case if ev.Response.IncompleteDetails != nil { reason := ev.Response.IncompleteDetails.Reason var stopKind uctypes.StopReasonKind var errorMsg string switch reason { case "max_output_tokens": stopKind = uctypes.StopKindMaxTokens errorMsg = "Maximum output tokens reached" case "max_prompt_tokens": stopKind = uctypes.StopKindError errorMsg = "Maximum prompt tokens reached" case "content_filter": stopKind = uctypes.StopKindContent errorMsg = "Content filtered" default: stopKind = uctypes.StopKindError errorMsg = fmt.Sprintf("Response incomplete: %s", reason) } // Extract partial message if available finalMessages, _ := extractMessageAndToolsFromResponse(ev.Response, state) _ = sse.AiMsgError(errorMsg) return &uctypes.WaveStopReason{ Kind: stopKind, RawReason: reason, ErrorText: errorMsg, }, finalMessages } // Extract the final message and tool calls from the response output finalMessages, toolCalls := extractMessageAndToolsFromResponse(ev.Response, state) stopKind := uctypes.StopKindDone if len(toolCalls) > 0 { stopKind = uctypes.StopKindToolUse } return &uctypes.WaveStopReason{ Kind: stopKind, RawReason: ev.Response.Status, ToolCalls: toolCalls, }, finalMessages case "response.function_call_arguments.delta": var ev openaiResponseFunctionCallArgumentsDeltaEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockToolUse { st.partialJSON = append(st.partialJSON, []byte(ev.Delta)...) aiutil.SendToolProgress(st.toolCallID, st.toolName, st.partialJSON, state.chatOpts, sse, true) } return nil, nil case "response.function_call_arguments.done": var ev openaiResponseFunctionCallArgumentsDoneEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } // Get the function call info from the block state if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockToolUse { aiutil.SendToolProgress(st.toolCallID, st.toolName, []byte(ev.Arguments), state.chatOpts, sse, false) } return nil, nil case "response.web_search_call.in_progress": return nil, nil case "response.web_search_call.searching": return nil, nil case "response.web_search_call.completed": state.webSearchCount++ return nil, nil case "response.output_text.annotation.added": return nil, nil case "response.reasoning_summary_part.added": var ev openaiResponseReasoningSummaryPartAddedEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockReasoning { if st.summaryCount > 0 { // Not the first summary part, emit separator _ = sse.AiMsgReasoningDelta(st.localID, "\n\n") } st.summaryCount++ } return nil, nil case "response.reasoning_summary_part.done": return nil, nil case "response.reasoning_summary_text.delta": var ev openaiResponseReasoningSummaryTextDeltaEvent if err := json.Unmarshal([]byte(data), &ev); err != nil { _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockReasoning { _ = sse.AiMsgReasoningDelta(st.localID, ev.Delta) } return nil, nil case "response.reasoning_summary_text.done": return nil, nil default: logutil.DevPrintf("OpenAI: unknown event: %s, data: %s", eventName, data) return nil, nil } } // extractMessageAndToolsFromResponse extracts the final OpenAI message and tool calls from the completed response func extractMessageAndToolsFromResponse(resp openaiResponse, state *openaiStreamingState) ([]*OpenAIChatMessage, []uctypes.WaveToolCall) { var messageContent []OpenAIMessageContent var toolCalls []uctypes.WaveToolCall var messages []*OpenAIChatMessage // Process all output items in the response for _, outputItem := range resp.Output { switch outputItem.Type { case "message": if outputItem.Role == "assistant" { // Copy ALL content parts from the output item for _, contentPart := range outputItem.Content { messageContent = append(messageContent, OpenAIMessageContent{ Type: contentPart.Type, Text: contentPart.Text, }) } } case "function_call": // Extract tool call information toolCall := uctypes.WaveToolCall{ ID: outputItem.CallId, Name: outputItem.Name, } // Parse arguments JSON string if present var parsedArguments any if outputItem.Arguments != "" { if err := json.Unmarshal([]byte(outputItem.Arguments), &parsedArguments); err == nil { toolCall.Input = parsedArguments } } toolCalls = append(toolCalls, toolCall) // Create separate FunctionCall message var argsStr string if outputItem.Arguments != "" { argsStr = outputItem.Arguments } functionCallMsg := &OpenAIChatMessage{ MessageId: uuid.New().String(), FunctionCall: &OpenAIFunctionCallInput{ Type: "function_call", CallId: outputItem.CallId, Name: outputItem.Name, Arguments: argsStr, }, } messages = append(messages, functionCallMsg) } } // Create OpenAIChatMessage with assistant message (first in slice) usage := resp.Usage if usage != nil { resp.Usage.Model = resp.Model if state.webSearchCount > 0 { usage.NativeWebSearchCount = state.webSearchCount } } assistantMessage := &OpenAIChatMessage{ MessageId: uuid.New().String(), Message: &OpenAIMessage{ Role: "assistant", Content: messageContent, }, Usage: usage, } // Return assistant message first, followed by function call messages allMessages := []*OpenAIChatMessage{assistantMessage} allMessages = append(allMessages, messages...) return allMessages, toolCalls } ================================================ FILE: pkg/aiusechat/openai/openai-convertmessage.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package openai import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "log" "net/http" "strings" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/wavebase" ) const ( OpenAIDefaultAPIVersion = "2024-12-31" OpenAIDefaultMaxTokens = 4096 // "medium" verbosity is more widely supported across models than "low" OpenAIDefaultVerbosity = "medium" ) // convertContentBlockToParts converts a single content block to UIMessageParts func convertContentBlockToParts(block OpenAIMessageContent, role string) []uctypes.UIMessagePart { var parts []uctypes.UIMessagePart switch block.Type { case "input_text", "output_text": if found, part := aiutil.ConvertDataUserFile(block.Text); found { if part != nil { parts = append(parts, *part) } } else { parts = append(parts, uctypes.UIMessagePart{ Type: "text", Text: block.Text, }) } case "input_image": if role == "user" { parts = append(parts, uctypes.UIMessagePart{ Type: "data-userfile", Data: uctypes.UIMessageDataUserFile{ FileName: block.Filename, MimeType: "image/*", PreviewUrl: block.PreviewUrl, }, }) } case "input_file": if role == "user" { parts = append(parts, uctypes.UIMessagePart{ Type: "data-userfile", Data: uctypes.UIMessageDataUserFile{ FileName: block.Filename, MimeType: "application/pdf", PreviewUrl: block.PreviewUrl, }, }) } } return parts } // appendToLastUserMessage appends a text block to the last user message in the inputs slice func appendToLastUserMessage(inputs []any, text string) { for i := len(inputs) - 1; i >= 0; i-- { if msg, ok := inputs[i].(OpenAIMessage); ok && msg.Role == "user" { block := OpenAIMessageContent{ Type: "input_text", Text: text, } msg.Content = append(msg.Content, block) inputs[i] = msg break } } } // ---------- OpenAI Request Types ---------- type StreamOptionsType struct { IncludeObfuscation bool `json:"include_obfuscation"` } type ReasoningType struct { Effort string `json:"effort,omitempty"` // "minimal", "low", "medium", "high" Summary string `json:"summary,omitempty"` // "auto", "concise", "detailed" } type TextType struct { Format interface{} `json:"format,omitempty"` // Format object, e.g. {"type": "text"}, {"type": "json_object"}, {"type": "json_schema"} Verbosity string `json:"verbosity,omitempty"` // "low", "medium", "high" } type PromptType struct { ID string `json:"id"` Variables map[string]interface{} `json:"variables,omitempty"` Version string `json:"version,omitempty"` } type OpenAIRequest struct { Background bool `json:"background,omitempty"` Conversation string `json:"conversation,omitempty"` Include []string `json:"include,omitempty"` Input []any `json:"input,omitempty"` // either OpenAIMessage or OpenAIFunctionCallInput Instructions string `json:"instructions,omitempty"` MaxOutputTokens int `json:"max_output_tokens,omitempty"` MaxToolCalls int `json:"max_tool_calls,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Model string `json:"model,omitempty"` ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"` Prompt *PromptType `json:"prompt,omitempty"` PromptCacheKey string `json:"prompt_cache_key,omitempty"` Reasoning *ReasoningType `json:"reasoning,omitempty"` SafetyIdentifier string `json:"safety_identifier,omitempty"` ServiceTier string `json:"service_tier,omitempty"` // "auto", "default", "flex", "priority" Store bool `json:"store,omitempty"` Stream bool `json:"stream,omitempty"` StreamOptions *StreamOptionsType `json:"stream_options,omitempty"` Temperature float64 `json:"temperature,omitempty"` Text *TextType `json:"text,omitempty"` ToolChoice interface{} `json:"tool_choice,omitempty"` // "none", "auto", "required", or object Tools []OpenAIRequestTool `json:"tools,omitempty"` TopLogprobs int `json:"top_logprobs,omitempty"` TopP float64 `json:"top_p,omitempty"` Truncation string `json:"truncation,omitempty"` // "auto", "disabled" } type OpenAIRequestTool struct { Type string `json:"type"` Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Parameters any `json:"parameters,omitempty"` Strict bool `json:"strict,omitempty"` } // ConvertToolDefinitionToOpenAI converts a generic ToolDefinition to OpenAI format func ConvertToolDefinitionToOpenAI(tool uctypes.ToolDefinition) OpenAIRequestTool { return OpenAIRequestTool{ Name: tool.Name, Description: tool.Description, Parameters: tool.InputSchema, Strict: tool.Strict, Type: "function", } } func debugPrintReq(req *OpenAIRequest, endpoint string) { if !wavebase.IsDevMode() { return } if endpoint != uctypes.DefaultAIEndpoint { log.Printf("endpoint: %s\n", endpoint) } var toolNames []string for _, tool := range req.Tools { toolNames = append(toolNames, tool.Name) } modelInfo := req.Model var details []string if req.Reasoning != nil && req.Reasoning.Effort != "" { details = append(details, fmt.Sprintf("reasoning: %s", req.Reasoning.Effort)) } if req.MaxOutputTokens > 0 { details = append(details, fmt.Sprintf("max_tokens: %d", req.MaxOutputTokens)) } if len(details) > 0 { log.Printf("model %s (%s)\n", modelInfo, strings.Join(details, ", ")) } else { log.Printf("model %s\n", modelInfo) } if len(toolNames) > 0 { log.Printf("tools: %s\n", strings.Join(toolNames, ",")) } log.Printf("inputs (%d):", len(req.Input)) for idx, input := range req.Input { debugPrintInput(idx, input) } } // buildOpenAIHTTPRequest creates a complete HTTP request for the OpenAI API func buildOpenAIHTTPRequest(ctx context.Context, inputs []any, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse) (*http.Request, error) { opts := chatOpts.Config // If continuing from premium rate limit, downgrade to default model and medium thinking // (medium is more widely supported than low across different models) if cont != nil && cont.ContinueFromKind == uctypes.StopKindPremiumRateLimit { opts.Model = uctypes.DefaultOpenAIModel opts.ThinkingLevel = uctypes.ThinkingLevelMedium } if opts.Model == "" { return nil, errors.New("ai:model is required") } if chatOpts.ClientId == "" { return nil, errors.New("chatOpts.ClientId is required") } // Set defaults endpoint := opts.Endpoint if endpoint == "" { return nil, errors.New("ai:endpoint is required") } maxTokens := opts.MaxTokens if maxTokens <= 0 { maxTokens = OpenAIDefaultMaxTokens } // injected data if chatOpts.TabState != "" { appendToLastUserMessage(inputs, chatOpts.TabState) } if chatOpts.PlatformInfo != "" { appendToLastUserMessage(inputs, "<PlatformInfo>\n"+chatOpts.PlatformInfo+"\n</PlatformInfo>") } if chatOpts.AppStaticFiles != "" { appendToLastUserMessage(inputs, "<CurrentAppStaticFiles>\n"+chatOpts.AppStaticFiles+"\n</CurrentAppStaticFiles>") } if chatOpts.AppGoFile != "" { appendToLastUserMessage(inputs, "<CurrentAppGoFile>\n"+chatOpts.AppGoFile+"\n</CurrentAppGoFile>") } // Build request body // Use configured verbosity, or fall back to default constant verbosity := opts.Verbosity if verbosity == "" { verbosity = OpenAIDefaultVerbosity } reqBody := &OpenAIRequest{ Model: opts.Model, Input: inputs, Stream: true, StreamOptions: &StreamOptionsType{IncludeObfuscation: false}, MaxOutputTokens: maxTokens, Text: &TextType{Verbosity: verbosity}, } // Add system prompt as instructions if provided if len(chatOpts.SystemPrompt) > 0 { reqBody.Instructions = strings.Join(chatOpts.SystemPrompt, "\n") } // Add tools if provided if len(chatOpts.Tools) > 0 { tools := make([]OpenAIRequestTool, len(chatOpts.Tools)) for i, tool := range chatOpts.Tools { tools[i] = ConvertToolDefinitionToOpenAI(tool) } reqBody.Tools = tools } for _, tool := range chatOpts.TabTools { convertedTool := ConvertToolDefinitionToOpenAI(tool) reqBody.Tools = append(reqBody.Tools, convertedTool) } // Add native web search tool if enabled if chatOpts.AllowNativeWebSearch { webSearchTool := OpenAIRequestTool{ Type: "web_search", } reqBody.Tools = append(reqBody.Tools, webSearchTool) } // Set reasoning based on thinking level from config if opts.ThinkingLevel != "" { reqBody.Reasoning = &ReasoningType{ Effort: opts.ThinkingLevel, } if opts.Model == "gpt-5" || opts.Model == "gpt-5.1" { reqBody.Reasoning.Summary = "auto" } } debugPrintReq(reqBody, endpoint) // Encode request body buf, err := aiutil.JsonEncodeRequestBody(reqBody) if err != nil { return nil, err } // Create HTTP request req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf) if err != nil { return nil, err } // Set headers req.Header.Set("Content-Type", "application/json") // Azure OpenAI uses "api-key" header instead of "Authorization: Bearer" if opts.Provider == uctypes.AIProvider_Azure || opts.Provider == uctypes.AIProvider_AzureLegacy { req.Header.Set("api-key", opts.APIToken) } else { req.Header.Set("Authorization", "Bearer "+opts.APIToken) } req.Header.Set("Accept", "text/event-stream") // Only send Wave-specific headers when using Wave provider if opts.Provider == uctypes.AIProvider_Wave { if chatOpts.ClientId != "" { req.Header.Set("X-Wave-ClientId", chatOpts.ClientId) } if chatOpts.ChatId != "" { req.Header.Set("X-Wave-ChatId", chatOpts.ChatId) } req.Header.Set("X-Wave-Version", wavebase.WaveVersion) req.Header.Set("X-Wave-APIType", uctypes.APIType_OpenAIResponses) req.Header.Set("X-Wave-RequestType", chatOpts.GetWaveRequestType()) } return req, nil } // convertFileAIMessagePart converts a file AIMessagePart to OpenAI format func convertFileAIMessagePart(part uctypes.AIMessagePart) (*OpenAIMessageContent, error) { if part.Type != uctypes.AIMessagePartTypeFile { return nil, fmt.Errorf("convertFileAIMessagePart expects 'file' type, got '%s'", part.Type) } if part.MimeType == "" { return nil, fmt.Errorf("file part missing mimetype") } // Handle different file types switch { case strings.HasPrefix(part.MimeType, "image/"): imageUrl, err := aiutil.ExtractImageUrl(part.Data, part.URL, part.MimeType) if err != nil { return nil, err } return &OpenAIMessageContent{ Type: "input_image", ImageUrl: imageUrl, Filename: part.FileName, PreviewUrl: part.PreviewUrl, }, nil case part.MimeType == "application/pdf": // Handle PDFs - OpenAI only supports base64 data for PDFs, not URLs if len(part.Data) == 0 { if part.URL != "" { return nil, fmt.Errorf("dropping PDF with URL (must be fetched and converted to base64 data)") } return nil, fmt.Errorf("PDF file part missing data") } // Convert raw data to base64 base64Data := base64.StdEncoding.EncodeToString(part.Data) return &OpenAIMessageContent{ Type: "input_file", Filename: part.FileName, // Optional filename FileData: base64Data, PreviewUrl: part.PreviewUrl, }, nil case part.MimeType == "text/plain": textData, err := aiutil.ExtractTextData(part.Data, part.URL) if err != nil { return nil, err } formattedText := aiutil.FormatAttachedTextFile(part.FileName, textData) return &OpenAIMessageContent{ Type: "input_text", Text: formattedText, }, nil case part.MimeType == "directory": var jsonContent string if len(part.Data) > 0 { jsonContent = string(part.Data) } else { return nil, fmt.Errorf("directory listing part missing data") } formattedText := aiutil.FormatAttachedDirectoryListing(part.FileName, jsonContent) return &OpenAIMessageContent{ Type: "input_text", Text: formattedText, }, nil default: return nil, fmt.Errorf("dropping file with unsupported mimetype '%s' (OpenAI supports images, PDFs, text/plain, and directories)", part.MimeType) } } // ConvertAIMessageToOpenAIChatMessage converts an AIMessage to OpenAIChatMessage // These messages are ALWAYS role "user" // Handles text parts, images, PDFs, and text/plain files func ConvertAIMessageToOpenAIChatMessage(aiMsg uctypes.AIMessage) (*OpenAIChatMessage, error) { if err := aiMsg.Validate(); err != nil { return nil, fmt.Errorf("invalid AIMessage: %w", err) } var contentBlocks []OpenAIMessageContent imageCount := 0 imageFailCount := 0 for i, part := range aiMsg.Parts { switch part.Type { case uctypes.AIMessagePartTypeText: if part.Text == "" { return nil, fmt.Errorf("part %d: text type requires non-empty text field", i) } contentBlocks = append(contentBlocks, OpenAIMessageContent{ Type: "input_text", Text: part.Text, }) case uctypes.AIMessagePartTypeFile: if strings.HasPrefix(part.MimeType, "image/") { imageCount++ } block, err := convertFileAIMessagePart(part) if err != nil { if strings.HasPrefix(part.MimeType, "image/") { imageFailCount++ } log.Printf("openai: %v", err) continue } contentBlocks = append(contentBlocks, *block) default: // Drop unknown part types log.Printf("openai: dropping unknown part type '%s'", part.Type) continue } } if len(contentBlocks) == 0 { if imageCount > 0 && imageFailCount == imageCount { return nil, fmt.Errorf("all %d image conversions failed", imageCount) } return nil, errors.New("message has no valid content after processing all parts") } return &OpenAIChatMessage{ MessageId: aiMsg.MessageId, Message: &OpenAIMessage{ Role: "user", Content: contentBlocks, }, }, nil } // ConvertToolResultsToOpenAIChatMessage converts AIToolResult slice to OpenAIChatMessage slice func ConvertToolResultsToOpenAIChatMessage(toolResults []uctypes.AIToolResult) ([]*OpenAIChatMessage, error) { if len(toolResults) == 0 { return nil, errors.New("toolResults cannot be empty") } var messages []*OpenAIChatMessage for _, result := range toolResults { if result.ToolUseID == "" { return nil, fmt.Errorf("tool result missing ToolUseID") } // Create the function call output with result data var outputData any if result.ErrorText != "" { // Marshal error output to string errorOutput := OpenAIFunctionCallErrorOutput{ Ok: "false", Error: result.ErrorText, } errorBytes, err := json.Marshal(errorOutput) if err != nil { return nil, fmt.Errorf("failed to marshal error output: %w", err) } outputData = string(errorBytes) } else { // Check if text looks like an image data URL if strings.HasPrefix(result.Text, "data:image/") { // Convert to output array with input_image type outputData = []OpenAIMessageContent{ { Type: "input_image", ImageUrl: result.Text, }, } } else { // Use text result for success outputData = result.Text } } functionCallOutput := &OpenAIFunctionCallOutputInput{ Type: "function_call_output", CallId: result.ToolUseID, Output: outputData, } messages = append(messages, &OpenAIChatMessage{ MessageId: uuid.New().String(), FunctionCallOutput: functionCallOutput, }) } return messages, nil } // convertToUIMessage converts an OpenAIChatMessage to a UIMessage func (m *OpenAIChatMessage) convertToUIMessage() *uctypes.UIMessage { var parts []uctypes.UIMessagePart var role string // Handle different message types if m.Message != nil { role = m.Message.Role for _, block := range m.Message.Content { blockParts := convertContentBlockToParts(block, role) parts = append(parts, blockParts...) } } else if m.FunctionCall != nil { // Handle function call input role = "assistant" if m.FunctionCall.ToolUseData != nil { parts = append(parts, uctypes.UIMessagePart{ Type: "data-tooluse", ID: m.FunctionCall.CallId, Data: *m.FunctionCall.ToolUseData, }) } } else if m.FunctionCallOutput != nil { // FunctionCallOutput messages are not converted to UIMessage return nil } if len(parts) == 0 { return nil } return &uctypes.UIMessage{ ID: m.MessageId, Role: role, Parts: parts, } } // ConvertAIChatToUIChat converts an AIChat to a UIChat for OpenAI func ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { if aiChat.APIType != uctypes.APIType_OpenAIResponses { return nil, fmt.Errorf("APIType must be '%s', got '%s'", uctypes.APIType_OpenAIResponses, aiChat.APIType) } uiMessages := make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages)) for i, nativeMsg := range aiChat.NativeMessages { openaiMsg, ok := nativeMsg.(*OpenAIChatMessage) if !ok { return nil, fmt.Errorf("message %d: expected *OpenAIChatMessage, got %T", i, nativeMsg) } uiMsg := openaiMsg.convertToUIMessage() if uiMsg != nil { uiMessages = append(uiMessages, *uiMsg) } } return &uctypes.UIChat{ ChatId: aiChat.ChatId, APIType: aiChat.APIType, Model: aiChat.Model, APIVersion: aiChat.APIVersion, Messages: uiMessages, }, nil } // GetFunctionCallInputByToolCallId returns the OpenAIFunctionCallInput associated with the given ToolCallId, // or nil if not found in the AIChat func GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *OpenAIFunctionCallInput { for _, nativeMsg := range aiChat.NativeMessages { openaiMsg, ok := nativeMsg.(*OpenAIChatMessage) if !ok { continue } if openaiMsg.FunctionCall != nil && openaiMsg.FunctionCall.CallId == toolCallId { return openaiMsg.FunctionCall } } return nil } ================================================ FILE: pkg/aiusechat/openai/openai-util.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package openai import ( "encoding/json" "log" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) func debugPrintInput(idx int, input any) { switch v := input.(type) { case OpenAIMessage: log.Printf(" [%d] message role=%s blocks=%d", idx, v.Role, len(v.Content)) for _, block := range v.Content { switch block.Type { case "input_text": log.Printf(" - text: %q", utilfn.TruncateString(block.Text, 40)) case "input_image": size := len(block.ImageUrl) log.Printf(" - image: size=%d", size) case "input_file": size := len(block.FileData) log.Printf(" - file: name=%s size=%d", block.Filename, size) case "function_call": log.Printf(" - function_call: name=%s callid=%s", block.Name, block.CallId) default: log.Printf(" - %s", block.Type) } } case OpenAIFunctionCallInput: log.Printf(" [%d] function_call: name=%s callid=%s args_len=%d", idx, v.Name, v.CallId, len(v.Arguments)) case OpenAIFunctionCallOutputInput: outputSize := 0 if outputBytes, err := json.Marshal(v.Output); err == nil { outputSize = len(outputBytes) } log.Printf(" [%d] function_call_output: callid=%s output_size=%d", idx, v.CallId, outputSize) default: log.Printf(" [%d] unknown type: %T", idx, input) } } ================================================ FILE: pkg/aiusechat/openai/stream-sample.txt ================================================ SSE Stream: --- event: response.created data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68d45a9c658c81979a8e5172ecba5f220b4ef1d7c1786ac7","object":"response","created_at":1758747292,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-mini-2025-08-07","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} event: response.in_progress data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_68d45a9c658c81979a8e5172ecba5f220b4ef1d7c1786ac7","object":"response","created_at":1758747292,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-mini-2025-08-07","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} event: response.output_item.added data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"rs_68d45a9da0388197ae920ca9f2c1864e0b4ef1d7c1786ac7","type":"reasoning","summary":[]}} event: response.output_item.done data: {"type":"response.output_item.done","sequence_number":3,"output_index":0,"item":{"id":"rs_68d45a9da0388197ae920ca9f2c1864e0b4ef1d7c1786ac7","type":"reasoning","summary":[]}} event: response.output_item.added data: {"type":"response.output_item.added","sequence_number":4,"output_index":1,"item":{"id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","type":"message","status":"in_progress","content":[],"role":"assistant"}} event: response.content_part.added data: {"type":"response.content_part.added","sequence_number":5,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":6,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":"Hi","logprobs":[],"obfuscation":"HMDbO6ayWjW1W2"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":7,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":"!","logprobs":[],"obfuscation":"O51xaIoYtnbIPAD"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":8,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" How","logprobs":[],"obfuscation":"xadqbnQ3oyln"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":9,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" can","logprobs":[],"obfuscation":"T8A4BDNVTT9d"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":10,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" I","logprobs":[],"obfuscation":"Rt0Kkzk6YBpji2"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":11,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" help","logprobs":[],"obfuscation":"utZij4PIdNV"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":12,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" you","logprobs":[],"obfuscation":"c0WOfWx4Zgn6"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":13,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" today","logprobs":[],"obfuscation":"ZJkSB4IZKP"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":14,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":"?","logprobs":[],"obfuscation":"gmnBYJ1iN9uvcOu"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":15,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" \n\n","logprobs":[],"obfuscation":"ammzfU3E2ygBL"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":16,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":"You","logprobs":[],"obfuscation":"4WNbNS8ETwBN3"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":17,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" can","logprobs":[],"obfuscation":"UeG90g1fjT3j"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":18,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" ask","logprobs":[],"obfuscation":"ZNtD4dweBhuK"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":19,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" me","logprobs":[],"obfuscation":"KTiA4OFK7OLNF"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":20,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" to","logprobs":[],"obfuscation":"sCkh2W9nJSyn4"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":21,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" answer","logprobs":[],"obfuscation":"EuWGaJ7dY"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":22,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" questions","logprobs":[],"obfuscation":"xBv16Y"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":23,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":",","logprobs":[],"obfuscation":"lSDlvMfjmfE2fvQ"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":24,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" draft","logprobs":[],"obfuscation":"WpDE4p4owZ"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":25,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" or","logprobs":[],"obfuscation":"6wbphTdqVYDXt"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":26,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" edit","logprobs":[],"obfuscation":"GpSyBD5TDaC"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":27,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" text","logprobs":[],"obfuscation":"GmPoqcLxX7q"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":28,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":",","logprobs":[],"obfuscation":"uccg3mjG8MZ6Cka"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":29,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" write","logprobs":[],"obfuscation":"H1PRUtUJ69"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":30,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" code","logprobs":[],"obfuscation":"puHievT1qB0"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":31,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":",","logprobs":[],"obfuscation":"8GilKv17xtSWLjk"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":32,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" brainstorm","logprobs":[],"obfuscation":"DbjVN"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":33,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" ideas","logprobs":[],"obfuscation":"5hZX5Gyav7"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":34,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":",","logprobs":[],"obfuscation":"QI3yTbBEjw6pXp0"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":35,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" summarize","logprobs":[],"obfuscation":"e9BfWi"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":36,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" something","logprobs":[],"obfuscation":"L2haa7"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":37,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":",","logprobs":[],"obfuscation":"kEk4GIDDD2KAyJa"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":38,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" translate","logprobs":[],"obfuscation":"FwqG3o"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":39,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":",","logprobs":[],"obfuscation":"u86XCSwAd64WOpl"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":40,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" or","logprobs":[],"obfuscation":"VucQ7yhFdQDxU"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":41,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" anything","logprobs":[],"obfuscation":"gSdKLJF"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":42,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" else","logprobs":[],"obfuscation":"C1tHZN4v4L8"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":43,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" —","logprobs":[],"obfuscation":"a0SpHUtgndp3ag"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":44,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" what","logprobs":[],"obfuscation":"3CcLqzdGwYf"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":45,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" do","logprobs":[],"obfuscation":"dDtJSJMevS0aF"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":46,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" you","logprobs":[],"obfuscation":"fAartZUPIxEh"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":47,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":" need","logprobs":[],"obfuscation":"rAygh1fmDO6"} event: response.output_text.delta data: {"type":"response.output_text.delta","sequence_number":48,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"delta":"?","logprobs":[],"obfuscation":"5aooX5ENzirqMuu"} event: response.output_text.done data: {"type":"response.output_text.done","sequence_number":49,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"text":"Hi! How can I help you today? \n\nYou can ask me to answer questions, draft or edit text, write code, brainstorm ideas, summarize something, translate, or anything else — what do you need?","logprobs":[]} event: response.content_part.done data: {"type":"response.content_part.done","sequence_number":50,"item_id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"Hi! How can I help you today? \n\nYou can ask me to answer questions, draft or edit text, write code, brainstorm ideas, summarize something, translate, or anything else — what do you need?"}} event: response.output_item.done data: {"type":"response.output_item.done","sequence_number":51,"output_index":1,"item":{"id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Hi! How can I help you today? \n\nYou can ask me to answer questions, draft or edit text, write code, brainstorm ideas, summarize something, translate, or anything else — what do you need?"}],"role":"assistant"}} event: response.completed data: {"type":"response.completed","sequence_number":52,"response":{"id":"resp_68d45a9c658c81979a8e5172ecba5f220b4ef1d7c1786ac7","object":"response","created_at":1758747292,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-mini-2025-08-07","output":[{"id":"rs_68d45a9da0388197ae920ca9f2c1864e0b4ef1d7c1786ac7","type":"reasoning","summary":[]},{"id":"msg_68d45a9f325c8197af3123a22cc7500a0b4ef1d7c1786ac7","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Hi! How can I help you today? \n\nYou can ask me to answer questions, draft or edit text, write code, brainstorm ideas, summarize something, translate, or anything else — what do you need?"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":7,"input_tokens_details":{"cached_tokens":0},"output_tokens":113,"output_tokens_details":{"reasoning_tokens":64},"total_tokens":120},"user":null,"metadata":{}}} ================================================ FILE: pkg/aiusechat/openai/tool-sample.txt ================================================ event: response.created data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68d72548e6f0819096e9a659a61b96c1041f8a7af432fbe9","object":"response","created_at":1758930249,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-mini-2025-08-07","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add an array of numbers together and return their sum","name":"adder","parameters":{"additionalProperties":false,"properties":{"values":{"description":"Array of numbers to add together","items":{"type":"integer"},"type":"array"}},"required":["values"],"type":"object"},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} event: response.in_progress data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_68d72548e6f0819096e9a659a61b96c1041f8a7af432fbe9","object":"response","created_at":1758930249,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-mini-2025-08-07","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add an array of numbers together and return their sum","name":"adder","parameters":{"additionalProperties":false,"properties":{"values":{"description":"Array of numbers to add together","items":{"type":"integer"},"type":"array"}},"required":["values"],"type":"object"},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} event: response.output_item.added data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"rs_68d72549a9888190b9efd3f27772f130041f8a7af432fbe9","type":"reasoning","summary":[]}} event: response.output_item.done data: {"type":"response.output_item.done","sequence_number":3,"output_index":0,"item":{"id":"rs_68d72549a9888190b9efd3f27772f130041f8a7af432fbe9","type":"reasoning","summary":[]}} event: response.output_item.added data: {"type":"response.output_item.added","sequence_number":4,"output_index":1,"item":{"id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","type":"function_call","status":"in_progress","arguments":"","call_id":"call_41sXC2VsjlN9yHucWy2980Og","name":"adder"}} event: response.function_call_arguments.delta data: {"type":"response.function_call_arguments.delta","sequence_number":5,"item_id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","output_index":1,"delta":"{\""} event: response.function_call_arguments.delta data: {"type":"response.function_call_arguments.delta","sequence_number":6,"item_id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","output_index":1,"delta":"values"} event: response.function_call_arguments.delta data: {"type":"response.function_call_arguments.delta","sequence_number":7,"item_id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","output_index":1,"delta":"\":["} event: response.function_call_arguments.delta data: {"type":"response.function_call_arguments.delta","sequence_number":8,"item_id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","output_index":1,"delta":"2"} event: response.function_call_arguments.delta data: {"type":"response.function_call_arguments.delta","sequence_number":9,"item_id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","output_index":1,"delta":","} event: response.function_call_arguments.delta data: {"type":"response.function_call_arguments.delta","sequence_number":10,"item_id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","output_index":1,"delta":"2"} event: response.function_call_arguments.delta data: {"type":"response.function_call_arguments.delta","sequence_number":11,"item_id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","output_index":1,"delta":"]}"} event: response.function_call_arguments.done data: {"type":"response.function_call_arguments.done","sequence_number":12,"item_id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","output_index":1,"arguments":"{\"values\":[2,2]}"} event: response.output_item.done data: {"type":"response.output_item.done","sequence_number":13,"output_index":1,"item":{"id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","type":"function_call","status":"completed","arguments":"{\"values\":[2,2]}","call_id":"call_41sXC2VsjlN9yHucWy2980Og","name":"adder"}} event: response.completed data: {"type":"response.completed","sequence_number":14,"response":{"id":"resp_68d72548e6f0819096e9a659a61b96c1041f8a7af432fbe9","object":"response","created_at":1758930249,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-mini-2025-08-07","output":[{"id":"rs_68d72549a9888190b9efd3f27772f130041f8a7af432fbe9","type":"reasoning","summary":[]},{"id":"fc_68d7254b85248190a2701b3938d772bc041f8a7af432fbe9","type":"function_call","status":"completed","arguments":"{\"values\":[2,2]}","call_id":"call_41sXC2VsjlN9yHucWy2980Og","name":"adder"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add an array of numbers together and return their sum","name":"adder","parameters":{"additionalProperties":false,"properties":{"values":{"description":"Array of numbers to add together","items":{"type":"integer"},"type":"array"}},"required":["values"],"type":"object"},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":67,"input_tokens_details":{"cached_tokens":0},"output_tokens":86,"output_tokens_details":{"reasoning_tokens":64},"total_tokens":153},"user":null,"metadata":{}}} ================================================ FILE: pkg/aiusechat/openaichat/openaichat-backend.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package openaichat import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "strings" "time" "github.com/google/uuid" "github.com/launchdarkly/eventsource" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/web/sse" ) // RunChatStep executes a chat step using the chat completions API func RunChatStep( ctx context.Context, sseHandler *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, []*StoredChatMessage, *uctypes.RateLimitInfo, error) { if sseHandler == nil { return nil, nil, nil, errors.New("sse handler is nil") } chat := chatstore.DefaultChatStore.Get(chatOpts.ChatId) if chat == nil { return nil, nil, nil, fmt.Errorf("chat not found: %s", chatOpts.ChatId) } if chatOpts.Config.TimeoutMs > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(chatOpts.Config.TimeoutMs)*time.Millisecond) defer cancel() } // Convert stored messages to chat completions format var messages []ChatRequestMessage // Convert native messages for _, genMsg := range chat.NativeMessages { chatMsg, ok := genMsg.(*StoredChatMessage) if !ok { return nil, nil, nil, fmt.Errorf("expected StoredChatMessage, got %T", genMsg) } messages = append(messages, *chatMsg.Message.clean()) } req, err := buildChatHTTPRequest(ctx, messages, chatOpts) if err != nil { return nil, nil, nil, err } client, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL) if err != nil { return nil, nil, nil, err } resp, err := client.Do(req) if err != nil { return nil, nil, nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, nil, nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) } // Setup SSE if this is a new request (not a continuation) if cont == nil { if err := sseHandler.SetupSSE(); err != nil { return nil, nil, nil, fmt.Errorf("failed to setup SSE: %w", err) } } // Stream processing stopReason, assistantMsg, err := processChatStream(ctx, resp.Body, sseHandler, chatOpts, cont) if err != nil { return nil, nil, nil, err } return stopReason, []*StoredChatMessage{assistantMsg}, nil, nil } func processChatStream( ctx context.Context, body io.Reader, sseHandler *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, *StoredChatMessage, error) { decoder := eventsource.NewDecoder(body) var textBuilder strings.Builder msgID := uuid.New().String() textID := uuid.New().String() var finishReason string textStarted := false var toolCallsInProgress []ToolCall if cont == nil { _ = sseHandler.AiMsgStart(msgID) } _ = sseHandler.AiMsgStartStep() for { if err := ctx.Err(); err != nil { _ = sseHandler.AiMsgError("request cancelled") return &uctypes.WaveStopReason{ Kind: uctypes.StopKindCanceled, ErrorType: "cancelled", ErrorText: "request cancelled", }, nil, err } event, err := decoder.Decode() if err != nil { if errors.Is(err, io.EOF) { break } if sseHandler.Err() != nil { partialMsg := extractPartialTextMessage(msgID, textBuilder.String()) return &uctypes.WaveStopReason{ Kind: uctypes.StopKindCanceled, ErrorType: "client_disconnect", ErrorText: "client disconnected", }, partialMsg, nil } _ = sseHandler.AiMsgError(err.Error()) return &uctypes.WaveStopReason{ Kind: uctypes.StopKindError, ErrorType: "stream", ErrorText: err.Error(), }, nil, fmt.Errorf("stream decode error: %w", err) } data := event.Data() if data == "[DONE]" { break } var chunk StreamChunk if err := json.Unmarshal([]byte(data), &chunk); err != nil { log.Printf("openaichat: failed to parse chunk: %v\n", err) continue } if len(chunk.Choices) == 0 { continue } choice := chunk.Choices[0] if choice.Delta.Content != "" { if !textStarted { _ = sseHandler.AiMsgTextStart(textID) textStarted = true } textBuilder.WriteString(choice.Delta.Content) _ = sseHandler.AiMsgTextDelta(textID, choice.Delta.Content) } if len(choice.Delta.ToolCalls) > 0 { for _, tcDelta := range choice.Delta.ToolCalls { idx := tcDelta.Index for len(toolCallsInProgress) <= idx { toolCallsInProgress = append(toolCallsInProgress, ToolCall{Type: "function"}) } tc := &toolCallsInProgress[idx] if tcDelta.ID != "" { tc.ID = tcDelta.ID } if tcDelta.Type != "" { tc.Type = tcDelta.Type } if tcDelta.Function != nil { if tcDelta.Function.Name != "" { tc.Function.Name = tcDelta.Function.Name } if tcDelta.Function.Arguments != "" { tc.Function.Arguments += tcDelta.Function.Arguments } } } } if choice.FinishReason != nil && *choice.FinishReason != "" { finishReason = *choice.FinishReason } } stopKind := uctypes.StopKindDone if finishReason == "length" { stopKind = uctypes.StopKindMaxTokens } else if finishReason == "tool_calls" { stopKind = uctypes.StopKindToolUse } var validToolCalls []ToolCall for _, tc := range toolCallsInProgress { if tc.ID != "" && tc.Function.Name != "" { validToolCalls = append(validToolCalls, tc) } } var waveToolCalls []uctypes.WaveToolCall if len(validToolCalls) > 0 { for _, tc := range validToolCalls { var inputJSON any if tc.Function.Arguments != "" { if err := json.Unmarshal([]byte(tc.Function.Arguments), &inputJSON); err != nil { log.Printf("openaichat: failed to parse tool call arguments: %v\n", err) continue } } waveToolCalls = append(waveToolCalls, uctypes.WaveToolCall{ ID: tc.ID, Name: tc.Function.Name, Input: inputJSON, }) } } stopReason := &uctypes.WaveStopReason{ Kind: stopKind, RawReason: finishReason, ToolCalls: waveToolCalls, } assistantMsg := &StoredChatMessage{ MessageId: msgID, Message: ChatRequestMessage{ Role: "assistant", }, } if len(validToolCalls) > 0 { assistantMsg.Message.ToolCalls = validToolCalls } else { assistantMsg.Message.Content = textBuilder.String() } if textStarted { _ = sseHandler.AiMsgTextEnd(textID) } _ = sseHandler.AiMsgFinishStep() if stopKind != uctypes.StopKindToolUse { _ = sseHandler.AiMsgFinish(finishReason, nil) } return stopReason, assistantMsg, nil } func extractPartialTextMessage(msgID string, text string) *StoredChatMessage { if text == "" { return nil } return &StoredChatMessage{ MessageId: msgID, Message: ChatRequestMessage{ Role: "assistant", Content: text, }, } } ================================================ FILE: pkg/aiusechat/openaichat/openaichat-convertmessage.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package openaichat import ( "bytes" "context" "encoding/json" "errors" "fmt" "log" "net/http" "slices" "strings" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/wavebase" ) const ( OpenAIChatDefaultMaxTokens = 4096 ) // appendToLastUserMessage appends text to the last user message in the messages slice func appendToLastUserMessage(messages []ChatRequestMessage, text string) { for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == "user" { if len(messages[i].ContentParts) > 0 { messages[i].ContentParts = append(messages[i].ContentParts, ChatContentPart{ Type: "text", Text: text, }) } else { messages[i].Content += "\n\n" + text } break } } } // convertToolDefinitions converts Wave ToolDefinitions to OpenAI format // Only includes tools whose required capabilities are met func convertToolDefinitions(waveTools []uctypes.ToolDefinition, capabilities []string) []ToolDefinition { if len(waveTools) == 0 { return nil } openaiTools := make([]ToolDefinition, 0, len(waveTools)) for _, waveTool := range waveTools { if !waveTool.HasRequiredCapabilities(capabilities) { continue } openaiTool := ToolDefinition{ Type: "function", Function: ToolFunctionDef{ Name: waveTool.Name, Description: waveTool.Description, Parameters: waveTool.InputSchema, }, } openaiTools = append(openaiTools, openaiTool) } return openaiTools } // buildChatHTTPRequest creates an HTTP request for the OpenAI chat completions API func buildChatHTTPRequest(ctx context.Context, messages []ChatRequestMessage, chatOpts uctypes.WaveChatOpts) (*http.Request, error) { opts := chatOpts.Config // Model is required for all providers except azure-legacy (which uses deployment name in URL) if opts.Model == "" && opts.Provider != uctypes.AIProvider_AzureLegacy { return nil, errors.New("ai:model is required") } if opts.Endpoint == "" { return nil, errors.New("ai:endpoint is required") } maxTokens := opts.MaxTokens if maxTokens <= 0 { maxTokens = OpenAIChatDefaultMaxTokens } finalMessages := messages if len(chatOpts.SystemPrompt) > 0 { systemMessage := ChatRequestMessage{ Role: "system", Content: strings.Join(chatOpts.SystemPrompt, "\n\n"), } finalMessages = append([]ChatRequestMessage{systemMessage}, messages...) } // injected data if chatOpts.TabState != "" { appendToLastUserMessage(finalMessages, chatOpts.TabState) } if chatOpts.PlatformInfo != "" { appendToLastUserMessage(finalMessages, "<PlatformInfo>\n"+chatOpts.PlatformInfo+"\n</PlatformInfo>") } reqBody := &ChatRequest{ Messages: finalMessages, Stream: true, } // Model is only added to request for non-azure-legacy providers if opts.Provider != uctypes.AIProvider_AzureLegacy { reqBody.Model = opts.Model } if aiutil.IsOpenAIReasoningModel(opts.Model) { reqBody.MaxCompletionTokens = maxTokens } else { reqBody.MaxTokens = maxTokens } // Add tool definitions if tools capability is available and tools exist var allTools []uctypes.ToolDefinition if opts.HasCapability(uctypes.AICapabilityTools) { allTools = append(allTools, chatOpts.Tools...) allTools = append(allTools, chatOpts.TabTools...) if len(allTools) > 0 { reqBody.Tools = convertToolDefinitions(allTools, opts.Capabilities) } } if wavebase.IsDevMode() { log.Printf("openaichat: model %s, messages: %d, tools: %d\n", opts.Model, len(messages), len(allTools)) } buf, err := json.Marshal(reqBody) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, opts.Endpoint, bytes.NewReader(buf)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") // Azure OpenAI uses "api-key" header instead of "Authorization: Bearer" if opts.Provider == uctypes.AIProvider_Azure || opts.Provider == uctypes.AIProvider_AzureLegacy { req.Header.Set("api-key", opts.APIToken) } else { req.Header.Set("Authorization", "Bearer "+opts.APIToken) } req.Header.Set("Accept", "text/event-stream") // Only send Wave-specific headers when using Wave provider if opts.Provider == uctypes.AIProvider_Wave { if chatOpts.ClientId != "" { req.Header.Set("X-Wave-ClientId", chatOpts.ClientId) } if chatOpts.ChatId != "" { req.Header.Set("X-Wave-ChatId", chatOpts.ChatId) } req.Header.Set("X-Wave-Version", wavebase.WaveVersion) req.Header.Set("X-Wave-APIType", uctypes.APIType_OpenAIChat) req.Header.Set("X-Wave-RequestType", chatOpts.GetWaveRequestType()) } return req, nil } // ConvertAIMessageToStoredChatMessage converts an AIMessage to StoredChatMessage // These messages are ALWAYS role "user" func ConvertAIMessageToStoredChatMessage(aiMsg uctypes.AIMessage) (*StoredChatMessage, error) { if err := aiMsg.Validate(); err != nil { return nil, fmt.Errorf("invalid AIMessage: %w", err) } hasImages := false for _, part := range aiMsg.Parts { if strings.HasPrefix(part.MimeType, "image/") { hasImages = true break } } if hasImages { return convertAIMessageMultimodal(aiMsg) } return convertAIMessageTextOnly(aiMsg) } func convertAIMessageTextOnly(aiMsg uctypes.AIMessage) (*StoredChatMessage, error) { var textBuilder strings.Builder firstText := true for _, part := range aiMsg.Parts { var partText string switch { case part.Type == uctypes.AIMessagePartTypeText: partText = part.Text case part.MimeType == "text/plain": textData, err := aiutil.ExtractTextData(part.Data, part.URL) if err != nil { log.Printf("openaichat: error extracting text data for %s: %v\n", part.FileName, err) continue } partText = aiutil.FormatAttachedTextFile(part.FileName, textData) case part.MimeType == "directory": if len(part.Data) == 0 { log.Printf("openaichat: directory listing part missing data for %s\n", part.FileName) continue } partText = aiutil.FormatAttachedDirectoryListing(part.FileName, string(part.Data)) default: continue } if partText != "" { if !firstText { textBuilder.WriteString("\n\n") } textBuilder.WriteString(partText) firstText = false } } return &StoredChatMessage{ MessageId: aiMsg.MessageId, Message: ChatRequestMessage{ Role: "user", Content: textBuilder.String(), }, }, nil } func convertAIMessageMultimodal(aiMsg uctypes.AIMessage) (*StoredChatMessage, error) { var contentParts []ChatContentPart imageCount := 0 imageFailCount := 0 for _, part := range aiMsg.Parts { switch { case part.Type == uctypes.AIMessagePartTypeText: if part.Text != "" { contentParts = append(contentParts, ChatContentPart{ Type: "text", Text: part.Text, }) } case strings.HasPrefix(part.MimeType, "image/"): imageCount++ imageUrl, err := aiutil.ExtractImageUrl(part.Data, part.URL, part.MimeType) if err != nil { imageFailCount++ log.Printf("openaichat: error extracting image URL for %s: %v\n", part.FileName, err) continue } contentParts = append(contentParts, ChatContentPart{ Type: "image_url", ImageUrl: &ChatImageUrl{Url: imageUrl}, FileName: part.FileName, PreviewUrl: part.PreviewUrl, MimeType: part.MimeType, }) case part.MimeType == "text/plain": textData, err := aiutil.ExtractTextData(part.Data, part.URL) if err != nil { log.Printf("openaichat: error extracting text data for %s: %v\n", part.FileName, err) continue } formattedText := aiutil.FormatAttachedTextFile(part.FileName, textData) if formattedText != "" { contentParts = append(contentParts, ChatContentPart{ Type: "text", Text: formattedText, }) } case part.MimeType == "directory": if len(part.Data) == 0 { log.Printf("openaichat: directory listing part missing data for %s\n", part.FileName) continue } formattedText := aiutil.FormatAttachedDirectoryListing(part.FileName, string(part.Data)) if formattedText != "" { contentParts = append(contentParts, ChatContentPart{ Type: "text", Text: formattedText, }) } case part.MimeType == "application/pdf": log.Printf("openaichat: PDF attachments are not supported by Chat Completions API, skipping %s\n", part.FileName) continue default: continue } } if len(contentParts) == 0 { if imageCount > 0 && imageFailCount == imageCount { return nil, fmt.Errorf("all %d image conversions failed", imageCount) } return nil, errors.New("message has no valid content after processing all parts") } return &StoredChatMessage{ MessageId: aiMsg.MessageId, Message: ChatRequestMessage{ Role: "user", ContentParts: contentParts, }, }, nil } // ConvertToolResultsToNativeChatMessage converts tool results to OpenAI tool messages func ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) { if len(toolResults) == 0 { return nil, nil } messages := make([]uctypes.GenAIMessage, 0, len(toolResults)) for _, toolResult := range toolResults { var content string if toolResult.ErrorText != "" { content = fmt.Sprintf("Error: %s", toolResult.ErrorText) } else { content = toolResult.Text } msg := &StoredChatMessage{ MessageId: toolResult.ToolUseID, Message: ChatRequestMessage{ Role: "tool", ToolCallID: toolResult.ToolUseID, Name: toolResult.ToolName, Content: content, }, } messages = append(messages, msg) } return messages, nil } // ConvertAIChatToUIChat converts stored chat to UI format func ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { uiChat := &uctypes.UIChat{ ChatId: aiChat.ChatId, APIType: aiChat.APIType, Model: aiChat.Model, APIVersion: aiChat.APIVersion, Messages: make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages)), } for _, genMsg := range aiChat.NativeMessages { chatMsg, ok := genMsg.(*StoredChatMessage) if !ok { continue } var parts []uctypes.UIMessagePart if len(chatMsg.Message.ContentParts) > 0 { for _, cp := range chatMsg.Message.ContentParts { switch cp.Type { case "text": if found, part := aiutil.ConvertDataUserFile(cp.Text); found { if part != nil { parts = append(parts, *part) } } else { parts = append(parts, uctypes.UIMessagePart{ Type: "text", Text: cp.Text, }) } case "image_url": mimeType := cp.MimeType if mimeType == "" { mimeType = "image/*" } parts = append(parts, uctypes.UIMessagePart{ Type: "data-userfile", Data: uctypes.UIMessageDataUserFile{ FileName: cp.FileName, MimeType: mimeType, PreviewUrl: cp.PreviewUrl, }, }) } } } else if chatMsg.Message.Content != "" { parts = append(parts, uctypes.UIMessagePart{ Type: "text", Text: chatMsg.Message.Content, }) } // Add tool calls if present (assistant requesting tool use) if len(chatMsg.Message.ToolCalls) > 0 { for _, toolCall := range chatMsg.Message.ToolCalls { if toolCall.Type != "function" { continue } // Only add if ToolUseData is available if toolCall.ToolUseData != nil { parts = append(parts, uctypes.UIMessagePart{ Type: "data-tooluse", ID: toolCall.ID, Data: *toolCall.ToolUseData, }) } } } // Tool result messages (role "tool") are not converted to UIMessage if chatMsg.Message.Role == "tool" && chatMsg.Message.ToolCallID != "" { continue } // Skip messages with no parts if len(parts) == 0 { continue } uiMsg := uctypes.UIMessage{ ID: chatMsg.MessageId, Role: chatMsg.Message.Role, Parts: parts, } uiChat.Messages = append(uiChat.Messages, uiMsg) } return uiChat, nil } // GetFunctionCallInputByToolCallId searches for a tool call by ID in the chat history func GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { for _, genMsg := range aiChat.NativeMessages { chatMsg, ok := genMsg.(*StoredChatMessage) if !ok { continue } idx := chatMsg.Message.FindToolCallIndex(toolCallId) if idx == -1 { continue } toolCall := chatMsg.Message.ToolCalls[idx] return &uctypes.AIFunctionCallInput{ CallId: toolCall.ID, Name: toolCall.Function.Name, Arguments: toolCall.Function.Arguments, ToolUseData: toolCall.ToolUseData, } } return nil } // UpdateToolUseData updates the ToolUseData for a specific tool call in the chat history func UpdateToolUseData(chatId string, callId string, newToolUseData uctypes.UIMessageDataToolUse) error { chat := chatstore.DefaultChatStore.Get(chatId) if chat == nil { return fmt.Errorf("chat not found: %s", chatId) } for _, genMsg := range chat.NativeMessages { chatMsg, ok := genMsg.(*StoredChatMessage) if !ok { continue } idx := chatMsg.Message.FindToolCallIndex(callId) if idx == -1 { continue } updatedMsg := chatMsg.Copy() updatedMsg.Message.ToolCalls[idx].ToolUseData = &newToolUseData aiOpts := &uctypes.AIOptsType{ APIType: chat.APIType, Model: chat.Model, APIVersion: chat.APIVersion, } return chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg) } return fmt.Errorf("tool call with callId %s not found in chat %s", callId, chatId) } func RemoveToolUseCall(chatId string, callId string) error { chat := chatstore.DefaultChatStore.Get(chatId) if chat == nil { return fmt.Errorf("chat not found: %s", chatId) } for _, genMsg := range chat.NativeMessages { chatMsg, ok := genMsg.(*StoredChatMessage) if !ok { continue } idx := chatMsg.Message.FindToolCallIndex(callId) if idx == -1 { continue } updatedMsg := chatMsg.Copy() updatedMsg.Message.ToolCalls = slices.Delete(updatedMsg.Message.ToolCalls, idx, idx+1) if len(updatedMsg.Message.ToolCalls) == 0 { chatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId) } else { aiOpts := &uctypes.AIOptsType{ APIType: chat.APIType, Model: chat.Model, APIVersion: chat.APIVersion, } if err := chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg); err != nil { return err } } return nil } return nil } ================================================ FILE: pkg/aiusechat/openaichat/openaichat-types.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package openaichat import ( "bytes" "encoding/json" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" ) // OpenAI Chat Completions API types (simplified) type ChatRequest struct { Model string `json:"model"` Messages []ChatRequestMessage `json:"messages"` Stream bool `json:"stream"` MaxTokens int `json:"max_tokens,omitempty"` // legacy MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // newer Temperature float64 `json:"temperature,omitempty"` Tools []ToolDefinition `json:"tools,omitempty"` // if you use tools ToolChoice any `json:"tool_choice,omitempty"` // "auto", "none", or struct } type ChatContentPart struct { Type string `json:"type"` // "text" or "image_url" Text string `json:"text,omitempty"` // for type "text" ImageUrl *ChatImageUrl `json:"image_url,omitempty"` // for type "image_url" FileName string `json:"filename,omitempty"` // internal: original filename PreviewUrl string `json:"previewurl,omitempty"` // internal: 128x128 webp preview MimeType string `json:"mimetype,omitempty"` // internal: original mimetype } func (cp *ChatContentPart) clean() *ChatContentPart { if cp.FileName == "" && cp.PreviewUrl == "" && cp.MimeType == "" { return cp } rtn := *cp rtn.FileName = "" rtn.PreviewUrl = "" rtn.MimeType = "" return &rtn } type ChatImageUrl struct { Url string `json:"url"` Detail string `json:"detail,omitempty"` // "auto", "low", "high" } type ChatRequestMessage struct { Role string `json:"role"` // "system","user","assistant","tool" Content string `json:"-"` // plain text (used when ContentParts is nil) ContentParts []ChatContentPart `json:"-"` // multimodal parts (used when images present) ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant tool-call message ToolCallID string `json:"tool_call_id,omitempty"` // for role:"tool" Name string `json:"name,omitempty"` // tool name on role:"tool" } // chatRequestMessageJSON is the wire format for ChatRequestMessage type chatRequestMessageJSON struct { Role string `json:"role"` Content json.RawMessage `json:"content"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` Name string `json:"name,omitempty"` } func (cm ChatRequestMessage) MarshalJSON() ([]byte, error) { raw := chatRequestMessageJSON{ Role: cm.Role, ToolCalls: cm.ToolCalls, ToolCallID: cm.ToolCallID, Name: cm.Name, } if len(cm.ContentParts) > 0 { b, err := json.Marshal(cm.ContentParts) if err != nil { return nil, err } raw.Content = b } else if cm.Content != "" { b, err := json.Marshal(cm.Content) if err != nil { return nil, err } raw.Content = b } return json.Marshal(raw) } func (cm *ChatRequestMessage) UnmarshalJSON(data []byte) error { var raw chatRequestMessageJSON if err := json.Unmarshal(data, &raw); err != nil { return err } cm.Role = raw.Role cm.ToolCalls = raw.ToolCalls cm.ToolCallID = raw.ToolCallID cm.Name = raw.Name cm.Content = "" cm.ContentParts = nil if len(raw.Content) == 0 || bytes.Equal(raw.Content, []byte("null")) { return nil } // try array first var parts []ChatContentPart if err := json.Unmarshal(raw.Content, &parts); err == nil { cm.ContentParts = parts return nil } // fall back to string var s string if err := json.Unmarshal(raw.Content, &s); err != nil { return err } cm.Content = s return nil } func (cm *ChatRequestMessage) clean() *ChatRequestMessage { rtn := *cm if len(cm.ToolCalls) > 0 { rtn.ToolCalls = make([]ToolCall, len(cm.ToolCalls)) for i, tc := range cm.ToolCalls { rtn.ToolCalls[i] = *tc.clean() } } if len(cm.ContentParts) > 0 { rtn.ContentParts = make([]ChatContentPart, len(cm.ContentParts)) for i, cp := range cm.ContentParts { rtn.ContentParts[i] = *cp.clean() } } return &rtn } func (cm *ChatRequestMessage) FindToolCallIndex(toolCallId string) int { for i, tc := range cm.ToolCalls { if tc.ID == toolCallId { return i } } return -1 } type ToolDefinition struct { Type string `json:"type"` // "function" Function ToolFunctionDef `json:"function"` } type ToolFunctionDef struct { Name string `json:"name"` Description string `json:"description,omitempty"` Parameters map[string]any `json:"parameters,omitempty"` // or jsonschema struct } type ToolCall struct { ID string `json:"id"` Type string `json:"type"` // "function" Function ToolFunctionCall `json:"function"` ToolUseData *uctypes.UIMessageDataToolUse `json:"toolusedata,omitempty"` // Internal field (must be cleaned before sending to API) } func (tc *ToolCall) clean() *ToolCall { if tc.ToolUseData == nil { return tc } rtn := *tc rtn.ToolUseData = nil return &rtn } type ToolFunctionCall struct { Name string `json:"name"` Arguments string `json:"arguments"` // raw JSON string } type StreamChunk struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` Model string `json:"model"` Choices []StreamChoice `json:"choices"` } type StreamChoice struct { Index int `json:"index"` Delta ContentDelta `json:"delta"` FinishReason *string `json:"finish_reason"` // "stop", "length" | "tool_calls" | "content_filter" } // This is the important part: type ContentDelta struct { Role string `json:"role,omitempty"` Content string `json:"content,omitempty"` ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"` } type ToolCallDelta struct { Index int `json:"index"` ID string `json:"id,omitempty"` // only on first chunk Type string `json:"type,omitempty"` // "function" Function *ToolFunctionDelta `json:"function,omitempty"` } type ToolFunctionDelta struct { Name string `json:"name,omitempty"` // only on first chunk Arguments string `json:"arguments,omitempty"` // streamed, append across chunks } // StoredChatMessage is the stored message type type StoredChatMessage struct { MessageId string `json:"messageid"` Message ChatRequestMessage `json:"message"` Usage *ChatUsage `json:"usage,omitempty"` } type ChatUsage struct { Model string `json:"model,omitempty"` InputTokens int `json:"prompt_tokens,omitempty"` OutputTokens int `json:"completion_tokens,omitempty"` TotalTokens int `json:"total_tokens,omitempty"` } func (m *StoredChatMessage) GetMessageId() string { return m.MessageId } func (m *StoredChatMessage) GetRole() string { return m.Message.Role } func (m *StoredChatMessage) GetUsage() *uctypes.AIUsage { if m.Usage == nil { return nil } return &uctypes.AIUsage{ APIType: uctypes.APIType_OpenAIChat, Model: m.Usage.Model, InputTokens: m.Usage.InputTokens, OutputTokens: m.Usage.OutputTokens, } } func (m *StoredChatMessage) Copy() *StoredChatMessage { if m == nil { return nil } copied := *m if len(m.Message.ToolCalls) > 0 { copied.Message.ToolCalls = make([]ToolCall, len(m.Message.ToolCalls)) for i, tc := range m.Message.ToolCalls { copied.Message.ToolCalls[i] = tc if tc.ToolUseData != nil { toolUseDataCopy := *tc.ToolUseData copied.Message.ToolCalls[i].ToolUseData = &toolUseDataCopy } } } if len(m.Message.ContentParts) > 0 { copied.Message.ContentParts = make([]ChatContentPart, len(m.Message.ContentParts)) copy(copied.Message.ContentParts, m.Message.ContentParts) } if m.Usage != nil { usageCopy := *m.Usage copied.Usage = &usageCopy } return &copied } ================================================ FILE: pkg/aiusechat/toolapproval.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "context" "sync" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/web/sse" ) type ApprovalRequest struct { approval string done bool doneChan chan struct{} mu sync.Mutex onCloseUnregFn func() } func (req *ApprovalRequest) updateApproval(approval string) { req.mu.Lock() defer req.mu.Unlock() if req.done { return } req.approval = approval req.done = true if req.onCloseUnregFn != nil { req.onCloseUnregFn() } close(req.doneChan) } type ApprovalRegistry struct { mu sync.Mutex requests map[string]*ApprovalRequest } var globalApprovalRegistry = &ApprovalRegistry{ requests: make(map[string]*ApprovalRequest), } func registerToolApprovalRequest(toolCallId string, req *ApprovalRequest) { globalApprovalRegistry.mu.Lock() defer globalApprovalRegistry.mu.Unlock() globalApprovalRegistry.requests[toolCallId] = req } func UnregisterToolApproval(toolCallId string) { globalApprovalRegistry.mu.Lock() defer globalApprovalRegistry.mu.Unlock() req := globalApprovalRegistry.requests[toolCallId] delete(globalApprovalRegistry.requests, toolCallId) if req != nil { req.updateApproval("") } } func getToolApprovalRequest(toolCallId string) (*ApprovalRequest, bool) { globalApprovalRegistry.mu.Lock() defer globalApprovalRegistry.mu.Unlock() req, exists := globalApprovalRegistry.requests[toolCallId] return req, exists } func RegisterToolApproval(toolCallId string, sseHandler *sse.SSEHandlerCh) { req := &ApprovalRequest{ doneChan: make(chan struct{}), } onCloseId := sseHandler.RegisterOnClose(func() { UpdateToolApproval(toolCallId, uctypes.ApprovalCanceled) }) req.onCloseUnregFn = func() { sseHandler.UnregisterOnClose(onCloseId) } registerToolApprovalRequest(toolCallId, req) } func UpdateToolApproval(toolCallId string, approval string) error { req, exists := getToolApprovalRequest(toolCallId) if !exists { return nil } req.updateApproval(approval) return nil } func WaitForToolApproval(ctx context.Context, toolCallId string) (string, error) { req, exists := getToolApprovalRequest(toolCallId) if !exists { return "", nil } select { case <-ctx.Done(): return "", ctx.Err() case <-req.doneChan: } req.mu.Lock() approval := req.approval req.mu.Unlock() globalApprovalRegistry.mu.Lock() delete(globalApprovalRegistry.requests, toolCallId) globalApprovalRegistry.mu.Unlock() return approval, nil } ================================================ FILE: pkg/aiusechat/tools.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "context" "fmt" "os/user" "strings" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wstore" ) func makeTerminalBlockDesc(block *waveobj.Block) string { connection, hasConnection := block.Meta["connection"].(string) cwd, hasCwd := block.Meta["cmd:cwd"].(string) blockORef := waveobj.MakeORef(waveobj.OType_Block, block.OID) rtInfo := wstore.GetRTInfo(blockORef) hasCurCwd := rtInfo != nil && rtInfo.ShellHasCurCwd var desc string if hasConnection && connection != "" { desc = fmt.Sprintf("CLI terminal connected to %q", connection) } else { desc = "local CLI terminal" } if rtInfo != nil && rtInfo.ShellType != "" { desc += fmt.Sprintf(" (%s", rtInfo.ShellType) if rtInfo.ShellVersion != "" { desc += fmt.Sprintf(" %s", rtInfo.ShellVersion) } desc += ")" } if rtInfo != nil { if rtInfo.ShellIntegration { var stateStr string switch rtInfo.ShellState { case "ready": stateStr = "waiting for input" case "running-command": stateStr = "running command" if rtInfo.ShellLastCmd != "" { cmdStr := rtInfo.ShellLastCmd if len(cmdStr) > 30 { cmdStr = cmdStr[:27] + "..." } cmdJSON := utilfn.MarshalJSONString(cmdStr) stateStr = fmt.Sprintf("running command %s", cmdJSON) } default: stateStr = "state unknown" } desc += fmt.Sprintf(", %s", stateStr) } else { desc += ", no shell integration" } } if hasCurCwd && hasCwd && cwd != "" { desc += fmt.Sprintf(", in directory %q", cwd) } return desc } func MakeBlockShortDesc(block *waveobj.Block) string { if block.Meta == nil { return "" } viewType, ok := block.Meta["view"].(string) if !ok { return "" } switch viewType { case "term": return makeTerminalBlockDesc(block) case "preview": file, hasFile := block.Meta["file"].(string) connection, hasConnection := block.Meta["connection"].(string) if hasConnection && connection != "" { if hasFile && file != "" { return fmt.Sprintf("preview widget viewing %q on %q", file, connection) } return fmt.Sprintf("preview widget viewing files on %q", connection) } if hasFile && file != "" { return fmt.Sprintf("preview widget viewing %q", file) } return "file and directory preview widget" case "web": if url, hasUrl := block.Meta["url"].(string); hasUrl && url != "" { return fmt.Sprintf("web browser widget pointing at %q", url) } return "web browser widget" case "waveai": return "AI chat widget" case "cpuplot": if connection, hasConnection := block.Meta["connection"].(string); hasConnection && connection != "" { return fmt.Sprintf("cpu graph for %q", connection) } return "cpu graph" case "tips": return "Wave quick tips widget" case "help": return "Wave documentation widget" case "launcher": return "placeholder widget used to launch other widgets" case "tsunami": return handleTsunamiBlockDesc(block) case "aifilediff": return "" // AI doesn't need to see these case "waveconfig": if file, hasFile := block.Meta["file"].(string); hasFile && file != "" { return fmt.Sprintf("wave config editor for %q", file) } return "wave config editor" default: return fmt.Sprintf("unknown widget with type %q", viewType) } } func GenerateTabStateAndTools(ctx context.Context, tabid string, widgetAccess bool, chatOpts *uctypes.WaveChatOpts) (string, []uctypes.ToolDefinition, error) { if tabid == "" { return "", nil, nil } var blocks []*waveobj.Block if widgetAccess { if _, err := uuid.Parse(tabid); err != nil { return "", nil, fmt.Errorf("tabid must be a valid UUID") } tabObj, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabid) if err != nil { return "", nil, fmt.Errorf("error getting tab: %v", err) } for _, blockId := range tabObj.BlockIds { block, err := wstore.DBGet[*waveobj.Block](ctx, blockId) if err != nil { continue } blocks = append(blocks, block) } } tabState := GenerateCurrentTabStatePrompt(blocks, widgetAccess) // for debugging // log.Printf("TABPROMPT %s\n", tabState) var tools []uctypes.ToolDefinition if widgetAccess { // Only add screenshot tool for: // - openai-responses API type // - google-gemini API type with Gemini 3+ models if chatOpts.Config.APIType == uctypes.APIType_OpenAIResponses || (chatOpts.Config.APIType == uctypes.APIType_GoogleGemini && aiutil.GeminiSupportsImageToolResults(chatOpts.Config.Model)) { tools = append(tools, GetCaptureScreenshotToolDefinition(tabid)) } tools = append(tools, GetReadTextFileToolDefinition()) tools = append(tools, GetReadDirToolDefinition()) tools = append(tools, GetWriteTextFileToolDefinition()) tools = append(tools, GetEditTextFileToolDefinition()) tools = append(tools, GetDeleteTextFileToolDefinition()) viewTypes := make(map[string]bool) for _, block := range blocks { if block.Meta == nil { continue } viewType, ok := block.Meta["view"].(string) if !ok { continue } viewTypes[viewType] = true if viewType == "tsunami" { blockTools := generateToolsForTsunamiBlock(block) tools = append(tools, blockTools...) } } if viewTypes["term"] { tools = append(tools, GetTermGetScrollbackToolDefinition(tabid)) // tools = append(tools, GetTermCommandOutputToolDefinition(tabid)) } if viewTypes["web"] { tools = append(tools, GetWebNavigateToolDefinition(tabid)) } } return tabState, tools, nil } func GenerateCurrentTabStatePrompt(blocks []*waveobj.Block, widgetAccess bool) string { if !widgetAccess { return `<current_tab_state>The user has chosen not to share widget context with you</current_tab_state>` } var widgetDescriptions []string for _, block := range blocks { desc := MakeBlockShortDesc(block) if desc == "" { continue } blockIdPrefix := block.OID[:8] fullDesc := fmt.Sprintf("(%s) %s", blockIdPrefix, desc) widgetDescriptions = append(widgetDescriptions, fullDesc) } var prompt strings.Builder prompt.WriteString("<current_tab_state>\n") systemInfo := wavebase.GetSystemSummary() if currentUser, err := user.Current(); err == nil && currentUser.Username != "" { prompt.WriteString(fmt.Sprintf("Local Machine: %s, User: %s\n", systemInfo, currentUser.Username)) } else { prompt.WriteString(fmt.Sprintf("Local Machine: %s\n", systemInfo)) } if len(widgetDescriptions) == 0 { prompt.WriteString("No widgets open\n") } else { prompt.WriteString("Open Widgets:\n") for _, desc := range widgetDescriptions { prompt.WriteString("* ") prompt.WriteString(desc) prompt.WriteString("\n") } } prompt.WriteString("</current_tab_state>") rtn := prompt.String() return rtn } func generateToolsForTsunamiBlock(block *waveobj.Block) []uctypes.ToolDefinition { var tools []uctypes.ToolDefinition status := blockcontroller.GetBlockControllerRuntimeStatus(block.OID) if status == nil || status.ShellProcStatus != blockcontroller.Status_Running || status.TsunamiPort <= 0 { return nil } blockORef := waveobj.MakeORef(waveobj.OType_Block, block.OID) rtInfo := wstore.GetRTInfo(blockORef) if tool := GetTsunamiGetDataToolDefinition(block, rtInfo, status); tool != nil { tools = append(tools, *tool) } if tool := GetTsunamiGetConfigToolDefinition(block, rtInfo, status); tool != nil { tools = append(tools, *tool) } if tool := GetTsunamiSetConfigToolDefinition(block, rtInfo, status); tool != nil { tools = append(tools, *tool) } return tools } // Used for internal testing of tool loops func GetAdderToolDefinition() uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "adder", DisplayName: "Adder", Description: "Add an array of numbers together and return their sum", ToolLogName: "gen:adder", Strict: true, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "values": map[string]any{ "type": "array", "items": map[string]any{ "type": "integer", }, "description": "Array of numbers to add together", }, }, "required": []string{"values"}, "additionalProperties": false, }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { inputMap, ok := input.(map[string]any) if !ok { return nil, fmt.Errorf("invalid input format") } valuesInterface, ok := inputMap["values"] if !ok { return nil, fmt.Errorf("missing values parameter") } valuesSlice, ok := valuesInterface.([]any) if !ok { return nil, fmt.Errorf("values must be an array") } if len(valuesSlice) == 0 { return 0, nil } sum := 0 for i, val := range valuesSlice { floatVal, ok := val.(float64) if !ok { return nil, fmt.Errorf("value at index %d is not a number", i) } sum += int(floatVal) } return sum, nil }, } } ================================================ FILE: pkg/aiusechat/tools_builder.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "context" "fmt" "log" "strings" "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/buildercontroller" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) const BuilderAppFileName = "app.go" type builderWriteAppFileParams struct { Contents string `json:"contents"` } func triggerBuildAndWait(builderId string, appId string) map[string]any { bc := buildercontroller.GetOrCreateController(builderId) rtInfo := wstore.GetRTInfo(waveobj.MakeORef(waveobj.OType_Builder, builderId)) var builderEnv map[string]string if rtInfo != nil { builderEnv = rtInfo.BuilderEnv } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() result, err := bc.RestartAndWaitForBuild(ctx, appId, builderEnv) if err != nil { log.Printf("Build failed for %s: %v", builderId, err) return map[string]any{ "build_success": false, "build_error": err.Error(), "build_output": "", } } return map[string]any{ "build_success": result.Success, "build_error": result.ErrorMessage, "build_output": result.BuildOutput, } } func parseBuilderWriteAppFileInput(input any) (*builderWriteAppFileParams, error) { result := &builderWriteAppFileParams{} if input == nil { return nil, fmt.Errorf("input is required") } if err := utilfn.ReUnmarshal(result, input); err != nil { return nil, fmt.Errorf("invalid input format: %w", err) } if result.Contents == "" { return nil, fmt.Errorf("missing contents parameter") } return result, nil } func GetBuilderWriteAppFileToolDefinition(appId string, builderId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "builder_write_app_file", DisplayName: "Write App File", Description: fmt.Sprintf("Write the app.go file for app %s", appId), ToolLogName: "builder:write_app", Strict: false, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "contents": map[string]any{ "type": "string", "description": "The contents to write to app.go", }, }, "required": []string{"contents"}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { params, err := parseBuilderWriteAppFileInput(input) if err != nil { if output != nil { return "wrote app.go" } return "writing app.go" } lineCount := len(strings.Split(params.Contents, "\n")) if output != nil { return fmt.Sprintf("wrote app.go (+%d lines)", lineCount) } return fmt.Sprintf("writing app.go (+%d lines)", lineCount) }, ToolProgressDesc: func(input any) ([]string, error) { params, err := parseBuilderWriteAppFileInput(input) if err != nil { return nil, err } lineCount := len(strings.Split(params.Contents, "\n")) return []string{fmt.Sprintf("writing app.go (+%d lines)", lineCount)}, nil }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderWriteAppFileInput(input) if err != nil { return nil, err } formattedContents := waveapputil.FormatGoCode([]byte(params.Contents)) err = waveappstore.WriteAppFile(appId, BuilderAppFileName, formattedContents) if err != nil { return nil, err } wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveAppAppGoUpdated, Scopes: []string{appId}, }) result := map[string]any{ "success": true, "message": fmt.Sprintf("Successfully wrote %s", BuilderAppFileName), } if builderId != "" { buildResult := triggerBuildAndWait(builderId, appId) result["build_success"] = buildResult["build_success"] result["build_error"] = buildResult["build_error"] result["build_output"] = buildResult["build_output"] } return result, nil }, } } type builderEditAppFileParams struct { Edits []fileutil.EditSpec `json:"edits"` } func parseBuilderEditAppFileInput(input any) (*builderEditAppFileParams, error) { result := &builderEditAppFileParams{} if input == nil { return nil, fmt.Errorf("input is required") } if err := utilfn.ReUnmarshal(result, input); err != nil { return nil, fmt.Errorf("invalid input format: %w", err) } if len(result.Edits) == 0 { return nil, fmt.Errorf("missing edits parameter") } return result, nil } func formatEditDescriptions(edits []fileutil.EditSpec) []string { numEdits := len(edits) editStr := "edits" if numEdits == 1 { editStr = "edit" } result := make([]string, len(edits)+1) result[0] = fmt.Sprintf("editing app.go (%d %s)", numEdits, editStr) for i, edit := range edits { newLines := len(strings.Split(edit.NewStr, "\n")) oldLines := len(strings.Split(edit.OldStr, "\n")) desc := edit.Desc if desc == "" { desc = fmt.Sprintf("edit #%d", i+1) } result[i+1] = fmt.Sprintf("* %s (+%d -%d)", desc, newLines, oldLines) } return result } func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "builder_edit_app_file", DisplayName: "Edit App File", Description: "Edit the app.go file for this app using precise search and replace. " + "Each old_str must appear EXACTLY ONCE in the file or the edit will fail. " + "Edits are applied sequentially - if an edit fails, all previous edits are kept and subsequent edits are skipped.", ToolLogName: "builder:edit_app", Strict: false, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "edits": map[string]any{ "type": "array", "description": "Array of edit specifications. Edits are applied sequentially - if one fails, previous edits are kept but remaining edits are skipped.", "items": map[string]any{ "type": "object", "properties": map[string]any{ "old_str": map[string]any{ "type": "string", "description": "The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, this edit will fail.", }, "new_str": map[string]any{ "type": "string", "description": "The string to replace with", }, "desc": map[string]any{ "type": "string", "description": "Description of what this edit does (keep short, half a line of text max)", }, }, "required": []string{"old_str", "new_str"}, }, }, }, "required": []string{"edits"}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { params, err := parseBuilderEditAppFileInput(input) if err != nil { return fmt.Sprintf("error parsing input: %v", err) } return strings.Join(formatEditDescriptions(params.Edits), "\n") }, ToolProgressDesc: func(input any) ([]string, error) { params, err := parseBuilderEditAppFileInput(input) if err != nil { return nil, err } return formatEditDescriptions(params.Edits), nil }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderEditAppFileInput(input) if err != nil { return nil, err } editResults, err := waveappstore.ReplaceInAppFilePartial(appId, BuilderAppFileName, params.Edits) if err != nil { return nil, err } // ignore format errors; gofmt can fail due to compilation errors which will be caught in the build step waveappstore.FormatGoFile(appId, BuilderAppFileName) wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveAppAppGoUpdated, Scopes: []string{appId}, }) result := map[string]any{ "edits": editResults, } if builderId != "" { buildResult := triggerBuildAndWait(builderId, appId) result["build_success"] = buildResult["build_success"] result["build_error"] = buildResult["build_error"] result["build_output"] = buildResult["build_output"] } return result, nil }, } } func GetBuilderListFilesToolDefinition(appId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "builder_list_files", DisplayName: "List App Files", Description: fmt.Sprintf("List all files in app %s", appId), ToolLogName: "builder:list_files", Strict: false, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { return "listing files" }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { result, err := waveappstore.ListAllAppFiles(appId) if err != nil { return nil, err } return result, nil }, } } ================================================ FILE: pkg/aiusechat/tools_readdir.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "fmt" "os" "path/filepath" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" ) const ReadDirDefaultMaxEntries = 500 const ReadDirHardMaxEntries = 10000 type readDirParams struct { Path string `json:"path"` MaxEntries *int `json:"max_entries"` } func parseReadDirInput(input any) (*readDirParams, error) { result := &readDirParams{} if input == nil { return nil, fmt.Errorf("input is required") } if err := utilfn.ReUnmarshal(result, input); err != nil { return nil, fmt.Errorf("invalid input format: %w", err) } if result.Path == "" { return nil, fmt.Errorf("missing path parameter") } if result.MaxEntries == nil { maxEntries := ReadDirDefaultMaxEntries result.MaxEntries = &maxEntries } if *result.MaxEntries < 1 { return nil, fmt.Errorf("max_entries must be at least 1, got %d", *result.MaxEntries) } if *result.MaxEntries > ReadDirHardMaxEntries { return nil, fmt.Errorf("max_entries cannot exceed %d, got %d", ReadDirHardMaxEntries, *result.MaxEntries) } return result, nil } func verifyReadDirInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error { params, err := parseReadDirInput(input) if err != nil { return err } expandedPath, err := wavebase.ExpandHomeDir(params.Path) if err != nil { return fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return fmt.Errorf("path must be absolute, got relative path: %s", params.Path) } fileInfo, err := os.Stat(expandedPath) if err != nil { return fmt.Errorf("failed to stat path: %w", err) } if !fileInfo.IsDir() { return fmt.Errorf("path is not a directory, cannot be read with the read_dir tool. use the read_text_file tool if available to read files") } return nil } func readDirCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseReadDirInput(input) if err != nil { return nil, err } expandedPath, err := wavebase.ExpandHomeDir(params.Path) if err != nil { return nil, fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return nil, fmt.Errorf("path must be absolute, got relative path: %s", params.Path) } result, err := fileutil.ReadDir(params.Path, *params.MaxEntries) if err != nil { return nil, err } resultMap := map[string]any{ "path": result.Path, "absolute_path": result.AbsolutePath, "entry_count": result.EntryCount, "total_entries": result.TotalEntries, "entries": result.Entries, } if result.Truncated { resultMap["truncated"] = true resultMap["truncated_message"] = fmt.Sprintf("Directory listing truncated to %d entries (out of %d total). Increase max_entries to see more.", result.EntryCount, result.TotalEntries) } if result.ParentDir != "" { resultMap["parent_dir"] = result.ParentDir } return resultMap, nil } func GetReadDirToolDefinition() uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "read_dir", DisplayName: "Read Directory", Description: "Read a directory from the filesystem and list its contents. Returns information about files and subdirectories including names, types, sizes, permissions, and modification times.", ToolLogName: "gen:readdir", Strict: false, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "Absolute path to the directory to read. Supports '~' for the user's home directory. Relative paths are not supported.", }, "max_entries": map[string]any{ "type": "integer", "minimum": 1, "maximum": 10000, "default": 500, "description": "Maximum number of entries to return. Defaults to 500, max 10000.", }, }, "required": []string{"path"}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { parsed, err := parseReadDirInput(input) if err != nil { return fmt.Sprintf("error parsing input: %v", err) } readFullDir := false if output != nil { if outputMap, ok := output.(map[string]any); ok { _, wasTruncated := outputMap["truncated"] readFullDir = !wasTruncated } } if readFullDir { return fmt.Sprintf("reading directory %q (entire directory)", parsed.Path) } return fmt.Sprintf("reading directory %q (max_entries: %d)", parsed.Path, *parsed.MaxEntries) }, ToolAnyCallback: readDirCallback, ToolApproval: func(input any) string { return uctypes.ApprovalNeedsApproval }, ToolVerifyInput: verifyReadDirInput, } } ================================================ FILE: pkg/aiusechat/tools_readdir_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "fmt" "os" "path/filepath" "strings" "testing" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/util/fileutil" ) func TestReadDirCallback(t *testing.T) { // Create a temporary test directory tmpDir, err := os.MkdirTemp("", "readdir_test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) // Create test files and directories testFile1 := filepath.Join(tmpDir, "file1.txt") testFile2 := filepath.Join(tmpDir, "file2.log") testSubDir := filepath.Join(tmpDir, "subdir") if err := os.WriteFile(testFile1, []byte("test content 1"), 0644); err != nil { t.Fatalf("Failed to create test file 1: %v", err) } if err := os.WriteFile(testFile2, []byte("test content 2"), 0644); err != nil { t.Fatalf("Failed to create test file 2: %v", err) } if err := os.Mkdir(testSubDir, 0755); err != nil { t.Fatalf("Failed to create test subdir: %v", err) } // Test reading the directory input := map[string]any{ "path": tmpDir, } result, err := readDirCallback(input, &uctypes.UIMessageDataToolUse{}) if err != nil { t.Fatalf("readDirCallback failed: %v", err) } resultMap, ok := result.(map[string]any) if !ok { t.Fatalf("Result is not a map") } // Verify the result contains expected fields if resultMap["path"] != tmpDir { t.Errorf("Expected path %q, got %q", tmpDir, resultMap["path"]) } entryCount, ok := resultMap["entry_count"].(int) if !ok { t.Fatalf("entry_count is not an int") } if entryCount != 3 { t.Errorf("Expected 3 entries, got %d", entryCount) } entries, ok := resultMap["entries"].([]fileutil.DirEntryOut) if !ok { t.Fatalf("entries is not a slice of DirEntryOut") } // Check that we have the expected entries foundFiles := 0 foundDirs := 0 for _, entry := range entries { if entry.Dir { foundDirs++ } else { foundFiles++ } } if foundFiles != 2 { t.Errorf("Expected 2 files, got %d", foundFiles) } if foundDirs != 1 { t.Errorf("Expected 1 directory, got %d", foundDirs) } } func TestReadDirOnFile(t *testing.T) { // Create a temporary test file tmpFile, err := os.CreateTemp("", "readdir_test_file") if err != nil { t.Fatalf("Failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) tmpFile.Close() // Test reading a file (should fail) input := map[string]any{ "path": tmpFile.Name(), } _, err = readDirCallback(input, &uctypes.UIMessageDataToolUse{}) if err == nil { t.Fatalf("Expected error when reading a file with read_dir, got nil") } expectedErrSubstr := "path is not a directory" if err.Error()[:len(expectedErrSubstr)] != expectedErrSubstr { t.Errorf("Expected error containing %q, got %q", expectedErrSubstr, err.Error()) } } func TestReadDirMaxEntries(t *testing.T) { // Create a temporary test directory with many files tmpDir, err := os.MkdirTemp("", "readdir_test_max") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) // Create 10 test files for i := 0; i < 10; i++ { testFile := filepath.Join(tmpDir, filepath.Base(tmpDir)+string(rune('a'+i))+".txt") if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { t.Fatalf("Failed to create test file: %v", err) } } // Test reading with max_entries=5 maxEntries := 5 input := map[string]any{ "path": tmpDir, "max_entries": maxEntries, } result, err := readDirCallback(input, &uctypes.UIMessageDataToolUse{}) if err != nil { t.Fatalf("readDirCallback failed: %v", err) } resultMap := result.(map[string]any) entryCount := resultMap["entry_count"].(int) totalEntries := resultMap["total_entries"].(int) if entryCount != maxEntries { t.Errorf("Expected %d entries, got %d", maxEntries, entryCount) } // Verify total_entries reports the original count, not the truncated count if totalEntries != 10 { t.Errorf("Expected total_entries to be 10, got %d", totalEntries) } if _, ok := resultMap["truncated"]; !ok { t.Error("Expected truncated field to be present") } // Verify the truncation message includes the correct total truncMsg, ok := resultMap["truncated_message"].(string) if !ok { t.Error("Expected truncated_message to be present") } expectedMsg := fmt.Sprintf("Directory listing truncated to %d entries (out of %d total)", maxEntries, 10) if !strings.Contains(truncMsg, expectedMsg[:len(expectedMsg)-1]) { t.Errorf("Expected truncated_message to contain %q, got %q", expectedMsg, truncMsg) } } func TestReadDirSortBeforeTruncate(t *testing.T) { // Create a temporary test directory tmpDir, err := os.MkdirTemp("", "readdir_test_sort") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) // Create files with names that would sort alphabetically before directories // but we want directories to appear first for i := 0; i < 5; i++ { testFile := filepath.Join(tmpDir, fmt.Sprintf("a_file_%d.txt", i)) if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { t.Fatalf("Failed to create test file: %v", err) } } // Create directories with names that sort alphabetically after the files for i := 0; i < 3; i++ { testDir := filepath.Join(tmpDir, fmt.Sprintf("z_dir_%d", i)) if err := os.Mkdir(testDir, 0755); err != nil { t.Fatalf("Failed to create test dir: %v", err) } } // Test with max_entries=5 (less than total of 8) // All 3 directories should still appear because they're sorted first maxEntries := 5 input := map[string]any{ "path": tmpDir, "max_entries": maxEntries, } result, err := readDirCallback(input, &uctypes.UIMessageDataToolUse{}) if err != nil { t.Fatalf("readDirCallback failed: %v", err) } resultMap := result.(map[string]any) entries, ok := resultMap["entries"].([]fileutil.DirEntryOut) if !ok { t.Fatalf("entries is not a slice of DirEntryOut") } // Count directories in the result dirCount := 0 for _, entry := range entries { if entry.Dir { dirCount++ } } // All 3 directories should be present because sorting happens before truncation if dirCount != 3 { t.Errorf("Expected 3 directories in truncated results, got %d", dirCount) } // First 3 entries should be directories for i := 0; i < 3; i++ { if !entries[i].Dir { t.Errorf("Expected entry %d to be a directory, but it was a file", i) } } } func TestParseReadDirInput(t *testing.T) { // Test valid input input := map[string]any{ "path": "/tmp/test", } params, err := parseReadDirInput(input) if err != nil { t.Fatalf("parseReadDirInput failed on valid input: %v", err) } if params.Path != "/tmp/test" { t.Errorf("Expected path '/tmp/test', got %q", params.Path) } if *params.MaxEntries != ReadDirDefaultMaxEntries { t.Errorf("Expected default max_entries %d, got %d", ReadDirDefaultMaxEntries, *params.MaxEntries) } // Test missing path input = map[string]any{} _, err = parseReadDirInput(input) if err == nil { t.Error("Expected error for missing path, got nil") } // Test invalid max_entries input = map[string]any{ "path": "/tmp/test", "max_entries": 0, } _, err = parseReadDirInput(input) if err == nil { t.Error("Expected error for max_entries < 1, got nil") } } func TestGetReadDirToolDefinition(t *testing.T) { toolDef := GetReadDirToolDefinition() if toolDef.Name != "read_dir" { t.Errorf("Expected tool name 'read_dir', got %q", toolDef.Name) } if toolDef.ToolLogName != "gen:readdir" { t.Errorf("Expected tool log name 'gen:readdir', got %q", toolDef.ToolLogName) } if toolDef.ToolAnyCallback == nil { t.Error("ToolAnyCallback should not be nil") } if toolDef.ToolApproval == nil { t.Error("ToolApproval should not be nil") } if toolDef.ToolCallDesc == nil { t.Error("ToolCallDesc should not be nil") } } ================================================ FILE: pkg/aiusechat/tools_readfile.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/util/readutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" ) const ReadFileDefaultLineCount = 100 const ReadFileDefaultMaxBytes = 50 * 1024 const StopReasonMaxBytes = "max_bytes" type readTextFileParams struct { Filename string `json:"filename"` Origin *string `json:"origin"` // "start" or "end", defaults to "start" Offset *int `json:"offset"` // lines to skip, defaults to 0 Count *int `json:"count"` // number of lines to read, defaults to DefaultLineCount MaxBytes *int `json:"max_bytes"` } func parseReadTextFileInput(input any) (*readTextFileParams, error) { result := &readTextFileParams{} if input == nil { return nil, fmt.Errorf("input is required") } if err := utilfn.ReUnmarshal(result, input); err != nil { return nil, fmt.Errorf("invalid input format: %w", err) } if result.Filename == "" { return nil, fmt.Errorf("missing filename parameter") } if result.Origin == nil { origin := "start" result.Origin = &origin } if *result.Origin != "start" && *result.Origin != "end" { return nil, fmt.Errorf("invalid origin value '%s': must be 'start' or 'end'", *result.Origin) } if result.Offset == nil { offset := 0 result.Offset = &offset } if *result.Offset < 0 { return nil, fmt.Errorf("offset must be non-negative, got %d", *result.Offset) } if result.Count == nil { count := ReadFileDefaultLineCount result.Count = &count } if *result.Count < 1 { return nil, fmt.Errorf("count must be at least 1, got %d", *result.Count) } if result.MaxBytes == nil { maxBytes := ReadFileDefaultMaxBytes result.MaxBytes = &maxBytes } return result, nil } // truncateData truncates data to maxBytes while respecting line boundaries. // For origin "start", keeps the beginning and truncates at last newline before maxBytes. // For origin "end", keeps the end and truncates from beginning at first newline after removing excess. func truncateData(data string, origin string, maxBytes int) string { if len(data) <= maxBytes { return data } if origin == "end" { excessBytes := len(data) - maxBytes truncateIdx := strings.Index(data[excessBytes:], "\n") if truncateIdx == -1 { return data[excessBytes:] } return data[excessBytes+truncateIdx+1:] } truncateIdx := strings.LastIndex(data[:maxBytes], "\n") if truncateIdx == -1 { return data[:maxBytes] } return data[:truncateIdx+1] } func isBlockedFile(expandedPath string) (bool, string) { homeDir := os.Getenv("HOME") if homeDir == "" { homeDir = os.Getenv("USERPROFILE") } cleanPath := filepath.Clean(expandedPath) baseName := filepath.Base(cleanPath) exactPaths := []struct { path string reason string }{ {filepath.Join(homeDir, ".aws", "credentials"), "AWS credentials file"}, {filepath.Join(homeDir, ".git-credentials"), "Git credentials file"}, {filepath.Join(homeDir, ".netrc"), "netrc credentials file"}, {filepath.Join(homeDir, ".pgpass"), "PostgreSQL password file"}, {filepath.Join(homeDir, ".my.cnf"), "MySQL credentials file"}, {filepath.Join(homeDir, ".kube", "config"), "Kubernetes config file"}, {"/etc/shadow", "system password file"}, {"/etc/sudoers", "system sudoers file"}, } for _, ep := range exactPaths { if cleanPath == ep.path { return true, ep.reason } } dirPrefixes := []struct { prefix string reason string }{ {filepath.Join(homeDir, ".gnupg") + string(filepath.Separator), "GPG directory"}, {filepath.Join(homeDir, ".password-store") + string(filepath.Separator), "password store directory"}, {"/etc/sudoers.d/", "system sudoers directory"}, {"/Library/Keychains/", "macOS keychain directory"}, {filepath.Join(homeDir, "Library", "Keychains") + string(filepath.Separator), "macOS keychain directory"}, } for _, dp := range dirPrefixes { if strings.HasPrefix(cleanPath, dp.prefix) { return true, dp.reason } } if strings.Contains(cleanPath, filepath.Join(homeDir, ".secrets")) { return true, "secrets directory" } if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { credPath := filepath.Join(localAppData, "Microsoft", "Credentials") if strings.HasPrefix(cleanPath, credPath) { return true, "Windows credentials" } } if appData := os.Getenv("APPDATA"); appData != "" { credPath := filepath.Join(appData, "Microsoft", "Credentials") if strings.HasPrefix(cleanPath, credPath) { return true, "Windows credentials" } } if strings.HasPrefix(baseName, "id_") && strings.Contains(cleanPath, ".ssh") { return true, "SSH private key" } if strings.Contains(baseName, "id_rsa") { return true, "SSH private key" } if strings.HasPrefix(baseName, "ssh_host_") && strings.Contains(baseName, "key") { return true, "SSH host key" } extensions := map[string]string{ ".pem": "certificate/key file", ".p12": "certificate file", ".key": "key file", ".pfx": "certificate file", ".pkcs12": "certificate file", ".keystore": "Java keystore file", ".jks": "Java keystore file", } if reason, exists := extensions[filepath.Ext(baseName)]; exists { return true, reason } if baseName == ".git-credentials" { return true, "Git credentials file" } return false, "" } func verifyReadTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error { params, err := parseReadTextFileInput(input) if err != nil { return err } expandedPath, err := wavebase.ExpandHomeDir(params.Filename) if err != nil { return fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return fmt.Errorf("path must be absolute, got relative path: %s", params.Filename) } if blocked, reason := isBlockedFile(expandedPath); blocked { return fmt.Errorf("access denied: potentially sensitive file: %s", reason) } fileInfo, err := os.Stat(expandedPath) if err != nil { return fmt.Errorf("failed to stat file: %w", err) } if fileInfo.IsDir() { return fmt.Errorf("path is a directory, cannot be read with the read_text_file tool. use the read_dir tool if available to read directories") } return nil } func readTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { const ReadLimit = 1024 * 1024 * 1024 params, err := parseReadTextFileInput(input) if err != nil { return nil, err } expandedPath, err := wavebase.ExpandHomeDir(params.Filename) if err != nil { return nil, fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return nil, fmt.Errorf("path must be absolute, got relative path: %s", params.Filename) } if blocked, reason := isBlockedFile(expandedPath); blocked { return nil, fmt.Errorf("access denied: potentially sensitive file: %s", reason) } fileInfo, err := os.Stat(expandedPath) if err != nil { return nil, fmt.Errorf("failed to stat file: %w", err) } if fileInfo.IsDir() { return nil, fmt.Errorf("path is a directory, cannot be read with the read_text_file tool. use the read_dir tool if available to read directories") } file, err := os.Open(expandedPath) if err != nil { return nil, fmt.Errorf("failed to open file: %w", err) } defer file.Close() totalSize := fileInfo.Size() modTime := fileInfo.ModTime() initialBuf := make([]byte, min(8192, int(totalSize))) n, err := file.Read(initialBuf) if err != nil && err != io.EOF { return nil, fmt.Errorf("failed to read file: %w", err) } initialBuf = initialBuf[:n] if utilfn.IsBinaryContent(initialBuf) { return nil, fmt.Errorf("file appears to be binary content") } origin := *params.Origin offset := *params.Offset count := *params.Count maxBytes := *params.MaxBytes var lines []string var stopReason string if _, err := file.Seek(0, 0); err != nil { return nil, fmt.Errorf("failed to seek to start of file: %w", err) } if origin == "end" { lines, stopReason, err = readutil.ReadTailLines(file, count, offset, int64(ReadLimit)) if err != nil { return nil, fmt.Errorf("error reading file from end: %w", err) } } else { lines, stopReason, err = readutil.ReadLines(file, count, offset, ReadLimit) if err != nil { return nil, fmt.Errorf("error reading file: %w", err) } } data := strings.Join(lines, "") data = strings.TrimSuffix(data, "\n") if len(data) > maxBytes { data = truncateData(data, origin, maxBytes) stopReason = StopReasonMaxBytes } result := map[string]any{ "total_size": totalSize, "data": data, "modified": utilfn.FormatRelativeTime(modTime), "modified_time": modTime.UTC().Format(time.RFC3339), "mode": fileInfo.Mode().String(), } if stopReason == "read_limit" || stopReason == StopReasonMaxBytes { result["truncated"] = stopReason } return result, nil } func GetReadTextFileToolDefinition() uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "read_text_file", DisplayName: "Read Text File", Description: "Read a text file from the filesystem. Can read specific line ranges or from the end. Detects and rejects binary files.", ToolLogName: "gen:readfile", Strict: false, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "filename": map[string]any{ "type": "string", "description": "Absolute path to the file to read. Supports '~' for the user's home directory. Relative paths are not supported.", }, "origin": map[string]any{ "type": "string", "enum": []string{"start", "end"}, "default": "start", "description": "Where to read from: 'start' (default) or 'end' of file", }, "offset": map[string]any{ "type": "integer", "minimum": 0, "default": 0, "description": "Lines to skip. From 'start': 0-based line index. From 'end': lines to skip from the end (0 = very last line)", }, "count": map[string]any{ "type": "integer", "minimum": 1, "default": ReadFileDefaultLineCount, "description": "Number of lines to return", }, "max_bytes": map[string]any{ "type": "integer", "minimum": 1, "default": ReadFileDefaultMaxBytes, "description": "Maximum bytes to return. If the result exceeds this, it will be truncated at line boundaries", }, }, "required": []string{"filename"}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { parsed, err := parseReadTextFileInput(input) if err != nil { return fmt.Sprintf("error parsing input: %v", err) } origin := *parsed.Origin offset := *parsed.Offset count := *parsed.Count readFullFile := false if output != nil { if outputMap, ok := output.(map[string]any); ok { _, wasTruncated := outputMap["truncated"] readFullFile = !wasTruncated } } if origin == "start" && offset == 0 { if readFullFile { return fmt.Sprintf("reading %q (entire file)", parsed.Filename) } return fmt.Sprintf("reading %q (first %d lines)", parsed.Filename, count) } if origin == "end" && offset == 0 { if readFullFile { return fmt.Sprintf("reading %q (entire file)", parsed.Filename) } return fmt.Sprintf("reading %q (last %d lines)", parsed.Filename, count) } if origin == "end" { return fmt.Sprintf("reading %q (from end: offset %d lines, count %d lines)", parsed.Filename, offset, count) } return fmt.Sprintf("reading %q (from start: offset %d lines, count %d lines)", parsed.Filename, offset, count) }, ToolAnyCallback: readTextFileCallback, ToolApproval: func(input any) string { return uctypes.ApprovalNeedsApproval }, ToolVerifyInput: verifyReadTextFileInput, } } ================================================ FILE: pkg/aiusechat/tools_screenshot.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "context" "fmt" "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) func makeTabCaptureBlockScreenshot(tabId string) func(any) (string, error) { return func(input any) (string, error) { inputMap, ok := input.(map[string]any) if !ok { return "", fmt.Errorf("invalid input format") } blockIdPrefix, ok := inputMap["widget_id"].(string) if !ok { return "", fmt.Errorf("missing or invalid widget_id parameter") } ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() fullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, tabId, blockIdPrefix) if err != nil { return "", err } rpcClient := wshclient.GetBareRpcClient() screenshotData, err := wshclient.CaptureBlockScreenshotCommand( rpcClient, wshrpc.CommandCaptureBlockScreenshotData{BlockId: fullBlockId}, &wshrpc.RpcOpts{Route: wshutil.MakeTabRouteId(tabId)}, ) if err != nil { return "", fmt.Errorf("failed to capture screenshot: %w", err) } return screenshotData, nil } } func GetCaptureScreenshotToolDefinition(tabId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "capture_screenshot", DisplayName: "Capture Screenshot", Description: "Capture a screenshot of a widget and return it as an image", ToolLogName: "gen:screenshot", Strict: true, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "widget_id": map[string]any{ "type": "string", "description": "8-character widget ID of the widget to screenshot", }, }, "required": []string{"widget_id"}, "additionalProperties": false, }, RequiredCapabilities: []string{uctypes.AICapabilityImages}, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { inputMap, ok := input.(map[string]any) if !ok { return "error parsing input: invalid format" } widgetId, ok := inputMap["widget_id"].(string) if !ok { return "error parsing input: missing widget_id" } return fmt.Sprintf("capturing screenshot of widget %s", widgetId) }, ToolTextCallback: makeTabCaptureBlockScreenshot(tabId), } } ================================================ FILE: pkg/aiusechat/tools_term.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wstore" ) type TermGetScrollbackToolInput struct { WidgetId string `json:"widget_id"` LineStart int `json:"line_start,omitempty"` Count int `json:"count,omitempty"` } type CommandInfo struct { Command string `json:"command"` Status string `json:"status"` ExitCode *int `json:"exitcode,omitempty"` } type TermGetScrollbackToolOutput struct { TotalLines int `json:"totallines"` LineStart int `json:"linestart"` LineEnd int `json:"lineend"` ReturnedLines int `json:"returnedlines"` Content string `json:"content"` SinceLastOutputSec *int `json:"sincelastoutputsec,omitempty"` HasMore bool `json:"hasmore"` NextStart *int `json:"nextstart"` LastCommand *CommandInfo `json:"lastcommand,omitempty"` } func parseTermGetScrollbackInput(input any) (*TermGetScrollbackToolInput, error) { const ( DefaultCount = 200 MaxCount = 1000 ) result := &TermGetScrollbackToolInput{ LineStart: 0, Count: 0, } if input == nil { result.Count = DefaultCount return result, nil } inputBytes, err := json.Marshal(input) if err != nil { return nil, fmt.Errorf("failed to marshal input: %w", err) } if err := json.Unmarshal(inputBytes, result); err != nil { return nil, fmt.Errorf("failed to unmarshal input: %w", err) } if result.Count == 0 { result.Count = DefaultCount } if result.Count < 0 { return nil, fmt.Errorf("count must be positive") } result.Count = min(result.Count, MaxCount) return result, nil } func getTermScrollbackOutput(tabId string, widgetId string, rpcData wshrpc.CommandTermGetScrollbackLinesData) (*TermGetScrollbackToolOutput, error) { ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() fullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, tabId, widgetId) if err != nil { return nil, err } rpcClient := wshclient.GetBareRpcClient() result, err := wshclient.TermGetScrollbackLinesCommand( rpcClient, rpcData, &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(fullBlockId)}, ) if err != nil { return nil, err } content := strings.Join(result.Lines, "\n") var effectiveLineEnd int if rpcData.LastCommand { effectiveLineEnd = result.LineStart + len(result.Lines) } else { effectiveLineEnd = min(rpcData.LineEnd, result.TotalLines) } hasMore := effectiveLineEnd < result.TotalLines var sinceLastOutputSec *int if result.LastUpdated > 0 { sec := max(0, int((time.Now().UnixMilli()-result.LastUpdated)/1000)) sinceLastOutputSec = &sec } var nextStart *int if hasMore { nextStart = &effectiveLineEnd } blockORef := waveobj.MakeORef(waveobj.OType_Block, fullBlockId) rtInfo := wstore.GetRTInfo(blockORef) var lastCommand *CommandInfo if rtInfo != nil && rtInfo.ShellIntegration && rtInfo.ShellLastCmd != "" { cmdInfo := &CommandInfo{ Command: rtInfo.ShellLastCmd, } if rtInfo.ShellState == "running-command" { cmdInfo.Status = "running" } else if rtInfo.ShellState == "ready" { cmdInfo.Status = "completed" exitCode := rtInfo.ShellLastCmdExitCode cmdInfo.ExitCode = &exitCode } lastCommand = cmdInfo } return &TermGetScrollbackToolOutput{ TotalLines: result.TotalLines, LineStart: result.LineStart, LineEnd: effectiveLineEnd, ReturnedLines: len(result.Lines), Content: content, SinceLastOutputSec: sinceLastOutputSec, HasMore: hasMore, NextStart: nextStart, LastCommand: lastCommand, }, nil } func GetTermGetScrollbackToolDefinition(tabId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "term_get_scrollback", DisplayName: "Get Terminal Scrollback", Description: "Fetch terminal scrollback from a widget as plain text. Index 0 is the most recent line; indices increase going upward (older lines). Also returns last command and exit code if shell integration is enabled.", ToolLogName: "term:getscrollback", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "widget_id": map[string]any{ "type": "string", "description": "8-character widget ID of the terminal widget", }, "line_start": map[string]any{ "type": "integer", "minimum": 0, "description": "Logical start index where 0 = most recent line (default: 0).", }, "count": map[string]any{ "type": "integer", "minimum": 1, "description": "Number of lines to return from line_start (default: 200).", }, }, "required": []string{"widget_id"}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { parsed, err := parseTermGetScrollbackInput(input) if err != nil { return fmt.Sprintf("error parsing input: %v", err) } if parsed.LineStart == 0 && parsed.Count == 200 { return fmt.Sprintf("reading terminal output from %s (most recent %d lines)", parsed.WidgetId, parsed.Count) } lineEnd := parsed.LineStart + parsed.Count return fmt.Sprintf("reading terminal output from %s (lines %d-%d)", parsed.WidgetId, parsed.LineStart, lineEnd) }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { parsed, err := parseTermGetScrollbackInput(input) if err != nil { return nil, err } lineEnd := parsed.LineStart + parsed.Count output, err := getTermScrollbackOutput( tabId, parsed.WidgetId, wshrpc.CommandTermGetScrollbackLinesData{ LineStart: parsed.LineStart, LineEnd: lineEnd, LastCommand: false, }, ) if err != nil { return nil, fmt.Errorf("failed to get terminal scrollback: %w", err) } return output, nil }, } } type TermCommandOutputToolInput struct { WidgetId string `json:"widget_id"` } func parseTermCommandOutputInput(input any) (*TermCommandOutputToolInput, error) { result := &TermCommandOutputToolInput{} if input == nil { return nil, fmt.Errorf("widget_id is required") } inputBytes, err := json.Marshal(input) if err != nil { return nil, fmt.Errorf("failed to marshal input: %w", err) } if err := json.Unmarshal(inputBytes, result); err != nil { return nil, fmt.Errorf("failed to unmarshal input: %w", err) } if result.WidgetId == "" { return nil, fmt.Errorf("widget_id is required") } return result, nil } func GetTermCommandOutputToolDefinition(tabId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "term_command_output", DisplayName: "Get Last Command Output", Description: "Retrieve output from the most recent command in a terminal widget. Requires shell integration to be enabled. Returns the command text, exit code, and up to 1000 lines of output.", ToolLogName: "term:commandoutput", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "widget_id": map[string]any{ "type": "string", "description": "8-character widget ID of the terminal widget", }, }, "required": []string{"widget_id"}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { parsed, err := parseTermCommandOutputInput(input) if err != nil { return fmt.Sprintf("error parsing input: %v", err) } return fmt.Sprintf("reading last command output from %s", parsed.WidgetId) }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { parsed, err := parseTermCommandOutputInput(input) if err != nil { return nil, err } ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() fullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, tabId, parsed.WidgetId) if err != nil { return nil, err } blockORef := waveobj.MakeORef(waveobj.OType_Block, fullBlockId) rtInfo := wstore.GetRTInfo(blockORef) if rtInfo == nil || !rtInfo.ShellIntegration { return nil, fmt.Errorf("shell integration is not enabled for this terminal") } output, err := getTermScrollbackOutput( tabId, parsed.WidgetId, wshrpc.CommandTermGetScrollbackLinesData{ LastCommand: true, }, ) if err != nil { return nil, fmt.Errorf("failed to get command output: %w", err) } return output, nil }, } } ================================================ FILE: pkg/aiusechat/tools_tsunami.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" ) func getTsunamiShortDesc(rtInfo *waveobj.ObjRTInfo) string { if rtInfo == nil || rtInfo.TsunamiAppMeta == nil { return "" } var appMeta wshrpc.AppMeta if err := utilfn.ReUnmarshal(&appMeta, rtInfo.TsunamiAppMeta); err == nil && appMeta.ShortDesc != "" { return appMeta.ShortDesc } return "" } func handleTsunamiBlockDesc(block *waveobj.Block) string { status := blockcontroller.GetBlockControllerRuntimeStatus(block.OID) if status == nil || status.ShellProcStatus != blockcontroller.Status_Running { return "tsunami framework widget that is currently not running" } blockORef := waveobj.MakeORef(waveobj.OType_Block, block.OID) rtInfo := wstore.GetRTInfo(blockORef) if shortDesc := getTsunamiShortDesc(rtInfo); shortDesc != "" { return fmt.Sprintf("tsunami widget - %s", shortDesc) } return "tsunami widget - unknown description" } func makeTsunamiGetCallback(status *blockcontroller.BlockControllerRuntimeStatus, apiPath string) func(any, *uctypes.UIMessageDataToolUse) (any, error) { return func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { if status.TsunamiPort == 0 { return nil, fmt.Errorf("tsunami port not available") } url := fmt.Sprintf("http://localhost:%d%s", status.TsunamiPort, apiPath) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request to tsunami: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("tsunami returned status %d", resp.StatusCode) } var result any if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode tsunami response: %w", err) } return result, nil } } func makeTsunamiPostCallback(status *blockcontroller.BlockControllerRuntimeStatus, apiPath string) func(any, *uctypes.UIMessageDataToolUse) (any, error) { return func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { if status.TsunamiPort == 0 { return nil, fmt.Errorf("tsunami port not available") } url := fmt.Sprintf("http://localhost:%d%s", status.TsunamiPort, apiPath) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var reqBody []byte var err error if input != nil { reqBody, err = json.Marshal(input) if err != nil { return nil, fmt.Errorf("failed to marshal input: %w", err) } } req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(reqBody))) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request to tsunami: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("tsunami returned status %d", resp.StatusCode) } return true, nil } } func GetTsunamiGetDataToolDefinition(block *waveobj.Block, rtInfo *waveobj.ObjRTInfo, status *blockcontroller.BlockControllerRuntimeStatus) *uctypes.ToolDefinition { blockIdPrefix := block.OID[:8] toolName := fmt.Sprintf("tsunami_getdata_%s", blockIdPrefix) desc := "tsunami widget" if shortDesc := getTsunamiShortDesc(rtInfo); shortDesc != "" { desc = shortDesc } return &uctypes.ToolDefinition{ Name: toolName, ToolLogName: "tsunami:getdata", Strict: true, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { return fmt.Sprintf("getting data from %s (%s)", desc, blockIdPrefix) }, ToolAnyCallback: makeTsunamiGetCallback(status, "/api/data"), } } func GetTsunamiGetConfigToolDefinition(block *waveobj.Block, rtInfo *waveobj.ObjRTInfo, status *blockcontroller.BlockControllerRuntimeStatus) *uctypes.ToolDefinition { blockIdPrefix := block.OID[:8] toolName := fmt.Sprintf("tsunami_getconfig_%s", blockIdPrefix) desc := "tsunami widget" if shortDesc := getTsunamiShortDesc(rtInfo); shortDesc != "" { desc = shortDesc } return &uctypes.ToolDefinition{ Name: toolName, ToolLogName: "tsunami:getconfig", Strict: true, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { return fmt.Sprintf("getting config from %s (%s)", desc, blockIdPrefix) }, ToolAnyCallback: makeTsunamiGetCallback(status, "/api/config"), } } func GetTsunamiSetConfigToolDefinition(block *waveobj.Block, rtInfo *waveobj.ObjRTInfo, status *blockcontroller.BlockControllerRuntimeStatus) *uctypes.ToolDefinition { blockIdPrefix := block.OID[:8] toolName := fmt.Sprintf("tsunami_setconfig_%s", blockIdPrefix) var inputSchema map[string]any if rtInfo != nil && rtInfo.TsunamiSchemas != nil { if schemasMap, ok := rtInfo.TsunamiSchemas.(map[string]any); ok { if configSchema, exists := schemasMap["config"]; exists { inputSchema = configSchema.(map[string]any) } } } if inputSchema == nil { return nil } desc := "tsunami widget" if shortDesc := getTsunamiShortDesc(rtInfo); shortDesc != "" { desc = shortDesc } return &uctypes.ToolDefinition{ Name: toolName, ToolLogName: "tsunami:setconfig", InputSchema: inputSchema, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { return fmt.Sprintf("updating config for %s (%s)", desc, blockIdPrefix) }, ToolAnyCallback: makeTsunamiPostCallback(status, "/api/config"), } } ================================================ FILE: pkg/aiusechat/tools_web.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "context" "encoding/json" "fmt" "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wstore" ) type WebNavigateToolInput struct { WidgetId string `json:"widget_id"` Url string `json:"url"` } func parseWebNavigateInput(input any) (*WebNavigateToolInput, error) { result := &WebNavigateToolInput{} if input == nil { return nil, fmt.Errorf("input is required") } inputBytes, err := json.Marshal(input) if err != nil { return nil, fmt.Errorf("failed to marshal input: %w", err) } if err := json.Unmarshal(inputBytes, result); err != nil { return nil, fmt.Errorf("failed to unmarshal input: %w", err) } if result.WidgetId == "" { return nil, fmt.Errorf("widget_id is required") } if result.Url == "" { return nil, fmt.Errorf("url is required") } return result, nil } func GetWebNavigateToolDefinition(tabId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "web_navigate", DisplayName: "Navigate Web Widget", Description: "Navigate a web browser widget to a new URL", ToolLogName: "web:navigate", Strict: true, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "widget_id": map[string]any{ "type": "string", "description": "8-character widget ID of the web browser widget", }, "url": map[string]any{ "type": "string", "description": "URL to navigate to", }, }, "required": []string{"widget_id", "url"}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { parsed, err := parseWebNavigateInput(input) if err != nil { return fmt.Sprintf("error parsing input: %v", err) } return fmt.Sprintf("navigating web widget %s to %q", parsed.WidgetId, parsed.Url) }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { parsed, err := parseWebNavigateInput(input) if err != nil { return nil, err } ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() fullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, tabId, parsed.WidgetId) if err != nil { return nil, err } blockORef := waveobj.MakeORef(waveobj.OType_Block, fullBlockId) meta := map[string]any{ "url": parsed.Url, } err = wstore.UpdateObjectMeta(ctx, blockORef, meta, false) if err != nil { return nil, fmt.Errorf("failed to update web block URL: %w", err) } wcore.SendWaveObjUpdate(blockORef) return true, nil }, } } ================================================ FILE: pkg/aiusechat/tools_writefile.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "fmt" "os" "path/filepath" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/filebackup" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" ) const MaxEditFileSize = 100 * 1024 // 100KB func validateTextFile(expandedPath string, verb string, mustExist bool) (os.FileInfo, error) { if blocked, reason := isBlockedFile(expandedPath); blocked { return nil, fmt.Errorf("access denied: potentially sensitive file: %s", reason) } fileInfo, err := os.Lstat(expandedPath) if err != nil { if os.IsNotExist(err) { if mustExist { return nil, fmt.Errorf("file does not exist: %s", expandedPath) } return nil, nil } return nil, fmt.Errorf("failed to stat file: %w", err) } if fileInfo.Mode()&os.ModeSymlink != 0 { target, _ := os.Readlink(expandedPath) if target == "" { target = "(unknown)" } return nil, fmt.Errorf("cannot %s symlinks (target: %s). %s the target file directly if needed", verb, utilfn.MarshalJSONString(target), verb) } if fileInfo.IsDir() { return nil, fmt.Errorf("path is a directory, cannot %s it", verb) } if !fileInfo.Mode().IsRegular() { return nil, fmt.Errorf("path is not a regular file (devices, pipes, sockets not supported)") } if fileInfo.Size() > MaxEditFileSize { return nil, fmt.Errorf("file is too large (%d bytes, max %d bytes)", fileInfo.Size(), MaxEditFileSize) } fileData, err := os.ReadFile(expandedPath) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } if utilfn.HasBinaryData(fileData) { return nil, fmt.Errorf("file appears to contain binary data") } dirPath := filepath.Dir(expandedPath) dirInfo, err := os.Stat(dirPath) if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("failed to stat directory: %w", err) } if err == nil && dirInfo.Mode().Perm()&0222 == 0 { return nil, fmt.Errorf("directory is not writable (no write permission)") } return fileInfo, nil } type writeTextFileParams struct { Filename string `json:"filename"` Contents string `json:"contents"` } func parseWriteTextFileInput(input any) (*writeTextFileParams, error) { result := &writeTextFileParams{} if input == nil { return nil, fmt.Errorf("input is required") } if err := utilfn.ReUnmarshal(result, input); err != nil { return nil, fmt.Errorf("invalid input format: %w", err) } if result.Filename == "" { return nil, fmt.Errorf("missing filename parameter") } if result.Contents == "" { return nil, fmt.Errorf("missing contents parameter") } return result, nil } func verifyWriteTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error { params, err := parseWriteTextFileInput(input) if err != nil { return err } expandedPath, err := wavebase.ExpandHomeDir(params.Filename) if err != nil { return fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return fmt.Errorf("path must be absolute, got relative path: %s", params.Filename) } contentsBytes := []byte(params.Contents) if utilfn.HasBinaryData(contentsBytes) { return fmt.Errorf("contents appear to contain binary data") } _, err = validateTextFile(expandedPath, "write to", false) if err != nil { return err } toolUseData.InputFileName = params.Filename return nil } func writeTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseWriteTextFileInput(input) if err != nil { return nil, err } expandedPath, err := wavebase.ExpandHomeDir(params.Filename) if err != nil { return nil, fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return nil, fmt.Errorf("path must be absolute, got relative path: %s", params.Filename) } contentsBytes := []byte(params.Contents) if utilfn.HasBinaryData(contentsBytes) { return nil, fmt.Errorf("contents appear to contain binary data") } fileInfo, err := validateTextFile(expandedPath, "write to", false) if err != nil { return nil, err } dirPath := filepath.Dir(expandedPath) err = os.MkdirAll(dirPath, 0755) if err != nil { return nil, fmt.Errorf("failed to create directory: %w", err) } if fileInfo != nil { backupPath, err := filebackup.MakeFileBackup(expandedPath) if err != nil { return nil, fmt.Errorf("failed to create backup: %w", err) } toolUseData.WriteBackupFileName = backupPath } err = os.WriteFile(expandedPath, contentsBytes, 0644) if err != nil { return nil, fmt.Errorf("failed to write file: %w", err) } return map[string]any{ "success": true, "message": fmt.Sprintf("Successfully wrote %s (%d bytes)", params.Filename, len(contentsBytes)), }, nil } func GetWriteTextFileToolDefinition() uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "write_text_file", DisplayName: "Write Text File", Description: "Write a text file to the filesystem. Will create or overwrite the file. Maximum file size: 100KB.", ToolLogName: "gen:writefile", Strict: true, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "filename": map[string]any{ "type": "string", "description": "Absolute path to the file to write. Supports '~' for the user's home directory. Relative paths are not supported.", }, "contents": map[string]any{ "type": "string", "description": "The contents to write to the file", }, }, "required": []string{"filename", "contents"}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { params, err := parseWriteTextFileInput(input) if err != nil { return fmt.Sprintf("error parsing input: %v", err) } return fmt.Sprintf("writing %q", params.Filename) }, ToolAnyCallback: writeTextFileCallback, ToolApproval: func(input any) string { return uctypes.ApprovalNeedsApproval }, ToolVerifyInput: verifyWriteTextFileInput, } } type editTextFileParams struct { Filename string `json:"filename"` Edits []fileutil.EditSpec `json:"edits"` } func parseEditTextFileInput(input any) (*editTextFileParams, error) { result := &editTextFileParams{} if input == nil { return nil, fmt.Errorf("input is required") } if err := utilfn.ReUnmarshal(result, input); err != nil { return nil, fmt.Errorf("invalid input format: %w", err) } if result.Filename == "" { return nil, fmt.Errorf("missing filename parameter") } if len(result.Edits) == 0 { return nil, fmt.Errorf("missing edits parameter") } return result, nil } func verifyEditTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error { params, err := parseEditTextFileInput(input) if err != nil { return err } expandedPath, err := wavebase.ExpandHomeDir(params.Filename) if err != nil { return fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return fmt.Errorf("path must be absolute, got relative path: %s", params.Filename) } _, err = validateTextFile(expandedPath, "edit", true) if err != nil { return err } toolUseData.InputFileName = params.Filename return nil } // EditTextFileDryRun applies edits to a file and returns the original and modified content // without writing to disk. Takes the same input format as editTextFileCallback. func EditTextFileDryRun(input any, fileOverride string) ([]byte, []byte, error) { params, err := parseEditTextFileInput(input) if err != nil { return nil, nil, err } expandedPath, err := wavebase.ExpandHomeDir(params.Filename) if err != nil { return nil, nil, fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return nil, nil, fmt.Errorf("path must be absolute, got relative path: %s", params.Filename) } _, err = validateTextFile(expandedPath, "edit", true) if err != nil { return nil, nil, err } readPath := expandedPath if fileOverride != "" { readPath = fileOverride } originalContent, err := os.ReadFile(readPath) if err != nil { return nil, nil, fmt.Errorf("failed to read file: %w", err) } modifiedContent, err := fileutil.ApplyEdits(originalContent, params.Edits) if err != nil { return nil, nil, err } return originalContent, modifiedContent, nil } func editTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseEditTextFileInput(input) if err != nil { return nil, err } expandedPath, err := wavebase.ExpandHomeDir(params.Filename) if err != nil { return nil, fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return nil, fmt.Errorf("path must be absolute, got relative path: %s", params.Filename) } _, err = validateTextFile(expandedPath, "edit", true) if err != nil { return nil, err } backupPath, err := filebackup.MakeFileBackup(expandedPath) if err != nil { return nil, fmt.Errorf("failed to create backup: %w", err) } toolUseData.WriteBackupFileName = backupPath err = fileutil.ReplaceInFile(expandedPath, params.Edits) if err != nil { return nil, err } return map[string]any{ "success": true, "message": fmt.Sprintf("Successfully edited %s with %d changes", params.Filename, len(params.Edits)), }, nil } func GetEditTextFileToolDefinition() uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "edit_text_file", DisplayName: "Edit Text File", Description: "Edit a text file using precise search and replace. " + "Each old_str must appear EXACTLY ONCE in the file or the edit will fail. " + "All edits are applied atomically - if any single edit fails, the entire operation fails and no changes are made. " + "Maximum file size: 100KB.", ToolLogName: "gen:editfile", Strict: true, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "filename": map[string]any{ "type": "string", "description": "Absolute path to the file to edit. Supports '~' for the user's home directory. Relative paths are not supported.", }, "edits": map[string]any{ "type": "array", "description": "Array of edit specifications. All edits are applied atomically - if any edit fails, none are applied.", "items": map[string]any{ "type": "object", "properties": map[string]any{ "old_str": map[string]any{ "type": "string", "description": "The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, the entire edit operation will fail.", }, "new_str": map[string]any{ "type": "string", "description": "The string to replace with", }, "desc": map[string]any{ "type": "string", "description": "Description of what this edit does (keep it VERY short, one sentence max)", }, }, "required": []string{"old_str", "new_str", "desc"}, "additionalProperties": false, }, }, }, "required": []string{"filename", "edits"}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { params, err := parseEditTextFileInput(input) if err != nil { return fmt.Sprintf("error parsing input: %v", err) } editCount := len(params.Edits) editWord := "edits" if editCount == 1 { editWord = "edit" } return fmt.Sprintf("editing %q (%d %s)", params.Filename, editCount, editWord) }, ToolAnyCallback: editTextFileCallback, ToolApproval: func(input any) string { return uctypes.ApprovalNeedsApproval }, ToolVerifyInput: verifyEditTextFileInput, } } type deleteTextFileParams struct { Filename string `json:"filename"` } func parseDeleteTextFileInput(input any) (*deleteTextFileParams, error) { result := &deleteTextFileParams{} if input == nil { return nil, fmt.Errorf("input is required") } if err := utilfn.ReUnmarshal(result, input); err != nil { return nil, fmt.Errorf("invalid input format: %w", err) } if result.Filename == "" { return nil, fmt.Errorf("missing filename parameter") } return result, nil } func verifyDeleteTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error { params, err := parseDeleteTextFileInput(input) if err != nil { return err } expandedPath, err := wavebase.ExpandHomeDir(params.Filename) if err != nil { return fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return fmt.Errorf("path must be absolute, got relative path: %s", params.Filename) } _, err = validateTextFile(expandedPath, "delete", true) if err != nil { return err } toolUseData.InputFileName = params.Filename return nil } func deleteTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseDeleteTextFileInput(input) if err != nil { return nil, err } expandedPath, err := wavebase.ExpandHomeDir(params.Filename) if err != nil { return nil, fmt.Errorf("failed to expand path: %w", err) } if !filepath.IsAbs(expandedPath) { return nil, fmt.Errorf("path must be absolute, got relative path: %s", params.Filename) } _, err = validateTextFile(expandedPath, "delete", true) if err != nil { return nil, err } backupPath, err := filebackup.MakeFileBackup(expandedPath) if err != nil { return nil, fmt.Errorf("failed to create backup: %w", err) } toolUseData.WriteBackupFileName = backupPath err = os.Remove(expandedPath) if err != nil { return nil, fmt.Errorf("failed to delete file: %w", err) } return map[string]any{ "success": true, "message": fmt.Sprintf("Successfully deleted %s", params.Filename), }, nil } func GetDeleteTextFileToolDefinition() uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "delete_text_file", DisplayName: "Delete Text File", Description: "Delete a text file from the filesystem. A backup is created before deletion. Maximum file size: 100KB.", ToolLogName: "gen:deletefile", Strict: true, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "filename": map[string]any{ "type": "string", "description": "Absolute path to the file to delete. Supports '~' for the user's home directory. Relative paths are not supported.", }, }, "required": []string{"filename"}, "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { params, err := parseDeleteTextFileInput(input) if err != nil { return fmt.Sprintf("error parsing input: %v", err) } return fmt.Sprintf("deleting %q", params.Filename) }, ToolAnyCallback: deleteTextFileCallback, ToolApproval: func(input any) string { return uctypes.ApprovalNeedsApproval }, ToolVerifyInput: verifyDeleteTextFileInput, } } ================================================ FILE: pkg/aiusechat/uctypes/uctypes.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package uctypes import ( "fmt" "net/url" "slices" "strings" ) const DefaultAIEndpoint = "https://cfapi.waveterm.dev/api/waveai" const WaveAIEndpointEnvName = "WAVETERM_WAVEAI_ENDPOINT" const DefaultAnthropicModel = "claude-sonnet-4-5" const DefaultOpenAIModel = "gpt-5-mini" const PremiumOpenAIModel = "gpt-5.1" const ( APIType_AnthropicMessages = "anthropic-messages" APIType_OpenAIResponses = "openai-responses" APIType_OpenAIChat = "openai-chat" APIType_GoogleGemini = "google-gemini" ) const ( AIProvider_Wave = "wave" AIProvider_Google = "google" AIProvider_Groq = "groq" AIProvider_OpenRouter = "openrouter" AIProvider_NanoGPT = "nanogpt" AIProvider_OpenAI = "openai" AIProvider_Azure = "azure" AIProvider_AzureLegacy = "azure-legacy" AIProvider_Custom = "custom" ) type UseChatRequest struct { Messages []UIMessage `json:"messages"` } type UIChat struct { ChatId string `json:"chatid"` APIType string `json:"apitype"` Model string `json:"model"` APIVersion string `json:"apiversion"` Messages []UIMessage `json:"messages"` } type UIMessage struct { ID string `json:"id"` Role string `json:"role"` // "system", "user", "assistant" Metadata any `json:"metadata,omitempty"` Parts []UIMessagePart `json:"parts,omitempty"` } type UIMessagePart struct { // text, reasoning, tool-[toolname], source-url, source-document, file, data-[dataname], step-start Type string `json:"type"` // TextUIPart & ReasoningUIPart Text string `json:"text,omitempty"` // State field: // - For "text"/"reasoning" types: optional, values are "streaming" or "done" // - For "tool-*" types: required, values are "input-streaming", "input-available", "output-available", or "output-error" State string `json:"state,omitempty"` // ToolUIPart ToolCallID string `json:"toolCallId,omitempty"` Input any `json:"input,omitempty"` Output any `json:"output,omitempty"` ErrorText string `json:"errorText,omitempty"` ProviderExecuted *bool `json:"providerExecuted,omitempty"` // SourceUrlUIPart & SourceDocumentUIPart SourceID string `json:"sourceId,omitempty"` URL string `json:"url,omitempty"` Title string `json:"title,omitempty"` Filename string `json:"filename,omitempty"` MediaType string `json:"mediaType,omitempty"` // FileUIPart (uses URL and MediaType above) // DataUIPart ID string `json:"id,omitempty"` Data any `json:"data,omitempty"` // Provider metadata (ReasoningUIPart, SourceUrlUIPart, SourceDocumentUIPart) ProviderMetadata map[string]any `json:"providerMetadata,omitempty"` } // when updating this struct, also modify frontend/app/aipanel/aitypes.ts WaveUIDataTypes.userfile type UIMessageDataUserFile struct { FileName string `json:"filename,omitempty"` Size int `json:"size,omitempty"` MimeType string `json:"mimetype,omitempty"` PreviewUrl string `json:"previewurl,omitempty"` } // ToolDefinition represents a tool that can be used by the AI model type ToolDefinition struct { Name string `json:"name"` DisplayName string `json:"displayname,omitempty"` // internal field (cannot marshal to API, must be stripped) Description string `json:"description"` ShortDescription string `json:"shortdescription,omitempty"` // internal field (cannot marshal to API, must be stripped) ToolLogName string `json:"-"` // short name for telemetry (e.g., "term:getscrollback") InputSchema map[string]any `json:"input_schema"` Strict bool `json:"strict,omitempty"` RequiredCapabilities []string `json:"requiredcapabilities,omitempty"` ToolTextCallback func(any) (string, error) `json:"-"` ToolAnyCallback func(any, *UIMessageDataToolUse) (any, error) `json:"-"` // *UIMessageDataToolUse will NOT be nil ToolCallDesc func(any, any, *UIMessageDataToolUse) string `json:"-"` // passed input, output (may be nil), *UIMessageDataToolUse (may be nil) ToolApproval func(any) string `json:"-"` ToolVerifyInput func(any, *UIMessageDataToolUse) error `json:"-"` // *UIMessageDataToolUse will NOT be nil ToolProgressDesc func(any) ([]string, error) `json:"-"` } func (td *ToolDefinition) Clean() *ToolDefinition { if td == nil { return nil } rtn := *td rtn.DisplayName = "" rtn.ShortDescription = "" return &rtn } func (td *ToolDefinition) Desc() string { if td == nil { return "" } if td.ShortDescription != "" { return td.ShortDescription } return td.Description } func (td *ToolDefinition) HasRequiredCapabilities(capabilities []string) bool { if td == nil || len(td.RequiredCapabilities) == 0 { return true } for _, reqCap := range td.RequiredCapabilities { if !slices.Contains(capabilities, reqCap) { return false } } return true } //------------------ // Wave specific types, stop reasons, tool calls, config // these are used internally to coordinate the calls/steps const ( ThinkingLevelLow = "low" ThinkingLevelMedium = "medium" ThinkingLevelHigh = "high" VerbosityLevelLow = "low" VerbosityLevelMedium = "medium" VerbosityLevelHigh = "high" ) const ( AIModeQuick = "waveai@quick" AIModeBalanced = "waveai@balanced" AIModeDeep = "waveai@deep" ) const ( ToolUseStatusPending = "pending" ToolUseStatusError = "error" ToolUseStatusCompleted = "completed" ) const ( AICapabilityTools = "tools" AICapabilityImages = "images" AICapabilityPdfs = "pdfs" ) const ( ApprovalNeedsApproval = "needs-approval" ApprovalUserApproved = "user-approved" ApprovalUserDenied = "user-denied" ApprovalTimeout = "timeout" ApprovalAutoApproved = "auto-approved" ApprovalCanceled = "canceled" ) // when updating this struct, also modify frontend/app/aipanel/aitypes.ts WaveUIDataTypes.tooluse type UIMessageDataToolUse struct { ToolCallId string `json:"toolcallid"` ToolName string `json:"toolname"` ToolDesc string `json:"tooldesc"` Status string `json:"status"` RunTs int64 `json:"runts,omitempty"` ErrorMessage string `json:"errormessage,omitempty"` Approval string `json:"approval,omitempty"` BlockId string `json:"blockid,omitempty"` WriteBackupFileName string `json:"writebackupfilename,omitempty"` InputFileName string `json:"inputfilename,omitempty"` } func (d *UIMessageDataToolUse) IsApproved() bool { return d.Approval == "" || d.Approval == ApprovalUserApproved || d.Approval == ApprovalAutoApproved } // when updating this struct, also modify frontend/app/aipanel/aitypes.ts WaveUIDataTypes.toolprogress type UIMessageDataToolProgress struct { ToolCallId string `json:"toolcallid"` ToolName string `json:"toolname"` StatusLines []string `json:"statuslines"` } type StopReasonKind string const ( StopKindDone StopReasonKind = "done" StopKindToolUse StopReasonKind = "tool_use" StopKindMaxTokens StopReasonKind = "max_tokens" StopKindContent StopReasonKind = "content_filter" StopKindCanceled StopReasonKind = "canceled" StopKindError StopReasonKind = "error" StopKindPauseTurn StopReasonKind = "pause_turn" StopKindPremiumRateLimit StopReasonKind = "premium_rate_limit" StopKindRateLimit StopReasonKind = "rate_limit" ) type WaveToolCall struct { ID string `json:"id"` // Anthropic tool_use.id Name string `json:"name,omitempty"` // tool name (if provided) Input any `json:"input,omitempty"` // accumulated input JSON ToolUseData *UIMessageDataToolUse `json:"toolusedata,omitempty"` // UI tool use data } type WaveStopReason struct { Kind StopReasonKind `json:"kind"` RawReason string `json:"raw_reason,omitempty"` ToolCalls []WaveToolCall `json:"tool_calls,omitempty"` ErrorType string `json:"error_type,omitempty"` ErrorText string `json:"error_text,omitempty"` } // Wave Specific parameter used to signal to our step function that this is a continuation step, not an initial step type WaveContinueResponse struct { Model string `json:"model,omitempty"` ContinueFromKind StopReasonKind `json:"continue_from_kind"` } // Wave Specific AI opts for configuration type AIOptsType struct { Provider string `json:"provider,omitempty"` APIType string `json:"apitype,omitempty"` Model string `json:"model"` APIToken string `json:"apitoken"` APIVersion string `json:"apiversion,omitempty"` Endpoint string `json:"endpoint,omitempty"` ProxyURL string `json:"proxyurl,omitempty"` MaxTokens int `json:"maxtokens,omitempty"` TimeoutMs int `json:"timeoutms,omitempty"` ThinkingLevel string `json:"thinkinglevel,omitempty"` // ThinkingLevelLow, ThinkingLevelMedium, or ThinkingLevelHigh Verbosity string `json:"verbosity,omitempty"` // Text verbosity level (OpenAI Responses API only, ignored by other backends) AIMode string `json:"aimode,omitempty"` Capabilities []string `json:"capabilities,omitempty"` WaveAIPremium bool `json:"waveaipremium,omitempty"` } func (opts AIOptsType) IsWaveProxy() bool { return opts.Provider == AIProvider_Wave } func (opts AIOptsType) IsPremiumModel() bool { return opts.WaveAIPremium } func (opts AIOptsType) HasCapability(cap string) bool { return slices.Contains(opts.Capabilities, cap) } type AIChat struct { ChatId string `json:"chatid"` APIType string `json:"apitype"` Model string `json:"model"` APIVersion string `json:"apiversion"` NativeMessages []GenAIMessage `json:"nativemessages"` } type AIUsage struct { APIType string `json:"apitype"` Model string `json:"model"` InputTokens int `json:"inputtokens,omitempty"` OutputTokens int `json:"outputtokens,omitempty"` NativeWebSearchCount int `json:"nativewebsearchcount,omitempty"` } type AIMetrics struct { ChatId string `json:"chatid"` StepNum int `json:"stepnum"` Usage AIUsage `json:"usage"` RequestCount int `json:"requestcount"` ToolUseCount int `json:"toolusecount"` ToolUseErrorCount int `json:"tooluseerrorcount"` ToolDetail map[string]int `json:"tooldetail,omitempty"` PremiumReqCount int `json:"premiumreqcount"` ProxyReqCount int `json:"proxyreqcount"` HadError bool `json:"haderror"` ImageCount int `json:"imagecount"` PDFCount int `json:"pdfcount"` TextDocCount int `json:"textdoccount"` TextLen int `json:"textlen"` FirstByteLatency int `json:"firstbytelatency"` // ms RequestDuration int `json:"requestduration"` // ms WidgetAccess bool `json:"widgetaccess"` ThinkingLevel string `json:"thinkinglevel,omitempty"` AIMode string `json:"aimode,omitempty"` AIProvider string `json:"aiprovider,omitempty"` IsLocal bool `json:"islocal,omitempty"` } type AIFunctionCallInput struct { CallId string `json:"call_id"` Name string `json:"name"` Arguments string `json:"arguments"` ToolUseData *UIMessageDataToolUse `json:"toolusedata,omitempty"` } // GenAIMessage interface for messages stored in conversations // All messages must have a unique identifier for idempotency checks type GenAIMessage interface { GetMessageId() string GetUsage() *AIUsage GetRole() string } const ( AIMessagePartTypeText = "text" AIMessagePartTypeFile = "file" ) // wave specific for POSTing a new message to a convo type AIMessage struct { MessageId string `json:"messageid"` // only for idempotency Parts []AIMessagePart `json:"parts"` } type AIMessagePart struct { Type string `json:"type"` // "text", "file" // for "text" Text string `json:"text,omitempty"` // for "file" // mimetype is required, filename is not // either data or url (not both) must be set // url must be either an "https" or "data" url FileName string `json:"filename,omitempty"` MimeType string `json:"mimetype,omitempty"` // required Data []byte `json:"data,omitempty"` // raw data (base64 on wire) URL string `json:"url,omitempty"` Size int `json:"size,omitempty"` PreviewUrl string `json:"previewurl,omitempty"` // 128x128 webp data url for images } type AIToolResult struct { ToolName string `json:"toolname"` ToolUseID string `json:"tooluseid"` ErrorText string `json:"errortext,omitempty"` Text string `json:"text,omitempty"` } func (m *AIMessage) GetMessageId() string { return m.MessageId } func (m *AIMessage) Validate() error { if m.MessageId == "" { return fmt.Errorf("messageid must be set") } if len(m.Parts) == 0 { return fmt.Errorf("parts must not be empty") } for i, part := range m.Parts { if err := part.Validate(); err != nil { return fmt.Errorf("part %d: %w", i, err) } } return nil } func (p *AIMessagePart) Validate() error { if p.Type == AIMessagePartTypeText { if p.Text == "" { return fmt.Errorf("text type requires non-empty text field") } // Check that no file fields are set if p.FileName != "" || p.MimeType != "" || len(p.Data) > 0 || p.URL != "" { return fmt.Errorf("text type cannot have file fields set") } return nil } if p.Type == AIMessagePartTypeFile { if p.Text != "" { return fmt.Errorf("file type cannot have text field set") } if p.MimeType == "" { return fmt.Errorf("file type requires mimetype") } // Either data or url (not both) must be set hasData := len(p.Data) > 0 hasURL := p.URL != "" if !hasData && !hasURL { return fmt.Errorf("file type requires either data or url") } if hasData && hasURL { return fmt.Errorf("file type cannot have both data and url set") } // If URL is set, validate it's https or data URL if hasURL { parsedURL, err := url.Parse(p.URL) if err != nil { return fmt.Errorf("invalid url: %w", err) } if parsedURL.Scheme != "https" && parsedURL.Scheme != "data" { return fmt.Errorf("url must be https or data URL, got %q", parsedURL.Scheme) } } return nil } return fmt.Errorf("type must be %q or %q, got %q", AIMessagePartTypeText, AIMessagePartTypeFile, p.Type) } // --------------------- // AI SDK Streaming Protocol // Type can be one of these consts... // text-start, text-delta, text-end, // reasoning-start, reasoning-delta, reasoning-end, // source-url, source-document, // file, // data-*, // tool-input-start, tool-input-delta, tool-input-available, tool-output-available, // error, start-step, finish-step, finish type UseChatStreamPart struct { Type string `json:"type"` // Text Text string `json:"text,omitempty"` // Reasoning Delta string `json:"delta,omitempty"` // Source parts SourceID string `json:"sourceId,omitempty"` URL string `json:"url,omitempty"` // also for file urls MediaType string `json:"mediaType,omitempty"` // also for file types Title string `json:"title,omitempty"` // Data (custom data-\*) Data any `json:"data,omitempty"` // Tool use / tool result ToolCallID string `json:"toolCallId,omitempty"` ToolName string `json:"toolName,omitempty"` Input any `json:"input,omitempty"` Output any `json:"output,omitempty"` InputTextDelta string `json:"inputTextDelta,omitempty"` // Control parts (start/finish steps, errors, etc.) ErrorText string `json:"errorText,omitempty"` } // GetContent extracts the text content from the parts array func (m *UIMessage) GetContent() string { if len(m.Parts) > 0 { var content strings.Builder for _, part := range m.Parts { if part.Type == "text" { content.WriteString(part.Text) } } return content.String() } return "" } type WaveChatOpts struct { ChatId string ClientId string Config AIOptsType Tools []ToolDefinition SystemPrompt []string TabStateGenerator func() (string, []ToolDefinition, string, error) BuilderAppGenerator func() (string, string, string, error) WidgetAccess bool AllowNativeWebSearch bool BuilderId string BuilderAppId string // ephemeral to the step TabState string TabTools []ToolDefinition TabId string AppGoFile string AppStaticFiles string PlatformInfo string } func (opts *WaveChatOpts) GetToolDefinition(toolName string) *ToolDefinition { for _, tool := range opts.Tools { if tool.Name == toolName { return &tool } } for _, tool := range opts.TabTools { if tool.Name == toolName { return &tool } } return nil } func (opts *WaveChatOpts) GetWaveRequestType() string { if opts.BuilderId != "" { return "waveapps-builder" } else { return "waveai" } } type ProxyErrorResponse struct { Success bool `json:"success"` Error string `json:"error"` } type RateLimitInfo struct { Req int `json:"req"` ReqLimit int `json:"reqlimit"` PReq int `json:"preq"` PReqLimit int `json:"preqlimit"` ResetEpoch int64 `json:"resetepoch"` Unknown bool `json:"unknown,omitempty"` } // ParseRateLimitHeader parses the X-Wave-RateLimit header // Format: X-Wave-RateLimit: req=<remaining>, reqlimit=<max_requests>, preq=<premium_remaining>, preqlimit=<max_premium>, reset=<expiration_epoch_seconds> // Example: X-Wave-RateLimit: req=180, reqlimit=200, preq=45, preqlimit=50, reset=1727818382 // - req: remaining regular requests in the current window // - reqlimit: maximum regular requests allowed in the window // - preq: remaining premium requests in the current window // - preqlimit: maximum premium requests allowed in the window // - reset: unix timestamp (epoch seconds) when the rate limit window resets func ParseRateLimitHeader(header string) *RateLimitInfo { if header == "" { return nil } info := &RateLimitInfo{} parts := strings.Split(header, ",") for _, part := range parts { part = strings.TrimSpace(part) kv := strings.SplitN(part, "=", 2) if len(kv) != 2 { continue } key := strings.TrimSpace(kv[0]) value := strings.TrimSpace(kv[1]) switch key { case "req": if val, err := fmt.Sscanf(value, "%d", &info.Req); err == nil && val == 1 { // Successfully parsed } case "reqlimit": if val, err := fmt.Sscanf(value, "%d", &info.ReqLimit); err == nil && val == 1 { // Successfully parsed } case "preq": if val, err := fmt.Sscanf(value, "%d", &info.PReq); err == nil && val == 1 { // Successfully parsed } case "preqlimit": if val, err := fmt.Sscanf(value, "%d", &info.PReqLimit); err == nil && val == 1 { // Successfully parsed } case "reset": if val, err := fmt.Sscanf(value, "%d", &info.ResetEpoch); err == nil && val == 1 { // Successfully parsed } } } return info } func AreModelsCompatible(apiType, model1, model2 string) bool { if model1 == model2 { return true } if apiType == APIType_OpenAIResponses { gpt5Models := map[string]bool{ "gpt-5.2": true, "gpt-5.1": true, "gpt-5": true, "gpt-5-mini": true, "gpt-5-nano": true, } if gpt5Models[model1] && gpt5Models[model2] { return true } } return false } ================================================ FILE: pkg/aiusechat/usechat-backend.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "context" "fmt" "github.com/wavetermdev/waveterm/pkg/aiusechat/anthropic" "github.com/wavetermdev/waveterm/pkg/aiusechat/gemini" "github.com/wavetermdev/waveterm/pkg/aiusechat/openai" "github.com/wavetermdev/waveterm/pkg/aiusechat/openaichat" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/web/sse" ) // UseChatBackend defines the interface for AI chat backend providers (OpenAI, Anthropic, etc.) // This interface abstracts the provider-specific API calls needed by the usechat system. type UseChatBackend interface { // RunChatStep executes a single step in the chat conversation with the AI backend. // Returns the stop reason, native messages from the response, rate limit info, and any error. // The cont parameter allows continuing from a previous response (e.g., after rate limiting). RunChatStep( ctx context.Context, sseHandler *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) // UpdateToolUseData updates the tool use data for a specific tool call in the chat. // This is used to update the UI state for tool execution (approval status, results, etc.) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error // RemoveToolUseCall removes a tool use call from the chat's native messages. // This is used to clean up incomplete or canceled tool calls when stopping execution. RemoveToolUseCall(chatId string, toolCallId string) error // ConvertToolResultsToNativeChatMessage converts tool execution results into native chat messages // that can be sent back to the AI backend. Returns a slice of messages (some backends may // require multiple messages per tool result). ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) // ConvertAIMessageToNativeChatMessage converts a generic AIMessage (from the user) // into the backend's native message format for sending to the API. ConvertAIMessageToNativeChatMessage(message uctypes.AIMessage) (uctypes.GenAIMessage, error) // GetFunctionCallInputByToolCallId retrieves the function call input data for a specific // tool call ID from the chat history. Returns the function call structure // or nil if not found. GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput // ConvertAIChatToUIChat converts a stored AIChat (with native backend messages) into // a UI-friendly UIChat format that can be displayed in the frontend. ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) } // Compile-time interface checks var _ UseChatBackend = (*openaiResponsesBackend)(nil) var _ UseChatBackend = (*openaiCompletionsBackend)(nil) var _ UseChatBackend = (*anthropicBackend)(nil) var _ UseChatBackend = (*geminiBackend)(nil) // GetBackendByAPIType returns the appropriate UseChatBackend implementation for the given API type func GetBackendByAPIType(apiType string) (UseChatBackend, error) { switch apiType { case uctypes.APIType_OpenAIResponses: return &openaiResponsesBackend{}, nil case uctypes.APIType_OpenAIChat: return &openaiCompletionsBackend{}, nil case uctypes.APIType_AnthropicMessages: return &anthropicBackend{}, nil case uctypes.APIType_GoogleGemini: return &geminiBackend{}, nil default: return nil, fmt.Errorf("unsupported API type: %s", apiType) } } // openaiResponsesBackend implements UseChatBackend for OpenAI API type openaiResponsesBackend struct{} func (b *openaiResponsesBackend) RunChatStep( ctx context.Context, sseHandler *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) { stopReason, msgs, rateLimitInfo, err := openai.RunOpenAIChatStep(ctx, sseHandler, chatOpts, cont) var genMsgs []uctypes.GenAIMessage for _, msg := range msgs { genMsgs = append(genMsgs, msg) } return stopReason, genMsgs, rateLimitInfo, err } func (b *openaiResponsesBackend) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error { return openai.UpdateToolUseData(chatId, toolCallId, toolUseData) } func (b *openaiResponsesBackend) RemoveToolUseCall(chatId string, toolCallId string) error { return openai.RemoveToolUseCall(chatId, toolCallId) } func (b *openaiResponsesBackend) ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) { msgs, err := openai.ConvertToolResultsToOpenAIChatMessage(toolResults) if err != nil { return nil, err } var genMsgs []uctypes.GenAIMessage for _, msg := range msgs { genMsgs = append(genMsgs, msg) } return genMsgs, nil } func (b *openaiResponsesBackend) ConvertAIMessageToNativeChatMessage(message uctypes.AIMessage) (uctypes.GenAIMessage, error) { return openai.ConvertAIMessageToOpenAIChatMessage(message) } func (b *openaiResponsesBackend) GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { openaiInput := openai.GetFunctionCallInputByToolCallId(aiChat, toolCallId) if openaiInput == nil { return nil } return &uctypes.AIFunctionCallInput{ CallId: openaiInput.CallId, Name: openaiInput.Name, Arguments: openaiInput.Arguments, ToolUseData: openaiInput.ToolUseData, } } func (b *openaiResponsesBackend) ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { return openai.ConvertAIChatToUIChat(aiChat) } // openaiCompletionsBackend implements UseChatBackend for OpenAI Completions API type openaiCompletionsBackend struct{} func (b *openaiCompletionsBackend) RunChatStep( ctx context.Context, sseHandler *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) { stopReason, msgs, rateLimitInfo, err := openaichat.RunChatStep(ctx, sseHandler, chatOpts, cont) var genMsgs []uctypes.GenAIMessage for _, msg := range msgs { genMsgs = append(genMsgs, msg) } return stopReason, genMsgs, rateLimitInfo, err } func (b *openaiCompletionsBackend) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error { return openaichat.UpdateToolUseData(chatId, toolCallId, toolUseData) } func (b *openaiCompletionsBackend) RemoveToolUseCall(chatId string, toolCallId string) error { return openaichat.RemoveToolUseCall(chatId, toolCallId) } func (b *openaiCompletionsBackend) ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) { return openaichat.ConvertToolResultsToNativeChatMessage(toolResults) } func (b *openaiCompletionsBackend) ConvertAIMessageToNativeChatMessage(message uctypes.AIMessage) (uctypes.GenAIMessage, error) { return openaichat.ConvertAIMessageToStoredChatMessage(message) } func (b *openaiCompletionsBackend) GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { return openaichat.GetFunctionCallInputByToolCallId(aiChat, toolCallId) } func (b *openaiCompletionsBackend) ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { return openaichat.ConvertAIChatToUIChat(aiChat) } // anthropicBackend implements UseChatBackend for Anthropic API type anthropicBackend struct{} func (b *anthropicBackend) RunChatStep( ctx context.Context, sseHandler *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) { stopReason, msg, rateLimitInfo, err := anthropic.RunAnthropicChatStep(ctx, sseHandler, chatOpts, cont) if msg == nil { return stopReason, nil, rateLimitInfo, err } return stopReason, []uctypes.GenAIMessage{msg}, rateLimitInfo, err } func (b *anthropicBackend) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error { return anthropic.UpdateToolUseData(chatId, toolCallId, toolUseData) } func (b *anthropicBackend) RemoveToolUseCall(chatId string, toolCallId string) error { return anthropic.RemoveToolUseCall(chatId, toolCallId) } func (b *anthropicBackend) ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) { msg, err := anthropic.ConvertToolResultsToAnthropicChatMessage(toolResults) if err != nil { return nil, err } return []uctypes.GenAIMessage{msg}, nil } func (b *anthropicBackend) ConvertAIMessageToNativeChatMessage(message uctypes.AIMessage) (uctypes.GenAIMessage, error) { return anthropic.ConvertAIMessageToAnthropicChatMessage(message) } func (b *anthropicBackend) GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { return anthropic.GetFunctionCallInputByToolCallId(aiChat, toolCallId) } func (b *anthropicBackend) ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { return anthropic.ConvertAIChatToUIChat(aiChat) } // geminiBackend implements UseChatBackend for Google Gemini API type geminiBackend struct{} func (b *geminiBackend) RunChatStep( ctx context.Context, sseHandler *sse.SSEHandlerCh, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) { stopReason, msg, rateLimitInfo, err := gemini.RunGeminiChatStep(ctx, sseHandler, chatOpts, cont) if msg == nil { return stopReason, nil, rateLimitInfo, err } return stopReason, []uctypes.GenAIMessage{msg}, rateLimitInfo, err } func (b *geminiBackend) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error { return gemini.UpdateToolUseData(chatId, toolCallId, toolUseData) } func (b *geminiBackend) RemoveToolUseCall(chatId string, toolCallId string) error { return gemini.RemoveToolUseCall(chatId, toolCallId) } func (b *geminiBackend) ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) { msg, err := gemini.ConvertToolResultsToGeminiChatMessage(toolResults) if err != nil { return nil, err } return []uctypes.GenAIMessage{msg}, nil } func (b *geminiBackend) ConvertAIMessageToNativeChatMessage(message uctypes.AIMessage) (uctypes.GenAIMessage, error) { return gemini.ConvertAIMessageToGeminiChatMessage(message) } func (b *geminiBackend) GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { return gemini.GetFunctionCallInputByToolCallId(aiChat, toolCallId) } func (b *geminiBackend) ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { return gemini.ConvertAIChatToUIChat(aiChat) } ================================================ FILE: pkg/aiusechat/usechat-mode.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "fmt" "log" "os" "regexp" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" ) var AzureResourceNameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) const ( OpenAIResponsesEndpoint = "https://api.openai.com/v1/responses" OpenAIChatEndpoint = "https://api.openai.com/v1/chat/completions" OpenRouterChatEndpoint = "https://openrouter.ai/api/v1/chat/completions" NanoGPTChatEndpoint = "https://nano-gpt.com/api/v1/chat/completions" GroqChatEndpoint = "https://api.groq.com/openai/v1/chat/completions" AzureLegacyEndpointTemplate = "https://%s.openai.azure.com/openai/deployments/%s/chat/completions?api-version=%s" AzureResponsesEndpointTemplate = "https://%s.openai.azure.com/openai/v1/responses" AzureChatEndpointTemplate = "https://%s.openai.azure.com/openai/v1/chat/completions" GoogleGeminiEndpointTemplate = "https://generativelanguage.googleapis.com/v1beta/models/%s:streamGenerateContent" AzureLegacyDefaultAPIVersion = "2025-04-01-preview" OpenAIAPITokenSecretName = "OPENAI_KEY" OpenRouterAPITokenSecretName = "OPENROUTER_KEY" NanoGPTAPITokenSecretName = "NANOGPT_KEY" GroqAPITokenSecretName = "GROQ_KEY" AzureOpenAIAPITokenSecretName = "AZURE_OPENAI_KEY" GoogleAIAPITokenSecretName = "GOOGLE_AI_KEY" ) func resolveAIMode(requestedMode string, premium bool) (string, *wconfig.AIModeConfigType, error) { mode := requestedMode config, err := getAIModeConfig(mode) if err != nil { return "", nil, err } if config.WaveAICloud && !premium { mode = uctypes.AIModeQuick config, err = getAIModeConfig(mode) if err != nil { return "", nil, err } } return mode, config, nil } func applyProviderDefaults(config *wconfig.AIModeConfigType) { if config.Provider == uctypes.AIProvider_Wave { config.WaveAICloud = true if config.Endpoint == "" { config.Endpoint = uctypes.DefaultAIEndpoint if os.Getenv(uctypes.WaveAIEndpointEnvName) != "" { config.Endpoint = os.Getenv(uctypes.WaveAIEndpointEnvName) } } } if config.Provider == uctypes.AIProvider_OpenAI { if config.APIType == "" { config.APIType = getOpenAIAPIType(config.Model) } if config.Endpoint == "" { switch config.APIType { case uctypes.APIType_OpenAIResponses: config.Endpoint = OpenAIResponsesEndpoint case uctypes.APIType_OpenAIChat: config.Endpoint = OpenAIChatEndpoint default: config.Endpoint = OpenAIChatEndpoint } } if config.APITokenSecretName == "" { config.APITokenSecretName = OpenAIAPITokenSecretName } if len(config.Capabilities) == 0 { if isO1Model(config.Model) { config.Capabilities = []string{} } else { config.Capabilities = []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs} } } } if config.Provider == uctypes.AIProvider_OpenRouter { if config.APIType == "" { config.APIType = uctypes.APIType_OpenAIChat } if config.Endpoint == "" { config.Endpoint = OpenRouterChatEndpoint } if config.APITokenSecretName == "" { config.APITokenSecretName = OpenRouterAPITokenSecretName } } if config.Provider == uctypes.AIProvider_NanoGPT { if config.APIType == "" { config.APIType = uctypes.APIType_OpenAIChat } if config.Endpoint == "" { config.Endpoint = NanoGPTChatEndpoint } if config.APITokenSecretName == "" { config.APITokenSecretName = NanoGPTAPITokenSecretName } } if config.Provider == uctypes.AIProvider_Groq { if config.APIType == "" { config.APIType = uctypes.APIType_OpenAIChat } if config.Endpoint == "" { config.Endpoint = GroqChatEndpoint } if config.APITokenSecretName == "" { config.APITokenSecretName = GroqAPITokenSecretName } } if config.Provider == uctypes.AIProvider_AzureLegacy { if config.AzureAPIVersion == "" { config.AzureAPIVersion = AzureLegacyDefaultAPIVersion } if config.Endpoint == "" && isValidAzureResourceName(config.AzureResourceName) && config.AzureDeployment != "" { config.Endpoint = fmt.Sprintf(AzureLegacyEndpointTemplate, config.AzureResourceName, config.AzureDeployment, config.AzureAPIVersion) } if config.APIType == "" { config.APIType = uctypes.APIType_OpenAIChat } if config.APITokenSecretName == "" { config.APITokenSecretName = AzureOpenAIAPITokenSecretName } } if config.Provider == uctypes.AIProvider_Azure { if config.AzureAPIVersion == "" { config.AzureAPIVersion = "v1" // purely informational for now } if config.APIType == "" { config.APIType = getAzureAPIType(config.Model) } if config.Endpoint == "" && isValidAzureResourceName(config.AzureResourceName) && isAzureAPIType(config.APIType) { switch config.APIType { case uctypes.APIType_OpenAIResponses: config.Endpoint = fmt.Sprintf(AzureResponsesEndpointTemplate, config.AzureResourceName) case uctypes.APIType_OpenAIChat: config.Endpoint = fmt.Sprintf(AzureChatEndpointTemplate, config.AzureResourceName) } } if config.APITokenSecretName == "" { config.APITokenSecretName = AzureOpenAIAPITokenSecretName } } if config.Provider == uctypes.AIProvider_Google { if config.APIType == "" { config.APIType = uctypes.APIType_GoogleGemini } if config.Endpoint == "" && config.Model != "" { config.Endpoint = fmt.Sprintf(GoogleGeminiEndpointTemplate, config.Model) } if config.APITokenSecretName == "" { config.APITokenSecretName = GoogleAIAPITokenSecretName } if len(config.Capabilities) == 0 { config.Capabilities = []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs} } } if config.APIType == "" { config.APIType = uctypes.APIType_OpenAIChat } } func isAzureAPIType(apiType string) bool { return apiType == uctypes.APIType_OpenAIChat || apiType == uctypes.APIType_OpenAIResponses } func getOpenAIAPIType(model string) string { if isLegacyOpenAIModel(model) { return uctypes.APIType_OpenAIChat } // All newer OpenAI models support openai-responses API: // gpt-5*, gpt-4.1*, o1*, o3*, and any future models return uctypes.APIType_OpenAIResponses } func getAzureAPIType(model string) string { if isNewOpenAIModel(model) { return uctypes.APIType_OpenAIResponses } return uctypes.APIType_OpenAIChat } func isNewOpenAIModel(model string) bool { if model == "" { return false } newPrefixes := []string{"gpt-6", "gpt-5", "gpt-4.1", "o1", "o3"} for _, prefix := range newPrefixes { if aiutil.CheckModelPrefix(model, prefix) { return true } } if aiutil.CheckModelSubPrefix(model, "gpt-5.") || aiutil.CheckModelSubPrefix(model, "gpt-6.") { return true } return false } func isLegacyOpenAIModel(model string) bool { if model == "" { return false } legacyPrefixes := []string{"gpt-4o", "gpt-3.5", "gpt-oss"} for _, prefix := range legacyPrefixes { if aiutil.CheckModelPrefix(model, prefix) { return true } } return false } func isO1Model(model string) bool { if model == "" { return false } o1Prefixes := []string{"o1", "o1-mini"} for _, prefix := range o1Prefixes { if aiutil.CheckModelPrefix(model, prefix) { return true } } return false } func isValidAzureResourceName(name string) bool { if name == "" || len(name) > 63 { return false } return AzureResourceNameRegex.MatchString(name) } func getAIModeConfig(aiMode string) (*wconfig.AIModeConfigType, error) { fullConfig := wconfig.GetWatcher().GetFullConfig() config, ok := fullConfig.WaveAIModes[aiMode] if !ok { return nil, fmt.Errorf("invalid AI mode: %s", aiMode) } applyProviderDefaults(&config) return &config, nil } func InitAIModeConfigWatcher() { watcher := wconfig.GetWatcher() watcher.RegisterUpdateHandler(handleConfigUpdate) log.Printf("AI mode config watcher initialized\n") } func handleConfigUpdate(fullConfig wconfig.FullConfigType) { resolvedConfigs := ComputeResolvedAIModeConfigs(fullConfig) broadcastAIModeConfigs(resolvedConfigs) } func ComputeResolvedAIModeConfigs(fullConfig wconfig.FullConfigType) map[string]wconfig.AIModeConfigType { resolvedConfigs := make(map[string]wconfig.AIModeConfigType) for modeName, modeConfig := range fullConfig.WaveAIModes { resolved := modeConfig applyProviderDefaults(&resolved) resolvedConfigs[modeName] = resolved } return resolvedConfigs } func broadcastAIModeConfigs(configs map[string]wconfig.AIModeConfigType) { update := wconfig.AIModeConfigUpdate{ Configs: configs, } wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_AIModeConfig, Data: update, }) } ================================================ FILE: pkg/aiusechat/usechat-prompts.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import "strings" var SystemPromptText_OpenAI = strings.Join([]string{ `You are Wave AI, an assistant embedded in Wave Terminal (a terminal with graphical widgets).`, `You appear as a pull-out panel on the left; widgets are on the right.`, // Capabilities & truthfulness `Tools define your only capabilities. If a capability is not provided by a tool, you cannot do it. Never fabricate data or pretend to call tools. If you lack data or access, say so directly and suggest the next best step.`, `Use read-only tools (capture_screenshot, read_text_file, read_dir, term_get_scrollback) automatically whenever they help answer the user's request. When a user clearly expresses intent to modify something (write/edit/delete files), call the corresponding tool directly.`, // Crisp behavior `Be concise and direct. Prefer determinism over speculation. If a brief clarifying question eliminates guesswork, ask it.`, // Attached text files `User-attached text files may appear inline as <AttachedTextFile_xxxxxxxx file_name="...">\ncontent\n</AttachedTextFile_xxxxxxxx>.`, `User-attached directories use the tag <AttachedDirectoryListing_xxxxxxxx directory_name="...">JSON DirInfo</AttachedDirectoryListing_xxxxxxxx>.`, `If multiple attached files exist, treat each as a separate source file with its own file_name.`, `When the user refers to these files, use their inline content directly; do NOT call any read_text_file or file-access tools to re-read them unless asked.`, // Output & formatting `When presenting commands or any runnable multi-line code, always use fenced Markdown code blocks.`, `Use an appropriate language hint after the opening fence (e.g., "bash" for shell commands, "go" for Go, "json" for JSON).`, `For shell commands, do NOT prefix lines with "$" or shell prompts. Use placeholders in ALL_CAPS (e.g., PROJECT_ID) and explain them once after the block if needed.`, "Reserve inline code (single backticks) for short references like command names (`grep`, `less`), flags, env vars, file paths, or tiny snippets not meant to be executed.", `You may use Markdown (lists, tables, bold/italics) to improve readability.`, `Never comment on or justify your formatting choices; just follow these rules.`, `When generating code or command blocks, try to keep lines under ~100 characters wide where practical (soft wrap; do not break tokens mid-word). Favor indentation and short variable names to stay compact, but correctness always takes priority.`, // Safety & limits `If a request would execute dangerous or destructive actions, warn briefly and provide a safer alternative.`, `If output is very long, prefer a brief summary plus a copy-ready fenced block or offer a follow-up chunking strategy.`, `You can write and edit local files on disk using available tools, but you cannot execute shell commands, run code in the terminal, or read/write remote files.`, `If the user asks you to execute commands or run code, or deal with remote files say that these features aren't available yet AND make sure to say that they are coming soon (stay tuned for updates).`, `Instead, show them exactly what command or code they could copy-paste to run manually.`, // Final reminder `You have NO API access to widgets or Wave unless provided via an explicit tool.`, }, " ") var SystemPromptText_NoTools = strings.Join([]string{ `You are Wave AI, an assistant embedded in Wave Terminal (a terminal with graphical widgets).`, `You appear as a pull-out panel on the left; widgets are on the right.`, // Capabilities & truthfulness `Be truthful about your capabilities. You can answer questions, explain concepts, provide code examples, and help with technical problems, but you cannot directly access files, execute commands, or interact with the terminal. If you lack specific data or access, say so directly and suggest what the user could do to provide it.`, // Crisp behavior `Be concise and direct. Prefer determinism over speculation. If a brief clarifying question eliminates guesswork, ask it.`, // Attached text files `User-attached text files may appear inline as <AttachedTextFile_xxxxxxxx file_name="...">\ncontent\n</AttachedTextFile_xxxxxxxx>.`, `User-attached directories use the tag <AttachedDirectoryListing_xxxxxxxx directory_name="...">JSON DirInfo</AttachedDirectoryListing_xxxxxxxx>.`, `If multiple attached files exist, treat each as a separate source file with its own file_name.`, `When the user refers to these files, use their inline content directly for analysis and discussion.`, // Output & formatting `When presenting commands or any runnable multi-line code, always use fenced Markdown code blocks.`, `Use an appropriate language hint after the opening fence (e.g., "bash" for shell commands, "go" for Go, "json" for JSON).`, `For shell commands, do NOT prefix lines with "$" or shell prompts. Use placeholders in ALL_CAPS (e.g., PROJECT_ID) and explain them once after the block if needed.`, "Reserve inline code (single backticks) for short references like command names (`grep`, `less`), flags, env vars, file paths, or tiny snippets not meant to be executed.", `You may use Markdown (lists, tables, bold/italics) to improve readability.`, `Never comment on or justify your formatting choices; just follow these rules.`, `When generating code or command blocks, try to keep lines under ~100 characters wide where practical (soft wrap; do not break tokens mid-word). Favor indentation and short variable names to stay compact, but correctness always takes priority.`, // Safety & limits `If a request would execute dangerous or destructive actions, warn briefly and provide a safer alternative.`, `If output is very long, prefer a brief summary plus a copy-ready fenced block or offer a follow-up chunking strategy.`, `You cannot directly write files, execute shell commands, run code in the terminal, or access remote files.`, `When users ask for code or commands, provide ready-to-use examples they can copy and execute themselves.`, `If they need file modifications, show the exact changes they should make.`, // Final reminder `You have NO API access to widgets or Wave Terminal internals.`, }, " ") var SystemPromptText_StrictToolAddOn = `## Tool Call Rules (STRICT) When you decide a file write/edit tool call is needed: - Output ONLY the tool call. - Do NOT include any explanation, summary, or file content in the chat. - Do NOT echo the file content before or after the tool call. - After the tool call result is returned, respond ONLY with what the user directly asked for. If they did not ask to see the file content, do NOT show it. ` ================================================ FILE: pkg/aiusechat/usechat-utils.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" ) // CombineConsecutiveSameRoleMessages combines consecutive UIMessages with the same role // by appending their Parts together. This is useful for APIs like OpenAI that may split // assistant messages into separate messages (e.g., one for text and one for tool calls). func CombineConsecutiveSameRoleMessages(uiChat *uctypes.UIChat) *uctypes.UIChat { if uiChat == nil || len(uiChat.Messages) == 0 { return uiChat } combined := make([]uctypes.UIMessage, 0, len(uiChat.Messages)) var current *uctypes.UIMessage for i := range uiChat.Messages { msg := &uiChat.Messages[i] if current == nil { // First message - start a new combined message current = &uctypes.UIMessage{ ID: msg.ID, Role: msg.Role, Metadata: msg.Metadata, Parts: make([]uctypes.UIMessagePart, len(msg.Parts)), } copy(current.Parts, msg.Parts) continue } if current.Role == msg.Role { // Same role - append parts to current message current.Parts = append(current.Parts, msg.Parts...) } else { // Different role - save current and start new combined = append(combined, *current) current = &uctypes.UIMessage{ ID: msg.ID, Role: msg.Role, Metadata: msg.Metadata, Parts: make([]uctypes.UIMessagePart, len(msg.Parts)), } copy(current.Parts, msg.Parts) } } // Don't forget the last message if current != nil { combined = append(combined, *current) } return &uctypes.UIChat{ ChatId: uiChat.ChatId, APIType: uiChat.APIType, Model: uiChat.Model, APIVersion: uiChat.APIVersion, Messages: combined, } } // ConvertAIChatToUIChat converts an AIChat to a UIChat by routing to the appropriate // provider-specific converter based on APIType, then combining consecutive same-role messages. func ConvertAIChatToUIChat(aiChat *uctypes.AIChat) (*uctypes.UIChat, error) { if aiChat == nil { return nil, nil } backend, err := GetBackendByAPIType(aiChat.APIType) if err != nil { return nil, err } uiChat, err := backend.ConvertAIChatToUIChat(*aiChat) if err != nil { return nil, err } return CombineConsecutiveSameRoleMessages(uiChat), nil } ================================================ FILE: pkg/aiusechat/usechat.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "context" _ "embed" "encoding/json" "fmt" "log" "net/http" "os" "os/user" "regexp" "strings" "sync" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/ds" "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/web/sse" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) const DefaultAPI = uctypes.APIType_OpenAIResponses const DefaultMaxTokens = 4 * 1024 const BuilderMaxTokens = 24 * 1024 var ( globalRateLimitInfo = &uctypes.RateLimitInfo{Unknown: true} rateLimitLock sync.Mutex activeChats = ds.MakeSyncMap[bool]() // key is chatid ) func getSystemPrompt(apiType string, model string, isBuilder bool, hasToolsCapability bool, widgetAccess bool) []string { if isBuilder { return []string{} } useNoToolsPrompt := !hasToolsCapability || !widgetAccess basePrompt := SystemPromptText_OpenAI if useNoToolsPrompt { basePrompt = SystemPromptText_NoTools } modelLower := strings.ToLower(model) needsStrictToolAddOn, _ := regexp.MatchString(`(?i)\b(mistral|o?llama|qwen|mixtral|yi|phi|deepseek)\b`, modelLower) if needsStrictToolAddOn && !useNoToolsPrompt { return []string{basePrompt, SystemPromptText_StrictToolAddOn} } return []string{basePrompt} } func isLocalEndpoint(endpoint string) bool { if endpoint == "" { return false } endpointLower := strings.ToLower(endpoint) return strings.Contains(endpointLower, "localhost") || strings.Contains(endpointLower, "127.0.0.1") } func getWaveAISettings(premium bool, builderMode bool, rtInfo waveobj.ObjRTInfo, aiModeName string) (*uctypes.AIOptsType, error) { maxTokens := DefaultMaxTokens if builderMode { maxTokens = BuilderMaxTokens } if rtInfo.WaveAIMaxOutputTokens > 0 { maxTokens = rtInfo.WaveAIMaxOutputTokens } aiMode, config, err := resolveAIMode(aiModeName, premium) if err != nil { return nil, err } if config.WaveAICloud && !telemetry.IsTelemetryEnabled() { return nil, fmt.Errorf("Wave AI cloud modes require telemetry to be enabled") } apiToken := config.APIToken if apiToken == "" && config.APITokenSecretName != "" { secret, exists, err := secretstore.GetSecret(config.APITokenSecretName) if err != nil { return nil, fmt.Errorf("failed to retrieve secret %s: %w", config.APITokenSecretName, err) } secret = strings.TrimSpace(secret) if !exists || secret == "" { return nil, fmt.Errorf("secret %s not found or empty", config.APITokenSecretName) } apiToken = secret } var baseUrl string if config.Endpoint != "" { baseUrl = config.Endpoint } else { return nil, fmt.Errorf("no ai:endpoint configured for AI mode %s", aiMode) } thinkingLevel := config.ThinkingLevel if thinkingLevel == "" { thinkingLevel = uctypes.ThinkingLevelMedium } verbosity := config.Verbosity if verbosity == "" { verbosity = uctypes.VerbosityLevelMedium // default to medium } opts := &uctypes.AIOptsType{ Provider: config.Provider, APIType: config.APIType, Model: config.Model, MaxTokens: maxTokens, ThinkingLevel: thinkingLevel, Verbosity: verbosity, AIMode: aiMode, Endpoint: baseUrl, ProxyURL: config.ProxyURL, Capabilities: config.Capabilities, WaveAIPremium: config.WaveAIPremium, } if apiToken != "" { opts.APIToken = apiToken } return opts, nil } func shouldUseChatCompletionsAPI(model string) bool { m := strings.ToLower(model) // Chat Completions API is required for older models: gpt-3.5-*, gpt-4, gpt-4-turbo, o1-* return strings.HasPrefix(m, "gpt-3.5") || strings.HasPrefix(m, "gpt-4-") || m == "gpt-4" || strings.HasPrefix(m, "o1-") } func shouldUsePremium() bool { info := GetGlobalRateLimit() if info == nil || info.Unknown { return true } if info.PReq > 0 { return true } nowEpoch := time.Now().Unix() if nowEpoch >= info.ResetEpoch { return true } return false } func updateRateLimit(info *uctypes.RateLimitInfo) { if info == nil { return } rateLimitLock.Lock() defer rateLimitLock.Unlock() globalRateLimitInfo = info go func() { wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveAIRateLimit, Data: info, }) }() } func GetGlobalRateLimit() *uctypes.RateLimitInfo { rateLimitLock.Lock() defer rateLimitLock.Unlock() return globalRateLimitInfo } func runAIChatStep(ctx context.Context, sseHandler *sse.SSEHandlerCh, backend UseChatBackend, chatOpts uctypes.WaveChatOpts, cont *uctypes.WaveContinueResponse) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, error) { if chatOpts.Config.APIType == uctypes.APIType_OpenAIResponses && shouldUseChatCompletionsAPI(chatOpts.Config.Model) { return nil, nil, fmt.Errorf("Chat completions API not available (must use newer OpenAI models)") } stopReason, messages, rateLimitInfo, err := backend.RunChatStep(ctx, sseHandler, chatOpts, cont) updateRateLimit(rateLimitInfo) return stopReason, messages, err } func getUsage(msgs []uctypes.GenAIMessage) uctypes.AIUsage { var rtn uctypes.AIUsage var found bool for _, msg := range msgs { if usage := msg.GetUsage(); usage != nil { if !found { rtn = *usage found = true } else { rtn.InputTokens += usage.InputTokens rtn.OutputTokens += usage.OutputTokens rtn.NativeWebSearchCount += usage.NativeWebSearchCount } } } return rtn } func GetChatUsage(chat *uctypes.AIChat) uctypes.AIUsage { usage := getUsage(chat.NativeMessages) usage.APIType = chat.APIType usage.Model = chat.Model return usage } func updateToolUseDataInChat(backend UseChatBackend, chatOpts uctypes.WaveChatOpts, toolCallID string, toolUseData uctypes.UIMessageDataToolUse) { if err := backend.UpdateToolUseData(chatOpts.ChatId, toolCallID, toolUseData); err != nil { log.Printf("failed to update tool use data in chat: %v\n", err) } } func processToolCallInternal(backend UseChatBackend, toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpts, toolDef *uctypes.ToolDefinition, sseHandler *sse.SSEHandlerCh) uctypes.AIToolResult { if toolCall.ToolUseData == nil { return uctypes.AIToolResult{ ToolName: toolCall.Name, ToolUseID: toolCall.ID, ErrorText: "Invalid Tool Call", } } if toolCall.ToolUseData.Status == uctypes.ToolUseStatusError { errorMsg := toolCall.ToolUseData.ErrorMessage if errorMsg == "" { errorMsg = "Unspecified Tool Error" } return uctypes.AIToolResult{ ToolName: toolCall.Name, ToolUseID: toolCall.ID, ErrorText: errorMsg, } } if toolDef != nil && toolDef.ToolVerifyInput != nil { if err := toolDef.ToolVerifyInput(toolCall.Input, toolCall.ToolUseData); err != nil { errorMsg := fmt.Sprintf("Input validation failed: %v", err) toolCall.ToolUseData.Status = uctypes.ToolUseStatusError toolCall.ToolUseData.ErrorMessage = errorMsg return uctypes.AIToolResult{ ToolName: toolCall.Name, ToolUseID: toolCall.ID, ErrorText: errorMsg, } } // ToolVerifyInput can modify the toolusedata. re-send it here. _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) updateToolUseDataInChat(backend, chatOpts, toolCall.ID, *toolCall.ToolUseData) } if toolCall.ToolUseData.Approval == uctypes.ApprovalNeedsApproval { log.Printf(" waiting for approval...\n") approval, err := WaitForToolApproval(sseHandler.Context(), toolCall.ID) if err != nil || approval == "" { approval = uctypes.ApprovalCanceled } log.Printf(" approval result: %q\n", approval) toolCall.ToolUseData.Approval = approval if !toolCall.ToolUseData.IsApproved() { errorMsg := "Tool use denied or timed out" if approval == uctypes.ApprovalUserDenied { errorMsg = "Tool use denied by user" } else if approval == uctypes.ApprovalTimeout { errorMsg = "Tool approval timed out" } else if approval == uctypes.ApprovalCanceled { errorMsg = "Tool approval canceled" } toolCall.ToolUseData.Status = uctypes.ToolUseStatusError toolCall.ToolUseData.ErrorMessage = errorMsg return uctypes.AIToolResult{ ToolName: toolCall.Name, ToolUseID: toolCall.ID, ErrorText: errorMsg, } } // this still happens here because we need to update the FE to say the tool call was approved _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) updateToolUseDataInChat(backend, chatOpts, toolCall.ID, *toolCall.ToolUseData) } toolCall.ToolUseData.RunTs = time.Now().UnixMilli() result := ResolveToolCall(toolDef, toolCall, chatOpts) if result.ErrorText != "" { toolCall.ToolUseData.Status = uctypes.ToolUseStatusError toolCall.ToolUseData.ErrorMessage = result.ErrorText } else { toolCall.ToolUseData.Status = uctypes.ToolUseStatusCompleted } return result } func processToolCall(backend UseChatBackend, toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpts, sseHandler *sse.SSEHandlerCh, metrics *uctypes.AIMetrics) uctypes.AIToolResult { inputJSON, _ := json.Marshal(toolCall.Input) logutil.DevPrintf("TOOLUSE name=%s id=%s input=%s approval=%q\n", toolCall.Name, toolCall.ID, utilfn.TruncateString(string(inputJSON), 40), toolCall.ToolUseData.Approval) toolDef := chatOpts.GetToolDefinition(toolCall.Name) result := processToolCallInternal(backend, toolCall, chatOpts, toolDef, sseHandler) if result.ErrorText != "" { log.Printf(" error=%s\n", result.ErrorText) metrics.ToolUseErrorCount++ } else { log.Printf(" result=%s\n", utilfn.TruncateString(result.Text, 40)) } if toolDef != nil && toolDef.ToolLogName != "" { metrics.ToolDetail[toolDef.ToolLogName]++ } if toolCall.ToolUseData != nil { _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) updateToolUseDataInChat(backend, chatOpts, toolCall.ID, *toolCall.ToolUseData) } return result } func processAllToolCalls(backend UseChatBackend, stopReason *uctypes.WaveStopReason, chatOpts uctypes.WaveChatOpts, sseHandler *sse.SSEHandlerCh, metrics *uctypes.AIMetrics) { // Create and send all data-tooluse packets at the beginning for i := range stopReason.ToolCalls { toolCall := &stopReason.ToolCalls[i] // Create toolUseData from the tool call input var argsJSON string if toolCall.Input != nil { argsBytes, err := json.Marshal(toolCall.Input) if err == nil { argsJSON = string(argsBytes) } } toolUseData := aiutil.CreateToolUseData(toolCall.ID, toolCall.Name, argsJSON, chatOpts) stopReason.ToolCalls[i].ToolUseData = &toolUseData log.Printf("AI data-tooluse %s\n", toolCall.ID) _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, toolUseData) updateToolUseDataInChat(backend, chatOpts, toolCall.ID, toolUseData) if toolUseData.Approval == uctypes.ApprovalNeedsApproval { RegisterToolApproval(toolCall.ID, sseHandler) } } // At this point, all ToolCalls are guaranteed to have non-nil ToolUseData var toolResults []uctypes.AIToolResult for _, toolCall := range stopReason.ToolCalls { if sseHandler.Err() != nil { log.Printf("AI tool processing stopped: %v\n", sseHandler.Err()) break } result := processToolCall(backend, toolCall, chatOpts, sseHandler, metrics) toolResults = append(toolResults, result) } // Cleanup: unregister approvals, remove incomplete/canceled tool calls, and filter results var filteredResults []uctypes.AIToolResult for i, toolCall := range stopReason.ToolCalls { UnregisterToolApproval(toolCall.ID) hasResult := i < len(toolResults) shouldRemove := !hasResult || (toolCall.ToolUseData != nil && toolCall.ToolUseData.Approval == uctypes.ApprovalCanceled) if shouldRemove { backend.RemoveToolUseCall(chatOpts.ChatId, toolCall.ID) } else if hasResult { filteredResults = append(filteredResults, toolResults[i]) } } if len(filteredResults) > 0 { toolResultMsgs, err := backend.ConvertToolResultsToNativeChatMessage(filteredResults) if err != nil { log.Printf("Failed to convert tool results to native chat messages: %v", err) } else { for _, msg := range toolResultMsgs { if err := chatstore.DefaultChatStore.PostMessage(chatOpts.ChatId, &chatOpts.Config, msg); err != nil { log.Printf("Failed to post tool result message: %v", err) } } } } } func RunAIChat(ctx context.Context, sseHandler *sse.SSEHandlerCh, backend UseChatBackend, chatOpts uctypes.WaveChatOpts) (*uctypes.AIMetrics, error) { if !activeChats.SetUnless(chatOpts.ChatId, true) { return nil, fmt.Errorf("chat %s is already running", chatOpts.ChatId) } defer activeChats.Delete(chatOpts.ChatId) stepNum := chatstore.DefaultChatStore.CountUserMessages(chatOpts.ChatId) aiProvider := chatOpts.Config.Provider if aiProvider == "" { aiProvider = uctypes.AIProvider_Custom } isLocal := isLocalEndpoint(chatOpts.Config.Endpoint) metrics := &uctypes.AIMetrics{ ChatId: chatOpts.ChatId, StepNum: stepNum, Usage: uctypes.AIUsage{ APIType: chatOpts.Config.APIType, Model: chatOpts.Config.Model, }, WidgetAccess: chatOpts.WidgetAccess, ToolDetail: make(map[string]int), ThinkingLevel: chatOpts.Config.ThinkingLevel, AIMode: chatOpts.Config.AIMode, AIProvider: aiProvider, IsLocal: isLocal, } firstStep := true var cont *uctypes.WaveContinueResponse for { if chatOpts.TabStateGenerator != nil { tabState, tabTools, tabId, tabErr := chatOpts.TabStateGenerator() if tabErr == nil { chatOpts.TabState = tabState chatOpts.TabTools = tabTools chatOpts.TabId = tabId } } if chatOpts.BuilderAppGenerator != nil { appGoFile, appStaticFiles, platformInfo, appErr := chatOpts.BuilderAppGenerator() if appErr == nil { chatOpts.AppGoFile = appGoFile chatOpts.AppStaticFiles = appStaticFiles chatOpts.PlatformInfo = platformInfo } } stopReason, rtnMessages, err := runAIChatStep(ctx, sseHandler, backend, chatOpts, cont) metrics.RequestCount++ if chatOpts.Config.IsWaveProxy() { metrics.ProxyReqCount++ if chatOpts.Config.IsPremiumModel() { metrics.PremiumReqCount++ } } if stopReason != nil { logutil.DevPrintf("stopreason: %s (%s) (%s) (%s)\n", stopReason.Kind, stopReason.ErrorText, stopReason.ErrorType, stopReason.RawReason) } if len(rtnMessages) > 0 { usage := getUsage(rtnMessages) log.Printf("usage: input=%d output=%d websearch=%d\n", usage.InputTokens, usage.OutputTokens, usage.NativeWebSearchCount) metrics.Usage.InputTokens += usage.InputTokens metrics.Usage.OutputTokens += usage.OutputTokens metrics.Usage.NativeWebSearchCount += usage.NativeWebSearchCount if usage.Model != "" && metrics.Usage.Model != usage.Model { metrics.Usage.Model = "mixed" } } if firstStep && err != nil { metrics.HadError = true return metrics, fmt.Errorf("failed to stream %s chat: %w", chatOpts.Config.APIType, err) } if err != nil { metrics.HadError = true _ = sseHandler.AiMsgError(err.Error()) _ = sseHandler.AiMsgFinish("", nil) break } for _, msg := range rtnMessages { if msg != nil { if err := chatstore.DefaultChatStore.PostMessage(chatOpts.ChatId, &chatOpts.Config, msg); err != nil { log.Printf("Failed to post message: %v", err) } } } firstStep = false if stopReason != nil && stopReason.Kind == uctypes.StopKindPremiumRateLimit && chatOpts.Config.APIType == uctypes.APIType_OpenAIResponses && chatOpts.Config.Model == uctypes.PremiumOpenAIModel { log.Printf("Premium rate limit hit with %s, switching to %s\n", uctypes.PremiumOpenAIModel, uctypes.DefaultOpenAIModel) cont = &uctypes.WaveContinueResponse{ Model: uctypes.DefaultOpenAIModel, ContinueFromKind: uctypes.StopKindPremiumRateLimit, } continue } if stopReason != nil && stopReason.Kind == uctypes.StopKindToolUse { metrics.ToolUseCount += len(stopReason.ToolCalls) processAllToolCalls(backend, stopReason, chatOpts, sseHandler, metrics) cont = &uctypes.WaveContinueResponse{ Model: chatOpts.Config.Model, ContinueFromKind: uctypes.StopKindToolUse, } continue } break } return metrics, nil } func ResolveToolCall(toolDef *uctypes.ToolDefinition, toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpts) (result uctypes.AIToolResult) { result = uctypes.AIToolResult{ ToolName: toolCall.Name, ToolUseID: toolCall.ID, } defer func() { if r := recover(); r != nil { result.ErrorText = fmt.Sprintf("panic in tool execution: %v", r) result.Text = "" } }() if toolDef == nil { result.ErrorText = fmt.Sprintf("tool '%s' not found", toolCall.Name) return } // Try ToolTextCallback first, then ToolAnyCallback if toolDef.ToolTextCallback != nil { text, err := toolDef.ToolTextCallback(toolCall.Input) if err != nil { result.ErrorText = err.Error() } else { result.Text = text // Recompute tool description with the result if toolDef.ToolCallDesc != nil && toolCall.ToolUseData != nil { toolCall.ToolUseData.ToolDesc = toolDef.ToolCallDesc(toolCall.Input, text, toolCall.ToolUseData) } } } else if toolDef.ToolAnyCallback != nil { output, err := toolDef.ToolAnyCallback(toolCall.Input, toolCall.ToolUseData) if err != nil { result.ErrorText = err.Error() } else { // Marshal the result to JSON jsonBytes, marshalErr := json.Marshal(output) if marshalErr != nil { result.ErrorText = fmt.Sprintf("failed to marshal tool output: %v", marshalErr) } else { result.Text = string(jsonBytes) // Recompute tool description with the result if toolDef.ToolCallDesc != nil && toolCall.ToolUseData != nil { toolCall.ToolUseData.ToolDesc = toolDef.ToolCallDesc(toolCall.Input, output, toolCall.ToolUseData) } } } } else { result.ErrorText = fmt.Sprintf("tool '%s' has no callback functions", toolCall.Name) } return } func WaveAIPostMessageWrap(ctx context.Context, sseHandler *sse.SSEHandlerCh, message *uctypes.AIMessage, chatOpts uctypes.WaveChatOpts) error { startTime := time.Now() // Convert AIMessage to native chat message using backend backend, err := GetBackendByAPIType(chatOpts.Config.APIType) if err != nil { return err } convertedMessage, err := backend.ConvertAIMessageToNativeChatMessage(*message) if err != nil { return fmt.Errorf("message conversion failed: %w", err) } // Post message to chat store if err := chatstore.DefaultChatStore.PostMessage(chatOpts.ChatId, &chatOpts.Config, convertedMessage); err != nil { return fmt.Errorf("failed to store message: %w", err) } metrics, err := RunAIChat(ctx, sseHandler, backend, chatOpts) if metrics != nil { metrics.RequestDuration = int(time.Since(startTime).Milliseconds()) for _, part := range message.Parts { if part.Type == uctypes.AIMessagePartTypeText { metrics.TextLen += len(part.Text) } else if part.Type == uctypes.AIMessagePartTypeFile { mimeType := strings.ToLower(part.MimeType) if strings.HasPrefix(mimeType, "image/") { metrics.ImageCount++ } else if mimeType == "application/pdf" { metrics.PDFCount++ } else { metrics.TextDocCount++ } } } log.Printf("WaveAI call metrics: requests=%d tools=%d premium=%d proxy=%d images=%d pdfs=%d textdocs=%d textlen=%d duration=%dms error=%v\n", metrics.RequestCount, metrics.ToolUseCount, metrics.PremiumReqCount, metrics.ProxyReqCount, metrics.ImageCount, metrics.PDFCount, metrics.TextDocCount, metrics.TextLen, metrics.RequestDuration, metrics.HadError) sendAIMetricsTelemetry(ctx, metrics) } return err } func sendAIMetricsTelemetry(ctx context.Context, metrics *uctypes.AIMetrics) { event := telemetrydata.MakeTEvent("waveai:post", telemetrydata.TEventProps{ WaveAIAPIType: metrics.Usage.APIType, WaveAIModel: metrics.Usage.Model, WaveAIChatId: metrics.ChatId, WaveAIStepNum: metrics.StepNum, WaveAIInputTokens: metrics.Usage.InputTokens, WaveAIOutputTokens: metrics.Usage.OutputTokens, WaveAINativeWebSearchCount: metrics.Usage.NativeWebSearchCount, WaveAIRequestCount: metrics.RequestCount, WaveAIToolUseCount: metrics.ToolUseCount, WaveAIToolUseErrorCount: metrics.ToolUseErrorCount, WaveAIToolDetail: metrics.ToolDetail, WaveAIPremiumReq: metrics.PremiumReqCount, WaveAIProxyReq: metrics.ProxyReqCount, WaveAIHadError: metrics.HadError, WaveAIImageCount: metrics.ImageCount, WaveAIPDFCount: metrics.PDFCount, WaveAITextDocCount: metrics.TextDocCount, WaveAITextLen: metrics.TextLen, WaveAIFirstByteMs: metrics.FirstByteLatency, WaveAIRequestDurMs: metrics.RequestDuration, WaveAIWidgetAccess: metrics.WidgetAccess, WaveAIThinkingLevel: metrics.ThinkingLevel, WaveAIMode: metrics.AIMode, WaveAIProvider: metrics.AIProvider, WaveAIIsLocal: metrics.IsLocal, }) _ = telemetry.RecordTEvent(ctx, event) } // PostMessageRequest represents the request body for posting a message type PostMessageRequest struct { TabId string `json:"tabid,omitempty"` BuilderId string `json:"builderid,omitempty"` BuilderAppId string `json:"builderappid,omitempty"` ChatID string `json:"chatid"` Msg uctypes.AIMessage `json:"msg"` WidgetAccess bool `json:"widgetaccess,omitempty"` AIMode string `json:"aimode"` } func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { // Only allow POST method if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Parse request body var req PostMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) return } // Validate chatid is present and is a UUID if req.ChatID == "" { http.Error(w, "chatid is required in request body", http.StatusBadRequest) return } if _, err := uuid.Parse(req.ChatID); err != nil { http.Error(w, "chatid must be a valid UUID", http.StatusBadRequest) return } // Get RTInfo from TabId or BuilderId var rtInfo *waveobj.ObjRTInfo if req.TabId != "" { oref := waveobj.MakeORef(waveobj.OType_Tab, req.TabId) rtInfo = wstore.GetRTInfo(oref) } else if req.BuilderId != "" { oref := waveobj.MakeORef(waveobj.OType_Builder, req.BuilderId) rtInfo = wstore.GetRTInfo(oref) } if rtInfo == nil { rtInfo = &waveobj.ObjRTInfo{} } // Get WaveAI settings premium := shouldUsePremium() builderMode := req.BuilderId != "" if req.AIMode == "" { http.Error(w, "aimode is required in request body", http.StatusBadRequest) return } aiOpts, err := getWaveAISettings(premium, builderMode, *rtInfo, req.AIMode) if err != nil { http.Error(w, fmt.Sprintf("WaveAI configuration error: %v", err), http.StatusInternalServerError) return } // Call the core WaveAIPostMessage function chatOpts := uctypes.WaveChatOpts{ ChatId: req.ChatID, ClientId: wstore.GetClientId(), Config: *aiOpts, WidgetAccess: req.WidgetAccess, AllowNativeWebSearch: true, BuilderId: req.BuilderId, BuilderAppId: req.BuilderAppId, } chatOpts.SystemPrompt = getSystemPrompt(chatOpts.Config.APIType, chatOpts.Config.Model, chatOpts.BuilderId != "", chatOpts.Config.HasCapability(uctypes.AICapabilityTools), chatOpts.WidgetAccess) if req.TabId != "" { chatOpts.TabStateGenerator = func() (string, []uctypes.ToolDefinition, string, error) { tabState, tabTools, err := GenerateTabStateAndTools(r.Context(), req.TabId, req.WidgetAccess, &chatOpts) return tabState, tabTools, req.TabId, err } } if req.BuilderAppId != "" { chatOpts.BuilderAppGenerator = func() (string, string, string, error) { return generateBuilderAppData(req.BuilderAppId) } } if req.BuilderAppId != "" { chatOpts.Tools = append(chatOpts.Tools, GetBuilderWriteAppFileToolDefinition(req.BuilderAppId, req.BuilderId), GetBuilderEditAppFileToolDefinition(req.BuilderAppId, req.BuilderId), GetBuilderListFilesToolDefinition(req.BuilderAppId), ) } // Validate the message if err := req.Msg.Validate(); err != nil { http.Error(w, fmt.Sprintf("Message validation failed: %v", err), http.StatusInternalServerError) return } // Create SSE handler and set up streaming sseHandler := sse.MakeSSEHandlerCh(w, r.Context()) defer sseHandler.Close() if err := WaveAIPostMessageWrap(r.Context(), sseHandler, &req.Msg, chatOpts); err != nil { http.Error(w, fmt.Sprintf("Failed to post message: %v", err), http.StatusInternalServerError) return } } func WaveAIGetChatHandler(w http.ResponseWriter, r *http.Request) { // Only allow GET method if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Get chatid from URL parameters chatID := r.URL.Query().Get("chatid") if chatID == "" { http.Error(w, "chatid parameter is required", http.StatusBadRequest) return } // Validate chatid is a UUID if _, err := uuid.Parse(chatID); err != nil { http.Error(w, "chatid must be a valid UUID", http.StatusBadRequest) return } // Get chat from store chat := chatstore.DefaultChatStore.Get(chatID) if chat == nil { http.Error(w, "chat not found", http.StatusNotFound) return } // Set response headers for JSON w.Header().Set("Content-Type", "application/json") // Encode and return the chat if err := json.NewEncoder(w).Encode(chat); err != nil { http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError) return } } // CreateWriteTextFileDiff generates a diff for write_text_file or edit_text_file tool calls. // Returns the original content, modified content, and any error. // For Anthropic, this returns an unimplemented error. func CreateWriteTextFileDiff(ctx context.Context, chatId string, toolCallId string) ([]byte, []byte, error) { aiChat := chatstore.DefaultChatStore.Get(chatId) if aiChat == nil { return nil, nil, fmt.Errorf("chat not found: %s", chatId) } backend, err := GetBackendByAPIType(aiChat.APIType) if err != nil { return nil, nil, err } funcCallInput := backend.GetFunctionCallInputByToolCallId(*aiChat, toolCallId) if funcCallInput == nil { return nil, nil, fmt.Errorf("tool call not found: %s", toolCallId) } toolName := funcCallInput.Name if toolName != "write_text_file" && toolName != "edit_text_file" { return nil, nil, fmt.Errorf("tool call %s is not a write_text_file or edit_text_file (got: %s)", toolCallId, toolName) } var backupFileName string if funcCallInput.ToolUseData != nil { backupFileName = funcCallInput.ToolUseData.WriteBackupFileName } var parsedArguments any if err := json.Unmarshal([]byte(funcCallInput.Arguments), &parsedArguments); err != nil { return nil, nil, fmt.Errorf("failed to unmarshal arguments: %w", err) } if toolName == "edit_text_file" { originalContent, modifiedContent, err := EditTextFileDryRun(parsedArguments, backupFileName) if err != nil { return nil, nil, fmt.Errorf("failed to generate diff: %w", err) } return originalContent, modifiedContent, nil } params, err := parseWriteTextFileInput(parsedArguments) if err != nil { return nil, nil, fmt.Errorf("failed to parse write_text_file input: %w", err) } var originalContent []byte if backupFileName != "" { originalContent, err = os.ReadFile(backupFileName) if err != nil { return nil, nil, fmt.Errorf("failed to read backup file: %w", err) } } else { expandedPath, err := wavebase.ExpandHomeDir(params.Filename) if err != nil { return nil, nil, fmt.Errorf("failed to expand path: %w", err) } originalContent, err = os.ReadFile(expandedPath) if err != nil && !os.IsNotExist(err) { return nil, nil, fmt.Errorf("failed to read original file: %w", err) } } modifiedContent := []byte(params.Contents) return originalContent, modifiedContent, nil } type StaticFileInfo struct { Name string `json:"name"` Size int64 `json:"size"` Modified string `json:"modified"` ModifiedTime string `json:"modified_time"` } func generateBuilderAppData(appId string) (string, string, string, error) { appGoFile := "" fileData, err := waveappstore.ReadAppFile(appId, "app.go") if err == nil { appGoFile = string(fileData.Contents) } staticFilesJSON := "" allFiles, err := waveappstore.ListAllAppFiles(appId) if err == nil { var staticFiles []StaticFileInfo for _, entry := range allFiles.Entries { if strings.HasPrefix(entry.Name, "static/") { staticFiles = append(staticFiles, StaticFileInfo{ Name: entry.Name, Size: entry.Size, Modified: entry.Modified, ModifiedTime: entry.ModifiedTime, }) } } if len(staticFiles) > 0 { staticFilesBytes, marshalErr := json.Marshal(staticFiles) if marshalErr == nil { staticFilesJSON = string(staticFilesBytes) } } } platformInfo := wavebase.GetSystemSummary() if currentUser, userErr := user.Current(); userErr == nil && currentUser.Username != "" { platformInfo = fmt.Sprintf("Local Machine: %s, User: %s", platformInfo, currentUser.Username) } else { platformInfo = fmt.Sprintf("Local Machine: %s", platformInfo) } return appGoFile, staticFilesJSON, platformInfo, nil } ================================================ FILE: pkg/aiusechat/usechat_mode_test.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package aiusechat import ( "testing" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/wconfig" ) func TestApplyProviderDefaultsGroq(t *testing.T) { config := wconfig.AIModeConfigType{ Provider: uctypes.AIProvider_Groq, } applyProviderDefaults(&config) if config.APIType != uctypes.APIType_OpenAIChat { t.Fatalf("expected API type %q, got %q", uctypes.APIType_OpenAIChat, config.APIType) } if config.Endpoint != GroqChatEndpoint { t.Fatalf("expected endpoint %q, got %q", GroqChatEndpoint, config.Endpoint) } if config.APITokenSecretName != GroqAPITokenSecretName { t.Fatalf("expected API token secret name %q, got %q", GroqAPITokenSecretName, config.APITokenSecretName) } } func TestApplyProviderDefaultsKeepsProxyURL(t *testing.T) { config := wconfig.AIModeConfigType{ Provider: uctypes.AIProvider_OpenAI, Model: "gpt-5-mini", ProxyURL: "http://localhost:8080", } applyProviderDefaults(&config) if config.ProxyURL != "http://localhost:8080" { t.Fatalf("expected proxy URL to be preserved, got %q", config.ProxyURL) } } ================================================ FILE: pkg/authkey/authkey.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package authkey import ( "fmt" "net/http" "os" ) var authkey string const WaveAuthKeyEnv = "WAVETERM_AUTH_KEY" const AuthKeyHeader = "X-AuthKey" func ValidateIncomingRequest(r *http.Request) error { reqAuthKey := r.Header.Get(AuthKeyHeader) if reqAuthKey == "" { return fmt.Errorf("no x-authkey header") } if reqAuthKey != GetAuthKey() { return fmt.Errorf("x-authkey header is invalid") } return nil } func SetAuthKeyFromEnv() error { authkey = os.Getenv(WaveAuthKeyEnv) if authkey == "" { return fmt.Errorf("no auth key found in environment variables") } os.Unsetenv(WaveAuthKeyEnv) return nil } func GetAuthKey() string { return authkey } ================================================ FILE: pkg/baseds/baseds.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // used for shared datastructures package baseds type LinkId int32 const NoLinkId = 0 type RpcInputChType struct { MsgBytes []byte IngressLinkId LinkId } type Badge struct { BadgeId string `json:"badgeid"` // must be a uuidv7 Icon string `json:"icon"` Color string `json:"color,omitempty"` Priority float64 `json:"priority"` PidLinked bool `json:"pidlinked,omitempty"` } type BadgeEvent struct { ORef string `json:"oref"` Clear bool `json:"clear,omitempty"` ClearAll bool `json:"clearall,omitempty"` ClearById string `json:"clearbyid,omitempty"` Badge *Badge `json:"badge,omitempty"` } ================================================ FILE: pkg/blockcontroller/.gitignore ================================================ *.old ================================================ FILE: pkg/blockcontroller/blockcontroller.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package blockcontroller import ( "context" "encoding/base64" "fmt" "io/fs" "log" "strings" "sync" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/util/ds" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wslconn" "github.com/wavetermdev/waveterm/pkg/wstore" ) const ( BlockController_Shell = "shell" BlockController_Cmd = "cmd" BlockController_Tsunami = "tsunami" ) const ( Status_Running = "running" Status_Done = "done" Status_Init = "init" ) const ( DefaultTermMaxFileSize = 2 * 1024 * 1024 DefaultHtmlMaxFileSize = 256 * 1024 MaxInitScriptSize = 50 * 1024 ) const DefaultTimeout = 2 * time.Second const DefaultGracefulKillWait = 400 * time.Millisecond type BlockInputUnion struct { InputData []byte `json:"inputdata,omitempty"` SigName string `json:"signame,omitempty"` TermSize *waveobj.TermSize `json:"termsize,omitempty"` } type BlockControllerRuntimeStatus struct { BlockId string `json:"blockid"` Version int64 `json:"version"` ShellProcStatus string `json:"shellprocstatus,omitempty"` ShellProcConnName string `json:"shellprocconnname,omitempty"` ShellProcExitCode int `json:"shellprocexitcode"` TsunamiPort int `json:"tsunamiport,omitempty"` } // Controller interface that all block controllers must implement type Controller interface { Start(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error Stop(graceful bool, newStatus string, destroy bool) GetRuntimeStatus() *BlockControllerRuntimeStatus // does not return nil GetConnName() string SendInput(input *BlockInputUnion) error } // Registry for all controllers var ( controllerRegistry = make(map[string]Controller) registryLock sync.RWMutex blockResyncMutexMap = ds.MakeSyncMap[*sync.Mutex]() ) func getBlockResyncMutex(blockId string) *sync.Mutex { return blockResyncMutexMap.GetOrCreate(blockId, func() *sync.Mutex { return &sync.Mutex{} }) } // Registry operations func getController(blockId string) Controller { registryLock.RLock() defer registryLock.RUnlock() return controllerRegistry[blockId] } func registerController(blockId string, controller Controller) { var existingController Controller registryLock.Lock() existing, exists := controllerRegistry[blockId] if exists { existingController = existing } controllerRegistry[blockId] = controller registryLock.Unlock() if existingController != nil { existingController.Stop(false, Status_Done, true) wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId)) } } func deleteController(blockId string) { registryLock.Lock() defer registryLock.Unlock() delete(controllerRegistry, blockId) } func getAllControllers() map[string]Controller { registryLock.RLock() defer registryLock.RUnlock() // Return a copy to avoid lock issues result := make(map[string]Controller) for k, v := range controllerRegistry { result[k] = v } return result } func InitBlockController() { rpcClient := wshclient.GetBareRpcClient() rpcClient.EventListener.On(wps.Event_BlockClose, handleBlockCloseEvent) wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{ Event: wps.Event_BlockClose, AllScopes: true, }, nil) } func handleBlockCloseEvent(event *wps.WaveEvent) { blockId, ok := event.Data.(string) if !ok { log.Printf("[blockclose] invalid event data type") return } go DestroyBlockController(blockId) } // Public API Functions func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts, force bool) error { if tabId == "" || blockId == "" { return fmt.Errorf("invalid tabId or blockId passed to ResyncController") } mu := getBlockResyncMutex(blockId) mu.Lock() defer mu.Unlock() blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { return fmt.Errorf("error getting block: %w", err) } controllerName := blockData.Meta.GetString(waveobj.MetaKey_Controller, "") connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "") // Get existing controller existing := getController(blockId) // Check for connection change FIRST - always destroy on conn change if existing != nil { existingConnName := existing.GetConnName() if existingConnName != connName { log.Printf("stopping blockcontroller %s due to conn change (from %q to %q)\n", blockId, existingConnName, connName) DestroyBlockController(blockId) time.Sleep(100 * time.Millisecond) existing = nil } } // If no controller needed, stop existing if present if controllerName == "" { if existing != nil { DestroyBlockController(blockId) } return nil } // Determine if we should use DurableShellController vs ShellController shouldUseDurableShellController := controllerName == BlockController_Shell && jobcontroller.IsBlockIdTermDurable(blockId) // Check if we need to morph controller type if existing != nil { needsReplace := false switch existing.(type) { case *ShellController: if controllerName != BlockController_Shell && controllerName != BlockController_Cmd { needsReplace = true } else if shouldUseDurableShellController { needsReplace = true } case *DurableShellController: if !shouldUseDurableShellController { needsReplace = true } case *TsunamiController: if controllerName != BlockController_Tsunami { needsReplace = true } } if needsReplace { log.Printf("stopping blockcontroller %s due to controller type change\n", blockId) DestroyBlockController(blockId) time.Sleep(100 * time.Millisecond) existing = nil } } // Force restart if requested if force && existing != nil { DestroyBlockController(blockId) time.Sleep(100 * time.Millisecond) existing = nil } // Destroy done controllers before restarting if existing != nil { status := existing.GetRuntimeStatus() if status.ShellProcStatus == Status_Done { log.Printf("destroying blockcontroller %s with done status before restart\n", blockId) DestroyBlockController(blockId) time.Sleep(100 * time.Millisecond) existing = nil } } // Create or restart controller var controller Controller if existing != nil { controller = existing } else { // Create new controller based on type switch controllerName { case BlockController_Shell, BlockController_Cmd: if shouldUseDurableShellController { controller = MakeDurableShellController(tabId, blockId, controllerName, connName) } else { controller = MakeShellController(tabId, blockId, controllerName, connName) } registerController(blockId, controller) case BlockController_Tsunami: controller = MakeTsunamiController(tabId, blockId, connName) registerController(blockId, controller) default: return fmt.Errorf("unknown controller type %q", controllerName) } } // Check if we need to start/restart status := controller.GetRuntimeStatus() if status.ShellProcStatus == Status_Init { // For shell/cmd, check connection status first (for non-local connections) if controllerName == BlockController_Shell || controllerName == BlockController_Cmd { if !conncontroller.IsLocalConnName(connName) { err = CheckConnStatus(blockId) if err != nil { return fmt.Errorf("cannot start shellproc: %w", err) } } } // Start controller err = controller.Start(ctx, blockData.Meta, rtOpts, force) if err != nil { return fmt.Errorf("error starting controller: %w", err) } } return nil } func GetBlockControllerRuntimeStatus(blockId string) *BlockControllerRuntimeStatus { controller := getController(blockId) if controller == nil { return nil } return controller.GetRuntimeStatus() } func DestroyBlockController(blockId string) { controller := getController(blockId) if controller == nil { return } controller.Stop(true, Status_Done, true) wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId)) deleteController(blockId) } func sendConnMonitorInputNotification(controller Controller) { connName := controller.GetConnName() if connName == "" || conncontroller.IsLocalConnName(connName) || conncontroller.IsWslConnName(connName) { return } connOpts, parseErr := remote.ParseOpts(connName) if parseErr != nil { return } sshConn := conncontroller.MaybeGetConn(connOpts) if sshConn != nil { monitor := sshConn.GetMonitor() if monitor != nil { monitor.NotifyInput() } } } func SendInput(blockId string, inputUnion *BlockInputUnion) error { controller := getController(blockId) if controller == nil { return fmt.Errorf("no controller found for block %s", blockId) } sendConnMonitorInputNotification(controller) return controller.SendInput(inputUnion) } // only call this on shutdown func StopAllBlockControllersForShutdown() { controllers := getAllControllers() for blockId, controller := range controllers { status := controller.GetRuntimeStatus() if status != nil && status.ShellProcStatus == Status_Running { go func(id string, c Controller) { c.Stop(true, Status_Done, false) wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, id)) }(blockId, controller) } } } func getBoolFromMeta(meta map[string]any, key string, def bool) bool { ival, found := meta[key] if !found || ival == nil { return def } if val, ok := ival.(bool); ok { return val } return def } func getTermSize(bdata *waveobj.Block) waveobj.TermSize { if bdata.RuntimeOpts != nil { return bdata.RuntimeOpts.TermSize } else { return waveobj.TermSize{ Rows: 25, Cols: 80, } } } func HandleAppendBlockFile(blockId string, blockFile string, data []byte) error { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() err := filestore.WFS.AppendData(ctx, blockId, blockFile, data) if err != nil { return fmt.Errorf("error appending to blockfile: %w", err) } wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_BlockFile, Scopes: []string{ waveobj.MakeORef(waveobj.OType_Block, blockId).String(), }, Data: &wps.WSFileEventData{ ZoneId: blockId, FileName: blockFile, FileOp: wps.FileOp_Append, Data64: base64.StdEncoding.EncodeToString(data), }, }) return nil } func HandleTruncateBlockFile(blockId string) error { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() err := filestore.WFS.WriteFile(ctx, blockId, wavebase.BlockFile_Term, nil) if err == fs.ErrNotExist { return nil } if err != nil { return fmt.Errorf("error truncating blockfile: %w", err) } err = filestore.WFS.DeleteFile(ctx, blockId, wavebase.BlockFile_Cache) if err == fs.ErrNotExist { err = nil } if err != nil { log.Printf("error deleting cache file (continuing): %v\n", err) } wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_BlockFile, Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, blockId).String()}, Data: &wps.WSFileEventData{ ZoneId: blockId, FileName: wavebase.BlockFile_Term, FileOp: wps.FileOp_Truncate, }, }) return nil } func debugLog(ctx context.Context, fmtStr string, args ...interface{}) { blocklogger.Infof(ctx, "[conndebug] "+fmtStr, args...) log.Printf(fmtStr, args...) } func CheckConnStatus(blockId string) error { bdata, err := wstore.DBMustGet[*waveobj.Block](context.Background(), blockId) if err != nil { return fmt.Errorf("error getting block: %w", err) } connName := bdata.Meta.GetString(waveobj.MetaKey_Connection, "") if conncontroller.IsLocalConnName(connName) { return nil } if strings.HasPrefix(connName, "wsl://") { distroName := strings.TrimPrefix(connName, "wsl://") conn := wslconn.GetWslConn(distroName) connStatus := conn.DeriveConnStatus() if connStatus.Status != conncontroller.Status_Connected { return fmt.Errorf("not connected: %s", connStatus.Status) } return nil } opts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) } conn := conncontroller.MaybeGetConn(opts) if conn == nil { return fmt.Errorf("no connection found") } connStatus := conn.DeriveConnStatus() if connStatus.Status != conncontroller.Status_Connected { return fmt.Errorf("not connected: %s", connStatus.Status) } return nil } func makeSwapToken(ctx context.Context, logCtx context.Context, blockId string, blockMeta waveobj.MetaMapType, remoteName string, shellType string) *shellutil.TokenSwapEntry { token := &shellutil.TokenSwapEntry{ Token: uuid.New().String(), Env: make(map[string]string), Exp: time.Now().Add(5 * time.Minute), } token.Env["TERM_PROGRAM"] = "waveterm" token.Env["WAVETERM_BLOCKID"] = blockId token.Env["WAVETERM_VERSION"] = wavebase.WaveVersion token.Env["WAVETERM"] = "1" tabId, err := wstore.DBFindTabForBlockId(ctx, blockId) if err != nil { log.Printf("error finding tab for block: %v\n", err) } else { token.Env["WAVETERM_TABID"] = tabId } if tabId != "" { wsId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId) if err != nil { log.Printf("error finding workspace for tab: %v\n", err) } else { token.Env["WAVETERM_WORKSPACEID"] = wsId } } token.Env["WAVETERM_CLIENTID"] = wstore.GetClientId() token.Env["WAVETERM_CONN"] = remoteName envMap, err := resolveEnvMap(blockId, blockMeta, remoteName) if err != nil { log.Printf("error resolving env map: %v\n", err) } for k, v := range envMap { token.Env[k] = v } token.ScriptText = getCustomInitScript(logCtx, blockMeta, remoteName, shellType) return token } ================================================ FILE: pkg/blockcontroller/durableshellcontroller.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package blockcontroller import ( "context" "encoding/base64" "fmt" "log" "sync" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/shellexec" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wstore" ) type DurableShellController struct { Lock *sync.Mutex ControllerType string TabId string BlockId string ConnName string BlockDef *waveobj.BlockDef VersionTs utilds.VersionTs InputSessionId string // random uuid inputSeqNum int // monotonic sequence number for inputs, starts at 1 JobId string LastKnownStatus string } func MakeDurableShellController(tabId string, blockId string, controllerType string, connName string) Controller { return &DurableShellController{ Lock: &sync.Mutex{}, ControllerType: controllerType, TabId: tabId, BlockId: blockId, ConnName: connName, LastKnownStatus: Status_Init, InputSessionId: uuid.New().String(), } } func (dsc *DurableShellController) WithLock(f func()) { dsc.Lock.Lock() defer dsc.Lock.Unlock() f() } func (dsc *DurableShellController) getJobId() string { dsc.Lock.Lock() defer dsc.Lock.Unlock() return dsc.JobId } func (dsc *DurableShellController) getNextInputSeq() (string, int) { dsc.Lock.Lock() defer dsc.Lock.Unlock() dsc.inputSeqNum++ return dsc.InputSessionId, dsc.inputSeqNum } func (dsc *DurableShellController) getJobStatus_withlock() string { if dsc.JobId == "" { dsc.LastKnownStatus = Status_Init return Status_Init } status, err := jobcontroller.GetJobManagerStatus(context.Background(), dsc.JobId) if err != nil { log.Printf("error getting job status for %s: %v, using last known status: %s", dsc.JobId, err, dsc.LastKnownStatus) return dsc.LastKnownStatus } dsc.LastKnownStatus = status return status } func (dsc *DurableShellController) getRuntimeStatus_withlock() BlockControllerRuntimeStatus { var rtn BlockControllerRuntimeStatus rtn.Version = dsc.VersionTs.GetVersionTs() rtn.BlockId = dsc.BlockId rtn.ShellProcStatus = dsc.getJobStatus_withlock() rtn.ShellProcConnName = dsc.ConnName return rtn } func (dsc *DurableShellController) GetRuntimeStatus() *BlockControllerRuntimeStatus { var rtn BlockControllerRuntimeStatus dsc.WithLock(func() { rtn = dsc.getRuntimeStatus_withlock() }) return &rtn } func (dsc *DurableShellController) GetConnName() string { dsc.Lock.Lock() defer dsc.Lock.Unlock() return dsc.ConnName } func (dsc *DurableShellController) sendUpdate_withlock() { rtStatus := dsc.getRuntimeStatus_withlock() log.Printf("sending blockcontroller update %#v\n", rtStatus) wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_ControllerStatus, Scopes: []string{ waveobj.MakeORef(waveobj.OType_Tab, dsc.TabId).String(), waveobj.MakeORef(waveobj.OType_Block, dsc.BlockId).String(), }, Data: rtStatus, }) } // Start initializes or reconnects to a durable shell for the block. // Logic: // - If block has no existing jobId: starts a new job and attaches it // - If block has existing jobId with running job manager: reconnects to existing job // - If block has existing jobId with non-running job manager: // - force=true: detaches old job and starts new one // - force=false: returns without starting (leaves block unstarted) // // After establishing jobId, ensures job connection is active (reconnects if needed) func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error { blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, dsc.BlockId) if err != nil { return fmt.Errorf("error getting block: %w", err) } if conncontroller.IsLocalConnName(dsc.ConnName) { return fmt.Errorf("durable shell controller requires a remote connection") } var jobId string if blockData.JobId != "" { status, err := jobcontroller.GetJobManagerStatus(ctx, blockData.JobId) if err != nil { return fmt.Errorf("error getting job manager status: %w", err) } if status == jobcontroller.JobManagerStatus_Running { jobId = blockData.JobId } else if !force { log.Printf("block %q has jobId %s but manager is not running (status: %s), not starting (force=false)\n", dsc.BlockId, blockData.JobId, status) return nil } else { log.Printf("block %q has jobId %s but manager is not running (status: %s), starting new job (force=true)\n", dsc.BlockId, blockData.JobId, status) // intentionally leave jobId empty to trigger starting a new job below } } if jobId == "" { log.Printf("block %q starting new durable shell\n", dsc.BlockId) newJobId, err := dsc.startNewJob(ctx, blockMeta, dsc.ConnName, rtOpts) if err != nil { return fmt.Errorf("failed to start new job: %w", err) } jobId = newJobId } dsc.WithLock(func() { dsc.JobId = jobId dsc.sendUpdate_withlock() }) err = jobcontroller.ReconnectJob(ctx, jobId, rtOpts) if err != nil { return fmt.Errorf("failed to reconnect to job: %w", err) } return nil } func (dsc *DurableShellController) Stop(graceful bool, newStatus string, destroy bool) { if !destroy { return } jobId := dsc.getJobId() if jobId == "" { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() jobcontroller.TerminateAndDetachJob(ctx, jobId) } func (dsc *DurableShellController) SendInput(inputUnion *BlockInputUnion) error { if inputUnion == nil { return nil } jobId := dsc.getJobId() if jobId == "" { return fmt.Errorf("no job attached to controller") } inputSessionId, seqNum := dsc.getNextInputSeq() data := wshrpc.CommandJobInputData{ JobId: jobId, InputSessionId: inputSessionId, SeqNum: seqNum, TermSize: inputUnion.TermSize, SigName: inputUnion.SigName, } if len(inputUnion.InputData) > 0 { data.InputData64 = base64.StdEncoding.EncodeToString(inputUnion.InputData) } return jobcontroller.SendInput(context.Background(), data) } func (dsc *DurableShellController) startNewJob(ctx context.Context, blockMeta waveobj.MetaMapType, connName string, rtOpts *waveobj.RuntimeOpts) (string, error) { termSize := waveobj.TermSize{ Rows: shellutil.DefaultTermRows, Cols: shellutil.DefaultTermCols, } if rtOpts != nil && rtOpts.TermSize.Rows > 0 && rtOpts.TermSize.Cols > 0 { termSize = rtOpts.TermSize } cmdStr := blockMeta.GetString(waveobj.MetaKey_Cmd, "") cwd := blockMeta.GetString(waveobj.MetaKey_CmdCwd, "") opts, err := remote.ParseOpts(connName) if err != nil { return "", fmt.Errorf("invalid ssh remote name (%s): %w", connName, err) } conn := conncontroller.MaybeGetConn(opts) if conn == nil { return "", fmt.Errorf("connection %q not found", connName) } connRoute := wshutil.MakeConnectionRouteId(connName) remoteInfo, err := wshclient.RemoteGetInfoCommand(wshclient.GetBareRpcClient(), &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000}) if err != nil { return "", fmt.Errorf("unable to obtain remote info from connserver: %w", err) } shellType := shellutil.GetShellTypeFromShellPath(remoteInfo.Shell) swapToken := makeSwapToken(ctx, ctx, dsc.BlockId, blockMeta, connName, shellType) sockName := wavebase.GetPersistentRemoteSockName(wstore.GetClientId()) rpcContext := wshrpc.RpcContext{ ProcRoute: true, SockName: sockName, BlockId: dsc.BlockId, Conn: connName, } jwtStr, err := wshutil.MakeClientJWTToken(rpcContext) if err != nil { return "", fmt.Errorf("error making jwt token: %w", err) } swapToken.RpcContext = &rpcContext swapToken.Env[wshutil.WaveJwtTokenVarName] = jwtStr cmdOpts := shellexec.CommandOptsType{ Interactive: true, Login: true, Cwd: cwd, SwapToken: swapToken, ForceJwt: blockMeta.GetBool(waveobj.MetaKey_CmdJwt, false), } jobId, err := shellexec.StartRemoteShellJob(ctx, ctx, termSize, cmdStr, cmdOpts, conn, dsc.BlockId) if err != nil { return "", fmt.Errorf("failed to start durable shell: %w", err) } return jobId, nil } ================================================ FILE: pkg/blockcontroller/shellcontroller.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package blockcontroller import ( "context" "fmt" "io" "io/fs" "log" "os" "runtime" "strings" "sync" "sync/atomic" "time" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/shellexec" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wslconn" "github.com/wavetermdev/waveterm/pkg/wstore" ) const ( ConnType_Local = "local" ConnType_Wsl = "wsl" ConnType_Ssh = "ssh" ) const ( LocalConnVariant_GitBash = "gitbash" ) type ShellController struct { Lock *sync.Mutex // shared fields ControllerType string TabId string BlockId string ConnName string BlockDef *waveobj.BlockDef RunLock *atomic.Bool ProcStatus string ProcExitCode int VersionTs utilds.VersionTs // for shell/cmd ShellProc *shellexec.ShellProc ShellInputCh chan *BlockInputUnion } // Constructor that returns the Controller interface func MakeShellController(tabId string, blockId string, controllerType string, connName string) Controller { return &ShellController{ Lock: &sync.Mutex{}, ControllerType: controllerType, TabId: tabId, BlockId: blockId, ConnName: connName, ProcStatus: Status_Init, RunLock: &atomic.Bool{}, } } // Implement Controller interface methods func (sc *ShellController) Start(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error { // Get the block data blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, sc.BlockId) if err != nil { return fmt.Errorf("error getting block: %w", err) } // Use the existing run method which handles all the start logic go sc.run(ctx, blockData, blockData.Meta, rtOpts, force) return nil } func (sc *ShellController) Stop(graceful bool, newStatus string, destroy bool) { sc.Lock.Lock() defer sc.Lock.Unlock() if sc.ShellProc == nil || sc.ProcStatus == Status_Done || sc.ProcStatus == Status_Init { if newStatus != sc.ProcStatus { sc.ProcStatus = newStatus sc.sendUpdate_nolock() } return } sc.ShellProc.Close() if graceful { doneCh := sc.ShellProc.DoneCh sc.Lock.Unlock() // Unlock before waiting <-doneCh sc.Lock.Lock() // Re-lock after waiting } // Update status sc.ProcStatus = newStatus sc.sendUpdate_nolock() } func (sc *ShellController) getRuntimeStatus_nolock() BlockControllerRuntimeStatus { var rtn BlockControllerRuntimeStatus rtn.Version = sc.VersionTs.GetVersionTs() rtn.BlockId = sc.BlockId rtn.ShellProcStatus = sc.ProcStatus rtn.ShellProcConnName = sc.ConnName rtn.ShellProcExitCode = sc.ProcExitCode return rtn } func (sc *ShellController) GetRuntimeStatus() *BlockControllerRuntimeStatus { var rtn BlockControllerRuntimeStatus sc.WithLock(func() { rtn = sc.getRuntimeStatus_nolock() }) return &rtn } func (sc *ShellController) GetConnName() string { return sc.ConnName } func (sc *ShellController) SendInput(inputUnion *BlockInputUnion) error { var shellInputCh chan *BlockInputUnion sc.WithLock(func() { shellInputCh = sc.ShellInputCh }) if shellInputCh == nil { return fmt.Errorf("no shell input chan") } shellInputCh <- inputUnion return nil } func (sc *ShellController) WithLock(f func()) { sc.Lock.Lock() defer sc.Lock.Unlock() f() } type RunShellOpts struct { TermSize waveobj.TermSize `json:"termsize,omitempty"` } // only call when holding the lock func (sc *ShellController) sendUpdate_nolock() { rtStatus := sc.getRuntimeStatus_nolock() log.Printf("sending blockcontroller update %#v\n", rtStatus) wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_ControllerStatus, Scopes: []string{ waveobj.MakeORef(waveobj.OType_Tab, sc.TabId).String(), waveobj.MakeORef(waveobj.OType_Block, sc.BlockId).String(), }, Data: rtStatus, }) } func (sc *ShellController) UpdateControllerAndSendUpdate(updateFn func() bool) { var sendUpdate bool sc.WithLock(func() { sendUpdate = updateFn() }) if sendUpdate { rtStatus := sc.GetRuntimeStatus() log.Printf("sending blockcontroller update %#v\n", rtStatus) wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_ControllerStatus, Scopes: []string{ waveobj.MakeORef(waveobj.OType_Tab, sc.TabId).String(), waveobj.MakeORef(waveobj.OType_Block, sc.BlockId).String(), }, Data: rtStatus, }) } } func (sc *ShellController) resetTerminalState(logCtx context.Context) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() wfile, statErr := filestore.WFS.Stat(ctx, sc.BlockId, wavebase.BlockFile_Term) if statErr == fs.ErrNotExist { return } if statErr != nil { log.Printf("error statting term file: %v\n", statErr) return } if wfile.Size == 0 { return } blocklogger.Debugf(logCtx, "[conndebug] resetTerminalState: resetting terminal state\n") resetSeq := shellutil.GetTerminalResetSeq() resetSeq += "\r\n" err := HandleAppendBlockFile(sc.BlockId, wavebase.BlockFile_Term, []byte(resetSeq)) if err != nil { log.Printf("error appending to blockfile (terminal reset): %v\n", err) } } func (sc *ShellController) writeMutedMessageToTerminal(msg string) { if sc.BlockId == "" { return } fullMsg := "\x1b[90m" + msg + "\x1b[0m\r\n" err := HandleAppendBlockFile(sc.BlockId, wavebase.BlockFile_Term, []byte(fullMsg)) if err != nil { log.Printf("error writing muted message to terminal (blockid=%s): %v", sc.BlockId, err) } } // [All the other existing private methods remain exactly the same - I'm not including them all here for brevity, but they would all be copied over with sc. replacing bc. throughout] func (sc *ShellController) DoRunShellCommand(logCtx context.Context, rc *RunShellOpts, blockMeta waveobj.MetaMapType) error { blocklogger.Debugf(logCtx, "[conndebug] DoRunShellCommand\n") shellProc, err := sc.setupAndStartShellProcess(logCtx, rc, blockMeta) if err != nil { return err } return sc.manageRunningShellProcess(shellProc, rc, blockMeta) } // [Continue with all other methods, replacing bc with sc throughout...] func (sc *ShellController) LockRunLock() bool { rtn := sc.RunLock.CompareAndSwap(false, true) if rtn { log.Printf("block %q run() lock\n", sc.BlockId) } return rtn } func (sc *ShellController) UnlockRunLock() { sc.RunLock.Store(false) log.Printf("block %q run() unlock\n", sc.BlockId) } func (sc *ShellController) run(logCtx context.Context, bdata *waveobj.Block, blockMeta map[string]any, rtOpts *waveobj.RuntimeOpts, force bool) { blocklogger.Debugf(logCtx, "[conndebug] ShellController.run() %q\n", sc.BlockId) runningShellCommand := false ok := sc.LockRunLock() if !ok { log.Printf("block %q is already executing run()\n", sc.BlockId) return } defer func() { if !runningShellCommand { sc.UnlockRunLock() } }() curStatus := sc.GetRuntimeStatus() controllerName := bdata.Meta.GetString(waveobj.MetaKey_Controller, "") if controllerName != BlockController_Shell && controllerName != BlockController_Cmd { log.Printf("unknown controller %q\n", controllerName) return } runOnce := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnce, false) runOnStart := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnStart, true) if ((runOnStart || runOnce) && curStatus.ShellProcStatus == Status_Init) || force { if getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdClearOnStart, false) { err := HandleTruncateBlockFile(sc.BlockId) if err != nil { log.Printf("error truncating term blockfile: %v\n", err) } } if runOnce { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() metaUpdate := map[string]any{ waveobj.MetaKey_CmdRunOnce: false, waveobj.MetaKey_CmdRunOnStart: false, } err := wstore.UpdateObjectMeta(ctx, waveobj.MakeORef(waveobj.OType_Block, sc.BlockId), metaUpdate, false) if err != nil { log.Printf("error updating block meta (in blockcontroller.run): %v\n", err) return } } runningShellCommand = true go func() { defer func() { panichandler.PanicHandler("blockcontroller:run-shell-command", recover()) }() defer sc.UnlockRunLock() var termSize waveobj.TermSize if rtOpts != nil { termSize = rtOpts.TermSize } else { termSize = getTermSize(bdata) } err := sc.DoRunShellCommand(logCtx, &RunShellOpts{TermSize: termSize}, bdata.Meta) if err != nil { debugLog(logCtx, "error running shell: %v\n", err) } }() } } // [Include all the remaining private methods with bc replaced by sc] type ConnUnion struct { ConnName string ConnType string SshConn *conncontroller.SSHConn WslConn *wslconn.WslConn WshEnabled bool ShellPath string ShellOpts []string ShellType string HomeDir string } func (bc *ShellController) getConnUnion(logCtx context.Context, remoteName string, blockMeta waveobj.MetaMapType) (ConnUnion, error) { rtn := ConnUnion{ConnName: remoteName} wshEnabled := !blockMeta.GetBool(waveobj.MetaKey_CmdNoWsh, false) if strings.HasPrefix(remoteName, "wsl://") { wslName := strings.TrimPrefix(remoteName, "wsl://") wslConn := wslconn.GetWslConn(wslName) if wslConn == nil { return ConnUnion{}, fmt.Errorf("wsl connection not found: %s", remoteName) } connStatus := wslConn.DeriveConnStatus() if connStatus.Status != conncontroller.Status_Connected { return ConnUnion{}, fmt.Errorf("wsl connection %s not connected, cannot start shellproc", remoteName) } rtn.ConnType = ConnType_Wsl rtn.WslConn = wslConn rtn.WshEnabled = wshEnabled && wslConn.WshEnabled.Load() } else if conncontroller.IsLocalConnName(remoteName) { rtn.ConnType = ConnType_Local rtn.WshEnabled = wshEnabled } else { opts, err := remote.ParseOpts(remoteName) if err != nil { return ConnUnion{}, fmt.Errorf("invalid ssh remote name (%s): %w", remoteName, err) } conn := conncontroller.MaybeGetConn(opts) if conn == nil { return ConnUnion{}, fmt.Errorf("ssh connection not found: %s", remoteName) } connStatus := conn.DeriveConnStatus() if connStatus.Status != conncontroller.Status_Connected { return ConnUnion{}, fmt.Errorf("ssh connection %s not connected, cannot start shellproc", remoteName) } rtn.ConnType = ConnType_Ssh rtn.SshConn = conn rtn.WshEnabled = wshEnabled && conn.WshEnabled.Load() } err := rtn.getRemoteInfoAndShellType(blockMeta) if err != nil { return ConnUnion{}, err } return rtn, nil } func (bc *ShellController) setupAndStartShellProcess(logCtx context.Context, rc *RunShellOpts, blockMeta waveobj.MetaMapType) (*shellexec.ShellProc, error) { // create a circular blockfile for the output ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() fsErr := filestore.WFS.MakeFile(ctx, bc.BlockId, wavebase.BlockFile_Term, nil, wshrpc.FileOpts{MaxSize: DefaultTermMaxFileSize, Circular: true}) if fsErr != nil && fsErr != fs.ErrExist { return nil, fmt.Errorf("error creating blockfile: %w", fsErr) } if fsErr == fs.ErrExist { // reset the terminal state bc.resetTerminalState(logCtx) } bcInitStatus := bc.GetRuntimeStatus() if bcInitStatus.ShellProcStatus == Status_Running { return nil, nil } // TODO better sync here (don't let two starts happen at the same times) remoteName := blockMeta.GetString(waveobj.MetaKey_Connection, "") connUnion, err := bc.getConnUnion(logCtx, remoteName, blockMeta) if err != nil { return nil, err } blocklogger.Infof(logCtx, "[conndebug] remoteName: %q, connType: %s, wshEnabled: %v, shell: %q, shellType: %s\n", remoteName, connUnion.ConnType, connUnion.WshEnabled, connUnion.ShellPath, connUnion.ShellType) var cmdStr string var cmdOpts shellexec.CommandOptsType if bc.ControllerType == BlockController_Shell { cmdOpts.Interactive = true cmdOpts.Login = true cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "") if cmdOpts.Cwd != "" { cwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd) if err != nil { return nil, err } cmdOpts.Cwd = cwdPath } } else if bc.ControllerType == BlockController_Cmd { var cmdOptsPtr *shellexec.CommandOptsType cmdStr, cmdOptsPtr, err = createCmdStrAndOpts(bc.BlockId, blockMeta, remoteName) if err != nil { return nil, err } cmdOpts = *cmdOptsPtr } else { return nil, fmt.Errorf("unknown controller type %q", bc.ControllerType) } var shellProc *shellexec.ShellProc swapToken := makeSwapToken(ctx, logCtx, bc.BlockId, blockMeta, remoteName, connUnion.ShellType) cmdOpts.SwapToken = swapToken blocklogger.Debugf(logCtx, "[conndebug] created swaptoken: %s\n", swapToken.Token) if connUnion.ConnType == ConnType_Wsl { wslConn := connUnion.WslConn if !connUnion.WshEnabled { shellProc, err = shellexec.StartWslShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn) if err != nil { return nil, err } } else { sockName := wslConn.GetDomainSocketName() rpcContext := wshrpc.RpcContext{ ProcRoute: true, SockName: sockName, BlockId: bc.BlockId, Conn: wslConn.GetName(), } jwtStr, err := wshutil.MakeClientJWTToken(rpcContext) if err != nil { return nil, fmt.Errorf("error making jwt token: %w", err) } swapToken.RpcContext = &rpcContext swapToken.Env[wshutil.WaveJwtTokenVarName] = jwtStr shellProc, err = shellexec.StartWslShellProc(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn) if err != nil { wslConn.SetWshError(err) wslConn.WshEnabled.Store(false) blocklogger.Infof(logCtx, "[conndebug] error starting wsl shell proc with wsh: %v\n", err) blocklogger.Infof(logCtx, "[conndebug] attempting install without wsh\n") shellProc, err = shellexec.StartWslShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn) if err != nil { return nil, err } } } } else if connUnion.ConnType == ConnType_Ssh { conn := connUnion.SshConn if !connUnion.WshEnabled { shellProc, err = shellexec.StartRemoteShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, conn) if err != nil { return nil, err } } else { sockName := conn.GetDomainSocketName() rpcContext := wshrpc.RpcContext{ ProcRoute: true, SockName: sockName, BlockId: bc.BlockId, Conn: conn.Opts.String(), } jwtStr, err := wshutil.MakeClientJWTToken(rpcContext) if err != nil { return nil, fmt.Errorf("error making jwt token: %w", err) } swapToken.RpcContext = &rpcContext swapToken.Env[wshutil.WaveJwtTokenVarName] = jwtStr shellProc, err = shellexec.StartRemoteShellProc(ctx, logCtx, rc.TermSize, cmdStr, cmdOpts, conn) if err != nil { conn.SetWshError(err) conn.WshEnabled.Store(false) blocklogger.Infof(logCtx, "[conndebug] error starting remote shell proc with wsh: %v\n", err) blocklogger.Infof(logCtx, "[conndebug] attempting install without wsh\n") shellProc, err = shellexec.StartRemoteShellProcNoWsh(ctx, rc.TermSize, cmdStr, cmdOpts, conn) if err != nil { return nil, err } } } } else if connUnion.ConnType == ConnType_Local { if connUnion.WshEnabled { sockName := wavebase.GetDomainSocketName() rpcContext := wshrpc.RpcContext{ ProcRoute: true, SockName: sockName, BlockId: bc.BlockId, } jwtStr, err := wshutil.MakeClientJWTToken(rpcContext) if err != nil { return nil, fmt.Errorf("error making jwt token: %w", err) } swapToken.RpcContext = &rpcContext swapToken.Env[wshutil.WaveJwtTokenVarName] = jwtStr } cmdOpts.ShellPath = connUnion.ShellPath cmdOpts.ShellOpts = getLocalShellOpts(blockMeta) shellProc, err = shellexec.StartLocalShellProc(logCtx, rc.TermSize, cmdStr, cmdOpts, remoteName) if err != nil { return nil, err } } else { return nil, fmt.Errorf("unknown connection type for conn %q: %s", remoteName, connUnion.ConnType) } bc.UpdateControllerAndSendUpdate(func() bool { bc.ShellProc = shellProc bc.ProcStatus = Status_Running return true }) return shellProc, nil } func (bc *ShellController) manageRunningShellProcess(shellProc *shellexec.ShellProc, rc *RunShellOpts, blockMeta waveobj.MetaMapType) error { shellInputCh := make(chan *BlockInputUnion, 32) bc.ShellInputCh = shellInputCh go func() { // handles regular output from the pty (goes to the blockfile and xterm) defer func() { panichandler.PanicHandler("blockcontroller:shellproc-pty-read-loop", recover()) }() defer func() { log.Printf("[shellproc] pty-read loop done\n") shellProc.Close() bc.WithLock(func() { // so no other events are sent bc.ShellInputCh = nil }) shellProc.Cmd.Wait() exitCode := shellProc.Cmd.ExitCode() blockData := bc.getBlockData_noErr() if blockData != nil && blockData.Meta.GetString(waveobj.MetaKey_Controller, "") == BlockController_Cmd { termMsg := fmt.Sprintf("\r\nprocess finished with exit code = %d\r\n\r\n", exitCode) HandleAppendBlockFile(bc.BlockId, wavebase.BlockFile_Term, []byte(termMsg)) } // to stop the inputCh loop time.Sleep(100 * time.Millisecond) close(shellInputCh) // don't use bc.ShellInputCh (it's nil) }() buf := make([]byte, 4096) for { nr, err := shellProc.Cmd.Read(buf) if nr > 0 { err := HandleAppendBlockFile(bc.BlockId, wavebase.BlockFile_Term, buf[:nr]) if err != nil { log.Printf("error appending to blockfile: %v\n", err) } } if err == io.EOF { break } if err != nil { log.Printf("error reading from shell: %v\n", err) break } } }() go func() { // handles input from the shellInputCh, sent to pty // use shellInputCh instead of bc.ShellInputCh (because we want to be attached to *this* ch. bc.ShellInputCh can be updated) defer func() { panichandler.PanicHandler("blockcontroller:shellproc-input-loop", recover()) }() for ic := range shellInputCh { if len(ic.InputData) > 0 { shellProc.Cmd.Write(ic.InputData) } if ic.TermSize != nil { updateTermSize(shellProc, bc.BlockId, *ic.TermSize) } } }() go func() { defer func() { panichandler.PanicHandler("blockcontroller:shellproc-wait-loop", recover()) }() // wait for the shell to finish var exitCode int defer func() { bc.UpdateControllerAndSendUpdate(func() bool { if bc.ProcStatus == Status_Running { bc.ProcStatus = Status_Done } bc.ProcExitCode = exitCode return true }) log.Printf("[shellproc] shell process wait loop done\n") }() waitErr := shellProc.Cmd.Wait() exitCode = shellProc.Cmd.ExitCode() shellProc.SetWaitErrorAndSignalDone(waitErr) bc.resetTerminalState(context.Background()) exitSignal := shellProc.Cmd.ExitSignal() var baseMsg string if bc.ControllerType == BlockController_Shell { baseMsg = "shell terminated" } else { baseMsg = "command exited" } msg := baseMsg if exitSignal != "" { msg = fmt.Sprintf("%s (signal %s)", baseMsg, exitSignal) } else if exitCode != 0 { msg = fmt.Sprintf("%s (exit code %d)", baseMsg, exitCode) } bc.writeMutedMessageToTerminal("[" + msg + "]") go checkCloseOnExit(bc.BlockId, exitCode) }() return nil } func (union *ConnUnion) getRemoteInfoAndShellType(blockMeta waveobj.MetaMapType) error { if !union.WshEnabled { return nil } if union.ConnType == ConnType_Ssh || union.ConnType == ConnType_Wsl { connRoute := wshutil.MakeConnectionRouteId(union.ConnName) remoteInfo, err := wshclient.RemoteGetInfoCommand(wshclient.GetBareRpcClient(), &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000}) if err != nil { // weird error, could flip the wshEnabled flag and allow it to go forward, but the connection should have already been vetted return fmt.Errorf("unable to obtain remote info from connserver: %w", err) } // TODO allow overriding remote shell path union.ShellPath = remoteInfo.Shell union.HomeDir = remoteInfo.HomeDir } else { shellPath, err := getLocalShellPath(blockMeta) if err != nil { return err } union.ShellPath = shellPath union.HomeDir = wavebase.GetHomeDir() } union.ShellType = shellutil.GetShellTypeFromShellPath(union.ShellPath) return nil } func checkCloseOnExit(blockId string, exitCode int) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { log.Printf("error getting block data: %v\n", err) return } closeOnExit := blockData.Meta.GetBool(waveobj.MetaKey_CmdCloseOnExit, false) closeOnExitForce := blockData.Meta.GetBool(waveobj.MetaKey_CmdCloseOnExitForce, false) if !closeOnExitForce && !(closeOnExit && exitCode == 0) { return } delayMs := blockData.Meta.GetFloat(waveobj.MetaKey_CmdCloseOnExitDelay, 2000) if delayMs < 0 { delayMs = 0 } time.Sleep(time.Duration(delayMs) * time.Millisecond) rpcClient := wshclient.GetBareRpcClient() err = wshclient.DeleteBlockCommand(rpcClient, wshrpc.CommandDeleteBlockData{BlockId: blockId}, nil) if err != nil { log.Printf("error deleting block data (close on exit): %v\n", err) } } func getLocalShellPath(blockMeta waveobj.MetaMapType) (string, error) { shellPath := blockMeta.GetString(waveobj.MetaKey_TermLocalShellPath, "") if shellPath != "" { return shellPath, nil } connName := blockMeta.GetString(waveobj.MetaKey_Connection, "") if strings.HasPrefix(connName, "local:") { variant := strings.TrimPrefix(connName, "local:") if variant == LocalConnVariant_GitBash { if runtime.GOOS != "windows" { return "", fmt.Errorf("connection \"local:gitbash\" is only supported on Windows") } fullConfig := wconfig.GetWatcher().GetFullConfig() gitBashPath := shellutil.FindGitBash(&fullConfig, false) if gitBashPath == "" { return "", fmt.Errorf("connection \"local:gitbash\": git bash not found on this system, please install Git for Windows or set term:localshellpath to specify the git bash location") } return gitBashPath, nil } return "", fmt.Errorf("unsupported local connection type: %q", connName) } settings := wconfig.GetWatcher().GetFullConfig().Settings if settings.TermLocalShellPath != "" { return settings.TermLocalShellPath, nil } return shellutil.DetectLocalShellPath(), nil } func getLocalShellOpts(blockMeta waveobj.MetaMapType) []string { if blockMeta.HasKey(waveobj.MetaKey_TermLocalShellOpts) { opts := blockMeta.GetStringList(waveobj.MetaKey_TermLocalShellOpts) return append([]string{}, opts...) } settings := wconfig.GetWatcher().GetFullConfig().Settings if len(settings.TermLocalShellOpts) > 0 { return append([]string{}, settings.TermLocalShellOpts...) } return nil } // for "cmd" type blocks func createCmdStrAndOpts(blockId string, blockMeta waveobj.MetaMapType, connName string) (string, *shellexec.CommandOptsType, error) { var cmdStr string var cmdOpts shellexec.CommandOptsType cmdStr = blockMeta.GetString(waveobj.MetaKey_Cmd, "") if cmdStr == "" { return "", nil, fmt.Errorf("missing cmd in block meta") } cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "") if cmdOpts.Cwd != "" { cwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd) if err != nil { return "", nil, err } cmdOpts.Cwd = cwdPath } useShell := blockMeta.GetBool(waveobj.MetaKey_CmdShell, true) if !useShell { if strings.Contains(cmdStr, " ") { return "", nil, fmt.Errorf("cmd should not have spaces if cmd:shell is false (use cmd:args)") } cmdArgs := blockMeta.GetStringList(waveobj.MetaKey_CmdArgs) // shell escape the args for _, arg := range cmdArgs { cmdStr = cmdStr + " " + utilfn.ShellQuote(arg, false, -1) } } cmdOpts.ForceJwt = blockMeta.GetBool(waveobj.MetaKey_CmdJwt, false) return cmdStr, &cmdOpts, nil } func (bc *ShellController) getBlockData_noErr() *waveobj.Block { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() blockData, err := wstore.DBGet[*waveobj.Block](ctx, bc.BlockId) if err != nil { log.Printf("error getting block data (getBlockData_noErr): %v\n", err) return nil } return blockData } func resolveEnvMap(blockId string, blockMeta waveobj.MetaMapType, connName string) (map[string]string, error) { rtn := make(map[string]string) config := wconfig.GetWatcher().GetFullConfig() connKeywords := config.Connections[connName] ckEnv := connKeywords.CmdEnv for k, v := range ckEnv { rtn[k] = v } ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() _, envFileData, err := filestore.WFS.ReadFile(ctx, blockId, wavebase.BlockFile_Env) if err == fs.ErrNotExist { err = nil } if err != nil { return nil, fmt.Errorf("error reading command env file: %w", err) } if len(envFileData) > 0 { envMap := envutil.EnvToMap(string(envFileData)) for k, v := range envMap { rtn[k] = v } } cmdEnv := blockMeta.GetStringMap(waveobj.MetaKey_CmdEnv, true) for k, v := range cmdEnv { if v == waveobj.MetaMap_DeleteSentinel { delete(rtn, k) continue } rtn[k] = v } connEnv := blockMeta.GetConnectionOverride(connName).GetStringMap(waveobj.MetaKey_CmdEnv, true) for k, v := range connEnv { if v == waveobj.MetaMap_DeleteSentinel { delete(rtn, k) continue } rtn[k] = v } return rtn, nil } func getCustomInitScriptKeyCascade(shellType string) []string { if shellType == "bash" { return []string{waveobj.MetaKey_CmdInitScriptBash, waveobj.MetaKey_CmdInitScriptSh, waveobj.MetaKey_CmdInitScript} } if shellType == "zsh" { return []string{waveobj.MetaKey_CmdInitScriptZsh, waveobj.MetaKey_CmdInitScriptSh, waveobj.MetaKey_CmdInitScript} } if shellType == "pwsh" { return []string{waveobj.MetaKey_CmdInitScriptPwsh, waveobj.MetaKey_CmdInitScript} } if shellType == "fish" { return []string{waveobj.MetaKey_CmdInitScriptFish, waveobj.MetaKey_CmdInitScript} } return []string{waveobj.MetaKey_CmdInitScript} } func getCustomInitScript(logCtx context.Context, meta waveobj.MetaMapType, connName string, shellType string) string { initScriptVal, metaKeyName := getCustomInitScriptValue(meta, connName, shellType) if initScriptVal == "" { return "" } if !fileutil.IsInitScriptPath(initScriptVal) { blocklogger.Infof(logCtx, "[conndebug] inline initScript (size=%d) found in meta key: %s\n", len(initScriptVal), metaKeyName) return initScriptVal } blocklogger.Infof(logCtx, "[conndebug] initScript detected as a file %q from meta key: %s\n", initScriptVal, metaKeyName) initScriptVal, err := wavebase.ExpandHomeDir(initScriptVal) if err != nil { blocklogger.Infof(logCtx, "[conndebug] cannot expand home dir in Wave initscript file: %v\n", err) return fmt.Sprintf("echo \"cannot expand home dir in Wave initscript file, from key %s\";\n", metaKeyName) } fileData, err := os.ReadFile(initScriptVal) if err != nil { blocklogger.Infof(logCtx, "[conndebug] cannot open Wave initscript file: %v\n", err) return fmt.Sprintf("echo \"cannot open Wave initscript file, from key %s\";\n", metaKeyName) } if len(fileData) > MaxInitScriptSize { blocklogger.Infof(logCtx, "[conndebug] initscript file too large, size=%d, max=%d\n", len(fileData), MaxInitScriptSize) return fmt.Sprintf("echo \"initscript file too large, from key %s\";\n", metaKeyName) } if utilfn.HasBinaryData(fileData) { blocklogger.Infof(logCtx, "[conndebug] initscript file contains binary data\n") return fmt.Sprintf("echo \"initscript file contains binary data, from key %s\";\n", metaKeyName) } blocklogger.Infof(logCtx, "[conndebug] initscript file read successfully, size=%d\n", len(fileData)) return string(fileData) } // returns (value, metakey) func getCustomInitScriptValue(meta waveobj.MetaMapType, connName string, shellType string) (string, string) { keys := getCustomInitScriptKeyCascade(shellType) connMeta := meta.GetConnectionOverride(connName) if connMeta != nil { for _, key := range keys { if connMeta.HasKey(key) { return connMeta.GetString(key, ""), "blockmeta/[" + connName + "]/" + key } } } for _, key := range keys { if meta.HasKey(key) { return meta.GetString(key, ""), "blockmeta/" + key } } fullConfig := wconfig.GetWatcher().GetFullConfig() connKeywords := fullConfig.Connections[connName] connKeywordsMap := make(map[string]any) err := utilfn.ReUnmarshal(&connKeywordsMap, connKeywords) if err != nil { log.Printf("error re-unmarshalling connKeywords: %v\n", err) return "", "" } ckMeta := waveobj.MetaMapType(connKeywordsMap) for _, key := range keys { if ckMeta.HasKey(key) { return ckMeta.GetString(key, ""), "connections.json/" + connName + "/" + key } } return "", "" } func updateTermSize(shellProc *shellexec.ShellProc, blockId string, termSize waveobj.TermSize) { err := setTermSizeInDB(blockId, termSize) if err != nil { log.Printf("error setting pty size: %v\n", err) } err = shellProc.Cmd.SetSize(termSize.Rows, termSize.Cols) if err != nil { log.Printf("error setting pty size: %v\n", err) } } func setTermSizeInDB(blockId string, termSize waveobj.TermSize) error { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) bdata, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { return fmt.Errorf("error getting block data: %v", err) } if bdata.RuntimeOpts == nil { bdata.RuntimeOpts = &waveobj.RuntimeOpts{} } bdata.RuntimeOpts.TermSize = termSize err = wstore.DBUpdate(ctx, bdata) if err != nil { return fmt.Errorf("error updating block data: %v", err) } updates := waveobj.ContextGetUpdatesRtn(ctx) wps.Broker.SendUpdateEvents(updates) return nil } ================================================ FILE: pkg/blockcontroller/tsunamicontroller.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package blockcontroller import ( "context" "fmt" "io" "log" "os" "os/exec" "path/filepath" "runtime" "sync" "syscall" "github.com/wavetermdev/waveterm/pkg/tsunamiutil" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/tsunami/build" ) type TsunamiAppProc struct { Cmd *exec.Cmd LineBuffer *utilds.MultiReaderLineBuffer StdinWriter io.WriteCloser Port int // Port the tsunami app is listening on WaitCh chan struct{} // Channel that gets closed when cmd.Wait() returns WaitRtn error // Error returned by cmd.Wait() } type TsunamiController struct { blockId string tabId string connName string runLock sync.Mutex tsunamiProc *TsunamiAppProc statusLock sync.Mutex status string versionTs utilds.VersionTs exitCode int port int } func (c *TsunamiController) setManifestMetadata(appId string) { manifest, err := waveappstore.ReadAppManifest(appId) if err != nil { return } blockRef := waveobj.MakeORef(waveobj.OType_Block, c.blockId) rtInfo := make(map[string]any) rtInfo["tsunami:appmeta"] = manifest.AppMeta if manifest.ConfigSchema != nil || manifest.DataSchema != nil { schemas := make(map[string]any) if manifest.ConfigSchema != nil { schemas["config"] = manifest.ConfigSchema } if manifest.DataSchema != nil { schemas["data"] = manifest.DataSchema } rtInfo["tsunami:schemas"] = schemas } wstore.SetRTInfo(blockRef, rtInfo) wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_TsunamiUpdateMeta, Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, c.blockId).String()}, Data: manifest.AppMeta, }) } func (c *TsunamiController) clearSchemas() { blockRef := waveobj.MakeORef(waveobj.OType_Block, c.blockId) wstore.SetRTInfo(blockRef, map[string]any{ "tsunami:schemas": nil, }) log.Printf("TsunamiController: cleared schemas for block %s", c.blockId) } func isBuildCacheUpToDate(appPath string) (bool, error) { appName := build.GetAppName(appPath) osArch := runtime.GOOS + "-" + runtime.GOARCH cachePath, err := tsunamiutil.GetTsunamiAppCachePath("local", appName, osArch) if err != nil { return false, err } cacheInfo, err := os.Stat(cachePath) if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } appModTime, err := build.GetAppModTime(appPath) if err != nil { return false, err } cacheModTime := cacheInfo.ModTime() return !cacheModTime.Before(appModTime), nil } func (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error { log.Printf("TsunamiController.Start called for block %s", c.blockId) c.runLock.Lock() defer c.runLock.Unlock() scaffoldPath := waveapputil.GetTsunamiScaffoldPath() settings := wconfig.GetWatcher().GetFullConfig().Settings sdkReplacePath := settings.TsunamiSdkReplacePath sdkVersion := settings.TsunamiSdkVersion if sdkVersion == "" { sdkVersion = waveapputil.DefaultTsunamiSdkVersion } goPath := settings.TsunamiGoPath appPath := blockMeta.GetString(waveobj.MetaKey_TsunamiAppPath, "") appId := blockMeta.GetString(waveobj.MetaKey_TsunamiAppId, "") if appPath == "" { if appId == "" { return fmt.Errorf("tsunami:apppath or tsunami:appid is required") } var err error appPath, err = waveappstore.GetAppDir(appId) if err != nil { return fmt.Errorf("failed to get app directory from tsunami:appid: %w", err) } } else { var err error appPath, err = wavebase.ExpandHomeDir(appPath) if err != nil { return fmt.Errorf("tsunami:apppath invalid: %w", err) } if !filepath.IsAbs(appPath) { return fmt.Errorf("tsunami:apppath must be absolute: %s", appPath) } } if appId != "" { c.setManifestMetadata(appId) } appName := build.GetAppName(appPath) osArch := runtime.GOOS + "-" + runtime.GOARCH cachePath, err := tsunamiutil.GetTsunamiAppCachePath("local", appName, osArch) if err != nil { return fmt.Errorf("failed to get cache path: %w", err) } upToDate, err := isBuildCacheUpToDate(appPath) if err != nil { return fmt.Errorf("failed to check build cache: %w", err) } if !upToDate || force { nodePath := wavebase.GetWaveAppElectronExecPath() if nodePath == "" { return fmt.Errorf("electron executable path not set") } opts := build.BuildOpts{ AppPath: appPath, Verbose: true, Open: false, KeepTemp: false, OutputFile: cachePath, ScaffoldPath: scaffoldPath, SdkReplacePath: sdkReplacePath, SdkVersion: sdkVersion, NodePath: nodePath, GoPath: goPath, } err = build.TsunamiBuild(opts) if err != nil { log.Printf("TsunamiController build error for block %s: %v", c.blockId, err) log.Printf("BuildOpts %#v\n", opts) return fmt.Errorf("failed to build tsunami app: %w", err) } } info, err := os.Stat(cachePath) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("app cache does not exist: %s", cachePath) } return fmt.Errorf("failed to stat app cache: %w", err) } if runtime.GOOS != "windows" && info.Mode()&0111 == 0 { return fmt.Errorf("app cache is not executable: %s", cachePath) } tsunamiProc, err := runTsunamiAppBinary(ctx, cachePath, appPath, blockMeta) if err != nil { return fmt.Errorf("failed to run tsunami app: %w", err) } c.tsunamiProc = tsunamiProc c.WithStatusLock(func() { c.status = Status_Running c.port = tsunamiProc.Port }) go c.sendStatusUpdate() // Monitor process completion go func() { <-tsunamiProc.WaitCh c.runLock.Lock() if c.tsunamiProc == tsunamiProc { c.tsunamiProc = nil c.WithStatusLock(func() { c.status = Status_Done c.port = 0 c.exitCode = exitCodeFromWaitErr(tsunamiProc.WaitRtn) }) c.clearSchemas() go c.sendStatusUpdate() } c.runLock.Unlock() }() return nil } func (c *TsunamiController) Stop(graceful bool, newStatus string, destroy bool) { log.Printf("TsunamiController.Stop called for block %s (graceful: %t, newStatus: %s)", c.blockId, graceful, newStatus) c.runLock.Lock() defer c.runLock.Unlock() if c.tsunamiProc == nil { return } if c.tsunamiProc.Cmd.Process != nil { c.tsunamiProc.Cmd.Process.Kill() } if c.tsunamiProc.StdinWriter != nil { c.tsunamiProc.StdinWriter.Close() } c.tsunamiProc = nil if newStatus == "" { newStatus = Status_Done } c.WithStatusLock(func() { c.status = newStatus c.port = 0 }) c.clearSchemas() go c.sendStatusUpdate() } func (c *TsunamiController) GetRuntimeStatus() *BlockControllerRuntimeStatus { var rtn *BlockControllerRuntimeStatus c.WithStatusLock(func() { rtn = &BlockControllerRuntimeStatus{ BlockId: c.blockId, Version: c.versionTs.GetVersionTs(), ShellProcStatus: c.status, ShellProcConnName: c.connName, ShellProcExitCode: c.exitCode, } if c.status == Status_Running && c.port > 0 { rtn.TsunamiPort = c.port } }) return rtn } func (c *TsunamiController) GetConnName() string { return c.connName } func (c *TsunamiController) SendInput(input *BlockInputUnion) error { return fmt.Errorf("tsunami controller send input not implemented") } func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, blockMeta waveobj.MetaMapType) (*TsunamiAppProc, error) { cmd := exec.Command(appBinPath) cmd.Env = append(os.Environ(), "TSUNAMI_CLOSEONSTDIN=1") if wavebase.IsDevMode() { cmd.Env = append(cmd.Env, "TSUNAMI_CORS="+tsunamiutil.DevModeCorsOrigins) } // Add TsunamiEnv variables if configured tsunamiEnv := blockMeta.GetMap(waveobj.MetaKey_TsunamiEnv) for key, value := range tsunamiEnv { if strValue, ok := value.(string); ok { cmd.Env = append(cmd.Env, key+"="+strValue) } } stdoutPipe, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("failed to create stdout pipe: %w", err) } stderrPipe, err := cmd.StderrPipe() if err != nil { return nil, fmt.Errorf("failed to create stderr pipe: %w", err) } stdinPipe, err := cmd.StdinPipe() if err != nil { return nil, fmt.Errorf("failed to create stdin pipe: %w", err) } appName := build.GetAppName(appPath) lineBuffer := utilds.MakeMultiReaderLineBuffer(1000) portChan := make(chan int, 1) portFound := false lineBuffer.SetLineCallback(func(line string) { log.Printf("[tsunami:%s] %s\n", appName, line) if !portFound { if port := build.ParseTsunamiPort(line); port > 0 { portFound = true portChan <- port } } }) err = cmd.Start() if err != nil { return nil, fmt.Errorf("failed to start tsunami app: %w", err) } // Create wait channel and tsunami proc first waitCh := make(chan struct{}) tsunamiProc := &TsunamiAppProc{ Cmd: cmd, LineBuffer: lineBuffer, StdinWriter: stdinPipe, WaitCh: waitCh, } // Start goroutine to handle cmd.Wait() go func() { tsunamiProc.WaitRtn = cmd.Wait() log.Printf("WAIT RETURN: %v\n", tsunamiProc.WaitRtn) if err := tsunamiProc.WaitRtn; err != nil { if ee, ok := err.(*exec.ExitError); ok { if ws, ok := ee.ProcessState.Sys().(syscall.WaitStatus); ok { if ws.Signaled() { sig := ws.Signal() log.Printf("tsunami proc killed by signal: %s (%d)", sig, int(sig)) } else { log.Printf("tsunami proc exited with code %d", ee.ExitCode()) } } } else { log.Printf("tsunami proc error: %v", err) } } close(waitCh) }() // Start reading both stdout and stderr go lineBuffer.ReadAll(stdoutPipe) go lineBuffer.ReadAll(stderrPipe) // Wait for either port detection, process death, or context timeout errChan := make(chan error, 1) go func() { <-tsunamiProc.WaitCh select { case <-portChan: // Port already found, nothing to do default: errChan <- fmt.Errorf("tsunami process died before emitting listening message") } }() select { case port := <-portChan: tsunamiProc.Port = port return tsunamiProc, nil case err := <-errChan: cmd.Process.Kill() return nil, err case <-ctx.Done(): cmd.Process.Kill() return nil, fmt.Errorf("timeout waiting for tsunami port: %w", ctx.Err()) } } func MakeTsunamiController(tabId string, blockId string, connName string) Controller { log.Printf("make tsunami controller: %s %s\n", tabId, blockId) return &TsunamiController{ blockId: blockId, tabId: tabId, connName: connName, status: Status_Init, } } // requires the lock (so do not call while holding statusLock) func (c *TsunamiController) sendStatusUpdate() { rtStatus := c.GetRuntimeStatus() log.Printf("sending blockcontroller update %#v\n", rtStatus) wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_ControllerStatus, Scopes: []string{ waveobj.MakeORef(waveobj.OType_Tab, c.tabId).String(), waveobj.MakeORef(waveobj.OType_Block, c.blockId).String(), }, Data: rtStatus, }) } func (c *TsunamiController) WithStatusLock(fn func()) { c.statusLock.Lock() defer c.statusLock.Unlock() fn() } func exitCodeFromWaitErr(waitErr error) int { if waitErr != nil { if exitError, ok := waitErr.(*exec.ExitError); ok { return exitError.ExitCode() } else { return 1 } } else { return 0 } } ================================================ FILE: pkg/blocklogger/blocklogger.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package blocklogger import ( "context" "encoding/base64" "fmt" "log" "strings" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) // Buffer size for the output channel const outputBufferSize = 1000 var outputChan chan wshrpc.CommandControllerAppendOutputData func InitBlockLogger() { outputChan = make(chan wshrpc.CommandControllerAppendOutputData, outputBufferSize) // Start the output runner go outputRunner() } func outputRunner() { defer log.Printf("blocklogger: outputRunner exiting") client := wshclient.GetBareRpcClient() for data := range outputChan { // Process each output request synchronously, waiting for response wshclient.ControllerAppendOutputCommand(client, data, nil) } } type logBlockIdContextKeyType struct{} var logBlockIdContextKey = logBlockIdContextKeyType{} type logBlockIdData struct { BlockId string Verbose bool } func ContextWithLogBlockId(ctx context.Context, blockId string, verbose bool) context.Context { return context.WithValue(ctx, logBlockIdContextKey, &logBlockIdData{BlockId: blockId, Verbose: verbose}) } func getLogBlockData(ctx context.Context) *logBlockIdData { if ctx == nil { return nil } dataPtr := ctx.Value(logBlockIdContextKey) if dataPtr == nil { return nil } return dataPtr.(*logBlockIdData) } func queueLogData(data wshrpc.CommandControllerAppendOutputData) { select { case outputChan <- data: default: } } func writeLogf(blockId string, format string, args []any) { logStr := fmt.Sprintf(format, args...) logStr = strings.ReplaceAll(logStr, "\n", "\r\n") data := wshrpc.CommandControllerAppendOutputData{ BlockId: blockId, Data64: base64.StdEncoding.EncodeToString([]byte(logStr)), } queueLogData(data) } func Infof(ctx context.Context, format string, args ...any) { logData := getLogBlockData(ctx) if logData == nil { return } writeLogf(logData.BlockId, format, args) } func Debugf(ctx context.Context, format string, args ...interface{}) { logData := getLogBlockData(ctx) if logData == nil || !logData.Verbose { return } writeLogf(logData.BlockId, format, args) } ================================================ FILE: pkg/buildercontroller/buildercontroller.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package buildercontroller import ( "context" "fmt" "io" "log" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/tsunamiutil" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/tsunami/build" ) const ( BuilderStatus_Init = "init" BuilderStatus_Building = "building" BuilderStatus_Running = "running" BuilderStatus_Error = "error" BuilderStatus_Stopped = "stopped" ) type BuilderProcess struct { Cmd *exec.Cmd StdinWriter io.WriteCloser Port int WaitCh chan struct{} WaitRtn error } type BuildResult struct { Success bool `json:"success"` ErrorMessage string `json:"errormessage,omitempty"` BuildOutput string `json:"buildoutput"` } type BuilderController struct { lock sync.Mutex builderId string appId string process *BuilderProcess outputBuffer *utilds.MultiReaderLineBuffer statusLock sync.Mutex status string statusVersion int port int exitCode int errorMsg string } var ( controllerMap = make(map[string]*BuilderController) // key is builderid mapLock sync.Mutex ) func GetOrCreateController(builderId string) *BuilderController { mapLock.Lock() defer mapLock.Unlock() bc := controllerMap[builderId] if bc != nil { return bc } bc = &BuilderController{ builderId: builderId, status: BuilderStatus_Init, statusVersion: 0, } controllerMap[builderId] = bc return bc } func GetController(builderId string) *BuilderController { mapLock.Lock() defer mapLock.Unlock() return controllerMap[builderId] } func DeleteController(builderId string) { mapLock.Lock() bc := controllerMap[builderId] delete(controllerMap, builderId) mapLock.Unlock() if bc != nil { bc.Stop() } } func GetBuilderAppExecutablePath(appPath string) (string, error) { binDir := filepath.Join(appPath, "bin") binaryName := "app" if runtime.GOOS == "windows" { binaryName = "app.exe" } binPath := filepath.Join(binDir, binaryName) err := wavebase.TryMkdirs(binDir, 0755, "app bin directory") if err != nil { return "", fmt.Errorf("failed to create app bin directory: %w", err) } return binPath, nil } func Shutdown() { mapLock.Lock() controllers := make([]*BuilderController, 0, len(controllerMap)) for _, bc := range controllerMap { controllers = append(controllers, bc) } mapLock.Unlock() for _, bc := range controllers { bc.Stop() } } func (bc *BuilderController) waitForBuildDone(ctx context.Context) error { for { select { case <-ctx.Done(): return ctx.Err() default: } bc.statusLock.Lock() status := bc.status bc.statusLock.Unlock() if status != BuilderStatus_Building { return nil } time.Sleep(100 * time.Millisecond) } } func (bc *BuilderController) Start(ctx context.Context, appId string, builderEnv map[string]string) error { if err := bc.waitForBuildDone(ctx); err != nil { return err } bc.lock.Lock() defer bc.lock.Unlock() if bc.appId != appId && bc.process != nil { log.Printf("BuilderController: stopping previous app %s for builder %s", bc.appId, bc.builderId) bc.stopProcess_nolock() } bc.appId = appId bc.outputBuffer = utilds.MakeMultiReaderLineBuffer(1000) bc.setStatus_nolock(BuilderStatus_Building, 0, 0, "") bc.publishOutputLine("", true) bc.outputBuffer.SetLineCallback(func(line string) { bc.publishOutputLine(line, false) }) buildCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) go func() { defer cancel() defer func() { panichandler.PanicHandler(fmt.Sprintf("buildercontroller[%s].buildAndRun", bc.builderId), recover()) }() bc.buildAndRun(buildCtx, appId, builderEnv, nil) }() return nil } func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, builderEnv map[string]string, resultCh chan<- *BuildResult) { appNS, _, err := waveappstore.ParseAppId(appId) if err != nil { bc.handleBuildError(fmt.Errorf("failed to parse app id: %w", err), resultCh) return } appPath, err := waveappstore.GetAppDir(appId) if err != nil { bc.handleBuildError(fmt.Errorf("failed to get app directory: %w", err), resultCh) return } cachePath, err := GetBuilderAppExecutablePath(appPath) if err != nil { bc.handleBuildError(fmt.Errorf("failed to get builder executable path: %w", err), resultCh) return } nodePath := wavebase.GetWaveAppElectronExecPath() if nodePath == "" { bc.handleBuildError(fmt.Errorf("electron executable path not set"), resultCh) return } scaffoldPath := waveapputil.GetTsunamiScaffoldPath() settings := wconfig.GetWatcher().GetFullConfig().Settings sdkReplacePath := settings.TsunamiSdkReplacePath sdkVersion := settings.TsunamiSdkVersion if sdkVersion == "" { sdkVersion = waveapputil.DefaultTsunamiSdkVersion } goPath := settings.TsunamiGoPath outputCapture := build.MakeOutputCapture() _, err = build.TsunamiBuildInternal(build.BuildOpts{ AppPath: appPath, AppNS: appNS, Verbose: true, Open: false, KeepTemp: false, OutputFile: cachePath, ScaffoldPath: scaffoldPath, SdkReplacePath: sdkReplacePath, SdkVersion: sdkVersion, NodePath: nodePath, GoPath: goPath, OutputCapture: outputCapture, MoveFileBack: true, }) for _, line := range outputCapture.GetLines() { bc.outputBuffer.AddLine(line) } if err != nil { bc.handleBuildError(fmt.Errorf("build failed: %w", err), resultCh) return } info, err := os.Stat(cachePath) if err != nil { bc.handleBuildError(fmt.Errorf("build output not found: %w", err), resultCh) return } if runtime.GOOS != "windows" && info.Mode()&0111 == 0 { bc.handleBuildError(fmt.Errorf("build output is not executable"), resultCh) return } process, err := bc.runBuilderApp(ctx, appId, cachePath, builderEnv) if err != nil { bc.handleBuildError(fmt.Errorf("failed to run app: %w", err), resultCh) return } bc.lock.Lock() bc.process = process bc.setStatus_nolock(BuilderStatus_Running, process.Port, 0, "") bc.lock.Unlock() time.Sleep(1 * time.Second) if resultCh != nil { buildOutput := "" if bc.outputBuffer != nil { lines := bc.outputBuffer.GetLines() buildOutput = strings.Join(lines, "\n") } select { case resultCh <- &BuildResult{ Success: true, BuildOutput: buildOutput, }: default: } } go func() { <-process.WaitCh bc.lock.Lock() if bc.process == process { bc.process = nil exitCode := exitCodeFromWaitErr(process.WaitRtn) bc.setStatus_nolock(BuilderStatus_Stopped, 0, exitCode, "") } bc.lock.Unlock() }() } func (bc *BuilderController) runBuilderApp(ctx context.Context, appId string, appBinPath string, builderEnv map[string]string) (*BuilderProcess, error) { manifest, err := waveappstore.ReadAppManifest(appId) if err != nil { return nil, fmt.Errorf("failed to read app manifest: %w", err) } secretBindings, err := waveappstore.ReadAppSecretBindings(appId) if err != nil { return nil, fmt.Errorf("failed to read secret bindings (ERR-SECRET): %w", err) } secretEnv, err := waveappstore.BuildAppSecretEnv(appId, manifest, secretBindings) if err != nil { return nil, fmt.Errorf("failed to build secret environment (ERR-SECRET): %w", err) } if builderEnv == nil { builderEnv = make(map[string]string) } for k, v := range secretEnv { builderEnv[k] = v } cmd := exec.Command(appBinPath) cmd.Env = append(os.Environ(), "TSUNAMI_CLOSEONSTDIN=1") if wavebase.IsDevMode() { cmd.Env = append(cmd.Env, "TSUNAMI_CORS="+tsunamiutil.DevModeCorsOrigins) } for key, value := range builderEnv { cmd.Env = append(cmd.Env, key+"="+value) } stdoutPipe, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("failed to create stdout pipe: %w", err) } stderrPipe, err := cmd.StderrPipe() if err != nil { return nil, fmt.Errorf("failed to create stderr pipe: %w", err) } stdinPipe, err := cmd.StdinPipe() if err != nil { return nil, fmt.Errorf("failed to create stdin pipe: %w", err) } portChan := make(chan int, 1) portFound := false bc.outputBuffer.SetLineCallback(func(line string) { if !portFound { if port := build.ParseTsunamiPort(line); port > 0 { portFound = true portChan <- port } } bc.publishOutputLine(line, false) }) err = cmd.Start() if err != nil { return nil, fmt.Errorf("failed to start process: %w", err) } waitCh := make(chan struct{}) process := &BuilderProcess{ Cmd: cmd, StdinWriter: stdinPipe, WaitCh: waitCh, } go func() { process.WaitRtn = cmd.Wait() close(waitCh) }() go bc.outputBuffer.ReadAll(stdoutPipe) go bc.outputBuffer.ReadAll(stderrPipe) errChan := make(chan error, 1) go func() { <-process.WaitCh select { case <-portChan: default: errChan <- fmt.Errorf("process died before emitting port") } }() timeout := time.NewTimer(5 * time.Second) defer timeout.Stop() select { case port := <-portChan: process.Port = port return process, nil case err := <-errChan: cmd.Process.Kill() return nil, err case <-timeout.C: cmd.Process.Kill() return nil, fmt.Errorf("timeout waiting for port") case <-ctx.Done(): cmd.Process.Kill() return nil, fmt.Errorf("cancelled while waiting for app port: %w", ctx.Err()) } } func (bc *BuilderController) handleBuildError(err error, resultCh chan<- *BuildResult) { bc.lock.Lock() defer bc.lock.Unlock() bc.setStatus_nolock(BuilderStatus_Error, 0, 1, err.Error()) if resultCh != nil { buildOutput := "" if bc.outputBuffer != nil { lines := bc.outputBuffer.GetLines() buildOutput = strings.Join(lines, "\n") } select { case resultCh <- &BuildResult{ Success: false, ErrorMessage: err.Error(), BuildOutput: buildOutput, }: default: } } } func (bc *BuilderController) RestartAndWaitForBuild(ctx context.Context, appId string, builderEnv map[string]string) (*BuildResult, error) { if err := bc.waitForBuildDone(ctx); err != nil { return nil, err } resultCh := make(chan *BuildResult, 1) bc.lock.Lock() if bc.appId != appId && bc.process != nil { log.Printf("BuilderController: stopping previous app %s for builder %s", bc.appId, bc.builderId) bc.stopProcess_nolock() } bc.appId = appId bc.outputBuffer = utilds.MakeMultiReaderLineBuffer(1000) bc.setStatus_nolock(BuilderStatus_Building, 0, 0, "") bc.publishOutputLine("", true) bc.outputBuffer.SetLineCallback(func(line string) { bc.publishOutputLine(line, false) }) bc.lock.Unlock() time.Sleep(500 * time.Millisecond) buildCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) go func() { defer cancel() defer func() { panichandler.PanicHandler(fmt.Sprintf("buildercontroller[%s].buildAndRun", bc.builderId), recover()) }() bc.buildAndRun(buildCtx, appId, builderEnv, resultCh) }() select { case result := <-resultCh: return result, nil case <-ctx.Done(): return nil, ctx.Err() } } func (bc *BuilderController) Stop() error { if err := bc.waitForBuildDone(context.Background()); err != nil { return err } bc.lock.Lock() defer bc.lock.Unlock() bc.stopProcess_nolock() bc.setStatus_nolock(BuilderStatus_Stopped, 0, 0, "") return nil } func (bc *BuilderController) stopProcess_nolock() { if bc.process == nil { return } if bc.process.Cmd.Process != nil { bc.process.Cmd.Process.Kill() } if bc.process.StdinWriter != nil { bc.process.StdinWriter.Close() } bc.process = nil } func (bc *BuilderController) GetStatus() wshrpc.BuilderStatusData { bc.statusLock.Lock() defer bc.statusLock.Unlock() bc.statusVersion++ statusData := wshrpc.BuilderStatusData{ Status: bc.status, Port: bc.port, ExitCode: bc.exitCode, ErrorMsg: bc.errorMsg, Version: bc.statusVersion, } if bc.appId != "" { manifest, err := waveappstore.ReadAppManifest(bc.appId) if err == nil && manifest != nil { wshrpcManifest := &wshrpc.AppManifest{ AppMeta: wshrpc.AppMeta{ Title: manifest.AppMeta.Title, ShortDesc: manifest.AppMeta.ShortDesc, }, ConfigSchema: manifest.ConfigSchema, DataSchema: manifest.DataSchema, Secrets: make(map[string]wshrpc.SecretMeta), } for k, v := range manifest.Secrets { wshrpcManifest.Secrets[k] = wshrpc.SecretMeta{ Desc: v.Desc, Optional: v.Optional, } } statusData.Manifest = wshrpcManifest } secretBindings, err := waveappstore.ReadAppSecretBindings(bc.appId) if err == nil { statusData.SecretBindings = secretBindings } if manifest != nil && secretBindings != nil { _, err := waveappstore.BuildAppSecretEnv(bc.appId, manifest, secretBindings) statusData.SecretBindingsComplete = (err == nil) } } return statusData } func (bc *BuilderController) GetOutput() []string { if bc.outputBuffer == nil { return []string{} } return bc.outputBuffer.GetLines() } func (bc *BuilderController) setStatus_nolock(status string, port int, exitCode int, errorMsg string) { bc.statusLock.Lock() bc.status = status bc.port = port bc.exitCode = exitCode bc.errorMsg = errorMsg bc.statusLock.Unlock() go bc.publishStatus() } func (bc *BuilderController) publishStatus() { status := bc.GetStatus() wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_BuilderStatus, Scopes: []string{waveobj.MakeORef(waveobj.OType_Builder, bc.builderId).String()}, Data: status, }) } func (bc *BuilderController) publishOutputLine(line string, reset bool) { wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_BuilderOutput, Scopes: []string{waveobj.MakeORef(waveobj.OType_Builder, bc.builderId).String()}, Data: map[string]any{ "lines": []string{line}, "reset": reset, }, }) } func exitCodeFromWaitErr(waitErr error) int { if waitErr == nil { return 0 } if exitError, ok := waitErr.(*exec.ExitError); ok { return exitError.ExitCode() } return 1 } ================================================ FILE: pkg/eventbus/eventbus.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package eventbus import ( "encoding/json" "fmt" "log" "os" "sync" ) const ( WSEvent_ElectronCloseWindow = "electron:closewindow" WSEvent_ElectronUpdateActiveTab = "electron:updateactivetab" WSEvent_Rpc = "rpc" ) type WSEventType struct { EventType string `json:"eventtype"` ORef string `json:"oref,omitempty"` Data any `json:"data"` } type WindowWatchData struct { WindowWSCh chan any RouteId string } var globalLock = &sync.Mutex{} var wsMap = make(map[string]*WindowWatchData) // websocketid => WindowWatchData func RegisterWSChannel(connId string, routeId string, ch chan any) { globalLock.Lock() defer globalLock.Unlock() wsMap[connId] = &WindowWatchData{ WindowWSCh: ch, RouteId: routeId, } } func UnregisterWSChannel(connId string) { globalLock.Lock() defer globalLock.Unlock() delete(wsMap, connId) } func SendEventToElectron(event WSEventType) { barr, err := json.Marshal(event) if err != nil { log.Printf("cannot marshal electron message: %v\n", err) return } // send to electron log.Printf("sending event to electron: %q\n", event.EventType) fmt.Fprintf(os.Stderr, "\nWAVESRV-EVENT:%s\n", string(barr)) } ================================================ FILE: pkg/faviconcache/faviconcache.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package faviconcache import ( "context" "encoding/base64" "fmt" "io" "log" "net/http" "net/url" "strings" "sync" "time" "github.com/wavetermdev/waveterm/pkg/panichandler" ) // --- Constants and Types --- // cacheDuration is how long a cached entry is considered “fresh.” const cacheDuration = 24 * time.Hour // maxIconSize limits the favicon size to 256 KB. const maxIconSize = 256 * 1024 // in bytes // FaviconCacheItem represents one cached favicon entry. type FaviconCacheItem struct { // Data is the base64-encoded data URL string (e.g. "data:image/png;base64,...") Data string // LastFetched is when this entry was last updated. LastFetched time.Time } // --- Global variables for managing in-flight fetches --- // We use a mutex and a simple map to prevent multiple simultaneous fetches for the same domain. var ( fetchLock sync.Mutex fetching = make(map[string]bool) ) // Use a semaphore (buffered channel) to limit concurrent fetches to 5. var fetchSemaphore = make(chan bool, 5) var ( faviconCacheLock sync.Mutex faviconCache = make(map[string]*FaviconCacheItem) ) // --- GetFavicon --- // // GetFavicon takes a URL string and returns a base64-encoded src URL for an <img> // tag. If the favicon is already in cache and “fresh,” it returns it immediately. // Otherwise it kicks off a background fetch (if one isn’t already in progress) // and returns whatever is in the cache (which may be empty). func GetFavicon(urlStr string) string { // Parse the URL and extract the domain. parsedURL, err := url.Parse(urlStr) if err != nil { log.Printf("GetFavicon: invalid URL %q: %v", urlStr, err) return "" } domain := parsedURL.Hostname() if domain == "" { log.Printf("GetFavicon: no hostname found in URL %q", urlStr) return "" } // Try to get from our cache. item, found := GetFromCache(domain) if found { // If the cached entry is not stale, return it. if time.Since(item.LastFetched) < cacheDuration { return item.Data } } // Either the item was not found or it’s stale: // Launch an async fetch if one isn’t already running for this domain. triggerAsyncFetch(domain) // Return the cached value (even if stale or empty). return item.Data } // triggerAsyncFetch starts a goroutine to update the favicon cache // for the given domain if one isn’t already in progress. func triggerAsyncFetch(domain string) { fetchLock.Lock() if fetching[domain] { // Already fetching this domain; nothing to do. fetchLock.Unlock() return } // Mark this domain as in-flight. fetching[domain] = true fetchLock.Unlock() go func() { defer func() { panichandler.PanicHandler("Favicon:triggerAsyncFetch", recover()) }() // Acquire a slot in the semaphore. fetchSemaphore <- true // When done, ensure that we clear the “fetching” flag. defer func() { <-fetchSemaphore fetchLock.Lock() delete(fetching, domain) fetchLock.Unlock() }() iconStr, err := fetchFavicon(domain) if err != nil { log.Printf("triggerAsyncFetch: error fetching favicon for %s: %v", domain, err) } SetInCache(domain, FaviconCacheItem{Data: iconStr, LastFetched: time.Now()}) }() } func fetchFavicon(domain string) (string, error) { // Create a context that times out after 5 seconds. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Special case for github.com - use their dark favicon from assets domain url := "https://" + domain + "/favicon.ico" if domain == "github.com" { url = "https://github.githubassets.com/favicons/favicon-dark.png" } // Create a new HTTP request with the context. req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "", fmt.Errorf("error creating request for %s: %w", url, err) } // Execute the HTTP request. resp, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("error fetching favicon from %s: %w", url, err) } defer resp.Body.Close() // Ensure we got a 200 OK. if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("non-OK HTTP status: %d fetching %s", resp.StatusCode, url) } // Read the favicon bytes. data, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("error reading favicon data from %s: %w", url, err) } // Encode the image bytes to base64. b64Data := base64.StdEncoding.EncodeToString(data) if len(b64Data) > maxIconSize { return "", fmt.Errorf("favicon too large: %d bytes", len(b64Data)) } // Try to detect MIME type from Content-Type header first mimeType := resp.Header.Get("Content-Type") if mimeType == "" { // If no Content-Type header, detect from content mimeType = http.DetectContentType(data) } if !strings.HasPrefix(mimeType, "image/") { return "", fmt.Errorf("unexpected MIME type: %s", mimeType) } return "data:" + mimeType + ";base64," + b64Data, nil } // TODO store in blockstore func GetFromCache(key string) (FaviconCacheItem, bool) { faviconCacheLock.Lock() defer faviconCacheLock.Unlock() item, found := faviconCache[key] if !found { return FaviconCacheItem{}, false } return *item, true } func SetInCache(key string, item FaviconCacheItem) { faviconCacheLock.Lock() defer faviconCacheLock.Unlock() faviconCache[key] = &item } ================================================ FILE: pkg/filebackup/filebackup.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package filebackup import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "log" "os" "path/filepath" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/wavebase" ) const BackupRetentionPeriod = 5 * 24 * time.Hour type BackupMetadata struct { FullPath string `json:"fullpath"` Timestamp string `json:"timestamp"` Perm string `json:"perm"` } func MakeFileBackup(absFilePath string) (string, error) { fileInfo, err := os.Stat(absFilePath) if err != nil { return "", fmt.Errorf("failed to stat file for backup: %w", err) } fileData, err := os.ReadFile(absFilePath) if err != nil { return "", fmt.Errorf("failed to read file for backup: %w", err) } dir := filepath.Dir(absFilePath) basename := filepath.Base(absFilePath) hash := sha256.Sum256([]byte(dir)) dirHash8 := hex.EncodeToString(hash[:])[:8] uuidV7, err := uuid.NewV7() if err != nil { return "", fmt.Errorf("failed to generate UUID: %w", err) } uuidStr := uuidV7.String() now := time.Now() dateStr := now.Format("2006-01-02") backupDir := filepath.Join(wavebase.GetWaveCachesDir(), "waveai-backups", dateStr) err = os.MkdirAll(backupDir, 0700) if err != nil { return "", fmt.Errorf("failed to create backup directory: %w", err) } backupName := fmt.Sprintf("%s.%s.%s.bak", basename, dirHash8, uuidStr) backupPath := filepath.Join(backupDir, backupName) err = os.WriteFile(backupPath, fileData, 0600) if err != nil { return "", fmt.Errorf("failed to write backup file: %w", err) } metadata := BackupMetadata{ FullPath: absFilePath, Timestamp: now.Format(time.RFC3339), Perm: fmt.Sprintf("%04o", fileInfo.Mode().Perm()), } metadataJSON, err := json.MarshalIndent(metadata, "", " ") if err != nil { return "", fmt.Errorf("failed to marshal backup metadata: %w", err) } metadataName := fmt.Sprintf("%s.%s.%s.json", basename, dirHash8, uuidStr) metadataPath := filepath.Join(backupDir, metadataName) err = os.WriteFile(metadataPath, metadataJSON, 0600) if err != nil { return "", fmt.Errorf("failed to write backup metadata: %w", err) } return backupPath, nil } func RestoreBackup(backupFilePath string, restoreToFileName string) error { backupData, err := os.ReadFile(backupFilePath) if err != nil { return fmt.Errorf("failed to read backup file: %w", err) } metadataPath := backupFilePath[:len(backupFilePath)-4] + ".json" metadataData, err := os.ReadFile(metadataPath) if err != nil { return fmt.Errorf("failed to read backup metadata: %w", err) } var metadata BackupMetadata err = json.Unmarshal(metadataData, &metadata) if err != nil { return fmt.Errorf("failed to unmarshal backup metadata: %w", err) } if metadata.FullPath != restoreToFileName { return fmt.Errorf("backup metadata mismatch: expected %s, got %s", restoreToFileName, metadata.FullPath) } var perm os.FileMode _, err = fmt.Sscanf(metadata.Perm, "%o", &perm) if err != nil { return fmt.Errorf("failed to parse file permissions: %w", err) } err = os.WriteFile(restoreToFileName, backupData, perm) if err != nil { return fmt.Errorf("failed to restore file: %w", err) } return nil } func CleanupOldBackups() error { backupBaseDir := filepath.Join(wavebase.GetWaveCachesDir(), "waveai-backups") if _, err := os.Stat(backupBaseDir); os.IsNotExist(err) { return nil } entries, err := os.ReadDir(backupBaseDir) if err != nil { return fmt.Errorf("failed to read backup directory: %w", err) } cutoffTime := time.Now().Add(-BackupRetentionPeriod) var removedCount int for _, entry := range entries { if !entry.IsDir() { continue } dirPath := filepath.Join(backupBaseDir, entry.Name()) info, err := entry.Info() if err != nil { log.Printf("failed to get info for backup dir %s: %v\n", entry.Name(), err) continue } if info.ModTime().Before(cutoffTime) { err = os.RemoveAll(dirPath) if err != nil { log.Printf("failed to remove old backup dir %s: %v\n", entry.Name(), err) } else { removedCount++ } } } if removedCount > 0 { log.Printf("cleaned up %d old backup directories\n", removedCount) } return nil } ================================================ FILE: pkg/filestore/blockstore.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package filestore // the blockstore package implements a write cache for wave files // it is not a read cache (reads still go to the DB -- unless items are in the cache) // but all writes only go to the cache, and then the cache is periodically flushed to the DB import ( "context" "fmt" "io/fs" "log" "math" "sync" "sync/atomic" "time" "github.com/wavetermdev/waveterm/pkg/ijson" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const ( // ijson meta keys IJsonNumCommands = "ijson:numcmds" IJsonIncrementalBytes = "ijson:incbytes" ) const ( IJsonHighCommands = 100 IJsonHighRatio = 3 IJsonLowRatio = 1 IJsonLowCommands = 10 ) const DefaultPartDataSize = 64 * 1024 const DefaultFlushTime = 5 * time.Second const NoPartIdx = -1 // for unit tests var warningCount = &atomic.Int32{} var flushErrorCount = &atomic.Int32{} var partDataSize int64 = DefaultPartDataSize // overridden in tests var stopFlush = &atomic.Bool{} var WFS *FileStore = &FileStore{ Lock: &sync.Mutex{}, Cache: make(map[cacheKey]*CacheEntry), } type WaveFile struct { // these fields are static (not updated) ZoneId string `json:"zoneid"` Name string `json:"name"` Opts wshrpc.FileOpts `json:"opts"` CreatedTs int64 `json:"createdts"` // these fields are mutable Size int64 `json:"size"` ModTs int64 `json:"modts"` Meta wshrpc.FileMeta `json:"meta"` // only top-level keys can be updated (lower levels are immutable) } // for regular files this is just Size // for circular files this is min(Size, MaxSize) func (f WaveFile) DataLength() int64 { if f.Opts.Circular { return minInt64(f.Size, f.Opts.MaxSize) } return f.Size } // for regular files this is just 0 // for circular files this is the index of the first byte of data we have func (f WaveFile) DataStartIdx() int64 { if f.Opts.Circular && f.Size > f.Opts.MaxSize { return f.Size - f.Opts.MaxSize } return 0 } // this works because lower levels are immutable func copyMeta(meta wshrpc.FileMeta) wshrpc.FileMeta { newMeta := make(wshrpc.FileMeta) for k, v := range meta { newMeta[k] = v } return newMeta } func (f *WaveFile) DeepCopy() *WaveFile { if f == nil { return nil } newFile := *f newFile.Meta = copyMeta(f.Meta) return &newFile } func (WaveFile) UseDBMap() {} type FileData struct { ZoneId string `json:"zoneid"` Name string `json:"name"` PartIdx int `json:"partidx"` Data []byte `json:"data"` } func (FileData) UseDBMap() {} // synchronous (does not interact with the cache) func (s *FileStore) MakeFile(ctx context.Context, zoneId string, name string, meta wshrpc.FileMeta, opts wshrpc.FileOpts) error { if opts.MaxSize < 0 { return fmt.Errorf("max size must be non-negative") } if opts.Circular && opts.MaxSize <= 0 { return fmt.Errorf("circular file must have a max size") } if opts.Circular && opts.IJson { return fmt.Errorf("circular file cannot be ijson") } if opts.Circular { if opts.MaxSize%partDataSize != 0 { opts.MaxSize = (opts.MaxSize/partDataSize + 1) * partDataSize } } if opts.IJsonBudget > 0 && !opts.IJson { return fmt.Errorf("ijson budget requires ijson") } if opts.IJsonBudget < 0 { return fmt.Errorf("ijson budget must be non-negative") } return withLock(s, zoneId, name, func(entry *CacheEntry) error { if entry.File != nil { return fs.ErrExist } now := time.Now().UnixMilli() file := &WaveFile{ ZoneId: zoneId, Name: name, Size: 0, CreatedTs: now, ModTs: now, Opts: opts, Meta: meta, } return dbInsertFile(ctx, file) }) } func (s *FileStore) DeleteFile(ctx context.Context, zoneId string, name string) error { return withLock(s, zoneId, name, func(entry *CacheEntry) error { err := dbDeleteFile(ctx, zoneId, name) if err != nil { return fmt.Errorf("error deleting file: %v", err) } entry.clear() return nil }) } func (s *FileStore) DeleteZone(ctx context.Context, zoneId string) error { fileNames, err := dbGetZoneFileNames(ctx, zoneId) if err != nil { return fmt.Errorf("error getting zone files: %v", err) } for _, name := range fileNames { s.DeleteFile(ctx, zoneId, name) } return nil } // if file doesn't exsit, returns fs.ErrNotExist func (s *FileStore) Stat(ctx context.Context, zoneId string, name string) (*WaveFile, error) { return withLockRtn(s, zoneId, name, func(entry *CacheEntry) (*WaveFile, error) { file, err := entry.loadFileForRead(ctx) if err != nil { if err == fs.ErrNotExist { return nil, err } return nil, fmt.Errorf("error getting file: %v", err) } return file.DeepCopy(), nil }) } func (s *FileStore) ListFiles(ctx context.Context, zoneId string) ([]*WaveFile, error) { files, err := dbGetZoneFiles(ctx, zoneId) if err != nil { return nil, fmt.Errorf("error getting zone files: %v", err) } for idx, file := range files { withLock(s, file.ZoneId, file.Name, func(entry *CacheEntry) error { if entry.File != nil { files[idx] = entry.File.DeepCopy() } return nil }) } return files, nil } func (s *FileStore) WriteMeta(ctx context.Context, zoneId string, name string, meta wshrpc.FileMeta, merge bool) error { return withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err } if merge { for k, v := range meta { if v == nil { delete(entry.File.Meta, k) continue } entry.File.Meta[k] = v } } else { entry.File.Meta = meta } entry.File.ModTs = time.Now().UnixMilli() return nil }) } func (s *FileStore) WriteFile(ctx context.Context, zoneId string, name string, data []byte) error { return withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err } entry.writeAt(0, data, true) // since WriteFile can *truncate* the file, we need to flush the file to the DB immediately return entry.flushToDB(ctx, true) }) } func (s *FileStore) WriteAt(ctx context.Context, zoneId string, name string, offset int64, data []byte) error { if offset < 0 { return fmt.Errorf("offset must be non-negative") } return withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err } file := entry.File if offset > file.Size { return fmt.Errorf("offset is past the end of the file") } partMap := file.computePartMap(offset, int64(len(data))) incompleteParts := incompletePartsFromMap(partMap) err = entry.loadDataPartsIntoCache(ctx, incompleteParts) if err != nil { return err } entry.writeAt(offset, data, false) return nil }) } func (s *FileStore) AppendData(ctx context.Context, zoneId string, name string, data []byte) error { return withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err } partMap := entry.File.computePartMap(entry.File.Size, int64(len(data))) incompleteParts := incompletePartsFromMap(partMap) if len(incompleteParts) > 0 { err = entry.loadDataPartsIntoCache(ctx, incompleteParts) if err != nil { return err } } entry.writeAt(entry.File.Size, data, false) return nil }) } func metaIncrement(file *WaveFile, key string, amount int) int { if file.Meta == nil { file.Meta = make(wshrpc.FileMeta) } val, ok := file.Meta[key].(int) if !ok { val = 0 } newVal := val + amount file.Meta[key] = newVal return newVal } func (s *FileStore) compactIJson(ctx context.Context, entry *CacheEntry) error { // we don't need to lock the entry because we have the lock on the filestore _, fullData, err := entry.readAt(ctx, 0, 0, true) if err != nil { return err } newBytes, err := ijson.CompactIJson(fullData, entry.File.Opts.IJsonBudget) if err != nil { return err } entry.writeAt(0, newBytes, true) return nil } func (s *FileStore) CompactIJson(ctx context.Context, zoneId string, name string) error { return withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err } if !entry.File.Opts.IJson { return fmt.Errorf("file %s:%s is not an ijson file", zoneId, name) } return s.compactIJson(ctx, entry) }) } func (s *FileStore) AppendIJson(ctx context.Context, zoneId string, name string, command map[string]any) error { data, err := ijson.ValidateAndMarshalCommand(command) if err != nil { return err } return withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err } if !entry.File.Opts.IJson { return fmt.Errorf("file %s:%s is not an ijson file", zoneId, name) } partMap := entry.File.computePartMap(entry.File.Size, int64(len(data))) incompleteParts := incompletePartsFromMap(partMap) if len(incompleteParts) > 0 { err = entry.loadDataPartsIntoCache(ctx, incompleteParts) if err != nil { return err } } oldSize := entry.File.Size entry.writeAt(entry.File.Size, data, false) entry.writeAt(entry.File.Size, []byte("\n"), false) if oldSize == 0 { return nil } // check if we should compact numCmds := metaIncrement(entry.File, IJsonNumCommands, 1) numBytes := metaIncrement(entry.File, IJsonIncrementalBytes, len(data)+1) incRatio := float64(numBytes) / float64(entry.File.Size) if numCmds > IJsonHighCommands || incRatio >= IJsonHighRatio || (numCmds > IJsonLowCommands && incRatio >= IJsonLowRatio) { err := s.compactIJson(ctx, entry) if err != nil { return err } } return nil }) } func (s *FileStore) GetAllZoneIds(ctx context.Context) ([]string, error) { return dbGetAllZoneIds(ctx) } // returns (offset, data, error) // we return the offset because the offset may have been adjusted if the size was too big (for circular files) func (s *FileStore) ReadAt(ctx context.Context, zoneId string, name string, offset int64, size int64) (rtnOffset int64, rtnData []byte, rtnErr error) { if size < 0 || size > math.MaxInt { return 0, nil, fmt.Errorf("size must be non-negative and less than MaxInt") } withLock(s, zoneId, name, func(entry *CacheEntry) error { rtnOffset, rtnData, rtnErr = entry.readAt(ctx, offset, size, false) return nil }) return } // returns (offset, data, error) func (s *FileStore) ReadFile(ctx context.Context, zoneId string, name string) (rtnOffset int64, rtnData []byte, rtnErr error) { withLock(s, zoneId, name, func(entry *CacheEntry) error { rtnOffset, rtnData, rtnErr = entry.readAt(ctx, 0, 0, true) return nil }) return } type FlushStats struct { FlushDuration time.Duration NumDirtyEntries int NumCommitted int } func (s *FileStore) FlushCache(ctx context.Context) (stats FlushStats, rtnErr error) { wasFlushing := s.setUnlessFlushing() if wasFlushing { return stats, fmt.Errorf("flush already in progress") } defer s.setIsFlushing(false) startTime := time.Now() defer func() { stats.FlushDuration = time.Since(startTime) }() // get a copy of dirty keys so we can iterate without the lock dirtyCacheKeys := s.getDirtyCacheKeys() stats.NumDirtyEntries = len(dirtyCacheKeys) for _, key := range dirtyCacheKeys { err := withLock(s, key.ZoneId, key.Name, func(entry *CacheEntry) error { return entry.flushToDB(ctx, false) }) if ctx.Err() != nil { // transient error (also must stop the loop) return stats, ctx.Err() } if err != nil { return stats, fmt.Errorf("error flushing cache entry[%v]: %v", key, err) } stats.NumCommitted++ } return stats, nil } /////////////////////////////////// func (f *WaveFile) partIdxAtOffset(offset int64) int { partIdx := int(offset / partDataSize) if f.Opts.Circular { maxPart := int(f.Opts.MaxSize / partDataSize) partIdx = partIdx % maxPart } return partIdx } func incompletePartsFromMap(partMap map[int]int) []int { var incompleteParts []int for partIdx, size := range partMap { if size != int(partDataSize) { incompleteParts = append(incompleteParts, partIdx) } } return incompleteParts } func getPartIdxsFromMap(partMap map[int]int) []int { var partIdxs []int for partIdx := range partMap { partIdxs = append(partIdxs, partIdx) } return partIdxs } // returns a map of partIdx to amount of data to write to that part func (file *WaveFile) computePartMap(startOffset int64, size int64) map[int]int { partMap := make(map[int]int) endOffset := startOffset + size startFileOffset := startOffset - (startOffset % partDataSize) for testOffset := startFileOffset; testOffset < endOffset; testOffset += partDataSize { partIdx := file.partIdxAtOffset(testOffset) partStartOffset := testOffset partEndOffset := testOffset + partDataSize partWriteStartOffset := 0 partWriteEndOffset := int(partDataSize) if startOffset > partStartOffset && startOffset < partEndOffset { partWriteStartOffset = int(startOffset - partStartOffset) } if endOffset > partStartOffset && endOffset < partEndOffset { partWriteEndOffset = int(endOffset - partStartOffset) } partMap[partIdx] = partWriteEndOffset - partWriteStartOffset } return partMap } func (s *FileStore) getDirtyCacheKeys() []cacheKey { s.Lock.Lock() defer s.Lock.Unlock() var dirtyCacheKeys []cacheKey for key, entry := range s.Cache { if entry.File != nil { dirtyCacheKeys = append(dirtyCacheKeys, key) } } return dirtyCacheKeys } func (s *FileStore) setIsFlushing(flushing bool) { s.Lock.Lock() defer s.Lock.Unlock() s.IsFlushing = flushing } // returns old value of IsFlushing func (s *FileStore) setUnlessFlushing() bool { s.Lock.Lock() defer s.Lock.Unlock() if s.IsFlushing { return true } s.IsFlushing = true return false } func (s *FileStore) runFlushWithNewContext() (FlushStats, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultFlushTime) defer cancelFn() return s.FlushCache(ctx) } func (s *FileStore) runFlusher() { defer func() { panichandler.PanicHandler("filestore flusher", recover()) }() for { stats, err := s.runFlushWithNewContext() if err != nil || stats.NumDirtyEntries > 0 { log.Printf("filestore flush: %d/%d entries flushed, err:%v\n", stats.NumCommitted, stats.NumDirtyEntries, err) } if stopFlush.Load() { log.Printf("filestore flusher stopping\n") return } time.Sleep(DefaultFlushTime) } } func minInt64(a, b int64) int64 { if a < b { return a } return b } ================================================ FILE: pkg/filestore/blockstore_cache.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package filestore import ( "bytes" "context" "fmt" "io/fs" "sync" "time" ) type cacheKey struct { ZoneId string Name string } type FileStore struct { Lock *sync.Mutex Cache map[cacheKey]*CacheEntry IsFlushing bool } type DataCacheEntry struct { PartIdx int Data []byte // capacity is always ZoneDataPartSize } // if File or DataEntries are not nil then they are dirty (need to be flushed to disk) type CacheEntry struct { PinCount int // this is synchronzed with the FileStore lock (not the entry lock) Lock *sync.Mutex ZoneId string Name string File *WaveFile DataEntries map[int]*DataCacheEntry FlushErrors int } //lint:ignore U1000 used for testing func (e *CacheEntry) dump() string { var buf bytes.Buffer fmt.Fprintf(&buf, "CacheEntry [ZoneId: %q, Name: %q] PinCount: %d\n", e.ZoneId, e.Name, e.PinCount) fmt.Fprintf(&buf, " FileEntry: %v\n", e.File) for idx, dce := range e.DataEntries { fmt.Fprintf(&buf, " DataEntry[%d]: %q\n", idx, string(dce.Data)) } return buf.String() } func makeDataCacheEntry(partIdx int) *DataCacheEntry { return &DataCacheEntry{ PartIdx: partIdx, Data: make([]byte, 0, partDataSize), } } // will create new entries func (s *FileStore) getEntryAndPin(zoneId string, name string) *CacheEntry { s.Lock.Lock() defer s.Lock.Unlock() entry := s.Cache[cacheKey{ZoneId: zoneId, Name: name}] if entry == nil { entry = makeCacheEntry(zoneId, name) s.Cache[cacheKey{ZoneId: zoneId, Name: name}] = entry } entry.PinCount++ return entry } func (s *FileStore) unpinEntryAndTryDelete(zoneId string, name string) { s.Lock.Lock() defer s.Lock.Unlock() entry := s.Cache[cacheKey{ZoneId: zoneId, Name: name}] if entry == nil { return } entry.PinCount-- if entry.PinCount <= 0 && entry.File == nil { delete(s.Cache, cacheKey{ZoneId: zoneId, Name: name}) } } func (entry *CacheEntry) clear() { entry.File = nil entry.DataEntries = make(map[int]*DataCacheEntry) entry.FlushErrors = 0 } func (entry *CacheEntry) getOrCreateDataCacheEntry(partIdx int) *DataCacheEntry { if entry.DataEntries[partIdx] == nil { entry.DataEntries[partIdx] = makeDataCacheEntry(partIdx) } return entry.DataEntries[partIdx] } // returns err if file does not exist func (entry *CacheEntry) loadFileIntoCache(ctx context.Context) error { if entry.File != nil { return nil } file, err := entry.loadFileForRead(ctx) if err != nil { return err } entry.File = file return nil } // does not populate the cache entry, returns err if file does not exist func (entry *CacheEntry) loadFileForRead(ctx context.Context) (*WaveFile, error) { if entry.File != nil { return entry.File, nil } file, err := dbGetZoneFile(ctx, entry.ZoneId, entry.Name) if err != nil { return nil, fmt.Errorf("error getting file: %w", err) } if file == nil { return nil, fs.ErrNotExist } return file, nil } func withLock(s *FileStore, zoneId string, name string, fn func(*CacheEntry) error) error { entry := s.getEntryAndPin(zoneId, name) defer s.unpinEntryAndTryDelete(zoneId, name) entry.Lock.Lock() defer entry.Lock.Unlock() return fn(entry) } func withLockRtn[T any](s *FileStore, zoneId string, name string, fn func(*CacheEntry) (T, error)) (T, error) { var rtnVal T rtnErr := withLock(s, zoneId, name, func(entry *CacheEntry) error { var err error rtnVal, err = fn(entry) return err }) return rtnVal, rtnErr } func (dce *DataCacheEntry) writeToPart(offset int64, data []byte) (int64, *DataCacheEntry) { leftInPart := partDataSize - offset toWrite := int64(len(data)) if toWrite > leftInPart { toWrite = leftInPart } if int64(len(dce.Data)) < offset+toWrite { dce.Data = dce.Data[:offset+toWrite] } copy(dce.Data[offset:], data[:toWrite]) return toWrite, dce } func (entry *CacheEntry) writeAt(offset int64, data []byte, replace bool) { if replace { entry.File.Size = 0 } if entry.File.Opts.Circular { startCirFileOffset := entry.File.Size - entry.File.Opts.MaxSize if offset+int64(len(data)) <= startCirFileOffset { // write is before the start of the circular file return } if offset < startCirFileOffset { // truncate data (from the front), update offset truncateAmt := startCirFileOffset - offset data = data[truncateAmt:] offset += truncateAmt } if int64(len(data)) > entry.File.Opts.MaxSize { // truncate data (from the front), update offset truncateAmt := int64(len(data)) - entry.File.Opts.MaxSize data = data[truncateAmt:] offset += truncateAmt } } endWriteOffset := offset + int64(len(data)) if replace { entry.DataEntries = make(map[int]*DataCacheEntry) } for len(data) > 0 { partIdx := int(offset / partDataSize) if entry.File.Opts.Circular { maxPart := int(entry.File.Opts.MaxSize / partDataSize) partIdx = partIdx % maxPart } partOffset := offset % partDataSize partData := entry.getOrCreateDataCacheEntry(partIdx) nw, newDce := partData.writeToPart(partOffset, data) entry.DataEntries[partIdx] = newDce data = data[nw:] offset += nw } if endWriteOffset > entry.File.Size || replace { entry.File.Size = endWriteOffset } entry.File.ModTs = time.Now().UnixMilli() } // returns (realOffset, data, error) func (entry *CacheEntry) readAt(ctx context.Context, offset int64, size int64, readFull bool) (int64, []byte, error) { if offset < 0 { return 0, nil, fmt.Errorf("offset cannot be negative") } file, err := entry.loadFileForRead(ctx) if err != nil { return 0, nil, err } if readFull { size = file.Size - offset } if offset+size > file.Size { size = file.Size - offset } if file.Opts.Circular { realDataOffset := int64(0) if file.Size > file.Opts.MaxSize { realDataOffset = file.Size - file.Opts.MaxSize } if offset < realDataOffset { truncateAmt := realDataOffset - offset offset += truncateAmt size -= truncateAmt } if size <= 0 { return realDataOffset, nil, nil } } partMap := file.computePartMap(offset, size) dataEntryMap, err := entry.loadDataPartsForRead(ctx, getPartIdxsFromMap(partMap)) if err != nil { return 0, nil, err } // combine the entries into a single byte slice // note that we only want part of the first and last part depending on offset and size rtnData := make([]byte, 0, size) amtLeftToRead := size curReadOffset := offset for amtLeftToRead > 0 { partIdx := file.partIdxAtOffset(curReadOffset) partDataEntry := dataEntryMap[partIdx] var partData []byte if partDataEntry == nil { partData = make([]byte, partDataSize) } else { partData = partDataEntry.Data[0:partDataSize] } partOffset := curReadOffset % partDataSize amtToRead := minInt64(partDataSize-partOffset, amtLeftToRead) rtnData = append(rtnData, partData[partOffset:partOffset+amtToRead]...) amtLeftToRead -= amtToRead curReadOffset += amtToRead } return offset, rtnData, nil } func prunePartsWithCache(dataEntries map[int]*DataCacheEntry, parts []int) []int { var rtn []int for _, partIdx := range parts { if dataEntries[partIdx] != nil { continue } rtn = append(rtn, partIdx) } return rtn } func (entry *CacheEntry) loadDataPartsIntoCache(ctx context.Context, parts []int) error { parts = prunePartsWithCache(entry.DataEntries, parts) if len(parts) == 0 { // parts are already loaded return nil } dbDataParts, err := dbGetFileParts(ctx, entry.ZoneId, entry.Name, parts) if err != nil { return fmt.Errorf("error getting data parts: %w", err) } for partIdx, dce := range dbDataParts { entry.DataEntries[partIdx] = dce } return nil } func (entry *CacheEntry) loadDataPartsForRead(ctx context.Context, parts []int) (map[int]*DataCacheEntry, error) { if len(parts) == 0 { return nil, nil } dbParts := prunePartsWithCache(entry.DataEntries, parts) var dbDataParts map[int]*DataCacheEntry if len(dbParts) > 0 { var err error dbDataParts, err = dbGetFileParts(ctx, entry.ZoneId, entry.Name, dbParts) if err != nil { return nil, fmt.Errorf("error getting data parts: %w", err) } } rtn := make(map[int]*DataCacheEntry) for _, partIdx := range parts { if entry.DataEntries[partIdx] != nil { rtn[partIdx] = entry.DataEntries[partIdx] continue } if dbDataParts[partIdx] != nil { rtn[partIdx] = dbDataParts[partIdx] continue } // part not found } return rtn, nil } func makeCacheEntry(zoneId string, name string) *CacheEntry { return &CacheEntry{ Lock: &sync.Mutex{}, ZoneId: zoneId, Name: name, PinCount: 0, File: nil, DataEntries: make(map[int]*DataCacheEntry), FlushErrors: 0, } } func (entry *CacheEntry) flushToDB(ctx context.Context, replace bool) error { if entry.File == nil { return nil } err := dbWriteCacheEntry(ctx, entry.File, entry.DataEntries, replace) if ctx.Err() != nil { // transient error return ctx.Err() } if err != nil { flushErrorCount.Add(1) entry.FlushErrors++ if entry.FlushErrors > 3 { entry.clear() return fmt.Errorf("too many flush errors (clearing entry): %w", err) } return err } // clear cache entry (data is now in db) entry.clear() return nil } ================================================ FILE: pkg/filestore/blockstore_dbops.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package filestore import ( "context" "fmt" "io/fs" "os" "github.com/wavetermdev/waveterm/pkg/util/dbutil" ) // can return fs.ErrExist func dbInsertFile(ctx context.Context, file *WaveFile) error { // will fail if file already exists return WithTx(ctx, func(tx *TxWrap) error { query := "SELECT zoneid FROM db_wave_file WHERE zoneid = ? AND name = ?" if tx.Exists(query, file.ZoneId, file.Name) { return fs.ErrExist } query = "INSERT INTO db_wave_file (zoneid, name, size, createdts, modts, opts, meta) VALUES (?, ?, ?, ?, ?, ?, ?)" tx.Exec(query, file.ZoneId, file.Name, file.Size, file.CreatedTs, file.ModTs, dbutil.QuickJson(file.Opts), dbutil.QuickJson(file.Meta)) return nil }) } func dbDeleteFile(ctx context.Context, zoneId string, name string) error { return WithTx(ctx, func(tx *TxWrap) error { query := "DELETE FROM db_wave_file WHERE zoneid = ? AND name = ?" tx.Exec(query, zoneId, name) query = "DELETE FROM db_file_data WHERE zoneid = ? AND name = ?" tx.Exec(query, zoneId, name) return nil }) } func dbGetZoneFileNames(ctx context.Context, zoneId string) ([]string, error) { return WithTxRtn(ctx, func(tx *TxWrap) ([]string, error) { var files []string query := "SELECT name FROM db_wave_file WHERE zoneid = ?" tx.Select(&files, query, zoneId) return files, nil }) } func dbGetZoneFile(ctx context.Context, zoneId string, name string) (*WaveFile, error) { return WithTxRtn(ctx, func(tx *TxWrap) (*WaveFile, error) { query := "SELECT * FROM db_wave_file WHERE zoneid = ? AND name = ?" file := dbutil.GetMappable[*WaveFile](tx, query, zoneId, name) return file, nil }) } func dbGetAllZoneIds(ctx context.Context) ([]string, error) { return WithTxRtn(ctx, func(tx *TxWrap) ([]string, error) { var ids []string query := "SELECT DISTINCT zoneid FROM db_wave_file" tx.Select(&ids, query) return ids, nil }) } func dbGetFileParts(ctx context.Context, zoneId string, name string, parts []int) (map[int]*DataCacheEntry, error) { if len(parts) == 0 { return nil, nil } return WithTxRtn(ctx, func(tx *TxWrap) (map[int]*DataCacheEntry, error) { var data []*DataCacheEntry query := "SELECT partidx, data FROM db_file_data WHERE zoneid = ? AND name = ? AND partidx IN (SELECT value FROM json_each(?))" tx.Select(&data, query, zoneId, name, dbutil.QuickJsonArr(parts)) rtn := make(map[int]*DataCacheEntry) for _, d := range data { if cap(d.Data) != int(partDataSize) { newData := make([]byte, len(d.Data), partDataSize) copy(newData, d.Data) d.Data = newData } rtn[d.PartIdx] = d } return rtn, nil }) } func dbGetZoneFiles(ctx context.Context, zoneId string) ([]*WaveFile, error) { return WithTxRtn(ctx, func(tx *TxWrap) ([]*WaveFile, error) { query := "SELECT * FROM db_wave_file WHERE zoneid = ?" files := dbutil.SelectMappable[*WaveFile](tx, query, zoneId) return files, nil }) } func dbWriteCacheEntry(ctx context.Context, file *WaveFile, dataEntries map[int]*DataCacheEntry, replace bool) error { return WithTx(ctx, func(tx *TxWrap) error { query := `SELECT zoneid FROM db_wave_file WHERE zoneid = ? AND name = ?` if !tx.Exists(query, file.ZoneId, file.Name) { // since deletion is synchronous this stops us from writing to a deleted file return os.ErrNotExist } // we don't update CreatedTs or Opts query = `UPDATE db_wave_file SET size = ?, modts = ?, meta = ? WHERE zoneid = ? AND name = ?` tx.Exec(query, file.Size, file.ModTs, dbutil.QuickJson(file.Meta), file.ZoneId, file.Name) if replace { query = `DELETE FROM db_file_data WHERE zoneid = ? AND name = ?` tx.Exec(query, file.ZoneId, file.Name) } dataPartQuery := `REPLACE INTO db_file_data (zoneid, name, partidx, data) VALUES (?, ?, ?, ?)` for partIdx, dataEntry := range dataEntries { if partIdx != dataEntry.PartIdx { panic(fmt.Sprintf("partIdx:%d and dataEntry.PartIdx:%d do not match", partIdx, dataEntry.PartIdx)) } tx.Exec(dataPartQuery, file.ZoneId, file.Name, dataEntry.PartIdx, dataEntry.Data) } return nil }) } ================================================ FILE: pkg/filestore/blockstore_dbsetup.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package filestore // setup for filestore db // includes migration support and txwrap setup import ( "context" "fmt" "log" "path/filepath" "time" "github.com/wavetermdev/waveterm/pkg/util/migrateutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "github.com/sawka/txwrap" dbfs "github.com/wavetermdev/waveterm/db" ) const FilestoreDBName = "filestore.db" type TxWrap = txwrap.TxWrap var globalDB *sqlx.DB var useTestingDb bool // just for testing (forces GetDB() to return an in-memory db) func InitFilestore() error { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() var err error globalDB, err = MakeDB(ctx) if err != nil { return err } err = migrateutil.Migrate("filestore", globalDB.DB, dbfs.FilestoreMigrationFS, "migrations-filestore") if err != nil { return err } if !stopFlush.Load() { go WFS.runFlusher() } log.Printf("filestore initialized\n") return nil } func GetDBName() string { waveHome := wavebase.GetWaveDataDir() return filepath.Join(waveHome, wavebase.WaveDBDir, FilestoreDBName) } func MakeDB(ctx context.Context) (*sqlx.DB, error) { var rtn *sqlx.DB var err error if useTestingDb { dbName := ":memory:" log.Printf("[db] using in-memory db\n") rtn, err = sqlx.Open("sqlite3", dbName) } else { dbName := GetDBName() log.Printf("[db] opening db %s\n", dbName) rtn, err = sqlx.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc&_journal_mode=WAL&_busy_timeout=5000", dbName)) } if err != nil { return nil, fmt.Errorf("opening db: %w", err) } rtn.DB.SetMaxOpenConns(1) return rtn, nil } func WithTx(ctx context.Context, fn func(tx *TxWrap) error) error { return txwrap.WithTx(ctx, globalDB, fn) } func WithTxRtn[RT any](ctx context.Context, fn func(tx *TxWrap) (RT, error)) (RT, error) { return txwrap.WithTxRtn(ctx, globalDB, fn) } ================================================ FILE: pkg/filestore/blockstore_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package filestore import ( "bytes" "context" "errors" "fmt" "io/fs" "log" "reflect" "sync" "sync/atomic" "testing" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/ijson" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) func initDb(t *testing.T) { t.Logf("initializing db for %q", t.Name()) useTestingDb = true partDataSize = 50 warningCount = &atomic.Int32{} stopFlush.Store(true) err := InitFilestore() if err != nil { t.Fatalf("error initializing filestore: %v", err) } } func cleanupDb(t *testing.T) { t.Logf("cleaning up db for %q", t.Name()) if globalDB != nil { globalDB.Close() globalDB = nil } useTestingDb = false partDataSize = DefaultPartDataSize WFS.clearCache() if warningCount.Load() > 0 { t.Errorf("warning count: %d", warningCount.Load()) } if flushErrorCount.Load() > 0 { t.Errorf("flush error count: %d", flushErrorCount.Load()) } } func (s *FileStore) getCacheSize() int { s.Lock.Lock() defer s.Lock.Unlock() return len(s.Cache) } func (s *FileStore) clearCache() { s.Lock.Lock() defer s.Lock.Unlock() s.Cache = make(map[cacheKey]*CacheEntry) } //lint:ignore U1000 used for testing func (s *FileStore) dump() string { s.Lock.Lock() defer s.Lock.Unlock() var buf bytes.Buffer buf.WriteString(fmt.Sprintf("FileStore %d entries\n", len(s.Cache))) for _, v := range s.Cache { entryStr := v.dump() buf.WriteString(entryStr) buf.WriteString("\n") } return buf.String() } func TestCreate(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() zoneId := uuid.NewString() err := WFS.MakeFile(ctx, zoneId, "testfile", nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } file, err := WFS.Stat(ctx, zoneId, "testfile") if err != nil { t.Fatalf("error stating file: %v", err) } if file == nil { t.Fatalf("file not found") } if file.ZoneId != zoneId { t.Fatalf("zone id mismatch") } if file.Name != "testfile" { t.Fatalf("name mismatch") } if file.Size != 0 { t.Fatalf("size mismatch") } if file.CreatedTs == 0 { t.Fatalf("created ts zero") } if file.ModTs == 0 { t.Fatalf("mod ts zero") } if file.CreatedTs != file.ModTs { t.Fatalf("create ts != mod ts") } if len(file.Meta) != 0 { t.Fatalf("meta should have no values") } if file.Opts.Circular || file.Opts.IJson || file.Opts.MaxSize != 0 { t.Fatalf("opts not empty") } zoneIds, err := WFS.GetAllZoneIds(ctx) if err != nil { t.Fatalf("error getting zone ids: %v", err) } if len(zoneIds) != 1 { t.Fatalf("zone id count mismatch") } if zoneIds[0] != zoneId { t.Fatalf("zone id mismatch") } err = WFS.DeleteFile(ctx, zoneId, "testfile") if err != nil { t.Fatalf("error deleting file: %v", err) } zoneIds, err = WFS.GetAllZoneIds(ctx) if err != nil { t.Fatalf("error getting zone ids: %v", err) } if len(zoneIds) != 0 { t.Fatalf("zone id count mismatch") } } func containsFile(arr []*WaveFile, name string) bool { for _, f := range arr { if f.Name == name { return true } } return false } func TestDelete(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() zoneId := uuid.NewString() err := WFS.MakeFile(ctx, zoneId, "testfile", nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } err = WFS.DeleteFile(ctx, zoneId, "testfile") if err != nil { t.Fatalf("error deleting file: %v", err) } _, err = WFS.Stat(ctx, zoneId, "testfile") if err == nil || !errors.Is(err, fs.ErrNotExist) { t.Errorf("expected file not found error") } // create two files in same zone, use DeleteZone to delete err = WFS.MakeFile(ctx, zoneId, "testfile1", nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } err = WFS.MakeFile(ctx, zoneId, "testfile2", nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } files, err := WFS.ListFiles(ctx, zoneId) if err != nil { t.Fatalf("error listing files: %v", err) } if len(files) != 2 { t.Fatalf("file count mismatch") } if !containsFile(files, "testfile1") || !containsFile(files, "testfile2") { t.Fatalf("file names mismatch") } err = WFS.DeleteZone(ctx, zoneId) if err != nil { t.Fatalf("error deleting zone: %v", err) } files, err = WFS.ListFiles(ctx, zoneId) if err != nil { t.Fatalf("error listing files: %v", err) } if len(files) != 0 { t.Fatalf("file count mismatch") } } func checkMapsEqual(t *testing.T, m1 map[string]any, m2 map[string]any, msg string) { if len(m1) != len(m2) { t.Errorf("%s: map length mismatch", msg) } for k, v := range m1 { if m2[k] != v { t.Errorf("%s: value mismatch for key %q", msg, k) } } } func TestSetMeta(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() zoneId := uuid.NewString() err := WFS.MakeFile(ctx, zoneId, "testfile", nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } if WFS.getCacheSize() != 0 { t.Errorf("cache size mismatch -- should have 0 entries after create") } err = WFS.WriteMeta(ctx, zoneId, "testfile", map[string]any{"a": 5, "b": "hello", "q": 8}, false) if err != nil { t.Fatalf("error setting meta: %v", err) } file, err := WFS.Stat(ctx, zoneId, "testfile") if err != nil { t.Fatalf("error stating file: %v", err) } if file == nil { t.Fatalf("file not found") } checkMapsEqual(t, map[string]any{"a": 5, "b": "hello", "q": 8}, file.Meta, "meta") if WFS.getCacheSize() != 1 { t.Errorf("cache size mismatch") } err = WFS.WriteMeta(ctx, zoneId, "testfile", map[string]any{"a": 6, "c": "world", "d": 7, "q": nil}, true) if err != nil { t.Fatalf("error setting meta: %v", err) } file, err = WFS.Stat(ctx, zoneId, "testfile") if err != nil { t.Fatalf("error stating file: %v", err) } if file == nil { t.Fatalf("file not found") } checkMapsEqual(t, map[string]any{"a": 6, "b": "hello", "c": "world", "d": 7}, file.Meta, "meta") err = WFS.WriteMeta(ctx, zoneId, "testfile-notexist", map[string]any{"a": 6}, true) if err == nil { t.Fatalf("expected error setting meta") } err = nil } func checkFileSize(t *testing.T, ctx context.Context, zoneId string, name string, size int64) { file, err := WFS.Stat(ctx, zoneId, name) if err != nil { t.Errorf("error stating file %q: %v", name, err) return } if file == nil { t.Errorf("file %q not found", name) return } if file.Size != size { t.Errorf("size mismatch for file %q: expected %d, got %d", name, size, file.Size) } } func checkFileData(t *testing.T, ctx context.Context, zoneId string, name string, data string) { _, rdata, err := WFS.ReadFile(ctx, zoneId, name) if err != nil { t.Errorf("error reading data for file %q: %v", name, err) return } if string(rdata) != data { t.Errorf("data mismatch for file %q: expected %q, got %q", name, data, string(rdata)) } } func checkFileByteCount(t *testing.T, ctx context.Context, zoneId string, name string, val byte, expected int) { _, rdata, err := WFS.ReadFile(ctx, zoneId, name) if err != nil { t.Errorf("error reading data for file %q: %v", name, err) return } var count int for _, b := range rdata { if b == val { count++ } } if count != expected { t.Errorf("byte count mismatch for file %q: expected %d, got %d", name, expected, count) } } func checkFileDataAt(t *testing.T, ctx context.Context, zoneId string, name string, offset int64, data string) { _, rdata, err := WFS.ReadAt(ctx, zoneId, name, offset, int64(len(data))) if err != nil { t.Errorf("error reading data for file %q: %v", name, err) return } if string(rdata) != data { t.Errorf("data mismatch for file %q: expected %q, got %q", name, data, string(rdata)) } } func TestWriteAt(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() fileName := "t3" zoneId := uuid.NewString() err := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } err = WFS.WriteFile(ctx, zoneId, fileName, []byte("hello world!")) if err != nil { t.Fatalf("error writing data: %v", err) } checkFileData(t, ctx, zoneId, fileName, "hello world!") err = WFS.WriteAt(ctx, zoneId, fileName, 0, []byte("foo")) if err != nil { t.Fatalf("error writing data: %v", err) } checkFileSize(t, ctx, zoneId, fileName, 12) checkFileData(t, ctx, zoneId, fileName, "foolo world!") } func TestAppend(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() zoneId := uuid.NewString() fileName := "t2" err := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } err = WFS.AppendData(ctx, zoneId, fileName, []byte("hello")) if err != nil { t.Fatalf("error appending data: %v", err) } // fmt.Print(GBS.dump()) checkFileSize(t, ctx, zoneId, fileName, 5) checkFileData(t, ctx, zoneId, fileName, "hello") err = WFS.AppendData(ctx, zoneId, fileName, []byte(" world")) if err != nil { t.Fatalf("error appending data: %v", err) } // fmt.Print(GBS.dump()) checkFileSize(t, ctx, zoneId, fileName, 11) checkFileData(t, ctx, zoneId, fileName, "hello world") } func TestWriteFile(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() zoneId := uuid.NewString() fileName := "t3" err := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } err = WFS.WriteFile(ctx, zoneId, fileName, []byte("hello world!")) if err != nil { t.Fatalf("error writing data: %v", err) } checkFileData(t, ctx, zoneId, fileName, "hello world!") err = WFS.WriteFile(ctx, zoneId, fileName, []byte("goodbye world!")) if err != nil { t.Fatalf("error writing data: %v", err) } checkFileData(t, ctx, zoneId, fileName, "goodbye world!") err = WFS.WriteFile(ctx, zoneId, fileName, []byte("hello")) if err != nil { t.Fatalf("error writing data: %v", err) } checkFileData(t, ctx, zoneId, fileName, "hello") // circular file err = WFS.MakeFile(ctx, zoneId, "c1", nil, wshrpc.FileOpts{Circular: true, MaxSize: 50}) if err != nil { t.Fatalf("error creating file: %v", err) } err = WFS.WriteFile(ctx, zoneId, "c1", []byte("123456789 123456789 123456789 123456789 123456789 apple")) if err != nil { t.Fatalf("error writing data: %v", err) } checkFileData(t, ctx, zoneId, "c1", "6789 123456789 123456789 123456789 123456789 apple") err = WFS.AppendData(ctx, zoneId, "c1", []byte(" banana")) if err != nil { t.Fatalf("error appending data: %v", err) } checkFileData(t, ctx, zoneId, "c1", "3456789 123456789 123456789 123456789 apple banana") } func TestCircularWrites(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() zoneId := uuid.NewString() err := WFS.MakeFile(ctx, zoneId, "c1", nil, wshrpc.FileOpts{Circular: true, MaxSize: 50}) if err != nil { t.Fatalf("error creating file: %v", err) } err = WFS.WriteFile(ctx, zoneId, "c1", []byte("123456789 123456789 123456789 123456789 123456789 ")) if err != nil { t.Fatalf("error writing data: %v", err) } checkFileData(t, ctx, zoneId, "c1", "123456789 123456789 123456789 123456789 123456789 ") err = WFS.AppendData(ctx, zoneId, "c1", []byte("apple")) if err != nil { t.Fatalf("error appending data: %v", err) } checkFileData(t, ctx, zoneId, "c1", "6789 123456789 123456789 123456789 123456789 apple") err = WFS.WriteAt(ctx, zoneId, "c1", 0, []byte("foo")) if err != nil { t.Fatalf("error writing data: %v", err) } // content should be unchanged because write is before the beginning of circular offset checkFileData(t, ctx, zoneId, "c1", "6789 123456789 123456789 123456789 123456789 apple") err = WFS.WriteAt(ctx, zoneId, "c1", 5, []byte("a")) if err != nil { t.Fatalf("error writing data: %v", err) } checkFileSize(t, ctx, zoneId, "c1", 55) checkFileData(t, ctx, zoneId, "c1", "a789 123456789 123456789 123456789 123456789 apple") err = WFS.AppendData(ctx, zoneId, "c1", []byte(" banana")) if err != nil { t.Fatalf("error appending data: %v", err) } checkFileSize(t, ctx, zoneId, "c1", 62) checkFileData(t, ctx, zoneId, "c1", "3456789 123456789 123456789 123456789 apple banana") err = WFS.WriteAt(ctx, zoneId, "c1", 20, []byte("foo")) if err != nil { t.Fatalf("error writing data: %v", err) } checkFileSize(t, ctx, zoneId, "c1", 62) checkFileData(t, ctx, zoneId, "c1", "3456789 foo456789 123456789 123456789 apple banana") offset, _, _ := WFS.ReadFile(ctx, zoneId, "c1") if offset != 12 { t.Errorf("offset mismatch: expected 12, got %d", offset) } err = WFS.AppendData(ctx, zoneId, "c1", []byte(" world")) if err != nil { t.Fatalf("error appending data: %v", err) } checkFileSize(t, ctx, zoneId, "c1", 68) offset, _, _ = WFS.ReadFile(ctx, zoneId, "c1") if offset != 18 { t.Errorf("offset mismatch: expected 18, got %d", offset) } checkFileData(t, ctx, zoneId, "c1", "9 foo456789 123456789 123456789 apple banana world") err = WFS.AppendData(ctx, zoneId, "c1", []byte(" 123456789 123456789 123456789 123456789 bar456789 123456789")) if err != nil { t.Fatalf("error appending data: %v", err) } checkFileSize(t, ctx, zoneId, "c1", 128) checkFileData(t, ctx, zoneId, "c1", " 123456789 123456789 123456789 bar456789 123456789") err = withLock(WFS, zoneId, "c1", func(entry *CacheEntry) error { if entry == nil { return fmt.Errorf("entry not found") } if len(entry.DataEntries) != 1 { return fmt.Errorf("data entries mismatch: expected 1, got %d", len(entry.DataEntries)) } return nil }) if err != nil { t.Fatalf("error checking data entries: %v", err) } } func makeText(n int) string { var buf bytes.Buffer for i := 0; i < n; i++ { buf.WriteByte(byte('0' + (i % 10))) } return buf.String() } func TestMultiPart(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() zoneId := uuid.NewString() fileName := "m2" data := makeText(80) err := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } err = WFS.AppendData(ctx, zoneId, fileName, []byte(data)) if err != nil { t.Fatalf("error appending data: %v", err) } checkFileSize(t, ctx, zoneId, fileName, 80) checkFileData(t, ctx, zoneId, fileName, data) _, barr, err := WFS.ReadAt(ctx, zoneId, fileName, 42, 10) if err != nil { t.Fatalf("error reading data: %v", err) } if string(barr) != data[42:52] { t.Errorf("data mismatch: expected %q, got %q", data[42:52], string(barr)) } WFS.WriteAt(ctx, zoneId, fileName, 49, []byte("world")) checkFileSize(t, ctx, zoneId, fileName, 80) checkFileDataAt(t, ctx, zoneId, fileName, 49, "world") checkFileDataAt(t, ctx, zoneId, fileName, 48, "8world4") } func testIntMapsEq(t *testing.T, msg string, m map[int]int, expected map[int]int) { if len(m) != len(expected) { t.Errorf("%s: map length mismatch got:%d expected:%d", msg, len(m), len(expected)) return } for k, v := range m { if expected[k] != v { t.Errorf("%s: value mismatch for key %d, got:%d expected:%d", msg, k, v, expected[k]) } } } func TestComputePartMap(t *testing.T) { partDataSize = 100 defer func() { partDataSize = DefaultPartDataSize }() file := &WaveFile{} m := file.computePartMap(0, 250) testIntMapsEq(t, "map1", m, map[int]int{0: 100, 1: 100, 2: 50}) m = file.computePartMap(110, 40) log.Printf("map2:%#v\n", m) testIntMapsEq(t, "map2", m, map[int]int{1: 40}) m = file.computePartMap(110, 90) testIntMapsEq(t, "map3", m, map[int]int{1: 90}) m = file.computePartMap(110, 91) testIntMapsEq(t, "map4", m, map[int]int{1: 90, 2: 1}) m = file.computePartMap(820, 340) testIntMapsEq(t, "map5", m, map[int]int{8: 80, 9: 100, 10: 100, 11: 60}) // now test circular file = &WaveFile{Opts: wshrpc.FileOpts{Circular: true, MaxSize: 1000}} m = file.computePartMap(10, 250) testIntMapsEq(t, "map6", m, map[int]int{0: 90, 1: 100, 2: 60}) m = file.computePartMap(990, 40) testIntMapsEq(t, "map7", m, map[int]int{9: 10, 0: 30}) m = file.computePartMap(990, 130) testIntMapsEq(t, "map8", m, map[int]int{9: 10, 0: 100, 1: 20}) m = file.computePartMap(5, 1105) testIntMapsEq(t, "map9", m, map[int]int{0: 100, 1: 10, 2: 100, 3: 100, 4: 100, 5: 100, 6: 100, 7: 100, 8: 100, 9: 100}) m = file.computePartMap(2005, 1105) testIntMapsEq(t, "map9", m, map[int]int{0: 100, 1: 10, 2: 100, 3: 100, 4: 100, 5: 100, 6: 100, 7: 100, 8: 100, 9: 100}) } func TestSimpleDBFlush(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() zoneId := uuid.NewString() fileName := "t1" err := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } err = WFS.WriteFile(ctx, zoneId, fileName, []byte("hello world!")) if err != nil { t.Fatalf("error writing data: %v", err) } checkFileData(t, ctx, zoneId, fileName, "hello world!") _, err = WFS.FlushCache(ctx) if err != nil { t.Fatalf("error flushing cache: %v", err) } if WFS.getCacheSize() != 0 { t.Errorf("cache size mismatch") } checkFileData(t, ctx, zoneId, fileName, "hello world!") if WFS.getCacheSize() != 0 { t.Errorf("cache size mismatch (after read)") } checkFileDataAt(t, ctx, zoneId, fileName, 6, "world!") checkFileSize(t, ctx, zoneId, fileName, 12) checkFileByteCount(t, ctx, zoneId, fileName, 'l', 3) } func TestConcurrentAppend(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() zoneId := uuid.NewString() fileName := "t1" err := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{}) if err != nil { t.Fatalf("error creating file: %v", err) } var wg sync.WaitGroup for i := 0; i < 16; i++ { wg.Add(1) go func(n int) { defer wg.Done() const hexChars = "0123456789abcdef" ch := hexChars[n] for j := 0; j < 100; j++ { err := WFS.AppendData(ctx, zoneId, fileName, []byte{ch}) if err != nil { t.Errorf("error appending data (%d): %v", n, err) } if j == 50 { // ignore error here (concurrent flushing) WFS.FlushCache(ctx) } } }(i) } wg.Wait() checkFileSize(t, ctx, zoneId, fileName, 1600) checkFileByteCount(t, ctx, zoneId, fileName, 'a', 100) checkFileByteCount(t, ctx, zoneId, fileName, 'e', 100) WFS.FlushCache(ctx) checkFileSize(t, ctx, zoneId, fileName, 1600) checkFileByteCount(t, ctx, zoneId, fileName, 'a', 100) checkFileByteCount(t, ctx, zoneId, fileName, 'e', 100) } func jsonDeepEqual(d1 any, d2 any) bool { if d1 == nil && d2 == nil { return true } if d1 == nil || d2 == nil { return false } t1 := reflect.TypeOf(d1) t2 := reflect.TypeOf(d2) if t1 != t2 { return false } switch d1.(type) { case float64: return d1.(float64) == d2.(float64) case string: return d1.(string) == d2.(string) case bool: return d1.(bool) == d2.(bool) case []any: a1 := d1.([]any) a2 := d2.([]any) if len(a1) != len(a2) { return false } for i := 0; i < len(a1); i++ { if !jsonDeepEqual(a1[i], a2[i]) { return false } } return true case map[string]any: m1 := d1.(map[string]any) m2 := d2.(map[string]any) if len(m1) != len(m2) { return false } for k, v := range m1 { if !jsonDeepEqual(v, m2[k]) { return false } } return true default: return false } } func TestIJson(t *testing.T) { initDb(t) defer cleanupDb(t) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() zoneId := uuid.NewString() fileName := "ij1" err := WFS.MakeFile(ctx, zoneId, fileName, nil, wshrpc.FileOpts{IJson: true}) if err != nil { t.Fatalf("error creating file: %v", err) } rootSet := ijson.MakeSetCommand(nil, map[string]any{"tag": "div", "class": "root"}) err = WFS.AppendIJson(ctx, zoneId, fileName, rootSet) if err != nil { t.Fatalf("error appending ijson: %v", err) } _, fullData, err := WFS.ReadFile(ctx, zoneId, fileName) if err != nil { t.Fatalf("error reading file: %v", err) } cmds, err := ijson.ParseIJson(fullData) if err != nil { t.Fatalf("error parsing ijson: %v", err) } outData, err := ijson.ApplyCommands(nil, cmds, 0) if err != nil { t.Fatalf("error applying ijson: %v", err) } if !jsonDeepEqual(rootSet["data"], outData) { t.Errorf("data mismatch: expected %v, got %v", rootSet["data"], outData) } childrenAppend := ijson.MakeAppendCommand(ijson.Path{"children"}, map[string]any{"tag": "div", "class": "child"}) err = WFS.AppendIJson(ctx, zoneId, fileName, childrenAppend) if err != nil { t.Fatalf("error appending ijson: %v", err) } _, fullData, err = WFS.ReadFile(ctx, zoneId, fileName) if err != nil { t.Fatalf("error reading file: %v", err) } cmds, err = ijson.ParseIJson(fullData) if err != nil { t.Fatalf("error parsing ijson: %v", err) } if len(cmds) != 2 { t.Fatalf("command count mismatch: expected 2, got %d", len(cmds)) } outData, err = ijson.ApplyCommands(nil, cmds, 0) if err != nil { t.Fatalf("error applying ijson: %v", err) } if !jsonDeepEqual(ijson.M{"tag": "div", "class": "root", "children": ijson.A{ijson.M{"tag": "div", "class": "child"}}}, outData) { t.Errorf("data mismatch: expected %v, got %v", rootSet["data"], outData) } err = WFS.CompactIJson(ctx, zoneId, fileName) if err != nil { t.Fatalf("error compacting ijson: %v", err) } _, fullData, err = WFS.ReadFile(ctx, zoneId, fileName) if err != nil { t.Fatalf("error reading file: %v", err) } cmds, err = ijson.ParseIJson(fullData) if err != nil { t.Fatalf("error parsing ijson: %v", err) } if len(cmds) != 1 { t.Fatalf("command count mismatch: expected 1, got %d", len(cmds)) } outData, err = ijson.ApplyCommands(nil, cmds, 0) if err != nil { t.Fatalf("error applying ijson: %v", err) } if !jsonDeepEqual(ijson.M{"tag": "div", "class": "root", "children": ijson.A{ijson.M{"tag": "div", "class": "child"}}}, outData) { t.Errorf("data mismatch: expected %v, got %v", rootSet["data"], outData) } } ================================================ FILE: pkg/genconn/genconn.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // generic connection code (WSL + SSH) package genconn import ( "context" "fmt" "io" "regexp" "strings" "sync" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/syncbuf" ) type connContextKeyType struct{} var connContextKey connContextKeyType type connData struct { BlockId string } func ContextWithConnData(ctx context.Context, blockId string) context.Context { if blockId == "" { return ctx } return context.WithValue(ctx, connContextKey, &connData{BlockId: blockId}) } func GetConnData(ctx context.Context) *connData { if ctx == nil { return nil } dataPtr := ctx.Value(connContextKey) if dataPtr == nil { return nil } return dataPtr.(*connData) } type CommandSpec struct { Cmd string Env map[string]string Cwd string } type ShellClient interface { MakeProcessController(cmd CommandSpec) (ShellProcessController, error) } type ShellProcessController interface { Start() error Wait() error Kill() // these are not required to be called, if they are not called, the impl will set to discard output StdinPipe() (io.WriteCloser, error) StdoutPipe() (io.Reader, error) StderrPipe() (io.Reader, error) } func RunSimpleCommand(ctx context.Context, client ShellClient, spec CommandSpec) (string, string, error) { proc, err := client.MakeProcessController(spec) if err != nil { return "", "", fmt.Errorf("failed to create process controller: %w", err) } stdout, err := proc.StdoutPipe() if err != nil { return "", "", fmt.Errorf("failed to get stdout pipe: %w", err) } stderr, err := proc.StderrPipe() if err != nil { return "", "", fmt.Errorf("failed to get stderr pipe: %w", err) } if err := proc.Start(); err != nil { return "", "", fmt.Errorf("failed to start process: %w", err) } stdoutBuf := syncbuf.MakeSyncBuffer() stderrBuf := syncbuf.MakeSyncBuffer() var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() io.Copy(stdoutBuf, stdout) }() go func() { defer wg.Done() io.Copy(stderrBuf, stderr) }() runErr := ProcessContextWait(ctx, proc) wg.Wait() return stdoutBuf.String(), stderrBuf.String(), runErr } func ProcessContextWait(ctx context.Context, proc ShellProcessController) error { done := make(chan error, 1) go func() { done <- proc.Wait() }() select { case <-ctx.Done(): proc.Kill() return ctx.Err() case err := <-done: return err } } func MakeStdoutSyncBuffer(proc ShellProcessController) (*syncbuf.SyncBuffer, error) { stdout, err := proc.StdoutPipe() if err != nil { return nil, fmt.Errorf("failed to get stdout pipe: %w", err) } return syncbuf.MakeSyncBufferFromReader(stdout), nil } func MakeStderrSyncBuffer(proc ShellProcessController) (*syncbuf.SyncBuffer, error) { stderr, err := proc.StderrPipe() if err != nil { return nil, fmt.Errorf("failed to get stderr pipe: %w", err) } return syncbuf.MakeSyncBufferFromReader(stderr), nil } func BuildShellCommand(opts CommandSpec) (string, error) { // Build environment variables var envVars strings.Builder for key, value := range opts.Env { if !isValidEnvVarName(key) { return "", fmt.Errorf("invalid environment variable name: %q", key) } envVars.WriteString(fmt.Sprintf("%s=%s ", key, shellutil.HardQuote(value))) } // Build the command shellCmd := opts.Cmd if opts.Cwd != "" { shellCmd = fmt.Sprintf("cd %s && %s", shellutil.HardQuote(opts.Cwd), shellCmd) } // Quote the command for `sh -c` return fmt.Sprintf("sh -c %s", shellutil.HardQuote(envVars.String()+shellCmd)), nil } func isValidEnvVarName(name string) bool { validEnvVarName := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) return validEnvVarName.MatchString(name) } ================================================ FILE: pkg/genconn/ssh-impl.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package genconn import ( "fmt" "io" "log" "sync" "golang.org/x/crypto/ssh" ) var _ ShellClient = (*SSHShellClient)(nil) type SSHShellClient struct { client *ssh.Client } func MakeSSHShellClient(client *ssh.Client) *SSHShellClient { return &SSHShellClient{client: client} } func (c *SSHShellClient) MakeProcessController(cmdSpec CommandSpec) (ShellProcessController, error) { return MakeSSHCmdClient(c.client, cmdSpec) } // SSHProcessController implements ShellCmd for SSH connections type SSHProcessController struct { client *ssh.Client session *ssh.Session lock *sync.Mutex once *sync.Once stdinPiped bool stdoutPiped bool stderrPiped bool waitErr error started bool cmdSpec CommandSpec } // MakeSSHCmdClient creates a new instance of SSHCmdClient func MakeSSHCmdClient(client *ssh.Client, cmdSpec CommandSpec) (*SSHProcessController, error) { log.Printf("SSH-NEWSESSION (cmdclient)\n") session, err := client.NewSession() if err != nil { return nil, fmt.Errorf("failed to create SSH session: %w", err) } return &SSHProcessController{ client: client, lock: &sync.Mutex{}, once: &sync.Once{}, cmdSpec: cmdSpec, session: session, }, nil } // Start begins execution of the command func (s *SSHProcessController) Start() error { s.lock.Lock() defer s.lock.Unlock() if s.started { return fmt.Errorf("command already started") } fullCmd, err := BuildShellCommand(s.cmdSpec) if err != nil { return fmt.Errorf("failed to build shell command: %w", err) } // if stdout/stderr weren't piped, then session.stdout/stderr will be nil // and the library guarantees that the outputs will be attached to io.Discard // if stdin hasn't been piped, then session.stdin will be nil // and the libary guarantees that it will be attached to an empty bytes.Buffer, which will produce an immediate EOF // tl;dr we don't need to worry about hanging beause of long input or explicitly closing stdin if err := s.session.Start(fullCmd); err != nil { return fmt.Errorf("failed to start command: %w", err) } s.started = true return nil } // Wait waits for the command to complete func (s *SSHProcessController) Wait() error { s.once.Do(func() { s.waitErr = s.session.Wait() }) return s.waitErr } // Kill terminates the command func (s *SSHProcessController) Kill() { s.lock.Lock() defer s.lock.Unlock() if s.session != nil { s.session.Close() } } func (s *SSHProcessController) StdinPipe() (io.WriteCloser, error) { s.lock.Lock() defer s.lock.Unlock() if s.started { return nil, fmt.Errorf("command already started") } if s.stdinPiped { return nil, fmt.Errorf("stdin already piped") } s.stdinPiped = true return s.session.StdinPipe() } func (s *SSHProcessController) StdoutPipe() (io.Reader, error) { s.lock.Lock() defer s.lock.Unlock() if s.started { return nil, fmt.Errorf("command already started") } if s.stdoutPiped { return nil, fmt.Errorf("stdout already piped") } s.stdoutPiped = true stdout, err := s.session.StdoutPipe() if err != nil { return nil, err } return stdout, nil } func (s *SSHProcessController) StderrPipe() (io.Reader, error) { s.lock.Lock() defer s.lock.Unlock() if s.started { return nil, fmt.Errorf("command already started") } if s.stderrPiped { return nil, fmt.Errorf("stderr already piped") } s.stderrPiped = true stderr, err := s.session.StderrPipe() if err != nil { return nil, err } return stderr, nil } ================================================ FILE: pkg/genconn/wsl-impl.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package genconn import ( "context" "fmt" "io" "sync" "github.com/wavetermdev/waveterm/pkg/wsl" ) var _ ShellClient = (*WSLShellClient)(nil) type WSLShellClient struct { distro *wsl.Distro } func MakeWSLShellClient(distro *wsl.Distro) *WSLShellClient { return &WSLShellClient{distro: distro} } func (c *WSLShellClient) MakeProcessController(cmdSpec CommandSpec) (ShellProcessController, error) { return MakeWSLProcessController(c.distro, cmdSpec) } type WSLProcessController struct { distro *wsl.Distro cmd *wsl.WslCmd lock *sync.Mutex once *sync.Once stdinPiped bool stdoutPiped bool stderrPiped bool waitErr error started bool cmdSpec CommandSpec } func MakeWSLProcessController(distro *wsl.Distro, cmdSpec CommandSpec) (*WSLProcessController, error) { fullCmd, err := BuildShellCommand(cmdSpec) if err != nil { return nil, fmt.Errorf("failed to build shell command: %w", err) } cmd := distro.WslCommand(context.Background(), fullCmd) if cmd == nil { return nil, fmt.Errorf("failed to create WSL command") } return &WSLProcessController{ distro: distro, cmd: cmd, lock: &sync.Mutex{}, once: &sync.Once{}, cmdSpec: cmdSpec, }, nil } func (w *WSLProcessController) Start() error { w.lock.Lock() defer w.lock.Unlock() if w.started { return fmt.Errorf("command already started") } if err := w.cmd.Start(); err != nil { return fmt.Errorf("failed to start command: %w", err) } w.started = true return nil } func (w *WSLProcessController) Wait() error { w.once.Do(func() { w.waitErr = w.cmd.Wait() }) return w.waitErr } func (w *WSLProcessController) Kill() { w.lock.Lock() defer w.lock.Unlock() if w.cmd == nil { return } process := w.cmd.GetProcess() if process == nil { return } process.Kill() } func (w *WSLProcessController) StdinPipe() (io.WriteCloser, error) { w.lock.Lock() defer w.lock.Unlock() if w.started { return nil, fmt.Errorf("command already started") } if w.stdinPiped { return nil, fmt.Errorf("stdin already piped") } w.stdinPiped = true return w.cmd.StdinPipe() } func (w *WSLProcessController) StdoutPipe() (io.Reader, error) { w.lock.Lock() defer w.lock.Unlock() if w.started { return nil, fmt.Errorf("command already started") } if w.stdoutPiped { return nil, fmt.Errorf("stdout already piped") } w.stdoutPiped = true stdout, err := w.cmd.StdoutPipe() if err != nil { return nil, err } return stdout, nil } func (w *WSLProcessController) StderrPipe() (io.Reader, error) { w.lock.Lock() defer w.lock.Unlock() if w.started { return nil, fmt.Errorf("command already started") } if w.stderrPiped { return nil, fmt.Errorf("stderr already piped") } w.stderrPiped = true stderr, err := w.cmd.StderrPipe() if err != nil { return nil, err } return stderr, nil } ================================================ FILE: pkg/gogen/gogen.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package gogen import ( "fmt" "reflect" "strings" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) func GenerateBoilerplate(buf *strings.Builder, pkgName string, imports []string) { buf.WriteString("// Copyright 2026, Command Line Inc.\n") buf.WriteString("// SPDX-License-Identifier: Apache-2.0\n") buf.WriteString("\n// Generated Code. DO NOT EDIT.\n\n") buf.WriteString(fmt.Sprintf("package %s\n\n", pkgName)) if len(imports) > 0 { buf.WriteString("import (\n") for _, imp := range imports { buf.WriteString(fmt.Sprintf("\t%q\n", imp)) } buf.WriteString(")\n\n") } } func getBeforeColonPart(s string) string { if colonIdx := strings.Index(s, ":"); colonIdx != -1 { return s[:colonIdx] } return s } func GenerateMetaMapConsts(buf *strings.Builder, constPrefix string, rtype reflect.Type, embedded bool) { if !embedded { buf.WriteString("const (\n") } else { buf.WriteString("\n") } var lastBeforeColon = "" isFirst := true for idx := 0; idx < rtype.NumField(); idx++ { field := rtype.Field(idx) if field.PkgPath != "" { continue } if field.Anonymous { var embeddedBuf strings.Builder GenerateMetaMapConsts(&embeddedBuf, constPrefix, field.Type, true) buf.WriteString(embeddedBuf.String()) continue } fieldName := field.Name jsonTag := utilfn.GetJsonTag(field) if jsonTag == "" { jsonTag = fieldName } beforeColon := getBeforeColonPart(jsonTag) if beforeColon != lastBeforeColon { if !isFirst { buf.WriteString("\n") } lastBeforeColon = beforeColon } cname := constPrefix + fieldName buf.WriteString(fmt.Sprintf("\t%-40s = %q\n", cname, jsonTag)) isFirst = false } if !embedded { buf.WriteString(")\n") } } func GenMethod_Call(buf *strings.Builder, methodDecl *wshrpc.WshRpcMethodDecl) { fmt.Fprintf(buf, "// command %q, wshserver.%s\n", methodDecl.Command, methodDecl.MethodName) dataType, dataVarName := getWshMethodDataParamsAndExpr(methodDecl) returnType := "error" respName := "_" tParamVal := "any" if methodDecl.DefaultResponseDataType != nil { returnType = "(" + methodDecl.DefaultResponseDataType.String() + ", error)" respName = "resp" tParamVal = methodDecl.DefaultResponseDataType.String() } fmt.Fprintf(buf, "func %s(w *wshutil.WshRpc%s, opts *wshrpc.RpcOpts) %s {\n", methodDecl.MethodName, dataType, returnType) fmt.Fprintf(buf, "\t%s, err := sendRpcRequestCallHelper[%s](w, %q, %s, opts)\n", respName, tParamVal, methodDecl.Command, dataVarName) if methodDecl.DefaultResponseDataType != nil { fmt.Fprintf(buf, "\treturn resp, err\n") } else { fmt.Fprintf(buf, "\treturn err\n") } fmt.Fprintf(buf, "}\n\n") } func GenMethod_ResponseStream(buf *strings.Builder, methodDecl *wshrpc.WshRpcMethodDecl) { fmt.Fprintf(buf, "// command %q, wshserver.%s\n", methodDecl.Command, methodDecl.MethodName) dataType, dataVarName := getWshMethodDataParamsAndExpr(methodDecl) respType := "any" if methodDecl.DefaultResponseDataType != nil { respType = methodDecl.DefaultResponseDataType.String() } fmt.Fprintf(buf, "func %s(w *wshutil.WshRpc%s, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[%s] {\n", methodDecl.MethodName, dataType, respType) fmt.Fprintf(buf, "\treturn sendRpcRequestResponseStreamHelper[%s](w, %q, %s, opts)\n", respType, methodDecl.Command, dataVarName) fmt.Fprintf(buf, "}\n\n") } func getWshMethodDataParamsAndExpr(methodDecl *wshrpc.WshRpcMethodDecl) (string, string) { dataTypes := methodDecl.GetCommandDataTypes() if len(dataTypes) == 0 { return "", "nil" } if len(dataTypes) == 1 { return ", data " + dataTypes[0].String(), "data" } var paramBuilder strings.Builder var argBuilder strings.Builder for idx, dataType := range dataTypes { argName := fmt.Sprintf("arg%d", idx+1) paramBuilder.WriteString(", ") paramBuilder.WriteString(argName) paramBuilder.WriteString(" ") paramBuilder.WriteString(dataType.String()) if idx > 0 { argBuilder.WriteString(", ") } argBuilder.WriteString(argName) } return paramBuilder.String(), fmt.Sprintf("wshrpc.MultiArg{Args: []any{%s}}", argBuilder.String()) } ================================================ FILE: pkg/gogen/gogen_test.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package gogen import ( "reflect" "strings" "testing" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) func TestGetWshMethodDataParamsAndExpr_MultiArg(t *testing.T) { methodDecl := &wshrpc.WshRpcMethodDecl{ CommandDataTypes: []reflect.Type{ reflect.TypeOf(""), reflect.TypeOf(0), }, } params, expr := getWshMethodDataParamsAndExpr(methodDecl) if params != ", arg1 string, arg2 int" { t.Fatalf("unexpected params: %q", params) } if expr != "wshrpc.MultiArg{Args: []any{arg1, arg2}}" { t.Fatalf("unexpected expr: %q", expr) } } func TestGenMethodCall_MultiArg(t *testing.T) { methodDecl := &wshrpc.WshRpcMethodDecl{ Command: "test", CommandType: wshrpc.RpcType_Call, MethodName: "TestCommand", CommandDataTypes: []reflect.Type{reflect.TypeOf(""), reflect.TypeOf(0)}, } var sb strings.Builder GenMethod_Call(&sb, methodDecl) out := sb.String() if !strings.Contains(out, "func TestCommand(w *wshutil.WshRpc, arg1 string, arg2 int, opts *wshrpc.RpcOpts) error {") { t.Fatalf("generated method missing multi-arg signature:\n%s", out) } if !strings.Contains(out, "sendRpcRequestCallHelper[any](w, \"test\", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts)") { t.Fatalf("generated method missing MultiArg payload:\n%s", out) } } ================================================ FILE: pkg/ijson/ijson.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // implements incremental json format package ijson import ( "bytes" "encoding/json" "fmt" "regexp" "strconv" "strings" ) // ijson values are built out of standard go building blocks: // string, float64, bool, nil, []any, map[string]any // paths are arrays of strings and ints const ( SetCommandStr = "set" DelCommandStr = "del" AppendCommandStr = "append" ) type Command = map[string]any type Path = []any type M = map[string]any type A = []any // instead of defining structs for commands, we just define a command shape // set: type, path, value // del: type, path // arrayappend: type, path, value func MakeSetCommand(path Path, value any) Command { return Command{ "type": SetCommandStr, "path": path, "data": value, } } func MakeDelCommand(path Path) Command { return Command{ "type": DelCommandStr, "path": path, } } func MakeAppendCommand(path Path, value any) Command { return Command{ "type": AppendCommandStr, "path": path, "data": value, } } var pathPartKeyRe = regexp.MustCompile(`^[a-zA-Z0-9:_#-]+`) func ParseSimplePath(input string) ([]any, error) { var path []any // Scan the input string character by character for i := 0; i < len(input); { if input[i] == '[' { // Handle the index end := strings.Index(input[i:], "]") if end == -1 { return nil, fmt.Errorf("unmatched bracket at position %d", i) } index, err := strconv.Atoi(input[i+1 : i+end]) if err != nil { return nil, fmt.Errorf("invalid index at position %d: %v", i, err) } path = append(path, index) i += end + 1 } else { // Handle the key j := i for j < len(input) && input[j] != '.' && input[j] != '[' { j++ } key := input[i:j] if !pathPartKeyRe.MatchString(key) { return nil, fmt.Errorf("invalid key at position %d: %s", i, key) } path = append(path, key) i = j } if i < len(input) && input[i] == '.' { i++ } } return path, nil } type PathError struct { Err string } func (e PathError) Error() string { return "PathError: " + e.Err } func MakePathTypeError(path Path, index int) error { return PathError{fmt.Sprintf("invalid path element type:%T at index:%d (%s)", path[index], index, FormatPath(path))} } func MakePathError(errStr string, path Path, index int) error { return PathError{fmt.Sprintf("%s at index:%d (%s)", errStr, index, FormatPath(path))} } type SetTypeError struct { Err string } func (e SetTypeError) Error() string { return "SetTypeError: " + e.Err } func MakeSetTypeError(errStr string, path Path, index int) error { return SetTypeError{fmt.Sprintf("%s at index:%d (%s)", errStr, index, FormatPath(path))} } type BudgetError struct { Err string } func (e BudgetError) Error() string { return "BudgetError: " + e.Err } func MakeBudgetError(errStr string, path Path, index int) error { return BudgetError{fmt.Sprintf("%s at index:%d (%s)", errStr, index, FormatPath(path))} } var simplePathStrRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) func FormatPath(path Path) string { if len(path) == 0 { return "$" } var buf bytes.Buffer buf.WriteByte('$') for _, elem := range path { switch elem := elem.(type) { case string: if simplePathStrRe.MatchString(elem) { buf.WriteByte('.') buf.WriteString(elem) } else { buf.WriteByte('[') buf.WriteString(strconv.Quote(elem)) buf.WriteByte(']') } case int: buf.WriteByte('[') buf.WriteString(strconv.Itoa(elem)) buf.WriteByte(']') default: // a placeholder for a bad value buf.WriteString(".*") } } return buf.String() } type pathWithPos struct { Path Path Index int } func (pp pathWithPos) isLast() bool { return pp.Index == len(pp.Path)-1 } func GetPath(data any, path []any) (any, error) { return getPathInternal(data, pathWithPos{Path: path, Index: 0}) } func getPathInternal(data any, pp pathWithPos) (any, error) { if data == nil { return nil, nil } if pp.Index >= len(pp.Path) { return data, nil } pathElemAny := pp.Path[pp.Index] switch pathElem := pathElemAny.(type) { case string: mapVal, ok := data.(map[string]any) if !ok { return nil, nil } return getPathInternal(mapVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1}) case int: if pathElem < 0 { return nil, MakePathError("negative index", pp.Path, pp.Index) } arrVal, ok := data.([]any) if !ok { return nil, nil } if pathElem >= len(arrVal) { return nil, nil } return getPathInternal(arrVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1}) default: return nil, MakePathTypeError(pp.Path, pp.Index) } } type CombiningFunc func(curValue any, newValue any, pp pathWithPos, opts SetPathOpts) (any, error) type SetPathOpts struct { Budget int // Budget 0 is unlimited (to set a 0 value, use -1) Force bool Remove bool CombineFn CombiningFunc } func SetPathNoErr(data any, path Path, value any, opts *SetPathOpts) any { ret, _ := SetPath(data, path, value, opts) return ret } func SetPath(data any, path Path, value any, opts *SetPathOpts) (any, error) { if opts == nil { opts = &SetPathOpts{} } if opts.Remove && opts.CombineFn != nil { return nil, fmt.Errorf("SetPath: Remove and CombineFn are mutually exclusive") } if opts.Remove && value != nil { return nil, fmt.Errorf("SetPath: Remove and value are mutually exclusive") } return setPathInternal(data, pathWithPos{Path: path, Index: 0}, value, *opts) } func checkAndModifyBudget(opts *SetPathOpts, pp pathWithPos, cost int) bool { if opts.Budget == 0 { return true } opts.Budget -= cost if opts.Budget < 0 { return false } if opts.Budget == 0 { // 0 is weird since it means unlimited, so we set it to -1 to fail the next operation opts.Budget = -1 } return true } func CombineFn_ArrayAppend(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) { if !checkAndModifyBudget(&opts, pp, 1) { return nil, MakeBudgetError("trying to append to array", pp.Path, pp.Index) } if data == nil { data = make([]any, 0) } arrVal, ok := data.([]any) if !ok && !opts.Force { return nil, MakeSetTypeError(fmt.Sprintf("expected array, but got %T", data), pp.Path, pp.Index) } if !ok { arrVal = make([]any, 0) } arrVal = append(arrVal, value) return arrVal, nil } func CombineFn_SetUnless(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) { if data != nil { return data, nil } return value, nil } func CombineFn_Max(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) { valueFloat, ok := value.(float64) if !ok { return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", value), pp.Path, pp.Index) } if data == nil { return value, nil } dataFloat, ok := data.(float64) if !ok && !opts.Force { return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", data), pp.Path, pp.Index) } if !ok { return value, nil } if dataFloat > valueFloat { return data, nil } return value, nil } func CombineFn_Min(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) { valueFloat, ok := value.(float64) if !ok { return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", value), pp.Path, pp.Index) } if data == nil { return value, nil } dataFloat, ok := data.(float64) if !ok && !opts.Force { return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", data), pp.Path, pp.Index) } if !ok { return value, nil } if dataFloat < valueFloat { return data, nil } return value, nil } func CombineFn_Inc(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) { valueFloat, ok := value.(float64) if !ok { return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", value), pp.Path, pp.Index) } if data == nil { return value, nil } dataFloat, ok := data.(float64) if !ok && !opts.Force { return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", data), pp.Path, pp.Index) } if !ok { return value, nil } return dataFloat + valueFloat, nil } // force will clobber existing values that don't conform to path // so SetPath(5, ["a"], 6 true) would return {"a": 6} func setPathInternal(data any, pp pathWithPos, value any, opts SetPathOpts) (any, error) { if pp.Index >= len(pp.Path) { if opts.CombineFn != nil { return opts.CombineFn(data, value, pp, opts) } return value, nil } pathElemAny := pp.Path[pp.Index] switch pathElem := pathElemAny.(type) { case string: if data == nil { if opts.Remove { return nil, nil } data = make(map[string]any) } mapVal, ok := data.(map[string]any) if !ok && !opts.Force { return nil, MakeSetTypeError(fmt.Sprintf("expected map, but got %T", data), pp.Path, pp.Index) } if !ok { mapVal = make(map[string]any) } if opts.Remove && pp.isLast() { delete(mapVal, pathElem) if len(mapVal) == 0 { return nil, nil } return mapVal, nil } if _, ok := mapVal[pathElem]; !ok { if opts.Remove { return mapVal, nil } if !checkAndModifyBudget(&opts, pp, 1) { return nil, MakeBudgetError("trying to allocate map entry", pp.Path, pp.Index) } } newVal, err := setPathInternal(mapVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1}, value, opts) if opts.Remove && newVal == nil { delete(mapVal, pathElem) if len(mapVal) == 0 { return nil, nil } return mapVal, nil } mapVal[pathElem] = newVal return mapVal, err case int: if pathElem < 0 { return nil, MakePathError("negative index", pp.Path, pp.Index) } if data == nil { if opts.Remove { return nil, nil } if !checkAndModifyBudget(&opts, pp, pathElem+1) { return nil, MakeBudgetError(fmt.Sprintf("trying to allocate array with %d elements", pathElem+1), pp.Path, pp.Index) } data = make([]any, pathElem+1) } arrVal, ok := data.([]any) if !ok && !opts.Force { return nil, MakeSetTypeError(fmt.Sprintf("expected array, but got %T", data), pp.Path, pp.Index) } if !ok { if opts.Remove { return nil, nil } if !checkAndModifyBudget(&opts, pp, pathElem+1) { return nil, MakeBudgetError(fmt.Sprintf("trying to allocate array with %d elements", pathElem+1), pp.Path, pp.Index) } arrVal = make([]any, pathElem+1) } if opts.Remove && pp.isLast() { if pathElem == len(arrVal)-1 { arrVal = arrVal[:pathElem] if len(arrVal) == 0 { return nil, nil } return arrVal, nil } arrVal[pathElem] = nil return arrVal, nil } entriesToAdd := pathElem + 1 - len(arrVal) if opts.Remove && entriesToAdd > 0 { return nil, nil } if !checkAndModifyBudget(&opts, pp, entriesToAdd) { return nil, MakeBudgetError(fmt.Sprintf("trying to add %d elements to array", entriesToAdd), pp.Path, pp.Index) } for len(arrVal) <= pathElem { arrVal = append(arrVal, nil) } newVal, err := setPathInternal(arrVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1}, value, opts) if opts.Remove && newVal == nil && pathElem == len(arrVal)-1 { arrVal = arrVal[:pathElem] if len(arrVal) == 0 { return nil, nil } return arrVal, nil } arrVal[pathElem] = newVal return arrVal, err default: return nil, PathError{fmt.Sprintf("invalid path element type %T", pathElem)} } } func NormalizeNumbers(v any) any { switch v := v.(type) { case int: return float64(v) case float32: return float64(v) case int8: return float64(v) case int16: return float64(v) case int32: return float64(v) case int64: return float64(v) case uint: return float64(v) case uint8: return float64(v) case uint16: return float64(v) case uint32: return float64(v) case uint64: return float64(v) case []any: for i, elem := range v { v[i] = NormalizeNumbers(elem) } case map[string]any: for k, elem := range v { v[k] = NormalizeNumbers(elem) } } return v } func DeepEqual(v1 any, v2 any) bool { if v1 == nil && v2 == nil { return true } if v1 == nil || v2 == nil { return false } switch v1 := v1.(type) { case bool: v2, ok := v2.(bool) return ok && v1 == v2 case float64: v2, ok := v2.(float64) return ok && v1 == v2 case string: v2, ok := v2.(string) return ok && v1 == v2 case []any: v2, ok := v2.([]any) if !ok || len(v1) != len(v2) { return false } for i := range v1 { if !DeepEqual(v1[i], v2[i]) { return false } } return true case map[string]any: v2, ok := v2.(map[string]any) if !ok || len(v1) != len(v2) { return false } for k, v := range v1 { if !DeepEqual(v, v2[k]) { return false } } return true default: // invalid data type, so just return false return false } } func getCommandType(command Command) string { typeVal, ok := command["type"] if !ok { return "" } typeStr, ok := typeVal.(string) if !ok { return "" } return typeStr } func getCommandPath(command Command) []any { pathVal, ok := command["path"] if !ok { return nil } path, ok := pathVal.([]any) if !ok { return nil } return path } func ValidatePath(path any) error { if path == nil { // nil path is allowed (sets the root) return nil } pathArr, ok := path.([]any) if !ok { return fmt.Errorf("path is not an array") } for idx, elem := range pathArr { switch elem.(type) { case string, int: continue default: return fmt.Errorf("path element %d is not a string or int", idx) } } return nil } func ValidateAndMarshalCommand(command Command) ([]byte, error) { cmdType := getCommandType(command) if cmdType != SetCommandStr && cmdType != DelCommandStr && cmdType != AppendCommandStr { return nil, fmt.Errorf("unknown ijson command type %q", cmdType) } path := getCommandPath(command) err := ValidatePath(path) if err != nil { return nil, err } barr, err := json.Marshal(command) if err != nil { return nil, fmt.Errorf("error marshalling ijson command to json: %w", err) } return barr, nil } func ApplyCommand(data any, command Command, budget int) (any, error) { commandType := getCommandType(command) if commandType == "" { return nil, fmt.Errorf("ApplyCommand: missing type field") } switch commandType { case SetCommandStr: path := getCommandPath(command) return SetPath(data, path, command["data"], &SetPathOpts{Budget: budget}) case DelCommandStr: path := getCommandPath(command) return SetPath(data, path, nil, &SetPathOpts{Remove: true, Budget: budget}) case AppendCommandStr: path := getCommandPath(command) return SetPath(data, path, command["data"], &SetPathOpts{CombineFn: CombineFn_ArrayAppend, Budget: budget}) default: return nil, fmt.Errorf("ApplyCommand: unknown command type %q", commandType) } } func ApplyCommands(data any, commands []Command, budget int) (any, error) { for _, command := range commands { var err error data, err = ApplyCommand(data, command, budget) if err != nil { return nil, err } } return data, nil } func CompactIJson(fullData []byte, budget int) ([]byte, error) { var newData any for len(fullData) > 0 { nlIdx := bytes.IndexByte(fullData, '\n') var cmdData []byte if nlIdx == -1 { cmdData = fullData fullData = nil } else { cmdData = fullData[:nlIdx] fullData = fullData[nlIdx+1:] } var cmdMap Command err := json.Unmarshal(cmdData, &cmdMap) if err != nil { return nil, fmt.Errorf("error unmarshalling ijson command: %w", err) } newData, err = ApplyCommand(newData, cmdMap, budget) if err != nil { return nil, fmt.Errorf("error applying ijson command: %w", err) } } newRootCmd := MakeSetCommand(nil, newData) return json.Marshal(newRootCmd) } // returns a list of commands func ParseIJson(fullData []byte) ([]Command, error) { var commands []Command for len(fullData) > 0 { nlIdx := bytes.IndexByte(fullData, '\n') var cmdData []byte if nlIdx == -1 { cmdData = fullData fullData = nil } else { cmdData = fullData[:nlIdx] fullData = fullData[nlIdx+1:] } var cmdMap Command err := json.Unmarshal(cmdData, &cmdMap) if err != nil { return nil, fmt.Errorf("error unmarshalling ijson command: %w", err) } commands = append(commands, cmdMap) } return commands, nil } ================================================ FILE: pkg/ijson/ijson_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package ijson import "testing" func TestDeepEqual(t *testing.T) { if !DeepEqual(float64(1), float64(1)) { t.Errorf("DeepEqual(1, 1) should be true") } if DeepEqual(float64(1), float64(2)) { t.Errorf("DeepEqual(1, 2) should be false") } if !DeepEqual([]any{"a", 2.8, true, map[string]any{"c": 1.1}}, []any{"a", 2.8, true, map[string]any{"c": 1.1}}) { t.Errorf("DeepEqual complex should be true") } } func TestGetPath(t *testing.T) { data := []any{"a", 2.8, true, map[string]any{"c": 1.1}} rtn, err := GetPath(data, []any{0}) if err != nil { t.Errorf("GetPath failed: %v", err) } if rtn != "a" { t.Errorf("GetPath failed: %v", rtn) } rtn, err = GetPath(data, []any{50}) if err != nil { t.Errorf("GetPath failed: %v", err) } if rtn != nil { t.Errorf("GetPath failed: %v", rtn) } rtn, err = GetPath(data, []any{3, "c"}) if err != nil { t.Errorf("GetPath failed: %v", err) } if rtn != 1.1 { t.Errorf("GetPath failed: %v", rtn) } } func makeValue() any { return []any{"a", 2.8, true, map[string]any{"c": 1.1}} } func TestSetPath(t *testing.T) { rtn, err := SetPath(makeValue(), []any{0}, "b", nil) if err != nil { t.Errorf("SetPath failed: %v", err) } if rtn.([]any)[0] != "b" { t.Errorf("SetPath failed: %v", rtn) } rtn, err = SetPath(makeValue(), []any{10}, "b", nil) if err != nil { t.Errorf("SetPath failed: %v", err) } if len(rtn.([]any)) != 11 { t.Errorf("SetPath failed: %v", rtn) } rtn, _ = GetPath(rtn, []any{10}) if rtn != "b" { t.Errorf("SetPath failed: %v", rtn) } _, err = SetPath(makeValue(), []any{"a"}, "b", nil) if err == nil { t.Errorf("SetPath should have failed") } rtn, err = SetPath(makeValue(), []any{"a"}, "b", &SetPathOpts{Force: true}) if err != nil { t.Errorf("SetPath failed: %v", err) } if !DeepEqual(rtn, map[string]any{"a": "b"}) { t.Errorf("SetPath failed: %v", rtn) } rtn, err = SetPath(makeValue(), nil, "c", &SetPathOpts{CombineFn: CombineFn_ArrayAppend}) if err != nil { t.Errorf("SetPath failed: %v", err) } if !DeepEqual(rtn, []any{"a", 2.8, true, map[string]any{"c": 1.1}, "c"}) { t.Errorf("SetPath failed: %v", rtn) } _, err = SetPath(makeValue(), nil, "c", &SetPathOpts{CombineFn: CombineFn_ArrayAppend, Budget: -1}) if err == nil { t.Errorf("SetPath should have failed") } rtn, err = SetPath(makeValue(), []any{5000}, "c", nil) if err != nil { t.Errorf("SetPath failed: %v", err) } if len(rtn.([]any)) != 5001 { t.Errorf("SetPath failed: %v", rtn) } _, err = SetPath(makeValue(), []any{5000}, "c", &SetPathOpts{Budget: 1000}) if err == nil { t.Errorf("SetPath should have failed") } rtn, err = SetPath(makeValue(), []any{3, "c"}, nil, &SetPathOpts{Remove: true}) if err != nil { t.Errorf("SetPath failed: %v", err) } if !DeepEqual(rtn, []any{"a", 2.8, true}) { t.Errorf("SetPath failed: %v", rtn) } rtn, _ = SetPath(makeValue(), []any{3}, nil, &SetPathOpts{Remove: true}) rtn, _ = SetPath(rtn, []any{2}, nil, &SetPathOpts{Remove: true}) rtn, _ = SetPath(rtn, []any{1}, nil, &SetPathOpts{Remove: true}) rtn, _ = SetPath(rtn, []any{0}, nil, &SetPathOpts{Remove: true}) if rtn != nil { t.Errorf("SetPath failed: %v", rtn) } rtn, err = SetPath(makeValue(), []any{3, "d"}, 2.2, nil) if err != nil { t.Errorf("SetPath failed: %v", err) } if !DeepEqual(rtn, []any{"a", 2.8, true, map[string]any{"c": 1.1, "d": 2.2}}) { t.Errorf("SetPath failed: %v", rtn) } rtn, err = SetPath(makeValue(), []any{1}, 2.2, &SetPathOpts{CombineFn: CombineFn_Inc}) if err != nil { t.Errorf("SetPath failed: %v", err) } if !DeepEqual(rtn, []any{"a", 5.0, true, map[string]any{"c": 1.1}}) { t.Errorf("SetPath failed: %v", rtn) } rtn, err = SetPath(makeValue(), []any{1}, 500.0, &SetPathOpts{CombineFn: CombineFn_Min}) if err != nil { t.Errorf("SetPath failed: %v", err) } if rtn.([]any)[1] != 2.8 { t.Errorf("SetPath failed: %v", rtn) } rtn, err = SetPath(makeValue(), []any{1}, 500.0, &SetPathOpts{CombineFn: CombineFn_Max}) if err != nil { t.Errorf("SetPath failed: %v", err) } if rtn.([]any)[1] != 500.0 { t.Errorf("SetPath failed: %v", rtn) } rtn, err = SetPath(makeValue(), []any{1}, 500.0, &SetPathOpts{CombineFn: CombineFn_SetUnless}) if err != nil { t.Errorf("SetPath failed: %v", err) } if rtn.([]any)[1] != 2.8 { t.Errorf("SetPath failed: %v", rtn) } rtn, err = SetPath(makeValue(), []any{8}, 500.0, &SetPathOpts{CombineFn: CombineFn_SetUnless}) if err != nil { t.Errorf("SetPath failed: %v", err) } if rtn.([]any)[8] != 500.0 { t.Errorf("SetPath failed: %v", rtn) } } ================================================ FILE: pkg/jobcontroller/jobcontroller.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package jobcontroller import ( "context" "encoding/base64" "fmt" "io" "io/fs" "log" "strings" "sync" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/streamclient" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/ds" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wstore" "golang.org/x/sync/singleflight" ) const DefaultTimeout = 2 * time.Second const ( JobManagerStatus_Init = "init" JobManagerStatus_Running = "running" JobManagerStatus_Done = "done" ) const ( JobDoneReason_StartupError = "startuperror" JobDoneReason_Gone = "gone" JobDoneReason_Terminated = "terminated" ) const ( JobConnStatus_Disconnected = "disconnected" JobConnStatus_Connecting = "connecting" JobConnStatus_Connected = "connected" ) const ( JobKind_Shell = "shell" JobKind_Task = "task" ) const DefaultStreamRwnd = 64 * 1024 const MetaKey_TotalGap = "totalgap" const JobOutputFileName = "term" const AutoReconnectDelay = 1 * time.Second const AutoReconnectCooldown = 30 * time.Second type connState struct { actual bool processed bool reconciling bool } type connStateManager struct { sync.Mutex m map[string]*connState reconcileCh chan struct{} } type jobState struct { stateLock sync.Mutex isConnecting bool connectedStatus string } var ( jobConnStates = make(map[string]string) jobControllerLock sync.Mutex blockJobStatusVersion utilds.VersionTs connStates = &connStateManager{ m: make(map[string]*connState), reconcileCh: make(chan struct{}, 1), } jobStreamIds = ds.MakeSyncMap[string]() jobTerminationMessageWritten = ds.MakeSyncMap[bool]() lastAutoReconnectAttempt = ds.MakeSyncMap[int64]() reconnectGroup singleflight.Group terminateJobManagerGroup singleflight.Group ) func InitJobController() { go connReconcileWorker() go jobPruningWorker() rpcClient := wshclient.GetBareRpcClient() rpcClient.EventListener.On(wps.Event_RouteUp, handleRouteUpEvent) rpcClient.EventListener.On(wps.Event_RouteDown, handleRouteDownEvent) rpcClient.EventListener.On(wps.Event_ConnChange, handleConnChangeEvent) rpcClient.EventListener.On(wps.Event_BlockClose, handleBlockCloseEvent) wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{ Event: wps.Event_RouteUp, AllScopes: true, }, nil) wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{ Event: wps.Event_RouteDown, AllScopes: true, }, nil) wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{ Event: wps.Event_ConnChange, AllScopes: true, }, nil) wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{ Event: wps.Event_BlockClose, AllScopes: true, }, nil) } func isJobManagerRunning(job *waveobj.Job) bool { return job.JobManagerStatus == JobManagerStatus_Running } func GetJobManagerStatus(ctx context.Context, jobId string) (string, error) { job, err := wstore.DBGet[*waveobj.Job](ctx, jobId) if err != nil { return "", fmt.Errorf("failed to get job: %w", err) } if job == nil { return JobManagerStatus_Done, nil } return job.JobManagerStatus, nil } func GetAllJobManagerStatus(ctx context.Context) ([]*wshrpc.JobManagerStatusUpdate, error) { allJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) if err != nil { return nil, fmt.Errorf("failed to get jobs: %w", err) } var statuses []*wshrpc.JobManagerStatusUpdate for _, job := range allJobs { statuses = append(statuses, &wshrpc.JobManagerStatusUpdate{ JobId: job.OID, JobManagerStatus: job.JobManagerStatus, }) } return statuses, nil } func GetBlockJobStatus(ctx context.Context, blockId string) (*wshrpc.BlockJobStatusData, error) { block, err := wstore.DBGet[*waveobj.Block](ctx, blockId) if err != nil { return nil, fmt.Errorf("failed to get block: %w", err) } if block == nil { return nil, fmt.Errorf("block not found: %s", blockId) } data := &wshrpc.BlockJobStatusData{ BlockId: blockId, VersionTs: blockJobStatusVersion.GetVersionTs(), } if block.JobId == "" { return data, nil } job, err := wstore.DBGet[*waveobj.Job](ctx, block.JobId) if err != nil { return nil, fmt.Errorf("failed to get job: %w", err) } if job == nil { return data, nil } data.JobId = job.OID data.DoneReason = job.JobManagerDoneReason data.StartupError = job.JobManagerStartupError data.CmdExitTs = job.CmdExitTs data.CmdExitCode = job.CmdExitCode data.CmdExitSignal = job.CmdExitSignal if job.JobManagerStatus == JobManagerStatus_Init { data.Status = "init" } else if job.JobManagerStatus == JobManagerStatus_Done { data.Status = "done" } else if job.JobManagerStatus == JobManagerStatus_Running { connStatus := GetJobConnStatus(job.OID) if connStatus == JobConnStatus_Connected { data.Status = "connected" } else { data.Status = "disconnected" } } return data, nil } func SendBlockJobStatusEvent(ctx context.Context, blockId string) { data, err := GetBlockJobStatus(ctx, blockId) if err != nil { log.Printf("[block:%s] error getting block job status: %v", blockId, err) return } wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_BlockJobStatus, Scopes: []string{fmt.Sprintf("block:%s", blockId)}, Data: data, }) } func sendBlockJobStatusEventByJob(ctx context.Context, job *waveobj.Job) { if job == nil || job.AttachedBlockId == "" { return } SendBlockJobStatusEvent(ctx, job.AttachedBlockId) } func connReconcileWorker() { defer func() { panichandler.PanicHandler("jobcontroller:connReconcileWorker", recover()) }() for range connStates.reconcileCh { reconcileAllConns() } } func reconcileAllConns() { connStates.Lock() defer connStates.Unlock() for connName, cs := range connStates.m { if cs.reconciling || cs.actual == cs.processed { continue } cs.reconciling = true actual := cs.actual go reconcileConn(connName, actual) } } func reconcileConn(connName string, targetState bool) { defer func() { panichandler.PanicHandler("jobcontroller:reconcileConn", recover()) }() if targetState { onConnectionUp(connName) } else { onConnectionDown(connName) } connStates.Lock() defer connStates.Unlock() if cs, exists := connStates.m[connName]; exists { cs.processed = targetState cs.reconciling = false } select { case connStates.reconcileCh <- struct{}{}: default: } } func getMetaInt64(meta wshrpc.FileMeta, key string) int64 { val, ok := meta[key] if !ok { return 0 } if intVal, ok := val.(int64); ok { return intVal } if floatVal, ok := val.(float64); ok { return int64(floatVal) } return 0 } func jobPruningWorker() { defer func() { panichandler.PanicHandler("jobcontroller:jobPruningWorker", recover()) }() ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() var previousCandidates []string for range ticker.C { previousCandidates = pruneUnusedJobs(previousCandidates) } } func pruneUnusedJobs(previousCandidates []string) []string { ctx, cancelFn := context.WithTimeout(context.Background(), 30*time.Second) defer cancelFn() allJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) if err != nil { log.Printf("[jobpruner] error getting all jobs: %v", err) return previousCandidates } var currentCandidates []string for _, job := range allJobs { if job.JobManagerStatus == JobManagerStatus_Done && job.AttachedBlockId == "" { currentCandidates = append(currentCandidates, job.OID) } } jobsToDelete := utilfn.StrSetIntersection(previousCandidates, currentCandidates) if len(previousCandidates) > 0 || len(currentCandidates) > 0 { log.Printf("[jobpruner] prev=%d current=%d deleting=%d", len(previousCandidates), len(currentCandidates), len(jobsToDelete)) } for _, jobId := range jobsToDelete { err := DeleteJob(ctx, jobId) if err != nil { log.Printf("[jobpruner] error deleting job %s: %v", jobId, err) } } return currentCandidates } func handleRouteUpEvent(event *wps.WaveEvent) { handleRouteEvent(event, JobConnStatus_Connected) } func handleRouteDownEvent(event *wps.WaveEvent) { handleRouteEvent(event, JobConnStatus_Disconnected) } func handleRouteEvent(event *wps.WaveEvent, newStatus string) { ctx := context.Background() for _, scope := range event.Scopes { if strings.HasPrefix(scope, "job:") { jobId := strings.TrimPrefix(scope, "job:") SetJobConnStatus(jobId, newStatus) log.Printf("[job:%s] connection status changed to %s", jobId, newStatus) job, err := wstore.DBGet[*waveobj.Job](ctx, jobId) if err != nil { log.Printf("[job:%s] error getting job for status event: %v", jobId, err) continue } sendBlockJobStatusEventByJob(ctx, job) if newStatus == JobConnStatus_Disconnected && job != nil && isJobManagerRunning(job) { if shouldAttemptAutoReconnect(jobId) { go attemptAutoReconnect(jobId, job.Connection) } } } } } func shouldAttemptAutoReconnect(jobId string) bool { now := time.Now().Unix() lastAttempt, exists := lastAutoReconnectAttempt.GetEx(jobId) if !exists { lastAutoReconnectAttempt.Set(jobId, now) return true } timeSinceLastAttempt := time.Duration(now-lastAttempt) * time.Second if timeSinceLastAttempt >= AutoReconnectCooldown { lastAutoReconnectAttempt.Set(jobId, now) return true } return false } func attemptAutoReconnect(jobId string, connName string) { defer func() { panichandler.PanicHandler("jobcontroller:attemptAutoReconnect", recover()) }() time.Sleep(AutoReconnectDelay) isConnected, err := conncontroller.IsConnected(connName) if err != nil || !isConnected { log.Printf("[job:%s] connection %s is down, skipping auto-reconnect", jobId, connName) return } log.Printf("[job:%s] connection %s still up after route down, attempting auto-reconnect to determine job manager status", jobId, connName) ctx, cancelFn := context.WithTimeout(context.Background(), 10*time.Second) defer cancelFn() err = ReconnectJob(ctx, jobId, nil) if err != nil { log.Printf("[job:%s] auto-reconnect failed: %v", jobId, err) } else { log.Printf("[job:%s] auto-reconnect succeeded", jobId) } } func handleConnChangeEvent(event *wps.WaveEvent) { var connStatus wshrpc.ConnStatus err := utilfn.ReUnmarshal(&connStatus, event.Data) if err != nil { log.Printf("[connchange] error unmarshaling ConnStatus: %v", err) return } var connName string for _, scope := range event.Scopes { if strings.HasPrefix(scope, "connection:") { connName = strings.TrimPrefix(scope, "connection:") break } } if connName == "" { return } connStates.Lock() cs, exists := connStates.m[connName] if !exists { cs = &connState{actual: false, processed: false, reconciling: false} connStates.m[connName] = cs } cs.actual = connStatus.Connected connStates.Unlock() select { case connStates.reconcileCh <- struct{}{}: default: } } func handleBlockCloseEvent(event *wps.WaveEvent) { ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() blockId, ok := event.Data.(string) if !ok { log.Printf("[blockclose] invalid event data type") return } jobIds, err := wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) ([]string, error) { query := `SELECT oid FROM db_job WHERE json_extract(data, '$.attachedblockid') = ?` jobIds := tx.SelectStrings(query, blockId) return jobIds, nil }) if err != nil { log.Printf("[block:%s] error looking up jobids: %v", blockId, err) return } if len(jobIds) == 0 { return } for _, jobId := range jobIds { TerminateAndDetachJob(ctx, jobId) } } func onConnectionUp(connName string) { log.Printf("[conn:%s] connection became connected, reconnecting jobs", connName) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() allJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) if err != nil { log.Printf("[conn:%s] failed to get jobs for reconnection: %v", connName, err) return } var jobsToReconnect []*waveobj.Job for _, job := range allJobs { if job.Connection == connName && isJobManagerRunning(job) { jobsToReconnect = append(jobsToReconnect, job) } } log.Printf("[conn:%s] found %d jobs to reconnect", connName, len(jobsToReconnect)) successCount := 0 for _, job := range jobsToReconnect { err = ReconnectJob(ctx, job.OID, nil) if err != nil { log.Printf("[job:%s] error reconnecting: %v", job.OID, err) } else { successCount++ } } log.Printf("[conn:%s] finished reconnecting jobs: %d/%d successful", connName, successCount, len(jobsToReconnect)) } func onConnectionDown(connName string) { log.Printf("[conn:%s] connection became disconnected", connName) } func GetJobConnStatus(jobId string) string { jobControllerLock.Lock() defer jobControllerLock.Unlock() status, exists := jobConnStates[jobId] if !exists { return JobConnStatus_Disconnected } return status } func SetJobConnStatus(jobId string, status string) { jobControllerLock.Lock() defer jobControllerLock.Unlock() if status == JobConnStatus_Disconnected { delete(jobConnStates, jobId) } else { jobConnStates[jobId] = status } } func GetConnectedJobIds() []string { jobControllerLock.Lock() defer jobControllerLock.Unlock() var connectedJobIds []string for jobId, status := range jobConnStates { if status == JobConnStatus_Connected { connectedJobIds = append(connectedJobIds, jobId) } } return connectedJobIds } func GetNumJobsRunning() int { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() allJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) if err != nil { return 0 } count := 0 for _, job := range allJobs { if job.JobManagerStatus == JobManagerStatus_Running { count++ } } return count } func GetNumJobsConnected() int { jobControllerLock.Lock() defer jobControllerLock.Unlock() count := 0 for _, status := range jobConnStates { if status == JobConnStatus_Connected { count++ } } return count } func CheckJobConnected(ctx context.Context, jobId string) (*waveobj.Job, error) { job, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId) if err != nil { return nil, fmt.Errorf("failed to get job: %w", err) } isConnected, err := conncontroller.IsConnected(job.Connection) if err != nil { return nil, fmt.Errorf("error checking connection status: %w", err) } if !isConnected { return nil, fmt.Errorf("connection %q is not connected", job.Connection) } jobConnStatus := GetJobConnStatus(jobId) if jobConnStatus != JobConnStatus_Connected { return nil, fmt.Errorf("job is not connected (status: %s)", jobConnStatus) } return job, nil } type StartJobParams struct { ConnName string JobKind string Cmd string Args []string Env map[string]string TermSize *waveobj.TermSize BlockId string } func StartJob(ctx context.Context, params StartJobParams) (string, error) { if params.ConnName == "" { return "", fmt.Errorf("connection name is required") } if params.JobKind != JobKind_Shell && params.JobKind != JobKind_Task { return "", fmt.Errorf("jobkind must be %q or %q", JobKind_Shell, JobKind_Task) } if params.Cmd == "" { return "", fmt.Errorf("command is required") } if params.TermSize == nil { params.TermSize = &waveobj.TermSize{Rows: 24, Cols: 80} } isConnected, err := conncontroller.IsConnected(params.ConnName) if err != nil { return "", fmt.Errorf("error checking connection status: %w", err) } if !isConnected { return "", fmt.Errorf("connection %q is not connected", params.ConnName) } jobId := uuid.New().String() jobAuthToken, err := utilfn.RandomHexString(32) if err != nil { return "", fmt.Errorf("failed to generate job auth token: %w", err) } jobAccessClaims := &wavejwt.WaveJwtClaims{ MainServer: true, JobId: jobId, } jobAccessToken, err := wavejwt.Sign(jobAccessClaims) if err != nil { return "", fmt.Errorf("failed to generate job access token: %w", err) } job := &waveobj.Job{ OID: jobId, Connection: params.ConnName, JobKind: params.JobKind, Cmd: params.Cmd, CmdArgs: params.Args, CmdEnv: params.Env, CmdTermSize: *params.TermSize, JobAuthToken: jobAuthToken, JobManagerStatus: JobManagerStatus_Init, AttachedBlockId: params.BlockId, WaveVersion: wavebase.WaveVersion, Meta: make(waveobj.MetaMapType), } err = wstore.DBInsert(ctx, job) if err != nil { return "", fmt.Errorf("failed to create job in database: %w", err) } if params.BlockId != "" { // AttachJobToBlock will send status err = AttachJobToBlock(ctx, jobId, params.BlockId) if err != nil { return "", fmt.Errorf("failed to attach job to block: %w", err) } } bareRpc := wshclient.GetBareRpcClient() broker := bareRpc.StreamBroker readerRouteId := wshclient.GetBareRpcClientRouteId() writerRouteId := wshutil.MakeJobRouteId(jobId) reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, DefaultStreamRwnd) jobStreamIds.Set(jobId, streamMeta.Id) fileOpts := wshrpc.FileOpts{ MaxSize: 10 * 1024 * 1024, Circular: true, } err = filestore.WFS.MakeFile(ctx, jobId, JobOutputFileName, wshrpc.FileMeta{}, fileOpts) if err != nil { return "", fmt.Errorf("failed to create WaveFS file: %w", err) } clientId := wstore.GetClientId() publicKey := wavejwt.GetPublicKey() publicKeyBase64 := base64.StdEncoding.EncodeToString(publicKey) jobEnv := envutil.CopyAndAddToEnvMap(params.Env, "WAVETERM_JOBID", jobId) startJobData := wshrpc.CommandRemoteStartJobData{ Cmd: params.Cmd, Args: params.Args, Env: jobEnv, TermSize: *params.TermSize, StreamMeta: streamMeta, JobAuthToken: jobAuthToken, JobId: jobId, MainServerJwtToken: jobAccessToken, ClientId: clientId, PublicKeyBase64: publicKeyBase64, } rpcOpts := &wshrpc.RpcOpts{ Route: wshutil.MakeConnectionRouteId(params.ConnName), Timeout: 30000, } writeSessionSeparatorToTerminal(params.BlockId, params.TermSize.Cols) log.Printf("[job:%s] sending RemoteStartJobCommand to connection %s, cmd=%q, args=%v", jobId, params.ConnName, params.Cmd, params.Args) log.Printf("[job:%s] env=%v", jobId, params.Env) rtnData, err := wshclient.RemoteStartJobCommand(bareRpc, startJobData, rpcOpts) if err != nil { log.Printf("[job:%s] RemoteStartJobCommand failed: %v", jobId, err) errMsg := fmt.Sprintf("failed to start job: %v", err) var updatedJob *waveobj.Job wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { job.JobManagerStatus = JobManagerStatus_Done job.JobManagerDoneReason = JobDoneReason_StartupError job.JobManagerStartupError = errMsg updatedJob = job }) sendBlockJobStatusEventByJob(ctx, updatedJob) telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "job:done", Props: telemetrydata.TEventProps{ JobDoneReason: JobDoneReason_StartupError, JobKind: params.JobKind, }, }) return "", fmt.Errorf("failed to start remote job: %w", err) } log.Printf("[job:%s] RemoteStartJobCommand succeeded, cmdpid=%d cmdstartts=%d jobmanagerpid=%d jobmanagerstartts=%d", jobId, rtnData.CmdPid, rtnData.CmdStartTs, rtnData.JobManagerPid, rtnData.JobManagerStartTs) var updatedJob *waveobj.Job err = wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { job.CmdPid = rtnData.CmdPid job.CmdStartTs = rtnData.CmdStartTs job.JobManagerPid = rtnData.JobManagerPid job.JobManagerStartTs = rtnData.JobManagerStartTs job.JobManagerStatus = JobManagerStatus_Running updatedJob = job }) if err != nil { log.Printf("[job:%s] warning: failed to update job status to running: %v", jobId, err) } else { log.Printf("[job:%s] job status updated to running", jobId) sendBlockJobStatusEventByJob(ctx, updatedJob) } telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "job:start", Props: telemetrydata.TEventProps{ JobKind: params.JobKind, }, }) go func() { defer func() { panichandler.PanicHandler("jobcontroller:runOutputLoop", recover()) }() runOutputLoop(context.Background(), jobId, streamMeta.Id, reader) }() return jobId, nil } func doWFSAppend(ctx context.Context, oref waveobj.ORef, fileName string, data []byte) error { err := filestore.WFS.AppendData(ctx, oref.OID, fileName, data) if err != nil { return err } wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_BlockFile, Scopes: []string{ oref.String(), }, Data: &wps.WSFileEventData{ ZoneId: oref.OID, FileName: fileName, FileOp: wps.FileOp_Append, Data64: base64.StdEncoding.EncodeToString(data), }, }) return nil } func handleAppendJobFile(ctx context.Context, jobId string, fileName string, data []byte) error { err := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Job, jobId), fileName, data) if err != nil { return fmt.Errorf("error appending to job file: %w", err) } job, err := wstore.DBGet[*waveobj.Job](ctx, jobId) if err != nil { return fmt.Errorf("error getting job: %w", err) } if job != nil && job.AttachedBlockId != "" { err = doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, job.AttachedBlockId), fileName, data) if err != nil { return fmt.Errorf("error appending to block file: %w", err) } } return nil } func runOutputLoop(ctx context.Context, jobId string, streamId string, reader *streamclient.Reader) { defer reader.Close() defer func() { log.Printf("[job:%s] [stream:%s] output loop finished", jobId, streamId) }() log.Printf("[job:%s] [stream:%s] output loop started", jobId, streamId) buf := make([]byte, 4096) for { n, err := reader.Read(buf) currentStreamId, _ := jobStreamIds.GetEx(jobId) if currentStreamId != streamId { log.Printf("[job:%s] [stream:%s] stream superseded by [stream:%s], exiting output loop", jobId, streamId, currentStreamId) break } if n > 0 { appendErr := handleAppendJobFile(ctx, jobId, JobOutputFileName, buf[:n]) if appendErr != nil { log.Printf("[job:%s] error appending data to WaveFS: %v", jobId, appendErr) } } if err == io.EOF { log.Printf("[job:%s] stream ended (EOF)", jobId) updateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { job.StreamDone = true }) if updateErr != nil { log.Printf("[job:%s] error updating job stream status: %v", jobId, updateErr) } tryTerminateJobManager(ctx, jobId) break } if err != nil { log.Printf("[job:%s] stream error: %v", jobId, err) streamErr := err.Error() updateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { job.StreamDone = true job.StreamError = streamErr }) if updateErr != nil { log.Printf("[job:%s] error updating job stream error: %v", jobId, updateErr) } tryTerminateJobManager(ctx, jobId) break } } } func HandleCmdJobExited(ctx context.Context, jobId string, data wshrpc.CommandJobCmdExitedData) error { var updatedJob *waveobj.Job err := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { job.CmdExitError = data.ExitErr job.CmdExitCode = data.ExitCode job.CmdExitSignal = data.ExitSignal job.CmdExitTs = data.ExitTs updatedJob = job }) if err != nil { return fmt.Errorf("failed to update job exit status: %w", err) } sendBlockJobStatusEventByJob(ctx, updatedJob) tryTerminateJobManager(ctx, jobId) shouldWrite := jobTerminationMessageWritten.TestAndSet(jobId, true, func(val bool, exists bool) bool { return !exists || !val }) if shouldWrite { resetTerminalState(ctx, updatedJob.AttachedBlockId) msg := "shell terminated" if updatedJob.CmdExitCode != nil && *updatedJob.CmdExitCode != 0 { msg = fmt.Sprintf("shell terminated (exit code %d)", *updatedJob.CmdExitCode) } else if updatedJob.CmdExitSignal != "" { msg = fmt.Sprintf("shell terminated (signal %s)", updatedJob.CmdExitSignal) } writeMutedMessageToTerminal(updatedJob.AttachedBlockId, "["+msg+"]") } return nil } func tryTerminateJobManager(ctx context.Context, jobId string) { job, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId) if err != nil { log.Printf("[job:%s] error getting job for termination check: %v", jobId, err) return } if job.JobManagerStatus != JobManagerStatus_Running { return } cmdExited := job.CmdExitTs != 0 if !cmdExited || !job.StreamDone { log.Printf("[job:%s] not ready for termination: exited=%v streamDone=%v", jobId, cmdExited, job.StreamDone) return } log.Printf("[job:%s] both job cmd exited and stream finished, terminating job manager", jobId) err = TerminateJobManager(ctx, jobId) if err != nil { log.Printf("[job:%s] error terminating job manager: %v", jobId, err) } } func TerminateAndDetachJob(ctx context.Context, jobId string) { err := TerminateJobManager(ctx, jobId) if err != nil { log.Printf("[job:%s] error terminating job manager: %v", jobId, err) } err = DetachJobFromBlock(ctx, jobId, true) if err != nil { log.Printf("[job:%s] error detaching job from block: %v", jobId, err) } } func TerminateJobManager(ctx context.Context, jobId string) error { _, err, _ := terminateJobManagerGroup.Do(jobId, func() (any, error) { err := doTerminateJobManager(ctx, jobId) return nil, err }) return err } func doTerminateJobManager(ctx context.Context, jobId string) error { var shouldTerminate bool var job *waveobj.Job err := wstore.DBUpdateFn(ctx, jobId, func(j *waveobj.Job) { job = j if j.JobManagerStatus == JobManagerStatus_Done { shouldTerminate = false return } j.TerminateOnReconnect = true shouldTerminate = true }) if err != nil { return fmt.Errorf("failed to set TerminateOnReconnect: %w", err) } if !shouldTerminate { log.Printf("[job:%s] already terminated, skipping", jobId) return nil } return remoteTerminateJobManager(ctx, job) } func DisconnectJob(ctx context.Context, jobId string) error { job, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId) if err != nil { return fmt.Errorf("failed to get job: %w", err) } bareRpc := wshclient.GetBareRpcClient() rpcOpts := &wshrpc.RpcOpts{ Route: wshutil.MakeConnectionRouteId(job.Connection), Timeout: 5000, } disconnectData := wshrpc.CommandRemoteDisconnectFromJobManagerData{ JobId: jobId, } err = wshclient.RemoteDisconnectFromJobManagerCommand(bareRpc, disconnectData, rpcOpts) if err != nil { return fmt.Errorf("failed to send disconnect command: %w", err) } log.Printf("[job:%s] job disconnect command sent successfully", jobId) return nil } func remoteTerminateJobManager(ctx context.Context, job *waveobj.Job) error { log.Printf("[job:%s] terminating job manager", job.OID) shouldWrite := jobTerminationMessageWritten.TestAndSet(job.OID, true, func(val bool, exists bool) bool { return !exists || !val }) if shouldWrite { resetTerminalState(ctx, job.AttachedBlockId) writeMutedMessageToTerminal(job.AttachedBlockId, "[shell terminated]") } if job.JobManagerStatus == JobManagerStatus_Done { log.Printf("[job:%s] job manager already marked as done, skipping termination", job.OID) return nil } bareRpc := wshclient.GetBareRpcClient() terminateData := wshrpc.CommandRemoteTerminateJobManagerData{ JobId: job.OID, JobManagerPid: job.JobManagerPid, JobManagerStartTs: job.JobManagerStartTs, } rpcOpts := &wshrpc.RpcOpts{ Route: wshutil.MakeConnectionRouteId(job.Connection), Timeout: 5000, } err := wshclient.RemoteTerminateJobManagerCommand(bareRpc, terminateData, rpcOpts) if err != nil { log.Printf("[job:%s] error terminating job manager: %v", job.OID, err) return fmt.Errorf("failed to terminate job manager: %w", err) } var updatedJob *waveobj.Job updateErr := wstore.DBUpdateFn(ctx, job.OID, func(job *waveobj.Job) { job.JobManagerStatus = JobManagerStatus_Done job.JobManagerDoneReason = JobDoneReason_Terminated job.TerminateOnReconnect = false if !job.StreamDone { job.StreamDone = true job.StreamError = "job manager terminated" } updatedJob = job }) if updateErr != nil { log.Printf("[job:%s] error updating job status after termination: %v", job.OID, updateErr) } else { sendBlockJobStatusEventByJob(ctx, updatedJob) } telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "job:done", Props: telemetrydata.TEventProps{ JobDoneReason: JobDoneReason_Terminated, JobKind: job.JobKind, }, }) log.Printf("[job:%s] job manager terminated successfully", job.OID) return nil } func ReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOpts) error { _, err, _ := reconnectGroup.Do(jobId, func() (any, error) { return nil, doReconnectJob(ctx, jobId, rtOpts) }) return err } func doReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOpts) error { job, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId) if err != nil { return fmt.Errorf("failed to get job: %w", err) } _, err = CheckJobConnected(ctx, jobId) if err == nil { log.Printf("[job:%s] already connected, skipping reconnect", jobId) return nil } log.Printf("[job:%s] not connected, proceeding with reconnect: %v", jobId, err) isConnected, err := conncontroller.IsConnected(job.Connection) if err != nil { return fmt.Errorf("error checking connection status: %w", err) } if !isConnected { return fmt.Errorf("connection %q is not connected", job.Connection) } if job.TerminateOnReconnect { return remoteTerminateJobManager(ctx, job) } if rtOpts == nil { rtOpts = &waveobj.RuntimeOpts{ TermSize: job.CmdTermSize, } } bareRpc := wshclient.GetBareRpcClient() jobAccessClaims := &wavejwt.WaveJwtClaims{ MainServer: true, JobId: jobId, } jobAccessToken, err := wavejwt.Sign(jobAccessClaims) if err != nil { return fmt.Errorf("failed to generate job access token: %w", err) } reconnectData := wshrpc.CommandRemoteReconnectToJobManagerData{ JobId: jobId, JobAuthToken: job.JobAuthToken, MainServerJwtToken: jobAccessToken, JobManagerPid: job.JobManagerPid, JobManagerStartTs: job.JobManagerStartTs, } rpcOpts := &wshrpc.RpcOpts{ Route: wshutil.MakeConnectionRouteId(job.Connection), Timeout: 5000, } log.Printf("[job:%s] sending RemoteReconnectToJobManagerCommand to connection %s", jobId, job.Connection) rtnData, err := wshclient.RemoteReconnectToJobManagerCommand(bareRpc, reconnectData, rpcOpts) if err != nil { log.Printf("[job:%s] RemoteReconnectToJobManagerCommand failed: %v", jobId, err) return fmt.Errorf("failed to reconnect to job manager: %w", err) } if !rtnData.Success { log.Printf("[job:%s] RemoteReconnectToJobManagerCommand returned error: %s", jobId, rtnData.Error) if rtnData.JobManagerGone { var updatedJob *waveobj.Job updateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { job.JobManagerStatus = JobManagerStatus_Done job.JobManagerDoneReason = JobDoneReason_Gone updatedJob = job }) if updateErr != nil { log.Printf("[job:%s] error updating job manager running status: %v", jobId, updateErr) } else { sendBlockJobStatusEventByJob(ctx, updatedJob) } telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "job:done", Props: telemetrydata.TEventProps{ JobDoneReason: JobDoneReason_Gone, JobKind: job.JobKind, }, }) writeJobTerminationMessage(ctx, jobId, updatedJob, "[session gone]") return fmt.Errorf("job manager has exited: %s", rtnData.Error) } return fmt.Errorf("failed to reconnect to job manager: %s", rtnData.Error) } log.Printf("[job:%s] RemoteReconnectToJobManagerCommand succeeded, waiting for route", jobId) routeId := wshutil.MakeJobRouteId(jobId) waitCtx, cancelFn := context.WithTimeout(ctx, 2*time.Second) defer cancelFn() err = wshutil.DefaultRouter.WaitForRegister(waitCtx, routeId) if err != nil { return fmt.Errorf("route did not establish after successful reconnection: %w", err) } SetJobConnStatus(jobId, JobConnStatus_Connected) sendBlockJobStatusEventByJob(ctx, job) telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "job:reconnect", Props: telemetrydata.TEventProps{ JobKind: job.JobKind, }, }) log.Printf("[job:%s] route established, restarting streaming", jobId) return restartStreaming(ctx, jobId, true, rtOpts) } func ReconnectJobsForConn(ctx context.Context, connName string) error { isConnected, err := conncontroller.IsConnected(connName) if err != nil { return fmt.Errorf("error checking connection status: %w", err) } if !isConnected { return fmt.Errorf("connection %q is not connected", connName) } allJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) if err != nil { return fmt.Errorf("failed to get jobs: %w", err) } var jobsToReconnect []*waveobj.Job for _, job := range allJobs { if job.Connection == connName && isJobManagerRunning(job) { jobsToReconnect = append(jobsToReconnect, job) } } log.Printf("[conn:%s] found %d jobs to reconnect", connName, len(jobsToReconnect)) for _, job := range jobsToReconnect { err = ReconnectJob(ctx, job.OID, nil) if err != nil { log.Printf("[job:%s] error reconnecting: %v", job.OID, err) } } return nil } func restartStreaming(ctx context.Context, jobId string, knownConnected bool, rtOpts *waveobj.RuntimeOpts) error { job, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId) if err != nil { return fmt.Errorf("failed to get job: %w", err) } termSize := job.CmdTermSize if rtOpts != nil && rtOpts.TermSize.Rows > 0 && rtOpts.TermSize.Cols > 0 { termSize = rtOpts.TermSize err = wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { job.CmdTermSize = termSize }) if err != nil { log.Printf("[job:%s] warning: failed to update termsize in DB: %v", jobId, err) } } if !knownConnected { isConnected, err := conncontroller.IsConnected(job.Connection) if err != nil { return fmt.Errorf("error checking connection status: %w", err) } if !isConnected { return fmt.Errorf("connection %q is not connected", job.Connection) } jobConnStatus := GetJobConnStatus(jobId) if jobConnStatus != JobConnStatus_Connected { return fmt.Errorf("job manager is not connected (status: %s)", jobConnStatus) } } var currentSeq int64 = 0 var totalGap int64 = 0 waveFile, err := filestore.WFS.Stat(ctx, jobId, JobOutputFileName) if err == nil { currentSeq = waveFile.Size totalGap = getMetaInt64(waveFile.Meta, MetaKey_TotalGap) currentSeq += totalGap } bareRpc := wshclient.GetBareRpcClient() broker := bareRpc.StreamBroker readerRouteId := wshclient.GetBareRpcClientRouteId() writerRouteId := wshutil.MakeJobRouteId(jobId) reader, streamMeta := broker.CreateStreamReaderWithSeq(readerRouteId, writerRouteId, DefaultStreamRwnd, currentSeq) jobStreamIds.Set(jobId, streamMeta.Id) prepareData := wshrpc.CommandJobPrepareConnectData{ StreamMeta: *streamMeta, Seq: currentSeq, TermSize: termSize, } rpcOpts := &wshrpc.RpcOpts{ Route: wshutil.MakeJobRouteId(jobId), Timeout: 5000, } log.Printf("[job:%s] sending JobPrepareConnectCommand with seq=%d (fileSize=%d, totalGap=%d)", jobId, currentSeq, waveFile.Size, totalGap) rtnData, err := wshclient.JobPrepareConnectCommand(bareRpc, prepareData, rpcOpts) if err != nil { reader.Close() return fmt.Errorf("failed to prepare connect: %w", err) } if rtnData.HasExited { exitCodeStr := "nil" if rtnData.ExitCode != nil { exitCodeStr = fmt.Sprintf("%d", *rtnData.ExitCode) } log.Printf("[job:%s] job has already exited: code=%s signal=%q err=%q", jobId, exitCodeStr, rtnData.ExitSignal, rtnData.ExitErr) exitData := wshrpc.CommandJobCmdExitedData{ ExitCode: rtnData.ExitCode, ExitSignal: rtnData.ExitSignal, ExitErr: rtnData.ExitErr, ExitTs: time.Now().UnixMilli(), } HandleCmdJobExited(ctx, jobId, exitData) } if rtnData.StreamDone { log.Printf("[job:%s] stream is already done: error=%q", jobId, rtnData.StreamError) updateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { if !job.StreamDone { job.StreamDone = true if rtnData.StreamError != "" { job.StreamError = rtnData.StreamError } } }) if updateErr != nil { log.Printf("[job:%s] error updating job stream status: %v", jobId, updateErr) } } if rtnData.StreamDone && rtnData.HasExited { reader.Close() log.Printf("[job:%s] both stream done and job exited, calling tryExitJobManager", jobId) tryTerminateJobManager(ctx, jobId) return nil } if rtnData.StreamDone { reader.Close() log.Printf("[job:%s] stream already done, no need to restart streaming", jobId) return nil } if rtnData.Seq > currentSeq { gap := rtnData.Seq - currentSeq totalGap += gap log.Printf("[job:%s] detected gap: our seq=%d, server seq=%d, gap=%d, new totalGap=%d", jobId, currentSeq, rtnData.Seq, gap, totalGap) metaErr := filestore.WFS.WriteMeta(ctx, jobId, JobOutputFileName, wshrpc.FileMeta{ MetaKey_TotalGap: totalGap, }, true) if metaErr != nil { log.Printf("[job:%s] error updating totalgap metadata: %v", jobId, metaErr) } reader.UpdateNextSeq(rtnData.Seq) } log.Printf("[job:%s] sending JobStartStreamCommand", jobId) startStreamData := wshrpc.CommandJobStartStreamData{} err = wshclient.JobStartStreamCommand(bareRpc, startStreamData, rpcOpts) if err != nil { reader.Close() return fmt.Errorf("failed to start stream: %w", err) } go func() { defer func() { panichandler.PanicHandler("jobcontroller:RestartStreaming:runOutputLoop", recover()) }() runOutputLoop(context.Background(), jobId, streamMeta.Id, reader) }() log.Printf("[job:%s] streaming restarted successfully", jobId) return nil } // this function must be kept up to date with getBlockTermDurableAtom in frontend/app/store/global.ts func IsBlockTermDurable(block *waveobj.Block) bool { if block == nil { return false } // Check if view is "term", and controller is "shell" if block.Meta.GetString(waveobj.MetaKey_View, "") != "term" || block.Meta.GetString(waveobj.MetaKey_Controller, "") != "shell" { return false } // 1. Check if block has a JobId if block.JobId != "" { return true } // 2. Check if connection is local or WSL (not durable) connName := block.Meta.GetString(waveobj.MetaKey_Connection, "") if conncontroller.IsLocalConnName(connName) || conncontroller.IsWslConnName(connName) { return false } // 3. Check config hierarchy: blockmeta → connection → global (default true) // Check block meta first if val, exists := block.Meta[waveobj.MetaKey_TermDurable]; exists { if boolVal, ok := val.(bool); ok { return boolVal } } // Check connection config fullConfig := wconfig.GetWatcher().GetFullConfig() if connName != "" { if connConfig, exists := fullConfig.Connections[connName]; exists { if connConfig.TermDurable != nil { return *connConfig.TermDurable } } } // Check global settings if fullConfig.Settings.TermDurable != nil { return *fullConfig.Settings.TermDurable } // Default to true for non-local connections return true } func IsBlockIdTermDurable(blockId string) bool { block, err := wstore.DBGet[*waveobj.Block](context.Background(), blockId) if err != nil || block == nil { return false } return IsBlockTermDurable(block) } func DeleteJob(ctx context.Context, jobId string) error { SetJobConnStatus(jobId, JobConnStatus_Disconnected) jobTerminationMessageWritten.Delete(jobId) err := filestore.WFS.DeleteZone(ctx, jobId) if err != nil { log.Printf("[job:%s] warning: error deleting WaveFS zone: %v", jobId, err) } return wstore.DBDelete(ctx, waveobj.OType_Job, jobId) } func AttachJobToBlock(ctx context.Context, jobId string, blockId string) error { err := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { var oldJobId string err := wstore.DBUpdateFn(tx.Context(), blockId, func(block *waveobj.Block) { oldJobId = block.JobId block.JobId = jobId }) if err != nil { return fmt.Errorf("failed to update block: %w", err) } if oldJobId != "" && oldJobId != jobId { err = wstore.DBUpdateFn(tx.Context(), oldJobId, func(oldJob *waveobj.Job) { if oldJob.AttachedBlockId == blockId { oldJob.AttachedBlockId = "" } }) if err != nil { log.Printf("[job:%s] warning: could not detach old job: %v", oldJobId, err) } } err = wstore.DBUpdateFnErr(tx.Context(), jobId, func(job *waveobj.Job) error { if job.AttachedBlockId != "" && job.AttachedBlockId != blockId { return fmt.Errorf("job %s already attached to block %s", jobId, job.AttachedBlockId) } job.AttachedBlockId = blockId return nil }) if err != nil { return fmt.Errorf("failed to update job: %w", err) } log.Printf("[job:%s] attached to block:%s", jobId, blockId) return nil }) if err != nil { return err } SendBlockJobStatusEvent(ctx, blockId) wcore.SendWaveObjUpdate(waveobj.MakeORef(waveobj.OType_Block, blockId)) return nil } func DetachJobFromBlock(ctx context.Context, jobId string, updateBlock bool) error { var blockId string var blockUpdated bool err := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { job, err := wstore.DBMustGet[*waveobj.Job](tx.Context(), jobId) if err != nil { return fmt.Errorf("failed to get job: %w", err) } blockId = job.AttachedBlockId if blockId == "" { return nil } if updateBlock { block, err := wstore.DBGet[*waveobj.Block](tx.Context(), blockId) if err == nil && block != nil { err = wstore.DBUpdateFn(tx.Context(), blockId, func(block *waveobj.Block) { block.JobId = "" }) if err != nil { log.Printf("[job:%s] warning: failed to clear JobId from block:%s: %v", jobId, blockId, err) } else { blockUpdated = true } } } err = wstore.DBUpdateFn(tx.Context(), jobId, func(job *waveobj.Job) { job.AttachedBlockId = "" }) if err != nil { return fmt.Errorf("failed to update job: %w", err) } log.Printf("[job:%s] detached from block:%s", jobId, blockId) return nil }) if err != nil { return err } if blockId != "" { SendBlockJobStatusEvent(ctx, blockId) if blockUpdated { wcore.SendWaveObjUpdate(waveobj.MakeORef(waveobj.OType_Block, blockId)) } } return nil } func SendInput(ctx context.Context, data wshrpc.CommandJobInputData) error { jobId := data.JobId if data.TermSize != nil { err := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { job.CmdTermSize = *data.TermSize }) if err != nil { log.Printf("[job:%s] warning: failed to update termsize in DB: %v", jobId, err) } } _, err := CheckJobConnected(ctx, jobId) if err != nil { return err } rpcOpts := &wshrpc.RpcOpts{ Route: wshutil.MakeJobRouteId(jobId), Timeout: 5000, NoResponse: false, } bareRpc := wshclient.GetBareRpcClient() err = wshclient.JobInputCommand(bareRpc, data, rpcOpts) if err != nil { return fmt.Errorf("failed to send input to job: %w", err) } return nil } func resetTerminalState(logCtx context.Context, blockId string) { if blockId == "" { return } ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() if isFileEmpty(ctx, blockId) { return } blocklogger.Debugf(logCtx, "[conndebug] resetTerminalState: resetting terminal state for block\n") resetSeq := shellutil.GetTerminalResetSeq() resetSeq += "\r\n" err := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(resetSeq)) if err != nil { log.Printf("error appending terminal reset to block file: %v\n", err) } } func isFileEmpty(ctx context.Context, blockId string) bool { if blockId == "" { return true } file, statErr := filestore.WFS.Stat(ctx, blockId, JobOutputFileName) if statErr == fs.ErrNotExist { return true } if statErr != nil { log.Printf("error statting block output file: %v\n", statErr) return true } return file.Size == 0 } func writeSessionSeparatorToTerminal(blockId string, termWidth int) { if blockId == "" { return } ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() if isFileEmpty(ctx, blockId) { return } separatorLine := "\r\n" err := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(separatorLine)) if err != nil { log.Printf("error writing session separator to terminal (blockid=%s): %v", blockId, err) } } // msg should not have a terminating newline func writeMutedMessageToTerminal(blockId string, msg string) { if blockId == "" { return } ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() fullMsg := "\x1b[90m" + msg + "\x1b[0m\r\n" err := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(fullMsg)) if err != nil { log.Printf("error writing muted message to terminal (blockid=%s): %v", blockId, err) } } func writeJobTerminationMessage(ctx context.Context, jobId string, job *waveobj.Job, msg string) { if job == nil { return } shouldWrite := jobTerminationMessageWritten.TestAndSet(jobId, true, func(val bool, exists bool) bool { return !exists || !val }) if shouldWrite { resetTerminalState(ctx, job.AttachedBlockId) writeMutedMessageToTerminal(job.AttachedBlockId, msg) } } ================================================ FILE: pkg/jobmanager/cirbuf.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package jobmanager import ( "fmt" "sync" ) type CirBuf struct { lock sync.Mutex waiterChan chan chan struct{} buf []byte readPos int writePos int count int totalSize int64 syncMode bool windowSize int } func MakeCirBuf(maxSize int, initSyncMode bool) *CirBuf { cb := &CirBuf{ buf: make([]byte, maxSize), syncMode: initSyncMode, waiterChan: make(chan chan struct{}, 1), windowSize: maxSize, } return cb } // SetEffectiveWindow changes the sync mode and effective window size for flow control. // The windowSize is capped at the buffer size. // When window shrinks: sync mode blocks new writes, async mode truncates old data to enforce limit. // When window increases: blocked writers are woken up if space becomes available. func (cb *CirBuf) SetEffectiveWindow(syncMode bool, windowSize int) { cb.lock.Lock() defer cb.lock.Unlock() maxSize := len(cb.buf) if windowSize > maxSize { windowSize = maxSize } oldSyncMode := cb.syncMode oldWindowSize := cb.windowSize cb.windowSize = windowSize cb.syncMode = syncMode // In async mode, enforce window size by truncating buffer if needed if !syncMode && cb.count > windowSize { excess := cb.count - windowSize cb.readPos = (cb.readPos + excess) % maxSize cb.count = windowSize } // Only sync mode blocks writers, so only wake if we were in sync mode. // Wake when window grows (more space available) or switching to async (no longer blocking). if oldSyncMode && (windowSize > oldWindowSize || !syncMode) { cb.tryWakeWriter() } } // WriteAvailable attempts to write as much data as possible without blocking. // Returns the number of bytes written and a channel to wait on if buffer is full (nil if not blocking). // In sync mode when buffer is full, returns 0 written and a channel that will be closed when space is available. // The caller should wait on the channel and retry the write. // NOTE: Only one concurrent blocked write is allowed. Multiple blocked writes will panic. func (cb *CirBuf) WriteAvailable(data []byte) (int, <-chan struct{}) { cb.lock.Lock() defer cb.lock.Unlock() size := len(cb.buf) written := 0 for i := 0; i < len(data); i++ { if cb.syncMode && cb.count >= cb.windowSize { if written > 0 { return written, nil } spaceAvailable := make(chan struct{}) if !tryWriteCh(cb.waiterChan, spaceAvailable) { panic("CirBuf: multiple concurrent blocked writes not allowed") } return 0, spaceAvailable } cb.buf[cb.writePos] = data[i] cb.writePos = (cb.writePos + 1) % size if cb.count < cb.windowSize { cb.count++ } else { cb.readPos = (cb.readPos + 1) % size } cb.totalSize++ written++ } return written, nil } func (cb *CirBuf) PeekData(data []byte) int { return cb.PeekDataAt(0, data) } func (cb *CirBuf) PeekDataAt(offset int, data []byte) int { cb.lock.Lock() defer cb.lock.Unlock() if cb.count == 0 || offset >= cb.count { return 0 } size := len(cb.buf) pos := (cb.readPos + offset) % size maxRead := cb.count - offset read := 0 for i := 0; i < len(data) && i < maxRead; i++ { data[i] = cb.buf[pos] pos = (pos + 1) % size read++ } return read } func (cb *CirBuf) Consume(numBytes int) error { cb.lock.Lock() defer cb.lock.Unlock() if numBytes > cb.count { return fmt.Errorf("cannot consume %d bytes, only %d available", numBytes, cb.count) } size := len(cb.buf) cb.readPos = (cb.readPos + numBytes) % size cb.count -= numBytes cb.tryWakeWriter() return nil } func (cb *CirBuf) HeadPos() int64 { cb.lock.Lock() defer cb.lock.Unlock() return cb.totalSize - int64(cb.count) } func (cb *CirBuf) Size() int { cb.lock.Lock() defer cb.lock.Unlock() return cb.count } func (cb *CirBuf) TotalSize() int64 { cb.lock.Lock() defer cb.lock.Unlock() return cb.totalSize } func tryWriteCh[T any](ch chan<- T, val T) bool { select { case ch <- val: return true default: return false } } func tryReadCh[T any](ch <-chan T) (*T, bool) { select { case rtn := <-ch: return &rtn, true default: return nil, false } } func (cb *CirBuf) tryWakeWriter() { if waiterCh, ok := tryReadCh(cb.waiterChan); ok { close(*waiterCh) } } ================================================ FILE: pkg/jobmanager/jobcmd.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package jobmanager import ( "encoding/base64" "fmt" "log" "os/exec" "sync" "syscall" "time" "github.com/creack/pty" "github.com/wavetermdev/waveterm/pkg/util/unixutil" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type CmdDef struct { Cmd string Args []string Env map[string]string TermSize waveobj.TermSize } type JobCmd struct { jobId string lock sync.Mutex cmd *exec.Cmd cmdPty pty.Pty ptsName string termSize waveobj.TermSize cleanedUp bool ptyClosed bool processExited bool exitCode *int exitSignal string exitErr error exitTs int64 } func MakeJobCmd(jobId string, cmdDef CmdDef) (*JobCmd, error) { jm := &JobCmd{ jobId: jobId, } if cmdDef.TermSize.Rows == 0 || cmdDef.TermSize.Cols == 0 { cmdDef.TermSize.Rows = 25 cmdDef.TermSize.Cols = 80 } if cmdDef.TermSize.Rows <= 0 || cmdDef.TermSize.Cols <= 0 { return nil, fmt.Errorf("invalid term size: %v", cmdDef.TermSize) } ecmd := exec.Command(cmdDef.Cmd, cmdDef.Args...) if len(cmdDef.Env) > 0 { ecmd.Env = make([]string, 0, len(cmdDef.Env)) for key, val := range cmdDef.Env { ecmd.Env = append(ecmd.Env, fmt.Sprintf("%s=%s", key, val)) } } cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(cmdDef.TermSize.Rows), Cols: uint16(cmdDef.TermSize.Cols)}) if err != nil { return nil, fmt.Errorf("failed to start command: %w", err) } unixutil.SetCloseOnExec(int(cmdPty.Fd())) jm.cmd = ecmd jm.cmdPty = cmdPty jm.ptsName = jm.cmdPty.Name() jm.termSize = cmdDef.TermSize go jm.waitForProcess() return jm, nil } func (jm *JobCmd) waitForProcess() { if jm.cmd == nil || jm.cmd.Process == nil { return } err := jm.cmd.Wait() jm.lock.Lock() defer jm.lock.Unlock() jm.processExited = true jm.exitTs = time.Now().UnixMilli() jm.exitErr = err if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { if status.Signaled() { jm.exitSignal = unixutil.GetSignalName(status.Signal()) } else if status.Exited() { code := status.ExitStatus() jm.exitCode = &code } else { log.Printf("Invalid WaitStatus, not exited or signaled: %v", status) } } } } else { code := 0 jm.exitCode = &code } exitCodeStr := "nil" if jm.exitCode != nil { exitCodeStr = fmt.Sprintf("%d", *jm.exitCode) } log.Printf("process exited: exitcode=%s, signal=%s, err=%v\n", exitCodeStr, jm.exitSignal, jm.exitErr) go WshCmdJobManager.sendJobExited() } func (jm *JobCmd) GetCmd() (*exec.Cmd, pty.Pty) { jm.lock.Lock() defer jm.lock.Unlock() return jm.cmd, jm.cmdPty } func (jm *JobCmd) GetPGID() (int, error) { jm.lock.Lock() defer jm.lock.Unlock() if jm.cmd == nil || jm.cmd.Process == nil { return 0, fmt.Errorf("no active process") } if jm.processExited { return 0, fmt.Errorf("process already exited") } pgid, err := unixutil.GetProcessGroupId(jm.cmd.Process.Pid) if err != nil { return 0, fmt.Errorf("failed to get pgid: %w", err) } if pgid <= 0 { return 0, fmt.Errorf("invalid pgid returned: %d", pgid) } return pgid, nil } func (jm *JobCmd) GetExitInfo() (bool, *wshrpc.CommandJobCmdExitedData) { jm.lock.Lock() defer jm.lock.Unlock() if !jm.processExited { return false, nil } exitData := &wshrpc.CommandJobCmdExitedData{ JobId: WshCmdJobManager.JobId, ExitCode: jm.exitCode, ExitSignal: jm.exitSignal, ExitTs: jm.exitTs, } if jm.exitErr != nil { exitData.ExitErr = jm.exitErr.Error() } return true, exitData } func (jm *JobCmd) setTermSize_withlock(termSize waveobj.TermSize) error { if jm.cmdPty == nil { return fmt.Errorf("no active pty") } if jm.termSize.Rows == termSize.Rows && jm.termSize.Cols == termSize.Cols { return nil } err := pty.Setsize(jm.cmdPty, &pty.Winsize{ Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols), }) if err != nil { return fmt.Errorf("error setting terminal size: %w", err) } jm.termSize = termSize return nil } func (jm *JobCmd) SetTermSize(termSize waveobj.TermSize) error { jm.lock.Lock() defer jm.lock.Unlock() return jm.setTermSize_withlock(termSize) } // TODO set up a single input handler loop + queue so we dont need to hold the lock but still get synchronized in-order execution func (jm *JobCmd) HandleInput(data wshrpc.CommandJobInputData) error { jm.lock.Lock() defer jm.lock.Unlock() if jm.cmd == nil || jm.cmdPty == nil { return fmt.Errorf("no active process") } if len(data.InputData64) > 0 { inputBuf := make([]byte, base64.StdEncoding.DecodedLen(len(data.InputData64))) nw, err := base64.StdEncoding.Decode(inputBuf, []byte(data.InputData64)) if err != nil { return fmt.Errorf("error decoding input data: %w", err) } _, err = jm.cmdPty.Write(inputBuf[:nw]) if err != nil { return fmt.Errorf("error writing to pty: %w", err) } } if data.SigName != "" { sig := unixutil.ParseSignal(data.SigName) if sig != nil && jm.cmd.Process != nil { err := jm.cmd.Process.Signal(sig) if err != nil { return fmt.Errorf("error sending signal: %w", err) } } } if data.TermSize != nil { err := jm.setTermSize_withlock(*data.TermSize) if err != nil { return err } } return nil } func (jm *JobCmd) TerminateByClosingPtyMaster() { jm.lock.Lock() defer jm.lock.Unlock() if jm.ptyClosed { return } if jm.cmdPty != nil { jm.cmdPty.Close() jm.ptyClosed = true log.Printf("pty closed for job %s\n", jm.jobId) } } ================================================ FILE: pkg/jobmanager/jobmanager.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package jobmanager import ( "fmt" "log" "net" "os" "path/filepath" "runtime" "sync" "time" "github.com/shirou/gopsutil/v4/process" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) const JobAccessTokenLabel = "Wave-JobAccessToken" const JobManagerStartLabel = "Wave-JobManagerStart" const JobInputQueueTimeout = 100 * time.Millisecond const JobInputQueueSize = 1000 var WshCmdJobManager JobManager type JobManager struct { ClientId string JobId string Cmd *JobCmd JwtPublicKey []byte JobAuthToken string StreamManager *StreamManager InputQueue *utilds.QuickReorderQueue[wshrpc.CommandJobInputData] lock sync.Mutex attachedClient *MainServerConn connectedStreamClient *MainServerConn pendingStreamMeta *wshrpc.StreamMeta } func SetupJobManager(clientId string, jobId string, publicKeyBytes []byte, jobAuthToken string, readyFile *os.File) error { if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { return fmt.Errorf("job manager only supported on unix systems, not %s", runtime.GOOS) } WshCmdJobManager.ClientId = clientId WshCmdJobManager.JobId = jobId WshCmdJobManager.JwtPublicKey = publicKeyBytes WshCmdJobManager.JobAuthToken = jobAuthToken WshCmdJobManager.StreamManager = MakeStreamManager() WshCmdJobManager.InputQueue = utilds.MakeQuickReorderQueue[wshrpc.CommandJobInputData](JobInputQueueSize, JobInputQueueTimeout) err := wavejwt.SetPublicKey(publicKeyBytes) if err != nil { return fmt.Errorf("failed to set public key: %w", err) } err = MakeJobDomainSocket(clientId, jobId) if err != nil { return err } go func() { defer func() { panichandler.PanicHandler("JobManager:processInputQueue", recover()) }() WshCmdJobManager.processInputQueue() }() fmt.Fprintf(readyFile, JobManagerStartLabel+"\n") readyFile.Close() err = daemonize(clientId, jobId) if err != nil { return fmt.Errorf("failed to daemonize: %w", err) } go func() { defer func() { panichandler.PanicHandler("JobManager:keepalive", recover()) }() ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for range ticker.C { log.Printf("keepalive: job manager active\n") } }() return nil } func (jm *JobManager) processInputQueue() { for data := range jm.InputQueue.C() { jm.lock.Lock() cmd := jm.Cmd jm.lock.Unlock() if cmd == nil { log.Printf("processInputQueue: skipping input, job not started\n") continue } err := cmd.HandleInput(data) if err != nil { log.Printf("processInputQueue: error handling input: %v\n", err) } } } func (jm *JobManager) GetCmd() *JobCmd { jm.lock.Lock() defer jm.lock.Unlock() return jm.Cmd } func (jm *JobManager) sendJobExited() { jm.lock.Lock() attachedClient := jm.attachedClient cmd := jm.Cmd jm.lock.Unlock() if attachedClient == nil { log.Printf("sendJobExited: no attached client, exit notification not sent\n") return } if attachedClient.WshRpc == nil { log.Printf("sendJobExited: no wsh rpc connection, exit notification not sent\n") return } if cmd == nil { log.Printf("sendJobExited: no cmd, exit notification not sent\n") return } exited, exitData := cmd.GetExitInfo() if !exited || exitData == nil { log.Printf("sendJobExited: process not exited yet\n") return } exitCodeStr := "nil" if exitData.ExitCode != nil { exitCodeStr = fmt.Sprintf("%d", *exitData.ExitCode) } log.Printf("sendJobExited: sending exit notification to main server exitcode=%s signal=%s\n", exitCodeStr, exitData.ExitSignal) err := wshclient.JobCmdExitedCommand(attachedClient.WshRpc, *exitData, nil) if err != nil { log.Printf("sendJobExited: error sending exit notification: %v\n", err) } } func (jm *JobManager) GetJobAuthInfo() (string, string) { jm.lock.Lock() defer jm.lock.Unlock() return jm.JobId, jm.JobAuthToken } func (jm *JobManager) IsJobStarted() bool { jm.lock.Lock() defer jm.lock.Unlock() return jm.Cmd != nil } func (jm *JobManager) connectToStreamHelper_withlock(mainServerConn *MainServerConn, streamMeta wshrpc.StreamMeta, seq int64) (int64, error) { rwndSize := int(streamMeta.RWnd) if rwndSize < 0 { return 0, fmt.Errorf("invalid rwnd size: %d", rwndSize) } if jm.connectedStreamClient != nil { log.Printf("connectToStreamHelper: disconnecting existing client\n") oldStreamId := jm.StreamManager.GetStreamId() jm.StreamManager.ClientDisconnected() if oldStreamId != "" { mainServerConn.WshRpc.StreamBroker.DetachStreamWriter(oldStreamId) log.Printf("connectToStreamHelper: detached old stream id=%s\n", oldStreamId) } jm.connectedStreamClient = nil } dataSender := &routedDataSender{ wshRpc: mainServerConn.WshRpc, route: streamMeta.ReaderRouteId, } serverSeq, err := jm.StreamManager.ClientConnected( streamMeta.Id, dataSender, rwndSize, seq, ) if err != nil { return 0, fmt.Errorf("failed to connect client: %w", err) } jm.connectedStreamClient = mainServerConn return serverSeq, nil } func (jm *JobManager) disconnectFromStreamHelper(mainServerConn *MainServerConn) { jm.lock.Lock() defer jm.lock.Unlock() if jm.connectedStreamClient == nil || jm.connectedStreamClient != mainServerConn { return } jm.StreamManager.ClientDisconnected() jm.connectedStreamClient = nil } func (jm *JobManager) SetAttachedClient(msc *MainServerConn) { jm.lock.Lock() defer jm.lock.Unlock() if jm.attachedClient != nil { log.Printf("SetAttachedClient: kicking out existing client\n") jm.attachedClient.Close() } jm.attachedClient = msc } func (jm *JobManager) StartJob(msc *MainServerConn, data wshrpc.CommandStartJobData) (*wshrpc.CommandStartJobRtnData, error) { jm.lock.Lock() defer jm.lock.Unlock() if jm.Cmd != nil { log.Printf("StartJob: job already started") return nil, fmt.Errorf("job already started") } cmdDef := CmdDef{ Cmd: data.Cmd, Args: data.Args, Env: data.Env, TermSize: data.TermSize, } log.Printf("StartJob: creating job cmd for jobid=%s", jm.JobId) jobCmd, err := MakeJobCmd(jm.JobId, cmdDef) if err != nil { log.Printf("StartJob: failed to make job cmd: %v", err) return nil, fmt.Errorf("failed to start job: %w", err) } jm.Cmd = jobCmd log.Printf("StartJob: job cmd created successfully") if data.StreamMeta != nil { serverSeq, err := jm.connectToStreamHelper_withlock(msc, *data.StreamMeta, 0) if err != nil { return nil, fmt.Errorf("failed to connect stream: %w", err) } err = msc.WshRpc.StreamBroker.AttachStreamWriter(data.StreamMeta, jm.StreamManager) if err != nil { return nil, fmt.Errorf("failed to attach stream writer: %w", err) } log.Printf("StartJob: connected stream streamid=%s serverSeq=%d\n", data.StreamMeta.Id, serverSeq) } cmd, cmdPty := jobCmd.GetCmd() if cmdPty != nil { log.Printf("StartJob: attaching pty reader to stream manager") err = jm.StreamManager.AttachReader(cmdPty) if err != nil { log.Printf("StartJob: failed to attach reader: %v", err) return nil, fmt.Errorf("failed to attach reader to stream manager: %w", err) } log.Printf("StartJob: pty reader attached successfully") } else { log.Printf("StartJob: no pty to attach") } if cmd == nil || cmd.Process == nil { log.Printf("StartJob: cmd or process is nil") return nil, fmt.Errorf("cmd or process is nil") } cmdPid := cmd.Process.Pid cmdProc, err := process.NewProcess(int32(cmdPid)) if err != nil { log.Printf("StartJob: failed to get cmd process: %v", err) return nil, fmt.Errorf("failed to get cmd process: %w", err) } cmdStartTs, err := cmdProc.CreateTime() if err != nil { log.Printf("StartJob: failed to get cmd start time: %v", err) return nil, fmt.Errorf("failed to get cmd start time: %w", err) } jobManagerPid := os.Getpid() jobManagerProc, err := process.NewProcess(int32(jobManagerPid)) if err != nil { log.Printf("StartJob: failed to get job manager process: %v", err) return nil, fmt.Errorf("failed to get job manager process: %w", err) } jobManagerStartTs, err := jobManagerProc.CreateTime() if err != nil { log.Printf("StartJob: failed to get job manager start time: %v", err) return nil, fmt.Errorf("failed to get job manager start time: %w", err) } log.Printf("StartJob: job started successfully cmdPid=%d cmdStartTs=%d jobManagerPid=%d jobManagerStartTs=%d", cmdPid, cmdStartTs, jobManagerPid, jobManagerStartTs) return &wshrpc.CommandStartJobRtnData{ CmdPid: cmdPid, CmdStartTs: cmdStartTs, JobManagerPid: jobManagerPid, JobManagerStartTs: jobManagerStartTs, }, nil } func (jm *JobManager) PrepareConnect(msc *MainServerConn, data wshrpc.CommandJobPrepareConnectData) (*wshrpc.CommandJobConnectRtnData, error) { jm.lock.Lock() defer jm.lock.Unlock() if jm.Cmd == nil { return nil, fmt.Errorf("job not started") } err := jm.Cmd.SetTermSize(data.TermSize) if err != nil { log.Printf("PrepareConnect: failed to set term size: %v\n", err) } rtnData := &wshrpc.CommandJobConnectRtnData{} streamDone, streamError := jm.StreamManager.GetStreamDoneInfo() if streamDone { log.Printf("PrepareConnect: stream already done, skipping connection streamError=%q\n", streamError) rtnData.Seq = data.Seq rtnData.StreamDone = true rtnData.StreamError = streamError } else { corkedStreamMeta := data.StreamMeta corkedStreamMeta.RWnd = 0 serverSeq, err := jm.connectToStreamHelper_withlock(msc, corkedStreamMeta, data.Seq) if err != nil { return nil, err } jm.pendingStreamMeta = &data.StreamMeta rtnData.Seq = serverSeq rtnData.StreamDone = false } hasExited, exitData := jm.Cmd.GetExitInfo() if hasExited && exitData != nil { rtnData.HasExited = true rtnData.ExitCode = exitData.ExitCode rtnData.ExitSignal = exitData.ExitSignal rtnData.ExitErr = exitData.ExitErr } log.Printf("PrepareConnect: streamid=%s clientSeq=%d serverSeq=%d streamDone=%v streamError=%q hasExited=%v\n", data.StreamMeta.Id, data.Seq, rtnData.Seq, rtnData.StreamDone, rtnData.StreamError, hasExited) return rtnData, nil } func (jm *JobManager) StartStream(msc *MainServerConn) error { jm.lock.Lock() defer jm.lock.Unlock() if jm.Cmd == nil { return fmt.Errorf("job not started") } if jm.pendingStreamMeta == nil { return fmt.Errorf("no pending stream (call PrepareConnect first)") } err := msc.WshRpc.StreamBroker.AttachStreamWriter(jm.pendingStreamMeta, jm.StreamManager) if err != nil { return fmt.Errorf("failed to attach stream writer: %w", err) } err = jm.StreamManager.SetRwndSize(int(jm.pendingStreamMeta.RWnd)) if err != nil { return fmt.Errorf("failed to set rwnd size: %w", err) } log.Printf("StartStream: streamid=%s rwnd=%d streaming started\n", jm.pendingStreamMeta.Id, jm.pendingStreamMeta.RWnd) jm.pendingStreamMeta = nil return nil } func MakeJobDomainSocket(clientId string, jobId string) error { socketDir := filepath.Join("/tmp", fmt.Sprintf("waveterm-%d", os.Getuid())) err := os.MkdirAll(socketDir, 0700) if err != nil { return fmt.Errorf("failed to create socket directory: %w", err) } socketPath := wavebase.GetRemoteJobSocketPath(jobId) os.Remove(socketPath) listener, err := net.Listen("unix", socketPath) if err != nil { return fmt.Errorf("failed to listen on domain socket: %w", err) } go func() { defer func() { panichandler.PanicHandler("MakeJobDomainSocket:accept", recover()) listener.Close() os.Remove(socketPath) }() for { conn, err := listener.Accept() if err != nil { log.Printf("error accepting connection: %v\n", err) return } go handleJobDomainSocketClient(conn) } }() return nil } func handleJobDomainSocketClient(conn net.Conn) { inputCh := make(chan baseds.RpcInputChType, wshutil.DefaultInputChSize) outputCh := make(chan []byte, wshutil.DefaultOutputChSize) serverImpl := &MainServerConn{ Conn: conn, inputCh: inputCh, } rpcCtx := wshrpc.RpcContext{} wshRpc := wshutil.MakeWshRpcWithChannels(inputCh, outputCh, rpcCtx, serverImpl, "job-domain") serverImpl.WshRpc = wshRpc defer WshCmdJobManager.disconnectFromStreamHelper(serverImpl) go func() { defer func() { panichandler.PanicHandler("handleJobDomainSocketClient:AdaptOutputChToStream", recover()) }() defer serverImpl.Close() writeErr := wshutil.AdaptOutputChToStream(outputCh, conn) if writeErr != nil { log.Printf("error writing to domain socket: %v\n", writeErr) } }() go func() { defer func() { panichandler.PanicHandler("handleJobDomainSocketClient:AdaptStreamToMsgCh", recover()) }() defer serverImpl.Close() wshutil.AdaptStreamToMsgCh(conn, inputCh, nil) }() _ = wshRpc } ================================================ FILE: pkg/jobmanager/jobmanager_unix.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 //go:build unix package jobmanager import ( "fmt" "log" "os" "os/signal" "path/filepath" "syscall" "github.com/wavetermdev/waveterm/pkg/wavebase" "golang.org/x/sys/unix" ) func daemonize(clientId string, jobId string) error { _, err := unix.Setsid() if err != nil { return fmt.Errorf("failed to setsid: %w", err) } devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0) if err != nil { return fmt.Errorf("failed to open /dev/null: %w", err) } err = unix.Dup2(int(devNull.Fd()), int(os.Stdin.Fd())) if err != nil { return fmt.Errorf("failed to dup2 stdin: %w", err) } devNull.Close() logPath := wavebase.GetRemoteJobFilePath(jobId, "log") logDir := filepath.Dir(logPath) err = os.MkdirAll(logDir, 0700) if err != nil { return fmt.Errorf("failed to create log directory: %w", err) } logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } err = unix.Dup2(int(logFile.Fd()), int(os.Stdout.Fd())) if err != nil { return fmt.Errorf("failed to dup2 stdout: %w", err) } err = unix.Dup2(int(logFile.Fd()), int(os.Stderr.Fd())) if err != nil { return fmt.Errorf("failed to dup2 stderr: %w", err) } log.SetOutput(logFile) log.Printf("job manager daemonized, logging to %s\n", logPath) log.Printf("job owner clientid: %s\n", clientId) signal.Ignore(syscall.SIGHUP) return nil } ================================================ FILE: pkg/jobmanager/jobmanager_windows.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 //go:build windows package jobmanager import ( "fmt" ) func daemonize(clientId string, jobId string) error { return fmt.Errorf("daemonize not supported on windows") } ================================================ FILE: pkg/jobmanager/mainserverconn.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package jobmanager import ( "context" "fmt" "log" "net" "sync" "sync/atomic" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) type MainServerConn struct { PeerAuthenticated atomic.Bool SelfAuthenticated atomic.Bool WshRpc *wshutil.WshRpc Conn net.Conn inputCh chan baseds.RpcInputChType closeOnce sync.Once } func (*MainServerConn) WshServerImpl() {} func (msc *MainServerConn) Close() { msc.closeOnce.Do(func() { msc.Conn.Close() close(msc.inputCh) }) } type routedDataSender struct { wshRpc *wshutil.WshRpc route string } func (rds *routedDataSender) SendData(dataPk wshrpc.CommandStreamData) { // log.Printf("SendData: sending seq=%d, len=%d, eof=%t, error=%s, route=%s", // dataPk.Seq, len(dataPk.Data64), dataPk.Eof, dataPk.Error, rds.route) err := wshclient.StreamDataCommand(rds.wshRpc, dataPk, &wshrpc.RpcOpts{NoResponse: true, Route: rds.route}) if err != nil { log.Printf("SendData: error sending stream data: %v\n", err) } } func (msc *MainServerConn) authenticateSelfToServer(jobAuthToken string) error { jobId, _ := WshCmdJobManager.GetJobAuthInfo() authData := wshrpc.CommandAuthenticateJobManagerData{ JobId: jobId, JobAuthToken: jobAuthToken, } err := wshclient.AuthenticateJobManagerCommand(msc.WshRpc, authData, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) if err != nil { log.Printf("authenticateSelfToServer: failed to authenticate to server: %v\n", err) return fmt.Errorf("failed to authenticate to server: %w", err) } msc.SelfAuthenticated.Store(true) log.Printf("authenticateSelfToServer: successfully authenticated to server\n") return nil } func (msc *MainServerConn) AuthenticateToJobManagerCommand(ctx context.Context, data wshrpc.CommandAuthenticateToJobData) error { jobId, jobAuthToken := WshCmdJobManager.GetJobAuthInfo() claims, err := wavejwt.ValidateAndExtract(data.JobAccessToken) if err != nil { log.Printf("AuthenticateToJobManager: failed to validate token: %v\n", err) return fmt.Errorf("failed to validate token: %w", err) } if !claims.MainServer { log.Printf("AuthenticateToJobManager: MainServer claim not set\n") return fmt.Errorf("MainServer claim not set") } if claims.JobId != jobId { log.Printf("AuthenticateToJobManager: JobId mismatch: expected %s, got %s\n", jobId, claims.JobId) return fmt.Errorf("JobId mismatch") } msc.PeerAuthenticated.Store(true) log.Printf("AuthenticateToJobManager: authentication successful for JobId=%s\n", claims.JobId) err = msc.authenticateSelfToServer(jobAuthToken) if err != nil { msc.PeerAuthenticated.Store(false) return err } WshCmdJobManager.SetAttachedClient(msc) return nil } func (msc *MainServerConn) StartJobCommand(ctx context.Context, data wshrpc.CommandStartJobData) (*wshrpc.CommandStartJobRtnData, error) { log.Printf("StartJobCommand: received command=%s args=%v", data.Cmd, data.Args) if !msc.PeerAuthenticated.Load() { log.Printf("StartJobCommand: not authenticated") return nil, fmt.Errorf("not authenticated") } return WshCmdJobManager.StartJob(msc, data) } func (msc *MainServerConn) JobPrepareConnectCommand(ctx context.Context, data wshrpc.CommandJobPrepareConnectData) (*wshrpc.CommandJobConnectRtnData, error) { if !msc.PeerAuthenticated.Load() { return nil, fmt.Errorf("peer not authenticated") } if !msc.SelfAuthenticated.Load() { return nil, fmt.Errorf("not authenticated to server") } return WshCmdJobManager.PrepareConnect(msc, data) } func (msc *MainServerConn) JobStartStreamCommand(ctx context.Context, data wshrpc.CommandJobStartStreamData) error { if !msc.PeerAuthenticated.Load() { return fmt.Errorf("not authenticated") } return WshCmdJobManager.StartStream(msc) } func (msc *MainServerConn) JobInputCommand(ctx context.Context, data wshrpc.CommandJobInputData) error { if !msc.PeerAuthenticated.Load() { return fmt.Errorf("not authenticated") } if !WshCmdJobManager.IsJobStarted() { return fmt.Errorf("job not started") } WshCmdJobManager.InputQueue.QueueItem(data.InputSessionId, data.SeqNum, data) return nil } ================================================ FILE: pkg/jobmanager/streammanager.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package jobmanager import ( "encoding/base64" "fmt" "io" "log" "sync" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const ( CwndSize = 64 * 1024 // 64 KB window for connected mode CirBufSize = 2 * 1024 * 1024 // 2 MB max buffer size DisconnReadSz = 4 * 1024 // 4 KB read chunks when disconnected MaxPacketSize = 4 * 1024 // 4 KB max data per packet ) type DataSender interface { SendData(dataPk wshrpc.CommandStreamData) } type streamTerminalEvent struct { isEof bool err string } // StreamManager handles PTY output buffering with ACK-based flow control type StreamManager struct { lock sync.Mutex drainCond *sync.Cond streamId string // this is the data read from the attached reader buf *CirBuf terminalEvent *streamTerminalEvent eofPos int64 // fixed position when EOF/error occurs (-1 if not yet) reader io.Reader cwndSize int rwndSize int // invariant: if connected is true, dataSender is non-nil connected bool dataSender DataSender // unacked state (reset on disconnect) sentNotAcked int64 terminalEventSent bool // track max acked to handle out-of-order ACKs (reset on disconnect) maxAckedSeq int64 maxAckedRwnd int64 // terminal state - once true, stream is complete terminalEventAcked bool closed bool } func MakeStreamManager() *StreamManager { return MakeStreamManagerWithSizes(CwndSize, CirBufSize) } func MakeStreamManagerWithSizes(cwndSize, cirbufSize int) *StreamManager { sm := &StreamManager{ buf: MakeCirBuf(cirbufSize, true), eofPos: -1, cwndSize: cwndSize, rwndSize: cwndSize, } sm.drainCond = sync.NewCond(&sm.lock) go sm.senderLoop() return sm } // AttachReader starts reading from the given reader func (sm *StreamManager) AttachReader(r io.Reader) error { sm.lock.Lock() defer sm.lock.Unlock() if sm.reader != nil { return fmt.Errorf("reader already attached") } sm.reader = r go sm.readLoop() return nil } // ClientConnected transitions to CONNECTED mode func (sm *StreamManager) ClientConnected(streamId string, dataSender DataSender, rwndSize int, clientSeq int64) (int64, error) { sm.lock.Lock() defer sm.lock.Unlock() if sm.closed || sm.terminalEventAcked { return 0, fmt.Errorf("stream is closed") } if sm.connected { return 0, fmt.Errorf("client already connected") } if dataSender == nil { return 0, fmt.Errorf("dataSender cannot be nil") } headPos := sm.buf.HeadPos() if clientSeq > headPos { bytesToConsume := int(clientSeq - headPos) available := sm.buf.Size() if bytesToConsume > available { return 0, fmt.Errorf("client seq %d is beyond our stream end (head=%d, size=%d)", clientSeq, headPos, available) } if bytesToConsume > 0 { if err := sm.buf.Consume(bytesToConsume); err != nil { return 0, fmt.Errorf("failed to consume buffer: %w", err) } headPos = sm.buf.HeadPos() } } sm.streamId = streamId sm.dataSender = dataSender sm.connected = true sm.rwndSize = rwndSize sm.sentNotAcked = 0 effectiveWindow := sm.cwndSize if sm.rwndSize < effectiveWindow { effectiveWindow = sm.rwndSize } sm.buf.SetEffectiveWindow(true, effectiveWindow) sm.drainCond.Signal() startSeq := headPos if clientSeq > startSeq { startSeq = clientSeq } return startSeq, nil } // GetStreamId returns the current stream ID (safe to call with lock held by caller) func (sm *StreamManager) GetStreamId() string { sm.lock.Lock() defer sm.lock.Unlock() return sm.streamId } // GetStreamDoneInfo returns whether the stream is done and the error if there was one. // The error is only meaningful if done=true, as the error is delivered as part of the stream otherwise. func (sm *StreamManager) GetStreamDoneInfo() (done bool, streamError string) { sm.lock.Lock() defer sm.lock.Unlock() if !sm.terminalEventAcked { return false, "" } if sm.terminalEvent != nil && !sm.terminalEvent.isEof { return true, sm.terminalEvent.err } return true, "" } // ClientDisconnected transitions to DISCONNECTED mode func (sm *StreamManager) ClientDisconnected() { sm.lock.Lock() defer sm.lock.Unlock() if !sm.connected { return } sm.connected = false sm.dataSender = nil sm.sentNotAcked = 0 sm.maxAckedSeq = 0 sm.maxAckedRwnd = 0 if !sm.terminalEventAcked { sm.terminalEventSent = false } sm.buf.SetEffectiveWindow(false, CirBufSize) sm.drainCond.Signal() } // RecvAck processes an ACK from the client // must be connected, and streamid must match func (sm *StreamManager) RecvAck(ackPk wshrpc.CommandStreamAckData) { sm.lock.Lock() defer sm.lock.Unlock() if !sm.connected || ackPk.Id != sm.streamId { return } if ackPk.Fin { sm.terminalEventAcked = true sm.drainCond.Signal() return } seq := ackPk.Seq rwnd := ackPk.RWnd // Ignore stale ACKs using tuple comparison (seq, rwnd) if seq < sm.maxAckedSeq || (seq == sm.maxAckedSeq && rwnd <= sm.maxAckedRwnd) { // log.Printf("streammanager ignoring stale ACK: seq=%d rwnd=%d (max: seq=%d rwnd=%d)", // seq, rwnd, sm.maxAckedSeq, sm.maxAckedRwnd) return } // Update max acked tuple sm.maxAckedSeq = seq sm.maxAckedRwnd = rwnd headPos := sm.buf.HeadPos() if seq < headPos { return } ackedBytes := seq - headPos if ackedBytes > sm.sentNotAcked { return } if ackedBytes > 0 { if err := sm.buf.Consume(int(ackedBytes)); err != nil { return } sm.sentNotAcked -= ackedBytes } prevRwnd := sm.rwndSize sm.rwndSize = int(ackPk.RWnd) effectiveWindow := sm.cwndSize if sm.rwndSize < effectiveWindow { effectiveWindow = sm.rwndSize } sm.buf.SetEffectiveWindow(true, effectiveWindow) if sm.rwndSize > prevRwnd || ackedBytes > 0 { sm.drainCond.Signal() } } // SetRwndSize dynamically updates the receive window size func (sm *StreamManager) SetRwndSize(rwndSize int) error { sm.lock.Lock() defer sm.lock.Unlock() if rwndSize < 0 { return fmt.Errorf("rwndSize cannot be negative") } if !sm.connected { return fmt.Errorf("not connected") } sm.rwndSize = rwndSize effectiveWindow := sm.cwndSize if sm.rwndSize < effectiveWindow { effectiveWindow = sm.rwndSize } sm.buf.SetEffectiveWindow(true, effectiveWindow) sm.drainCond.Signal() return nil } // Close shuts down the sender loop. The reader loop will exit on its next iteration // or when the underlying reader is closed. func (sm *StreamManager) Close() { sm.lock.Lock() defer sm.lock.Unlock() sm.closed = true sm.drainCond.Signal() } // readLoop is the main read goroutine func (sm *StreamManager) readLoop() { readBuf := make([]byte, MaxPacketSize) for { sm.lock.Lock() closed := sm.closed sm.lock.Unlock() if closed { return } n, err := sm.reader.Read(readBuf) if n > 0 { sm.handleReadData(readBuf[:n]) } if err != nil { if err == io.EOF { sm.handleEOF() } else { sm.handleError(err) } return } } } func (sm *StreamManager) handleReadData(data []byte) { offset := 0 for offset < len(data) { n, waitCh := sm.buf.WriteAvailable(data[offset:]) offset += n if n > 0 { sm.lock.Lock() sm.drainCond.Signal() sm.lock.Unlock() } if waitCh != nil { <-waitCh } } } func (sm *StreamManager) handleEOF() { sm.lock.Lock() defer sm.lock.Unlock() log.Printf("handleEOF: PTY reached EOF, totalSize=%d", sm.buf.TotalSize()) sm.eofPos = sm.buf.TotalSize() sm.terminalEvent = &streamTerminalEvent{isEof: true} sm.drainCond.Signal() } func (sm *StreamManager) handleError(err error) { sm.lock.Lock() defer sm.lock.Unlock() log.Printf("handleError: PTY error=%v, totalSize=%d", err, sm.buf.TotalSize()) sm.eofPos = sm.buf.TotalSize() sm.terminalEvent = &streamTerminalEvent{err: err.Error()} sm.drainCond.Signal() } func (sm *StreamManager) senderLoop() { for { done, pkt, sender := sm.prepareNextPacket() if done { return } if pkt == nil { continue } sender.SendData(*pkt) } } func (sm *StreamManager) prepareNextPacket() (done bool, pkt *wshrpc.CommandStreamData, sender DataSender) { sm.lock.Lock() defer sm.lock.Unlock() available := sm.buf.Size() if sm.closed || sm.terminalEventAcked { return true, nil, nil } if !sm.connected { sm.drainCond.Wait() return false, nil, nil } if available == 0 { if sm.terminalEvent != nil && !sm.terminalEventSent { return false, sm.prepareTerminalPacket(), sm.dataSender } sm.drainCond.Wait() return false, nil, nil } effectiveRwnd := sm.rwndSize if sm.cwndSize < effectiveRwnd { effectiveRwnd = sm.cwndSize } availableToSend := int64(effectiveRwnd) - sm.sentNotAcked if availableToSend <= 0 { sm.drainCond.Wait() return false, nil, nil } peekSize := int(availableToSend) if peekSize > MaxPacketSize { peekSize = MaxPacketSize } if peekSize > available { peekSize = available } data := make([]byte, peekSize) n := sm.buf.PeekDataAt(int(sm.sentNotAcked), data) if n == 0 { sm.drainCond.Wait() return false, nil, nil } data = data[:n] seq := sm.buf.HeadPos() + sm.sentNotAcked sm.sentNotAcked += int64(n) return false, &wshrpc.CommandStreamData{ Id: sm.streamId, Seq: seq, Data64: base64.StdEncoding.EncodeToString(data), }, sm.dataSender } func (sm *StreamManager) prepareTerminalPacket() *wshrpc.CommandStreamData { if sm.terminalEventSent || sm.terminalEvent == nil { return nil } pkt := &wshrpc.CommandStreamData{ Id: sm.streamId, Seq: sm.eofPos, } if sm.terminalEvent.isEof { pkt.Eof = true } else { pkt.Error = sm.terminalEvent.err } sm.terminalEventSent = true return pkt } ================================================ FILE: pkg/jobmanager/streammanager_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package jobmanager import ( "encoding/base64" "io" "strings" "sync" "testing" "time" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type testWriter struct { mu sync.Mutex packets []wshrpc.CommandStreamData } func (tw *testWriter) SendData(pkt wshrpc.CommandStreamData) { tw.mu.Lock() defer tw.mu.Unlock() tw.packets = append(tw.packets, pkt) } func (tw *testWriter) GetPackets() []wshrpc.CommandStreamData { tw.mu.Lock() defer tw.mu.Unlock() result := make([]wshrpc.CommandStreamData, len(tw.packets)) copy(result, tw.packets) return result } func (tw *testWriter) Clear() { tw.mu.Lock() defer tw.mu.Unlock() tw.packets = nil } func decodeData(data64 string) string { decoded, _ := base64.StdEncoding.DecodeString(data64) return string(decoded) } func TestBasicDisconnectedMode(t *testing.T) { tw := &testWriter{} sm := MakeStreamManager() reader := strings.NewReader("hello world") err := sm.AttachReader(reader) if err != nil { t.Fatalf("AttachReader failed: %v", err) } time.Sleep(50 * time.Millisecond) packets := tw.GetPackets() if len(packets) > 0 { t.Errorf("Expected no packets in DISCONNECTED mode without client, got %d", len(packets)) } sm.Close() } func TestConnectedModeBasicFlow(t *testing.T) { tw := &testWriter{} sm := MakeStreamManager() reader := strings.NewReader("hello") err := sm.AttachReader(reader) if err != nil { t.Fatalf("AttachReader failed: %v", err) } _, err = sm.ClientConnected("1", tw, CwndSize, 0) if err != nil { t.Fatalf("ClientConnected failed: %v", err) } time.Sleep(100 * time.Millisecond) packets := tw.GetPackets() if len(packets) == 0 { t.Fatal("Expected packets after ClientConnected") } // Verify we got the data allData := "" for _, pkt := range packets { if pkt.Data64 != "" { allData += decodeData(pkt.Data64) } } if allData != "hello" { t.Errorf("Expected 'hello', got '%s'", allData) } // Send ACK sm.RecvAck(wshrpc.CommandStreamAckData{Id: "1", Seq: 5, RWnd: CwndSize}) time.Sleep(50 * time.Millisecond) // Check for EOF packet packets = tw.GetPackets() hasEof := false for _, pkt := range packets { if pkt.Eof { hasEof = true } } if !hasEof { t.Error("Expected EOF packet after ACKing all data") } sm.Close() } func TestDisconnectedToConnectedTransition(t *testing.T) { tw := &testWriter{} sm := MakeStreamManager() reader := strings.NewReader("test data") err := sm.AttachReader(reader) if err != nil { t.Fatalf("AttachReader failed: %v", err) } time.Sleep(100 * time.Millisecond) _, err = sm.ClientConnected("1", tw, CwndSize, 0) if err != nil { t.Fatalf("ClientConnected failed: %v", err) } time.Sleep(100 * time.Millisecond) packets := tw.GetPackets() if len(packets) == 0 { t.Fatal("Expected cirbuf drain after connect") } allData := "" for _, pkt := range packets { if pkt.Data64 != "" { allData += decodeData(pkt.Data64) } } if allData != "test data" { t.Errorf("Expected 'test data', got '%s'", allData) } sm.Close() } func TestConnectedToDisconnectedTransition(t *testing.T) { tw := &testWriter{} sm := MakeStreamManager() reader := &slowReader{data: []byte("slow data"), delay: 50 * time.Millisecond} err := sm.AttachReader(reader) if err != nil { t.Fatalf("AttachReader failed: %v", err) } _, err = sm.ClientConnected("1", tw, CwndSize, 0) if err != nil { t.Fatalf("ClientConnected failed: %v", err) } time.Sleep(150 * time.Millisecond) sm.ClientDisconnected() time.Sleep(100 * time.Millisecond) sm.Close() } func TestFlowControl(t *testing.T) { cwndSize := 1024 tw := &testWriter{} sm := MakeStreamManagerWithSizes(cwndSize, 8*1024) largeData := strings.Repeat("x", cwndSize+500) reader := strings.NewReader(largeData) err := sm.AttachReader(reader) if err != nil { t.Fatalf("AttachReader failed: %v", err) } _, err = sm.ClientConnected("1", tw, cwndSize, 0) if err != nil { t.Fatalf("ClientConnected failed: %v", err) } time.Sleep(100 * time.Millisecond) packets := tw.GetPackets() totalData := 0 for _, pkt := range packets { if pkt.Data64 != "" { decoded, _ := base64.StdEncoding.DecodeString(pkt.Data64) totalData += len(decoded) } } if totalData > cwndSize { t.Errorf("Sent %d bytes without ACK, exceeds cwnd size %d", totalData, cwndSize) } sm.RecvAck(wshrpc.CommandStreamAckData{Id: "1", Seq: int64(totalData), RWnd: int64(cwndSize)}) time.Sleep(100 * time.Millisecond) sm.Close() } func TestSequenceNumbering(t *testing.T) { tw := &testWriter{} sm := MakeStreamManager() reader := strings.NewReader("abcdefghij") err := sm.AttachReader(reader) if err != nil { t.Fatalf("AttachReader failed: %v", err) } _, err = sm.ClientConnected("1", tw, CwndSize, 0) if err != nil { t.Fatalf("ClientConnected failed: %v", err) } time.Sleep(100 * time.Millisecond) packets := tw.GetPackets() if len(packets) == 0 { t.Fatal("Expected packets") } expectedSeq := int64(0) for _, pkt := range packets { if pkt.Data64 == "" { continue } if pkt.Seq != expectedSeq { t.Errorf("Expected seq %d, got %d", expectedSeq, pkt.Seq) } decoded, _ := base64.StdEncoding.DecodeString(pkt.Data64) expectedSeq += int64(len(decoded)) } sm.Close() } func TestTerminalEventOrdering(t *testing.T) { tw := &testWriter{} sm := MakeStreamManager() reader := strings.NewReader("data") err := sm.AttachReader(reader) if err != nil { t.Fatalf("AttachReader failed: %v", err) } _, err = sm.ClientConnected("1", tw, CwndSize, 0) if err != nil { t.Fatalf("ClientConnected failed: %v", err) } time.Sleep(100 * time.Millisecond) packets := tw.GetPackets() if len(packets) == 0 { t.Fatal("Expected data packets") } hasData := false hasEof := false eofSeq := int64(-1) for _, pkt := range packets { if pkt.Data64 != "" { hasData = true } if pkt.Eof { hasEof = true eofSeq = pkt.Seq } } if !hasData { t.Error("Expected data packet") } if hasEof { t.Error("Should not have EOF before ACK") } sm.RecvAck(wshrpc.CommandStreamAckData{Id: "1", Seq: 4, RWnd: CwndSize}) time.Sleep(50 * time.Millisecond) packets = tw.GetPackets() hasEof = false for _, pkt := range packets { if pkt.Eof { hasEof = true eofSeq = pkt.Seq } } if !hasEof { t.Error("Expected EOF after ACKing all data") } if eofSeq != 4 { t.Errorf("Expected EOF at seq 4, got %d", eofSeq) } sm.Close() } type slowReader struct { data []byte pos int delay time.Duration } func (sr *slowReader) Read(p []byte) (n int, err error) { if sr.pos >= len(sr.data) { return 0, io.EOF } time.Sleep(sr.delay) n = copy(p, sr.data[sr.pos:]) sr.pos += n return n, nil } ================================================ FILE: pkg/panichandler/panichandler.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package panichandler import ( "fmt" "log" "runtime/debug" ) // to log NumPanics into the local telemetry system // gets around import cycles var PanicTelemetryHandler func(panicType string) func PanicHandlerNoTelemetry(debugStr string, recoverVal any) { if recoverVal == nil { return } log.Printf("[panic] in %s: %v\n", debugStr, recoverVal) debug.PrintStack() } // returns an error (wrapping the panic) if a panic occurred func PanicHandler(debugStr string, recoverVal any) error { if recoverVal == nil { return nil } log.Printf("[panic] in %s: %v\n", debugStr, recoverVal) debug.PrintStack() if PanicTelemetryHandler != nil { go func() { defer func() { PanicHandlerNoTelemetry("PanicTelemetryHandler", recover()) }() PanicTelemetryHandler(debugStr) }() } if err, ok := recoverVal.(error); ok { return fmt.Errorf("panic in %s: %w", debugStr, err) } return fmt.Errorf("panic in %s: %v", debugStr, recoverVal) } ================================================ FILE: pkg/remote/conncontroller/conncontroller.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package conncontroller import ( "context" "errors" "fmt" "io" "io/fs" "log" "net" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "github.com/kevinburke/ssh_config" "github.com/skeema/knownhosts" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wstore" "golang.org/x/crypto/ssh" "golang.org/x/mod/semver" ) const ( Status_Init = "init" Status_Connecting = "connecting" Status_Connected = "connected" Status_Disconnected = "disconnected" Status_Error = "error" ) const ( NoWshCode_Disabled = "disabled" NoWshCode_PermissionError = "permission-error" NoWshCode_UserDeclined = "user-declined" NoWshCode_DomainSocketError = "domainsocket-error" NoWshCode_ConnServerStartError = "connserver-start-error" NoWshCode_InstallError = "install-error" NoWshCode_PostInstallStartError = "postinstall-start-error" NoWshCode_InstallVerifyError = "install-verify-error" ) const ( ConnHealthStatus_Good = "good" ConnHealthStatus_Degraded = "degraded" ConnHealthStatus_Stalled = "stalled" ) const DefaultConnectionTimeout = 60 * time.Second var globalLock = &sync.Mutex{} var clientControllerMap = make(map[remote.SSHOpts]*SSHConn) var activeConnCounter = &atomic.Int32{} type SSHConn struct { lock *sync.Mutex // this lock protects the fields in the struct from concurrent access lifecycleLock *sync.Mutex // this protects the lifecycle from concurrent calls Status string ConnHealthStatus string WshEnabled *atomic.Bool Opts *remote.SSHOpts Client *ssh.Client DomainSockName string // if "", then no domain socket DomainSockListener net.Listener ConnController *ssh.Session Error string WshError string NoWshReason string WshVersion string LastConnectTime int64 ActiveConnNum int Monitor *ConnMonitor // will not be nil } var ConnServerCmdTemplate = strings.TrimSpace( strings.Join([]string{ "%s version 2> /dev/null || (echo -n \"not-installed \"; uname -sm; exit 0);", "exec %s connserver --conn %s %s %s", }, "\n")) func IsLocalConnName(connName string) bool { return strings.HasPrefix(connName, "local:") || connName == "local" || connName == "" } func IsWslConnName(connName string) bool { return strings.HasPrefix(connName, "wsl://") } func GetAllConnStatus() []wshrpc.ConnStatus { globalLock.Lock() defer globalLock.Unlock() var connStatuses []wshrpc.ConnStatus for _, conn := range clientControllerMap { connStatuses = append(connStatuses, conn.DeriveConnStatus()) } return connStatuses } func GetNumSSHHasConnected() int { globalLock.Lock() defer globalLock.Unlock() var numConnected int for _, conn := range clientControllerMap { if conn.LastConnectTime > 0 { numConnected++ } } return numConnected } func (conn *SSHConn) DeriveConnStatus() wshrpc.ConnStatus { conn.lock.Lock() defer conn.lock.Unlock() var lastActivityBeforeStalledTime int64 var keepAliveSentTime int64 monitor := conn.Monitor if conn.ConnHealthStatus == ConnHealthStatus_Stalled && monitor != nil { lastActivityBeforeStalledTime = monitor.LastActivityTime.Load() keepAliveSentTime = monitor.KeepAliveSentTime.Load() } return wshrpc.ConnStatus{ Status: conn.Status, Connected: conn.Status == Status_Connected, Connection: conn.Opts.String(), HasConnected: (conn.LastConnectTime > 0), ActiveConnNum: conn.ActiveConnNum, Error: conn.Error, WshEnabled: conn.WshEnabled.Load(), WshError: conn.WshError, NoWshReason: conn.NoWshReason, WshVersion: conn.WshVersion, ConnHealthStatus: conn.ConnHealthStatus, LastActivityBeforeStalledTime: lastActivityBeforeStalledTime, KeepAliveSentTime: keepAliveSentTime, } } func (conn *SSHConn) Infof(ctx context.Context, format string, args ...any) { log.Print(fmt.Sprintf("[conn:%s] ", conn.GetName()) + fmt.Sprintf(format, args...)) blocklogger.Infof(ctx, "[conndebug] "+format, args...) } func (conn *SSHConn) Debugf(ctx context.Context, format string, args ...any) { blocklogger.Debugf(ctx, "[conndebug] "+format, args...) } func (conn *SSHConn) FireConnChangeEvent() { status := conn.DeriveConnStatus() event := wps.WaveEvent{ Event: wps.Event_ConnChange, Scopes: []string{ fmt.Sprintf("connection:%s", conn.GetName()), }, Data: status, } log.Printf("sending event: %+#v", event) wps.Broker.Publish(event) } func (conn *SSHConn) Close() error { conn.lifecycleLock.Lock() defer conn.lifecycleLock.Unlock() defer conn.FireConnChangeEvent() conn.WithLock(func() { if conn.Status == Status_Connected || conn.Status == Status_Connecting { // if status is init, disconnected, or error don't change it conn.Status = Status_Disconnected } }) conn.closeInternal_withlifecyclelock() return nil } func (conn *SSHConn) closeInternal_withlifecyclelock() { // does not set status (that should happen at another level) conn.WithLock(func() { if conn.Monitor != nil { conn.Monitor.Close() conn.Monitor = nil } conn.Monitor = nil }) client := conn.GetClient() if client != nil { // this MUST go first to force close the connection. // the DomainSockListener.Close() sends SSH protocol packets which can block on a dead network conn startTime := time.Now() client.Close() duration := time.Since(startTime).Milliseconds() if duration > 100 { log.Printf("[conncontroller] conn:%s Client.Close() took %d ms", conn.GetName(), duration) } conn.WithLock(func() { conn.Client = nil }) } listener := WithLockRtn(conn, func() net.Listener { return conn.DomainSockListener }) if listener != nil { startTime := time.Now() listener.Close() duration := time.Since(startTime).Milliseconds() if duration > 100 { log.Printf("[conncontroller] conn:%s DomainSockListener.Close() took %d ms", conn.GetName(), duration) } conn.WithLock(func() { conn.DomainSockListener = nil conn.DomainSockName = "" }) } controller := WithLockRtn(conn, func() *ssh.Session { return conn.ConnController }) if controller != nil { startTime := time.Now() controller.Close() duration := time.Since(startTime).Milliseconds() if duration > 100 { log.Printf("[conncontroller] conn:%s ConnController.Close() took %d ms", conn.GetName(), duration) } conn.WithLock(func() { conn.ConnController = nil }) } } func (conn *SSHConn) GetDomainSocketName() string { conn.lock.Lock() defer conn.lock.Unlock() return conn.DomainSockName } func (conn *SSHConn) GetStatus() string { conn.lock.Lock() defer conn.lock.Unlock() return conn.Status } func (conn *SSHConn) GetName() string { // no lock required because opts is immutable return conn.Opts.String() } func (conn *SSHConn) OpenDomainSocketListener(ctx context.Context) error { conn.Infof(ctx, "running OpenDomainSocketListener...\n") allowed := WithLockRtn(conn, func() bool { return conn.Status == Status_Connecting }) if !allowed { return fmt.Errorf("cannot open domain socket for %q when status is %q", conn.GetName(), conn.GetStatus()) } client := conn.GetClient() randStr, err := utilfn.RandomHexString(16) // 64-bits of randomness if err != nil { return fmt.Errorf("error generating random string: %w", err) } sockName := fmt.Sprintf("/tmp/waveterm-%s.sock", randStr) conn.Infof(ctx, "generated domain socket name %s\n", sockName) listener, err := client.ListenUnix(sockName) if err != nil { return fmt.Errorf("unable to request connection domain socket: %v", err) } conn.WithLock(func() { conn.DomainSockName = sockName conn.DomainSockListener = listener }) conn.Infof(ctx, "successfully connected domain socket\n") go func() { defer func() { panichandler.PanicHandler("conncontroller:OpenDomainSocketListener", recover()) }() defer conn.WithLock(func() { conn.DomainSockListener = nil conn.DomainSockName = "" }) monitor := conn.GetMonitor() var updateCallback func() if monitor != nil { updateCallback = monitor.UpdateLastActivityTime } wshutil.RunWshRpcOverListener(listener, updateCallback) }() return nil } // expects the output of `wsh version` which looks like `wsh v0.10.4` or "not-installed [os] [arch]" // returns (up-to-date, semver, osArchStr, error) // if not up to date, or error, version might be "" func IsWshVersionUpToDate(logCtx context.Context, wshVersionLine string) (bool, string, string, error) { wshVersionLine = strings.TrimSpace(wshVersionLine) if strings.HasPrefix(wshVersionLine, "not-installed") { return false, "not-installed", strings.TrimSpace(strings.TrimPrefix(wshVersionLine, "not-installed")), nil } parts := strings.Fields(wshVersionLine) if len(parts) != 2 { return false, "", "", fmt.Errorf("unexpected version format: %s", wshVersionLine) } clientVersion := parts[1] expectedVersion := fmt.Sprintf("v%s", wavebase.WaveVersion) if semver.Compare(clientVersion, expectedVersion) < 0 { return false, clientVersion, "", nil } return true, clientVersion, "", nil } // for testing only -- trying to determine the env difference when attaching or not attaching a pty to an ssh session func (conn *SSHConn) GetEnvironmentMaps(ctx context.Context) (map[string]string, map[string]string, error) { client := conn.GetClient() if client == nil { return nil, nil, fmt.Errorf("ssh client is not connected") } noPtyEnv, err := conn.getEnvironmentNoPty(ctx, client) if err != nil { return nil, nil, fmt.Errorf("error getting environment without PTY: %w", err) } ptyEnv, err := conn.getEnvironmentWithPty(ctx, client) if err != nil { return nil, nil, fmt.Errorf("error getting environment with PTY: %w", err) } return noPtyEnv, ptyEnv, nil } func runSessionWithContext(ctx context.Context, session *ssh.Session, cmd string) error { errCh := make(chan error, 1) go func() { errCh <- session.Run(cmd) }() select { case <-ctx.Done(): session.Close() return ctx.Err() case err := <-errCh: return err } } func (conn *SSHConn) getEnvironmentNoPty(ctx context.Context, client *ssh.Client) (map[string]string, error) { session, err := client.NewSession() if err != nil { return nil, fmt.Errorf("unable to create ssh session: %w", err) } defer session.Close() outputBuf := &strings.Builder{} session.Stdout = outputBuf session.Stderr = outputBuf err = runSessionWithContext(ctx, session, "env -0") if err != nil { return nil, fmt.Errorf("error running env command: %w", err) } return envutil.EnvToMap(outputBuf.String()), nil } func (conn *SSHConn) getEnvironmentWithPty(ctx context.Context, client *ssh.Client) (map[string]string, error) { session, err := client.NewSession() if err != nil { return nil, fmt.Errorf("unable to create ssh session: %w", err) } defer session.Close() termSize := waveobj.TermSize{Rows: 24, Cols: 80} err = session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) if err != nil { return nil, fmt.Errorf("unable to request PTY: %w", err) } outputBuf := &strings.Builder{} session.Stdout = outputBuf session.Stderr = outputBuf err = runSessionWithContext(ctx, session, "env -0") if err != nil { return nil, fmt.Errorf("error running env command: %w", err) } return envutil.EnvToMap(outputBuf.String()), nil } func (conn *SSHConn) getWshPath() string { config, ok := conn.getConnectionConfig() if ok && config.ConnWshPath != "" { return config.ConnWshPath } return wavebase.RemoteFullWshBinPath } func (conn *SSHConn) GetConfigShellPath() string { config, ok := conn.getConnectionConfig() if !ok { return "" } return config.ConnShellPath } // returns (needsInstall, clientVersion, osArchStr, error) // if wsh is not installed, the clientVersion will be "not-installed", and it will also return an osArchStr // if clientVersion is set, then no osArchStr will be returned // if useRouterMode is true, will start connserver with --router-domainsocket flag func (conn *SSHConn) StartConnServer(ctx context.Context, afterUpdate bool, useRouterMode bool) (bool, string, string, error) { conn.Infof(ctx, "running StartConnServer (routerMode=%v)...\n", useRouterMode) allowed := WithLockRtn(conn, func() bool { return conn.Status == Status_Connecting }) if !allowed { return false, "", "", fmt.Errorf("cannot start conn server for %q when status is %q", conn.GetName(), conn.GetStatus()) } client := conn.GetClient() wshPath := conn.getWshPath() sockName := conn.GetDomainSocketName() var rpcCtx wshrpc.RpcContext if useRouterMode { rpcCtx = wshrpc.RpcContext{ IsRouter: true, SockName: sockName, Conn: conn.GetName(), } } else { rpcCtx = wshrpc.RpcContext{ RouteId: wshutil.MakeConnectionRouteId(conn.GetName()), SockName: sockName, Conn: conn.GetName(), } } jwtToken, err := wshutil.MakeClientJWTToken(rpcCtx) if err != nil { return false, "", "", fmt.Errorf("unable to create jwt token for conn controller: %w", err) } conn.Infof(ctx, "SSH-NEWSESSION (StartConnServer)\n") sshSession, err := client.NewSession() if err != nil { return false, "", "", fmt.Errorf("unable to create ssh session for conn controller: %w", err) } pipeRead, pipeWrite := io.Pipe() sshSession.Stdout = pipeWrite sshSession.Stderr = pipeWrite stdinPipe, err := sshSession.StdinPipe() if err != nil { return false, "", "", fmt.Errorf("unable to get stdin pipe: %w", err) } devFlag := "" if wavebase.IsDevMode() { devFlag = "--dev" } routerFlag := "" if useRouterMode { routerFlag = "--router-domainsocket" } cmdStr := fmt.Sprintf(ConnServerCmdTemplate, wshPath, wshPath, shellutil.HardQuote(conn.GetName()), devFlag, routerFlag) log.Printf("starting conn controller: %q\n", cmdStr) shWrappedCmdStr := fmt.Sprintf("sh -c %s", shellutil.HardQuote(cmdStr)) blocklogger.Debugf(ctx, "[conndebug] wrapped command:\n%s\n", shWrappedCmdStr) err = sshSession.Start(shWrappedCmdStr) if err != nil { return false, "", "", fmt.Errorf("unable to start conn controller command: %w", err) } linesChan := utilfn.StreamToLinesChan(pipeRead) versionLine, err := utilfn.ReadLineWithTimeout(linesChan, utilfn.TimeoutFromContext(ctx, 30*time.Second)) if err != nil { sshSession.Close() return false, "", "", fmt.Errorf("error reading wsh version: %w", err) } conn.Infof(ctx, "actual connnserverversion: %q\n", versionLine) conn.Infof(ctx, "got connserver version: %s\n", strings.TrimSpace(versionLine)) isUpToDate, clientVersion, osArchStr, err := IsWshVersionUpToDate(ctx, versionLine) if err != nil { sshSession.Close() return false, "", "", fmt.Errorf("error checking wsh version: %w", err) } if isUpToDate && !afterUpdate && os.Getenv(wavebase.WaveWshForceUpdateVarName) != "" { isUpToDate = false conn.Infof(ctx, "%s set, forcing wsh update\n", wavebase.WaveWshForceUpdateVarName) } conn.Infof(ctx, "connserver up-to-date: %v\n", isUpToDate) if !isUpToDate { sshSession.Close() return true, clientVersion, osArchStr, nil } jwtLine, err := utilfn.ReadLineWithTimeout(linesChan, 3*time.Second) if err != nil { sshSession.Close() return false, clientVersion, "", fmt.Errorf("error reading jwt status line: %w", err) } conn.Infof(ctx, "got jwt status line: %s\n", jwtLine) if strings.TrimSpace(jwtLine) == wavebase.NeedJwtConst { // write the jwt conn.Infof(ctx, "writing jwt token to connserver\n") _, err = fmt.Fprintf(stdinPipe, "%s\n", jwtToken) if err != nil { sshSession.Close() return false, clientVersion, "", fmt.Errorf("failed to write JWT token: %w", err) } } conn.WithLock(func() { conn.ConnController = sshSession }) // service the I/O go func() { defer func() { panichandler.PanicHandler("conncontroller:sshSession.Wait", recover()) }() // wait for termination, clear the controller var waitErr error defer conn.WithLock(func() { if conn.ConnController != nil { conn.WshEnabled.Store(false) conn.NoWshReason = "connserver terminated" if waitErr != nil { conn.WshError = fmt.Sprintf("connserver terminated unexpectedly with error: %v", waitErr) } } conn.ConnController = nil }) waitErr = sshSession.Wait() log.Printf("conn controller (%q) terminated: %v", conn.GetName(), waitErr) }() go func() { defer func() { panichandler.PanicHandler("conncontroller:sshSession-output", recover()) }() for output := range linesChan { if output.Error != nil { log.Printf("[conncontroller:%s:output] error: %v\n", conn.GetName(), output.Error) continue } monitor := conn.GetMonitor() if monitor != nil { monitor.UpdateLastActivityTime() } line := output.Line if !strings.HasSuffix(line, "\n") { line += "\n" } log.Printf("[conncontroller:%s:output] %s", conn.GetName(), line) } }() conn.Infof(ctx, "connserver started, waiting for route to be registered\n") regCtx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() connRoute := wshutil.MakeConnectionRouteId(rpcCtx.Conn) err = wshutil.DefaultRouter.WaitForRegister(regCtx, connRoute) if err != nil { return false, clientVersion, "", fmt.Errorf("timeout waiting for connserver to register") } time.Sleep(300 * time.Millisecond) // TODO remove this sleep (but we need to wait until connserver is "ready") err = wshclient.ConnServerInitCommand( wshclient.GetBareRpcClient(), wshrpc.CommandConnServerInitData{ClientId: wstore.GetClientId()}, &wshrpc.RpcOpts{Route: connRoute}, ) if err != nil { return false, clientVersion, "", fmt.Errorf("connserver init failed: %w", err) } conn.Infof(ctx, "connserver is registered and ready\n") return false, clientVersion, "", nil } type WshInstallOpts struct { Force bool NoUserPrompt bool } var queryTextTemplate = strings.TrimSpace(` Wave requires Wave Shell Extensions to be installed on %q to ensure a seamless experience. Would you like to install them? `) func (conn *SSHConn) UpdateWsh(ctx context.Context, clientDisplayName string, remoteInfo *wshrpc.RemoteInfo) error { conn.Infof(ctx, "attempting to update wsh for connection %s (os:%s arch:%s version:%s)\n", conn.GetName(), remoteInfo.ClientOs, remoteInfo.ClientArch, remoteInfo.ClientVersion) client := conn.GetClient() if client == nil { return fmt.Errorf("cannot update wsh: ssh client is not connected") } err := remote.CpWshToRemote(ctx, client, remoteInfo.ClientOs, remoteInfo.ClientArch) if err != nil { return fmt.Errorf("error installing wsh to remote: %w", err) } conn.Infof(ctx, "successfully updated wsh on %s\n", conn.GetName()) return nil } // returns (allowed, error) func (conn *SSHConn) getPermissionToInstallWsh(ctx context.Context, clientDisplayName string) (bool, error) { conn.Infof(ctx, "running getPermissionToInstallWsh...\n") queryText := fmt.Sprintf(queryTextTemplate, clientDisplayName) title := "Install Wave Shell Extensions" request := &userinput.UserInputRequest{ ResponseType: "confirm", QueryText: queryText, Title: title, Markdown: true, CheckBoxMsg: "Automatically install for all connections", OkLabel: "Install wsh", CancelLabel: "No wsh", } conn.Infof(ctx, "requesting user confirmation...\n") response, err := userinput.GetUserInput(ctx, request) if err != nil { conn.Infof(ctx, "error getting user input: %v\n", err) return false, err } conn.Infof(ctx, "user response to allowing wsh: %v\n", response.Confirm) meta := make(map[string]any) meta["conn:wshenabled"] = response.Confirm conn.Infof(ctx, "writing conn:wshenabled=%v to connections.json\n", response.Confirm) err = wconfig.SetConnectionsConfigValue(conn.GetName(), meta) if err != nil { log.Printf("warning: error writing to connections file: %v", err) } if !response.Confirm { return false, nil } if response.CheckboxStat { conn.Infof(ctx, "writing conn:askbeforewshinstall=false to settings.json\n") meta := waveobj.MetaMapType{ wconfig.ConfigKey_ConnAskBeforeWshInstall: false, } setConfigErr := wconfig.SetBaseConfigValue(meta) if setConfigErr != nil { // this is not a critical error, just log and continue log.Printf("warning: error writing to base config file: %v", err) } } return true, nil } func (conn *SSHConn) InstallWsh(ctx context.Context, osArchStr string) error { conn.Infof(ctx, "running installWsh...\n") client := conn.GetClient() if client == nil { conn.Infof(ctx, "ERROR ssh client is not connected, cannot install\n") return fmt.Errorf("ssh client is not connected, cannot install") } var clientOs, clientArch string var err error if osArchStr != "" { clientOs, clientArch, err = remote.GetClientPlatformFromOsArchStr(ctx, osArchStr) } else { clientOs, clientArch, err = remote.GetClientPlatform(ctx, genconn.MakeSSHShellClient(client)) } if err != nil { conn.Infof(ctx, "ERROR detecting client platform: %v\n", err) return fmt.Errorf("error detecting client platform: %w", err) } conn.Infof(ctx, "detected remote platform os:%s arch:%s\n", clientOs, clientArch) err = remote.CpWshToRemote(ctx, client, clientOs, clientArch) if err != nil { conn.Infof(ctx, "ERROR copying wsh binary to remote: %v\n", err) return fmt.Errorf("error copying wsh binary to remote: %w", err) } conn.Infof(ctx, "successfully installed wsh\n") return nil } func (conn *SSHConn) GetClient() *ssh.Client { conn.lock.Lock() defer conn.lock.Unlock() return conn.Client } func (conn *SSHConn) GetMonitor() *ConnMonitor { conn.lock.Lock() defer conn.lock.Unlock() return conn.Monitor } func (conn *SSHConn) WaitForConnect(ctx context.Context) error { for { status := conn.DeriveConnStatus() if status.Status == Status_Connected { return nil } if status.Status == Status_Connecting { select { case <-ctx.Done(): return fmt.Errorf("context timeout") case <-time.After(100 * time.Millisecond): continue } } if status.Status == Status_Init || status.Status == Status_Disconnected { return fmt.Errorf("disconnected") } if status.Status == Status_Error { return fmt.Errorf("error: %v", status.Error) } return fmt.Errorf("unknown status: %q", status.Status) } } // does not return an error since that error is stored inside of SSHConn func (conn *SSHConn) Connect(ctx context.Context, connFlags *wconfig.ConnKeywords) error { conn.lifecycleLock.Lock() defer conn.lifecycleLock.Unlock() blocklogger.Infof(ctx, "\n") var connectAllowed bool conn.WithLock(func() { if conn.Status == Status_Connecting || conn.Status == Status_Connected { connectAllowed = false } else { conn.Status = Status_Connecting conn.Error = "" connectAllowed = true } }) if !connectAllowed { conn.Infof(ctx, "cannot connect to %q when status is %q\n", conn.GetName(), conn.GetStatus()) return fmt.Errorf("cannot connect to %q when status is %q", conn.GetName(), conn.GetStatus()) } conn.Infof(ctx, "trying to connect to %q...\n", conn.GetName()) conn.FireConnChangeEvent() err := conn.connectInternal(ctx, connFlags) if err != nil { errorCode, subCode := remote.ClassifyConnError(err) isContextError := errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) conn.Infof(ctx, "ERROR [%s] %v\n\n", errorCode, err) conn.WithLock(func() { conn.Status = Status_Error conn.Error = err.Error() }) conn.closeInternal_withlifecyclelock() telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{ Conn: map[string]int{"ssh:connecterror": 1}, }, "ssh-connconnect") telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "conn:connecterror", Props: telemetrydata.TEventProps{ ConnType: "ssh", ConnErrorCode: errorCode, ConnSubErrorCode: subCode, ConnContextError: isContextError, }, }) } else { conn.Infof(ctx, "successfully connected (wsh:%v)\n\n", conn.WshEnabled.Load()) conn.WithLock(func() { conn.Status = Status_Connected conn.LastConnectTime = time.Now().UnixMilli() if conn.ActiveConnNum == 0 { conn.ActiveConnNum = int(activeConnCounter.Add(1)) } }) telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{ Conn: map[string]int{"ssh:connect": 1}, }, "ssh-connconnect") telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "conn:connect", Props: telemetrydata.TEventProps{ ConnType: "ssh", }, }) } conn.FireConnChangeEvent() if err != nil { return err } // logic for saving connection and potential flags (we only save once a connection has been made successfully) // at the moment, identity files is the only saved flag var identityFiles []string existingConnection, ok := conn.getConnectionConfig() if ok { identityFiles = existingConnection.SshIdentityFile } if err != nil { // i do not consider this a critical failure log.Printf("config read error: unable to save connection %s: %v", conn.GetName(), err) } meta := make(map[string]any) if connFlags.SshIdentityFile != nil { for _, identityFile := range connFlags.SshIdentityFile { if utilfn.ContainsStr(identityFiles, identityFile) { continue } identityFiles = append(identityFiles, connFlags.SshIdentityFile...) } meta["ssh:identityfile"] = identityFiles } err = wconfig.SetConnectionsConfigValue(conn.GetName(), meta) if err != nil { // i do not consider this a critical failure log.Printf("config write error: unable to save connection %s: %v", conn.GetName(), err) } return nil } func (conn *SSHConn) WithLock(fn func()) { conn.lock.Lock() defer conn.lock.Unlock() fn() } func WithLockRtn[T any](conn *SSHConn, fn func() T) T { conn.lock.Lock() defer conn.lock.Unlock() return fn() } // returns (enable-wsh, ask-before-install) func (conn *SSHConn) getConnWshSettings() (bool, bool) { config := wconfig.GetWatcher().GetFullConfig() enableWsh := config.Settings.ConnWshEnabled askBeforeInstall := wconfig.DefaultBoolPtr(config.Settings.ConnAskBeforeWshInstall, true) connSettings, ok := conn.getConnectionConfig() if ok { if connSettings.ConnWshEnabled != nil { enableWsh = *connSettings.ConnWshEnabled } // if the connection object exists, and conn:askbeforewshinstall is not set, the user must have allowed it // TODO: in v0.12+ this should be removed. we'll explicitly write a "false" into the connection object on successful connection if connSettings.ConnAskBeforeWshInstall == nil { askBeforeInstall = false } else { askBeforeInstall = *connSettings.ConnAskBeforeWshInstall } } return enableWsh, askBeforeInstall } type WshCheckResult struct { WshEnabled bool ClientVersion string NoWshReason string NoWshCode string WshError error } // returns (wsh-enabled, clientVersion, text-reason, wshError) func (conn *SSHConn) tryEnableWsh(ctx context.Context, clientDisplayName string) WshCheckResult { conn.Infof(ctx, "running tryEnableWsh...\n") enableWsh, askBeforeInstall := conn.getConnWshSettings() conn.Infof(ctx, "wsh settings enable:%v ask:%v\n", enableWsh, askBeforeInstall) if !enableWsh { return WshCheckResult{NoWshReason: "conn:wshenabled set to false", NoWshCode: NoWshCode_Disabled} } if askBeforeInstall { allowInstall, err := conn.getPermissionToInstallWsh(ctx, clientDisplayName) if err != nil { log.Printf("error getting permission to install wsh: %v\n", err) return WshCheckResult{NoWshReason: "error getting user permission to install", NoWshCode: NoWshCode_PermissionError, WshError: err} } if !allowInstall { return WshCheckResult{NoWshReason: "user selected not to install wsh extensions", NoWshCode: NoWshCode_UserDeclined} } } err := conn.OpenDomainSocketListener(ctx) if err != nil { conn.Infof(ctx, "ERROR opening domain socket listener: %v\n", err) err = fmt.Errorf("error opening domain socket listener: %w", err) return WshCheckResult{NoWshReason: "error opening domain socket", NoWshCode: NoWshCode_DomainSocketError, WshError: err} } needsInstall, clientVersion, osArchStr, err := conn.StartConnServer(ctx, false, true) if err != nil { conn.Infof(ctx, "ERROR starting conn server: %v\n", err) err = fmt.Errorf("error starting conn server: %w", err) return WshCheckResult{NoWshReason: "error starting connserver", NoWshCode: NoWshCode_ConnServerStartError, WshError: err} } if needsInstall { conn.Infof(ctx, "connserver needs to be (re)installed\n") err = conn.InstallWsh(ctx, osArchStr) if err != nil { conn.Infof(ctx, "ERROR installing wsh: %v\n", err) err = fmt.Errorf("error installing wsh: %w", err) return WshCheckResult{NoWshReason: "error installing wsh/connserver", NoWshCode: NoWshCode_InstallError, WshError: err} } needsInstall, clientVersion, _, err = conn.StartConnServer(ctx, true, true) if err != nil { conn.Infof(ctx, "ERROR starting conn server (after install): %v\n", err) err = fmt.Errorf("error starting conn server (after install): %w", err) return WshCheckResult{NoWshReason: "error starting connserver", NoWshCode: NoWshCode_PostInstallStartError, WshError: err} } if needsInstall { conn.Infof(ctx, "conn server not installed correctly (after install)\n") err = fmt.Errorf("conn server not installed correctly (after install)") return WshCheckResult{NoWshReason: "connserver not installed properly", NoWshCode: NoWshCode_InstallVerifyError, WshError: err} } return WshCheckResult{WshEnabled: true, ClientVersion: clientVersion} } else { return WshCheckResult{WshEnabled: true, ClientVersion: clientVersion} } } func (conn *SSHConn) getConnectionConfig() (wconfig.ConnKeywords, bool) { config := wconfig.GetWatcher().GetFullConfig() connSettings, ok := config.Connections[conn.GetName()] if !ok { return wconfig.ConnKeywords{}, false } return connSettings, true } func (conn *SSHConn) persistWshInstalled(ctx context.Context, result WshCheckResult) { conn.WshEnabled.Store(result.WshEnabled) conn.SetWshError(result.WshError) conn.WithLock(func() { conn.NoWshReason = result.NoWshReason conn.WshVersion = result.ClientVersion }) connConfig, ok := conn.getConnectionConfig() if ok && connConfig.ConnWshEnabled != nil { return } meta := make(map[string]any) meta["conn:wshenabled"] = result.WshEnabled err := wconfig.SetConnectionsConfigValue(conn.GetName(), meta) if err != nil { conn.Infof(ctx, "WARN could not write conn:wshenabled=%v to connections.json: %v\n", result.WshEnabled, err) log.Printf("warning: error writing to connections file: %v", err) } // doesn't return an error since none of this is required for connection to work } // returns (connect-error) func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wconfig.ConnKeywords) error { conn.Infof(ctx, "connectInternal %s\n", conn.GetName()) client, _, err := remote.ConnectToClient(ctx, conn.Opts, nil, 0, connFlags) if err != nil { conn.Infof(ctx, "ERROR ConnectToClient: %s\n", remote.SimpleMessageFromPossibleConnectionError(err)) log.Printf("error: failed to connect to client %s: %s\n", conn.GetName(), err) return err } conn.WithLock(func() { if conn.Monitor != nil { conn.Monitor.Close() conn.Monitor = nil } conn.Client = client conn.ConnHealthStatus = ConnHealthStatus_Good conn.Monitor = MakeConnMonitor(conn, client) }) go func() { defer func() { panichandler.PanicHandler("conncontroller:waitForDisconnect", recover()) }() conn.waitForDisconnect() }() fmtAddr := knownhosts.Normalize(fmt.Sprintf("%s@%s", client.User(), client.RemoteAddr().String())) conn.Infof(ctx, "normalized knownhosts address: %s\n", fmtAddr) clientDisplayName := fmt.Sprintf("%s (%s)", conn.GetName(), fmtAddr) wshResult := conn.tryEnableWsh(ctx, clientDisplayName) if !wshResult.WshEnabled { if wshResult.WshError != nil { conn.Infof(ctx, "ERROR enabling wsh: %v\n", wshResult.WshError) conn.Infof(ctx, "will connect with wsh disabled\n") } else { conn.Infof(ctx, "wsh not enabled: %s\n", wshResult.NoWshReason) } telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "conn:nowsh", Props: telemetrydata.TEventProps{ ConnType: "ssh", ConnWshErrorCode: wshResult.NoWshCode, }, }) } conn.persistWshInstalled(ctx, wshResult) return nil } func (conn *SSHConn) waitForDisconnect() { defer conn.FireConnChangeEvent() client := conn.GetClient() if client == nil { return } err := client.Wait() if err != nil { log.Printf("[conn:%s] client.Wait() returned error: %v", conn.GetName(), err) } else { log.Printf("[conn:%s] client.Wait() completed (clean disconnect)", conn.GetName()) } conn.lifecycleLock.Lock() defer conn.lifecycleLock.Unlock() conn.WithLock(func() { // disconnects happen for a variety of reasons (like network, etc. and are typically transient) // so we just set the status to "disconnected" here (not error) // don't overwrite any existing error (or error status) if err != nil && conn.Error == "" { conn.Error = err.Error() } if conn.Status != Status_Error { conn.Status = Status_Disconnected } }) conn.closeInternal_withlifecyclelock() } func (conn *SSHConn) SetWshError(err error) { conn.WithLock(func() { if err == nil { conn.WshError = "" } else { conn.WshError = err.Error() } }) } func (conn *SSHConn) ClearWshError() { conn.WithLock(func() { conn.WshError = "" }) } func (conn *SSHConn) SetConnHealthStatus(client *ssh.Client, status string) { changed := false conn.WithLock(func() { if conn.Client != client { return } if conn.ConnHealthStatus != status { conn.ConnHealthStatus = status changed = true } }) if changed { conn.FireConnChangeEvent() } } func (conn *SSHConn) GetConnHealthStatus() string { var status string conn.WithLock(func() { status = conn.ConnHealthStatus }) return status } func getConnInternal(opts *remote.SSHOpts, createIfNotExists bool) *SSHConn { globalLock.Lock() defer globalLock.Unlock() rtn := clientControllerMap[*opts] if rtn == nil && createIfNotExists { rtn = &SSHConn{ lock: &sync.Mutex{}, lifecycleLock: &sync.Mutex{}, Status: Status_Init, ConnHealthStatus: ConnHealthStatus_Good, WshEnabled: &atomic.Bool{}, Opts: opts, } clientControllerMap[*opts] = rtn } return rtn } // does NOT connect, does not return nil func GetConn(opts *remote.SSHOpts) *SSHConn { conn := getConnInternal(opts, true) return conn } // does NOT connect, can return nil func MaybeGetConn(opts *remote.SSHOpts) *SSHConn { conn := getConnInternal(opts, false) return conn } func IsConnected(connName string) (bool, error) { if IsLocalConnName(connName) { return true, nil } connOpts, err := remote.ParseOpts(connName) if err != nil { return false, fmt.Errorf("error parsing connection name: %w", err) } conn := getConnInternal(connOpts, false) if conn == nil { return false, nil } return conn.GetStatus() == Status_Connected, nil } // Convenience function for ensuring a connection is established func EnsureConnection(ctx context.Context, connName string) error { if IsLocalConnName(connName) { return nil } connOpts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) } conn := GetConn(connOpts) if conn == nil { return fmt.Errorf("connection not found: %s", connName) } connStatus := conn.DeriveConnStatus() switch connStatus.Status { case Status_Connected: return nil case Status_Connecting: return conn.WaitForConnect(ctx) case Status_Init, Status_Disconnected: return conn.Connect(ctx, &wconfig.ConnKeywords{}) case Status_Error: return fmt.Errorf("connection error: %s", connStatus.Error) default: return fmt.Errorf("unknown connection status %q", connStatus.Status) } } func DisconnectClient(opts *remote.SSHOpts) error { conn := getConnInternal(opts, false) if conn == nil { return fmt.Errorf("client %q not found", opts.String()) } err := conn.Close() return err } func resolveSshConfigPatterns(configFiles []string) ([]string, error) { // using two separate containers to track order and have O(1) lookups // since go does not have an ordered map primitive var discoveredPatterns []string alreadyUsed := make(map[string]bool) alreadyUsed[""] = true // this excludes the empty string from potential alias var openedFiles []fs.File defer func() { for _, openedFile := range openedFiles { openedFile.Close() } }() var errs []error for _, configFile := range configFiles { fd, openErr := os.Open(configFile) openedFiles = append(openedFiles, fd) if fd == nil { errs = append(errs, openErr) continue } cfg, _ := ssh_config.Decode(fd, true) for _, host := range cfg.Hosts { // for each host, find the first good alias for _, hostPattern := range host.Patterns { hostPatternStr := hostPattern.String() if hostPatternStr == "" || strings.Contains(hostPatternStr, "*") || strings.Contains(hostPatternStr, "?") || strings.Contains(hostPatternStr, "!") { continue } normalized := remote.NormalizeConfigPattern(hostPatternStr) if !alreadyUsed[normalized] { discoveredPatterns = append(discoveredPatterns, normalized) alreadyUsed[normalized] = true break } } } } if len(errs) == len(configFiles) { errs = append([]error{fmt.Errorf("no ssh config files could be opened: ")}, errs...) return nil, errors.Join(errs...) } if len(discoveredPatterns) == 0 { return nil, fmt.Errorf("no compatible hostnames found in ssh config files") } return discoveredPatterns, nil } func GetConnectionsList() ([]string, error) { existing := GetAllConnStatus() var currentlyRunning []string var hasConnected []string // populate all lists for _, stat := range existing { if stat.Connected { currentlyRunning = append(currentlyRunning, stat.Connection) } if stat.HasConnected { hasConnected = append(hasConnected, stat.Connection) } } fromInternal := GetConnectionsFromInternalConfig() fromConfig, err := GetConnectionsFromConfig() if err != nil { // this is not a fatal error. do not return log.Printf("warning: no connections from ssh config found: %v", err) } // sort into one final list and remove duplicates alreadyUsed := make(map[string]struct{}) var connList []string for _, subList := range [][]string{currentlyRunning, hasConnected, fromInternal, fromConfig} { for _, pattern := range subList { if _, used := alreadyUsed[pattern]; !used { connList = append(connList, pattern) alreadyUsed[pattern] = struct{}{} } } } return connList, nil } func GetConnectionsFromInternalConfig() []string { var internalNames []string config := wconfig.GetWatcher().GetFullConfig() for internalName := range config.Connections { if strings.HasPrefix(internalName, "wsl://") { // don't add wsl conns to this list continue } internalNames = append(internalNames, internalName) } return internalNames } func GetConnectionsFromConfig() ([]string, error) { home := wavebase.GetHomeDir() localConfig := filepath.Join(home, ".ssh", "config") systemConfig := filepath.Join("/etc", "ssh", "config") sshConfigFiles := []string{localConfig, systemConfig} remote.WaveSshConfigUserSettings().ReloadConfigs() return resolveSshConfigPatterns(sshConfigFiles) } ================================================ FILE: pkg/remote/conncontroller/connmonitor.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package conncontroller import ( "context" "log" "sync" "sync/atomic" "time" "github.com/wavetermdev/waveterm/pkg/panichandler" "golang.org/x/crypto/ssh" ) // Lock ordering: conn.lock > cm.lock (conn.lock is outer, cm.lock is inner) // CRITICAL: Methods that hold cm.lock must NEVER call into SSHConn (deadlock - violates ordering). // Methods called from SSHConn while conn.lock is held should avoid acquiring cm.lock (keep locking simple). type ConnMonitor struct { lock *sync.Mutex Conn *SSHConn // always non-nil, set at creation Client *ssh.Client // always non-nil, set at creation LastActivityTime atomic.Int64 LastInputTime atomic.Int64 KeepAliveSentTime atomic.Int64 KeepAliveInFlight bool ctx context.Context cancelFunc context.CancelFunc inputNotifyCh chan int64 } func MakeConnMonitor(conn *SSHConn, client *ssh.Client) *ConnMonitor { if conn == nil { panic("conn cannot be nil") } if client == nil { panic("client cannot be nil") } ctx, cancelFunc := context.WithCancel(context.Background()) cm := &ConnMonitor{ lock: &sync.Mutex{}, Conn: conn, Client: client, ctx: ctx, cancelFunc: cancelFunc, inputNotifyCh: make(chan int64, 1), } go cm.keepAliveMonitor() return cm } // setConnHealthStatus calls into SSHConn.SetConnHealthStatus // CRITICAL: cm.lock must NOT be held when calling this method (violates lock ordering) func (cm *ConnMonitor) setConnHealthStatus(status string) { cm.Conn.SetConnHealthStatus(cm.Client, status) } func (cm *ConnMonitor) UpdateLastActivityTime() { cm.LastActivityTime.Store(time.Now().UnixMilli()) cm.setConnHealthStatus(ConnHealthStatus_Good) } func (cm *ConnMonitor) NotifyInput() { inputTime := time.Now().UnixMilli() cm.LastInputTime.Store(inputTime) select { case cm.inputNotifyCh <- inputTime: default: } } func (cm *ConnMonitor) isUrgent() bool { lastInput := cm.LastInputTime.Load() if lastInput == 0 { return false } return time.Now().UnixMilli()-lastInput < 10000 } func (cm *ConnMonitor) setKeepAliveInFlight() bool { cm.lock.Lock() defer cm.lock.Unlock() if cm.KeepAliveInFlight { return false } cm.KeepAliveInFlight = true cm.KeepAliveSentTime.Store(time.Now().UnixMilli()) return true } func (cm *ConnMonitor) clearKeepAliveInFlight() { cm.lock.Lock() defer cm.lock.Unlock() cm.KeepAliveInFlight = false } func (cm *ConnMonitor) getTimeSinceKeepAlive() int64 { cm.lock.Lock() defer cm.lock.Unlock() if !cm.KeepAliveInFlight { return 0 } return time.Now().UnixMilli() - cm.KeepAliveSentTime.Load() } func (cm *ConnMonitor) SendKeepAlive() error { client := cm.Client if !cm.setKeepAliveInFlight() { return nil } go func() { defer func() { panichandler.PanicHandler("conncontroller:SendKeepAlive", recover()) }() defer cm.clearKeepAliveInFlight() startTime := time.Now() _, _, err := client.SendRequest("keepalive@openssh.com", true, nil) if err != nil { // errors are only returned for network and I/O issues (likely disconnection). do not update last activity time duration := time.Since(startTime).Milliseconds() log.Printf("[conncontroller] conn:%s keepalive error (duration=%dms): %v", cm.Conn.GetName(), duration, err) return } cm.UpdateLastActivityTime() }() return nil } func (cm *ConnMonitor) checkConnection() { lastActivity := cm.LastActivityTime.Load() if lastActivity == 0 { return } urgent := cm.isUrgent() timeSinceActivity := time.Now().UnixMilli() - lastActivity keepAliveThreshold := int64(10000) if urgent { keepAliveThreshold = 1000 } if timeSinceActivity > keepAliveThreshold { cm.SendKeepAlive() } stalledThreshold := int64(10000) if urgent { stalledThreshold = 5000 } timeSinceKeepAlive := cm.getTimeSinceKeepAlive() if timeSinceKeepAlive > stalledThreshold { cm.setConnHealthStatus(ConnHealthStatus_Stalled) } } func (cm *ConnMonitor) keepAliveMonitor() { defer func() { panichandler.PanicHandler("conncontroller:keepAliveMonitor", recover()) }() ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { // check if our client is still the active one if cm.Conn.GetClient() != cm.Client { return } select { case <-ticker.C: cm.checkConnection() case inputTime := <-cm.inputNotifyCh: select { case <-time.After(1 * time.Second): if cm.LastActivityTime.Load() >= inputTime { break } cm.setConnHealthStatus(ConnHealthStatus_Degraded) cm.checkConnection() case <-cm.ctx.Done(): return } case <-cm.ctx.Done(): return } } } func (cm *ConnMonitor) Close() { if cm.cancelFunc != nil { cm.cancelFunc() } } ================================================ FILE: pkg/remote/connparse/connparse.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package connparse import ( "context" "fmt" "regexp" "strings" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" ) const ( ConnectionTypeWsh = "wsh" ConnHostCurrent = "current" ConnHostWaveSrv = "wavesrv" ) var windowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:`) var wslConnRegex = regexp.MustCompile(`^wsl://[^/]+`) type Connection struct { Scheme string Host string Path string } func (c *Connection) GetSchemeParts() []string { return strings.Split(c.Scheme, ":") } func (c *Connection) GetType() string { lastInd := strings.LastIndex(c.Scheme, ":") if lastInd == -1 { return c.Scheme } return c.Scheme[lastInd+1:] } func (c *Connection) GetPathWithHost() string { if c.Host == "" { return "" } if c.Path == "" { return c.Host } if strings.HasPrefix(c.Path, "/") { return c.Host + c.Path } return c.Host + "/" + c.Path } func (c *Connection) GetFullURI() string { return c.Scheme + "://" + c.GetPathWithHost() } func (c *Connection) GetSchemeAndHost() string { return c.Scheme + "://" + c.Host } func ParseURIAndReplaceCurrentHost(ctx context.Context, uri string) (*Connection, error) { conn, err := ParseURI(uri) if err != nil { return nil, fmt.Errorf("error parsing connection: %v", err) } if conn.Host == ConnHostCurrent { source, err := GetConnNameFromContext(ctx) if err != nil { return nil, fmt.Errorf("error getting connection name from context: %v", err) } // RPC context connection is empty for local connections if source == "" { source = wshrpc.LocalConnName } conn.Host = source } return conn, nil } func GetConnNameFromContext(ctx context.Context) (string, error) { handler := wshutil.GetRpcResponseHandlerFromContext(ctx) if handler == nil { return "", fmt.Errorf("error getting rpc response handler from context") } return handler.GetRpcContext().Conn, nil } // ParseURI parses a connection URI and returns the connection type, host/path, and parameters. func ParseURI(uri string) (*Connection, error) { isWshShorthand := strings.HasPrefix(uri, "//") split := strings.SplitN(uri, "://", 2) var scheme string var rest string if isWshShorthand { rest = strings.TrimPrefix(uri, "//") } else if len(split) > 1 { scheme = split[0] rest = strings.TrimPrefix(split[1], "//") } else { rest = split[0] } var host string var remotePath string parseGenericPath := func() { split = strings.SplitN(rest, "/", 2) host = split[0] if len(split) > 1 && split[1] != "" { remotePath = split[1] } else if strings.HasSuffix(rest, "/") { // preserve trailing slash remotePath = "/" } else { remotePath = "" } } parseWshPath := func() { if strings.HasPrefix(rest, "wsl://") { host = wslConnRegex.FindString(rest) remotePath = strings.TrimPrefix(rest, host) } else { parseGenericPath() } } addPrecedingSlash := true if scheme == "" { scheme = ConnectionTypeWsh addPrecedingSlash = false if isWshShorthand { parseWshPath() } else if strings.HasPrefix(rest, "/~") { host = wshrpc.LocalConnName remotePath = rest } else { host = ConnHostCurrent remotePath = rest } } else if scheme == ConnectionTypeWsh { parseWshPath() } else { parseGenericPath() } if scheme == ConnectionTypeWsh { if host == "" { host = wshrpc.LocalConnName } if strings.HasPrefix(remotePath, "/~") { remotePath = strings.TrimPrefix(remotePath, "/") } else if addPrecedingSlash && (len(remotePath) > 1 && !windowsDriveRegex.MatchString(remotePath) && !strings.HasPrefix(remotePath, "/") && !strings.HasPrefix(remotePath, "~") && !strings.HasPrefix(remotePath, "./") && !strings.HasPrefix(remotePath, "../") && !strings.HasPrefix(remotePath, ".\\") && !strings.HasPrefix(remotePath, "..\\") && remotePath != "..") { remotePath = "/" + remotePath } } conn := &Connection{ Scheme: scheme, Host: host, Path: remotePath, } return conn, nil } ================================================ FILE: pkg/remote/connparse/connparse_test.go ================================================ package connparse_test import ( "testing" "github.com/wavetermdev/waveterm/pkg/remote/connparse" ) func TestParseURI_WSHWithScheme(t *testing.T) { t.Parallel() // Test with localhost cstr := "wsh://user@localhost:8080/path/to/file" c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected := "/path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "user@localhost:8080" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "user@localhost:8080/path/to/file" pathWithHost := c.GetPathWithHost() if pathWithHost != expected { t.Fatalf("expected path with host to be \"%q\", got \"%q\"", expected, pathWithHost) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } if len(c.GetSchemeParts()) != 1 { t.Fatalf("expected scheme parts to be 1, got %d", len(c.GetSchemeParts())) } // Test with an IP address cstr = "wsh://user@192.168.0.1:22/path/to/file" c, err = connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected = "/path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "user@192.168.0.1:22" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "user@192.168.0.1:22/path/to/file" pathWithHost = c.GetPathWithHost() if pathWithHost != expected { t.Fatalf("expected path with host to be \"%q\", got \"%q\"", expected, pathWithHost) } expected = "wsh" if c.GetType() != expected { t.Fatalf("expected conn type to be \"%q\", got \"%q\"", expected, c.Scheme) } if len(c.GetSchemeParts()) != 1 { t.Fatalf("expected scheme parts to be 1, got %d", len(c.GetSchemeParts())) } got := c.GetFullURI() if got != cstr { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", cstr, got) } } func TestParseURI_WSHRemoteShorthand(t *testing.T) { t.Parallel() // Test with a simple remote path cstr := "//conn/path/to/file" c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected := "path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "conn" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://conn/path/to/file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } // Test with a complex remote path cstr = "//user@localhost:8080/path/to/file" c, err = connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected = "path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "user@localhost:8080" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://user@localhost:8080/path/to/file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } // Test with an IP address cstr = "//user@192.168.0.1:8080/path/to/file" c, err = connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected = "path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "user@192.168.0.1:8080" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://user@192.168.0.1:8080/path/to/file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } } func TestParseURI_WSHCurrentPathShorthand(t *testing.T) { t.Parallel() // Test with a relative path to home cstr := "~/path/to/file" c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected := "~/path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "current" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://current/~/path/to/file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } // Test with a absolute path cstr = "/path/to/file" c, err = connparse.ParseURI(cstr) if err != nil { t.Fatalf("expected nil, got %v", err) } expected = "/path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "current" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://current/path/to/file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } } func TestParseURI_WSHCurrentPath(t *testing.T) { cstr := "./Documents/path/to/file" c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected := "./Documents/path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "current" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://current/./Documents/path/to/file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } cstr = "path/to/file" c, err = connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected = "path/to/file" if c.Path != expected { t.Fatalf("expected path to be %q, got %q", expected, c.Path) } expected = "current" if c.Host != expected { t.Fatalf("expected host to be %q, got %q", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be %q, got %q", expected, c.Scheme) } expected = "wsh://current/path/to/file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be %q, got %q", expected, c.GetFullURI()) } cstr = "/etc/path/to/file" c, err = connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected = "/etc/path/to/file" if c.Path != expected { t.Fatalf("expected path to be %q, got %q", expected, c.Path) } expected = "current" if c.Host != expected { t.Fatalf("expected host to be %q, got %q", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be %q, got %q", expected, c.Scheme) } expected = "wsh://current/etc/path/to/file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be %q, got %q", expected, c.GetFullURI()) } } func TestParseURI_WSHCurrentPathWindows(t *testing.T) { cstr := ".\\Documents\\path\\to\\file" c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected := ".\\Documents\\path\\to\\file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "current" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://current/.\\Documents\\path\\to\\file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } } func TestParseURI_WSHLocalShorthand(t *testing.T) { t.Parallel() cstr := "/~/path/to/file" c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected := "~/path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } if c.Host != "local" { t.Fatalf("expected host to be empty, got \"%q\"", c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } cstr = "wsh:///~/path/to/file" c, err = connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected = "~/path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } if c.Host != "local" { t.Fatalf("expected host to be empty, got \"%q\"", c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://local/~/path/to/file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } } func TestParseURI_WSHWSL(t *testing.T) { t.Parallel() cstr := "wsh://wsl://Ubuntu/path/to/file" testUri := func() { c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected := "/path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "wsl://Ubuntu" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://wsl://Ubuntu/path/to/file" if expected != c.GetFullURI() { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } } t.Log("Testing with scheme") testUri() t.Log("Testing without scheme") cstr = "//wsl://Ubuntu/path/to/file" testUri() } func TestParseUri_LocalWindowsAbsPath(t *testing.T) { t.Parallel() cstr := "wsh://local/C:\\path\\to\\file" testAbsPath := func() { c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected := "C:\\path\\to\\file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "local" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://local/C:\\path\\to\\file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } } t.Log("Testing with scheme") testAbsPath() t.Log("Testing without scheme") cstr = "//local/C:\\path\\to\\file" testAbsPath() } func TestParseURI_LocalWindowsRelativeShorthand(t *testing.T) { cstr := "/~\\path\\to\\file" c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected := "~\\path\\to\\file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "local" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "wsh" if c.Scheme != expected { t.Fatalf("expected scheme to be \"%q\", got \"%q\"", expected, c.Scheme) } expected = "wsh://local/~\\path\\to\\file" if c.GetFullURI() != expected { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", expected, c.GetFullURI()) } } func TestParseURI_BasicS3(t *testing.T) { t.Parallel() cstr := "profile:s3://bucket/path/to/file" c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } expected := "path/to/file" if c.Path != expected { t.Fatalf("expected path to be \"%q\", got \"%q\"", expected, c.Path) } expected = "bucket" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } expected = "bucket/path/to/file" pathWithHost := c.GetPathWithHost() if pathWithHost != expected { t.Fatalf("expected path with host to be \"%q\", got \"%q\"", expected, pathWithHost) } expected = "s3" if c.GetType() != expected { t.Fatalf("expected conn type to be \"%q\", got \"%q\"", expected, c.GetType()) } if len(c.GetSchemeParts()) != 2 { t.Fatalf("expected scheme parts to be 2, got %d", len(c.GetSchemeParts())) } } func TestParseURI_S3BucketOnly(t *testing.T) { t.Parallel() testUri := func(cstr string, pathExpected string, pathWithHostExpected string) { c, err := connparse.ParseURI(cstr) if err != nil { t.Fatalf("failed to parse URI: %v", err) } if c.Path != pathExpected { t.Fatalf("expected path to be \"%q\", got \"%q\"", pathExpected, c.Path) } expected := "bucket" if c.Host != expected { t.Fatalf("expected host to be \"%q\", got \"%q\"", expected, c.Host) } pathWithHost := c.GetPathWithHost() if pathWithHost != pathWithHostExpected { t.Fatalf("expected path with host to be \"%q\", got \"%q\"", expected, pathWithHost) } expected = "s3" if c.GetType() != expected { t.Fatalf("expected conn type to be \"%q\", got \"%q\"", expected, c.GetType()) } if len(c.GetSchemeParts()) != 2 { t.Fatalf("expected scheme parts to be 2, got %d", len(c.GetSchemeParts())) } fullUri := c.GetFullURI() if fullUri != cstr { t.Fatalf("expected full URI to be \"%q\", got \"%q\"", cstr, fullUri) } } t.Log("Testing with no trailing slash") testUri("profile:s3://bucket", "", "bucket") t.Log("Testing with trailing slash") testUri("profile:s3://bucket/", "/", "bucket/") } ================================================ FILE: pkg/remote/connutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package remote import ( "bytes" "context" "fmt" "io" "log" "os" "os/user" "path/filepath" "regexp" "strings" "text/template" "time" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/util/iterfn" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wconfig" "golang.org/x/crypto/ssh" ) var userHostRe = regexp.MustCompile(`^([a-zA-Z0-9][a-zA-Z0-9._@\\-]*@)?([a-zA-Z0-9][a-zA-Z0-9.-]*)(?::([0-9]+))?$`) func ParseOpts(input string) (*SSHOpts, error) { m := userHostRe.FindStringSubmatch(input) if m == nil { return nil, fmt.Errorf("invalid format of user@host argument") } remoteUser, remoteHost, remotePort := m[1], m[2], m[3] remoteUser = strings.Trim(remoteUser, "@") return &SSHOpts{SSHHost: remoteHost, SSHUser: remoteUser, SSHPort: remotePort}, nil } func normalizeOs(os string) string { os = strings.ToLower(strings.TrimSpace(os)) return os } func normalizeArch(arch string) string { arch = strings.ToLower(strings.TrimSpace(arch)) switch arch { case "x86_64", "amd64": arch = "x64" case "arm64", "aarch64": arch = "arm64" } return arch } // returns (os, arch, error) // guaranteed to return a supported platform func GetClientPlatform(ctx context.Context, shell genconn.ShellClient) (string, string, error) { blocklogger.Infof(ctx, "[conndebug] running `uname -sm` to detect client platform\n") stdout, stderr, err := genconn.RunSimpleCommand(ctx, shell, genconn.CommandSpec{ Cmd: "uname -sm", }) if err != nil { return "", "", fmt.Errorf("error running uname -sm: %w, stderr: %s", err, stderr) } // Parse and normalize output parts := strings.Fields(strings.ToLower(strings.TrimSpace(stdout))) if len(parts) != 2 { return "", "", fmt.Errorf("unexpected output from uname: %s", stdout) } os, arch := normalizeOs(parts[0]), normalizeArch(parts[1]) if err := wavebase.ValidateWshSupportedArch(os, arch); err != nil { return "", "", err } return os, arch, nil } func GetClientPlatformFromOsArchStr(ctx context.Context, osArchStr string) (string, string, error) { parts := strings.Fields(strings.TrimSpace(osArchStr)) if len(parts) != 2 { return "", "", fmt.Errorf("unexpected output from uname: %s", osArchStr) } os, arch := normalizeOs(parts[0]), normalizeArch(parts[1]) if err := wavebase.ValidateWshSupportedArch(os, arch); err != nil { return "", "", err } return os, arch, nil } var installTemplateRawDefault = strings.TrimSpace(` mkdir -p {{.installDir}} || exit 1; cat > {{.tempPath}} || exit 1; mv {{.tempPath}} {{.installPath}} || exit 1; chmod a+x {{.installPath}} || exit 1; `) var installTemplate = template.Must(template.New("wsh-install-template").Parse(installTemplateRawDefault)) func CpWshToRemote(ctx context.Context, client *ssh.Client, clientOs string, clientArch string) error { deadline, ok := ctx.Deadline() if ok { blocklogger.Debugf(ctx, "[conndebug] CpWshToRemote, timeout: %v\n", time.Until(deadline)) } wshLocalPath, err := shellutil.GetLocalWshBinaryPath(wavebase.WaveVersion, clientOs, clientArch) if err != nil { return err } input, err := os.Open(wshLocalPath) if err != nil { return fmt.Errorf("cannot open local file %s: %w", wshLocalPath, err) } defer input.Close() installWords := map[string]string{ "installDir": filepath.ToSlash(filepath.Dir(wavebase.RemoteFullWshBinPath)), "tempPath": wavebase.RemoteFullWshBinPath + ".temp", "installPath": wavebase.RemoteFullWshBinPath, } var installCmd bytes.Buffer if err := installTemplate.Execute(&installCmd, installWords); err != nil { return fmt.Errorf("failed to prepare install command: %w", err) } blocklogger.Infof(ctx, "[conndebug] copying %q to remote server %q\n", wshLocalPath, wavebase.RemoteFullWshBinPath) genCmd, err := genconn.MakeSSHCmdClient(client, genconn.CommandSpec{ Cmd: installCmd.String(), }) if err != nil { return fmt.Errorf("failed to create remote command: %w", err) } stdin, err := genCmd.StdinPipe() if err != nil { return fmt.Errorf("failed to get stdin pipe: %w", err) } defer stdin.Close() stderrBuf, err := genconn.MakeStderrSyncBuffer(genCmd) if err != nil { return fmt.Errorf("failed to get stderr pipe: %w", err) } if err := genCmd.Start(); err != nil { return fmt.Errorf("failed to start remote command: %w", err) } copyDone := make(chan error, 1) go func() { defer close(copyDone) defer stdin.Close() if _, err := io.Copy(stdin, input); err != nil && err != io.EOF { copyDone <- fmt.Errorf("failed to copy data: %w", err) } else { copyDone <- nil } }() procErr := genconn.ProcessContextWait(ctx, genCmd) if procErr != nil { return fmt.Errorf("remote command failed: %w (stderr: %s)", procErr, stderrBuf.String()) } copyErr := <-copyDone if copyErr != nil { return fmt.Errorf("failed to copy data: %w (stderr: %s)", copyErr, stderrBuf.String()) } return nil } func IsPowershell(shellPath string) bool { // get the base path, and then check contains shellBase := filepath.Base(shellPath) return strings.Contains(shellBase, "powershell") || strings.Contains(shellBase, "pwsh") } func NormalizeConfigPattern(pattern string) string { userName, err := WaveSshConfigUserSettings().GetStrict(pattern, "User") if err != nil || userName == "" { log.Printf("warning: error parsing username of %s for conn dropdown: %v", pattern, err) localUser, err := user.Current() if err == nil { userName = localUser.Username } } port, err := WaveSshConfigUserSettings().GetStrict(pattern, "Port") if err != nil { port = "22" } if userName != "" { userName += "@" } if port == "22" { port = "" } else { port = ":" + port } return fmt.Sprintf("%s%s%s", userName, pattern, port) } func ParseProfiles() []string { connfile, cerrs := wconfig.ReadWaveHomeConfigFile(wconfig.ProfilesFile) if len(cerrs) > 0 { log.Printf("error reading config file: %v", cerrs[0]) return nil } return iterfn.MapKeysToSorted(connfile) } ================================================ FILE: pkg/remote/fileshare/fspath/fspath.go ================================================ package fspath import ( pathpkg "path" "strings" ) const ( // Separator is the path separator Separator = "/" ) func Dir(path string) string { return pathpkg.Dir(ToSlash(path)) } func Base(path string) string { return pathpkg.Base(ToSlash(path)) } func Join(elem ...string) string { joined := pathpkg.Join(elem...) return ToSlash(joined) } // FirstLevelDir returns the first level directory of a path and a boolean indicating if the path has more than one level. func FirstLevelDir(path string) (string, bool) { if strings.Count(path, Separator) > 0 { path = strings.SplitN(path, Separator, 2)[0] return path, true } return path, false } func ToSlash(path string) string { return strings.ReplaceAll(path, "\\", Separator) } ================================================ FILE: pkg/remote/fileshare/fsutil/fsutil.go ================================================ package fsutil import ( "bytes" "context" "encoding/base64" "fmt" "io" "strings" "github.com/wavetermdev/waveterm/pkg/remote/connparse" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/fspath" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) func GetParentPath(conn *connparse.Connection) string { hostAndPath := conn.GetPathWithHost() return GetParentPathString(hostAndPath) } func GetParentPathString(hostAndPath string) string { if hostAndPath == "" || hostAndPath == fspath.Separator { return "" } // Remove trailing slash if present if strings.HasSuffix(hostAndPath, fspath.Separator) { hostAndPath = hostAndPath[:len(hostAndPath)-1] } lastSlash := strings.LastIndex(hostAndPath, fspath.Separator) if lastSlash <= 0 { return "" } return hostAndPath[:lastSlash+1] } // CleanPathPrefix corrects paths for prefix filesystems (i.e. ones that don't have directories) func CleanPathPrefix(path string) (string, error) { if path == "" { return "", nil } if strings.HasPrefix(path, fspath.Separator) { path = path[1:] } if strings.HasPrefix(path, "~") || strings.HasPrefix(path, ".") || strings.HasPrefix(path, "..") { return "", fmt.Errorf("path cannot start with ~, ., or ..") } var newParts []string for _, part := range strings.Split(path, fspath.Separator) { if part == ".." { if len(newParts) > 0 { newParts = newParts[:len(newParts)-1] } } else if part != "." { newParts = append(newParts, part) } } return fspath.Join(newParts...), nil } func ReadFileStream(ctx context.Context, readCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData], fileInfoCallback func(finfo wshrpc.FileInfo), dirCallback func(entries []*wshrpc.FileInfo) error, fileCallback func(data io.Reader) error) error { var fileData *wshrpc.FileData firstPk := true isDir := false drain := true defer func() { if drain { utilfn.DrainChannelSafe(readCh, "ReadFileStream") } }() for { select { case <-ctx.Done(): return fmt.Errorf("context cancelled: %v", context.Cause(ctx)) case respUnion, ok := <-readCh: if !ok { drain = false return nil } if respUnion.Error != nil { return respUnion.Error } resp := respUnion.Response if firstPk { firstPk = false // first packet has the fileinfo if resp.Info == nil { return fmt.Errorf("stream file protocol error, first pk fileinfo is empty") } fileData = &resp if fileData.Info.IsDir { isDir = true } fileInfoCallback(*fileData.Info) continue } if isDir { if len(resp.Entries) == 0 { continue } if resp.Data64 != "" { return fmt.Errorf("stream file protocol error, directory entry has data") } if err := dirCallback(resp.Entries); err != nil { return err } } else { if resp.Data64 == "" { continue } decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader([]byte(resp.Data64))) if err := fileCallback(decoder); err != nil { return err } } } } } func ReadStreamToFileData(ctx context.Context, readCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData]) (*wshrpc.FileData, error) { var fileData *wshrpc.FileData var dataBuf bytes.Buffer var entries []*wshrpc.FileInfo err := ReadFileStream(ctx, readCh, func(finfo wshrpc.FileInfo) { fileData = &wshrpc.FileData{ Info: &finfo, } }, func(fileEntries []*wshrpc.FileInfo) error { entries = append(entries, fileEntries...) return nil }, func(data io.Reader) error { if _, err := io.Copy(&dataBuf, data); err != nil { return err } return nil }) if err != nil { return nil, err } if fileData == nil { return nil, fmt.Errorf("stream file protocol error, no file info") } if !fileData.Info.IsDir { fileData.Data64 = base64.StdEncoding.EncodeToString(dataBuf.Bytes()) } else { fileData.Entries = entries } return fileData, nil } func ReadFileStreamToWriter(ctx context.Context, readCh <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData], writer io.Writer) error { return ReadFileStream(ctx, readCh, func(finfo wshrpc.FileInfo) { }, func(entries []*wshrpc.FileInfo) error { return nil }, func(data io.Reader) error { _, err := io.Copy(writer, data) return err }) } ================================================ FILE: pkg/remote/fileshare/wshfs/wshfs.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshfs import ( "context" "encoding/base64" "fmt" "log" "os" "time" "github.com/wavetermdev/waveterm/pkg/remote/connparse" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/fsutil" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) const ( RemoteFileTransferSizeLimit = 32 * 1024 * 1024 DefaultTimeout = 30 * time.Second FileMode = os.FileMode(0644) DirMode = os.FileMode(0755) | os.ModeDir RecursiveRequiredError = "recursive flag must be set for directory operations" MergeRequiredError = "directory already exists at %q, set overwrite flag to delete the existing contents or set merge flag to merge the contents" OverwriteRequiredError = "file already exists at %q, set overwrite flag to delete the existing file" ) // This needs to be set by whoever initializes the client, either main-server or wshcmd-connserver var RpcClient *wshutil.WshRpc func parseConnection(ctx context.Context, path string) (*connparse.Connection, error) { conn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, path) if err != nil { return nil, fmt.Errorf("error parsing connection %s: %w", path, err) } return conn, nil } func Read(ctx context.Context, data wshrpc.FileData) (*wshrpc.FileData, error) { log.Printf("Read: %v", data.Info.Path) conn, err := parseConnection(ctx, data.Info.Path) if err != nil { return nil, err } rtnCh := readStream(conn, data) return fsutil.ReadStreamToFileData(ctx, rtnCh) } func ReadStream(ctx context.Context, data wshrpc.FileData) <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { log.Printf("ReadStream: %v", data.Info.Path) conn, err := parseConnection(ctx, data.Info.Path) if err != nil { return wshutil.SendErrCh[wshrpc.FileData](err) } return readStream(conn, data) } func readStream(conn *connparse.Connection, data wshrpc.FileData) <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { byteRange := "" if data.At != nil && data.At.Size > 0 { byteRange = fmt.Sprintf("%d-%d", data.At.Offset, data.At.Offset+int64(data.At.Size)-1) } streamFileData := wshrpc.CommandRemoteStreamFileData{Path: conn.Path, ByteRange: byteRange} return wshclient.RemoteStreamFileCommand(RpcClient, streamFileData, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) } func GetConnectionRouteId(ctx context.Context, path string) (string, error) { conn, err := parseConnection(ctx, path) if err != nil { return "", err } return wshutil.MakeConnectionRouteId(conn.Host), nil } func FileStream(ctx context.Context, data wshrpc.CommandFileStreamData) (*wshrpc.FileInfo, error) { if data.Info == nil { return nil, fmt.Errorf("file info is required") } log.Printf("FileStream: %v", data.Info.Path) conn, err := parseConnection(ctx, data.Info.Path) if err != nil { return nil, err } remoteData := wshrpc.CommandRemoteFileStreamData{ Path: conn.Path, ByteRange: data.ByteRange, StreamMeta: data.StreamMeta, } return wshclient.RemoteFileStreamCommand(RpcClient, remoteData, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) } func ListEntries(ctx context.Context, path string, opts *wshrpc.FileListOpts) ([]*wshrpc.FileInfo, error) { log.Printf("ListEntries: %v", path) conn, err := parseConnection(ctx, path) if err != nil { return nil, err } var entries []*wshrpc.FileInfo rtnCh := listEntriesStream(conn, opts) for respUnion := range rtnCh { if respUnion.Error != nil { return nil, respUnion.Error } resp := respUnion.Response entries = append(entries, resp.FileInfo...) } return entries, nil } func ListEntriesStream(ctx context.Context, path string, opts *wshrpc.FileListOpts) <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] { log.Printf("ListEntriesStream: %v", path) conn, err := parseConnection(ctx, path) if err != nil { return wshutil.SendErrCh[wshrpc.CommandRemoteListEntriesRtnData](err) } return listEntriesStream(conn, opts) } func listEntriesStream(conn *connparse.Connection, opts *wshrpc.FileListOpts) <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] { return wshclient.RemoteListEntriesCommand(RpcClient, wshrpc.CommandRemoteListEntriesData{Path: conn.Path, Opts: opts}, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) } func Stat(ctx context.Context, path string) (*wshrpc.FileInfo, error) { log.Printf("Stat: %v", path) conn, err := parseConnection(ctx, path) if err != nil { return nil, err } return stat(conn) } func stat(conn *connparse.Connection) (*wshrpc.FileInfo, error) { return wshclient.RemoteFileInfoCommand(RpcClient, conn.Path, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) } func PutFile(ctx context.Context, data wshrpc.FileData) error { log.Printf("PutFile: %v", data.Info.Path) conn, err := parseConnection(ctx, data.Info.Path) if err != nil { return err } dataSize := base64.StdEncoding.DecodedLen(len(data.Data64)) if dataSize > RemoteFileTransferSizeLimit { return fmt.Errorf("file data size %d exceeds transfer limit of %d bytes", dataSize, RemoteFileTransferSizeLimit) } info := data.Info if info == nil { info = &wshrpc.FileInfo{Opts: &wshrpc.FileOpts{}} } else if info.Opts == nil { info.Opts = &wshrpc.FileOpts{} } info.Path = conn.Path info.Opts.Truncate = true data.Info = info return wshclient.RemoteWriteFileCommand(RpcClient, data, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) } func Append(ctx context.Context, data wshrpc.FileData) error { log.Printf("Append: %v", data.Info.Path) conn, err := parseConnection(ctx, data.Info.Path) if err != nil { return err } dataSize := base64.StdEncoding.DecodedLen(len(data.Data64)) if dataSize > RemoteFileTransferSizeLimit { return fmt.Errorf("file data size %d exceeds transfer limit of %d bytes", dataSize, RemoteFileTransferSizeLimit) } info := data.Info if info == nil { info = &wshrpc.FileInfo{Path: conn.Path, Opts: &wshrpc.FileOpts{}} } else if info.Opts == nil { info.Opts = &wshrpc.FileOpts{} } info.Path = conn.Path info.Opts.Append = true data.Info = info return wshclient.RemoteWriteFileCommand(RpcClient, data, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) } func Mkdir(ctx context.Context, path string) error { log.Printf("Mkdir: %v", path) conn, err := parseConnection(ctx, path) if err != nil { return err } return wshclient.RemoteMkdirCommand(RpcClient, conn.Path, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) } func Move(ctx context.Context, data wshrpc.CommandFileCopyData) error { opts := data.Opts if opts == nil { opts = &wshrpc.FileCopyOpts{} } log.Printf("Move: srcuri: %v, desturi: %v, opts: %v", data.SrcUri, data.DestUri, opts) srcConn, err := parseConnection(ctx, data.SrcUri) if err != nil { return fmt.Errorf("error parsing source connection: %w", err) } destConn, err := parseConnection(ctx, data.DestUri) if err != nil { return fmt.Errorf("error parsing destination connection: %w", err) } if srcConn.Host != destConn.Host { isDir, err := copyInternal(srcConn, destConn, opts) if err != nil { return fmt.Errorf("cannot copy %q to %q: %w", data.SrcUri, data.DestUri, err) } return delete_(srcConn, opts.Recursive && isDir) } return moveInternal(srcConn, destConn, opts) } func Copy(ctx context.Context, data wshrpc.CommandFileCopyData) error { opts := data.Opts if opts == nil { opts = &wshrpc.FileCopyOpts{} } log.Printf("Copy: srcuri: %v, desturi: %v, opts: %v", data.SrcUri, data.DestUri, opts) srcConn, err := parseConnection(ctx, data.SrcUri) if err != nil { return fmt.Errorf("error parsing source connection: %w", err) } destConn, err := parseConnection(ctx, data.DestUri) if err != nil { return fmt.Errorf("error parsing destination connection: %w", err) } _, err = copyInternal(srcConn, destConn, opts) return err } func Delete(ctx context.Context, data wshrpc.CommandDeleteFileData) error { log.Printf("Delete: %v", data) conn, err := parseConnection(ctx, data.Path) if err != nil { return err } return delete_(conn, data.Recursive) } func delete_(conn *connparse.Connection, recursive bool) error { return wshclient.RemoteFileDeleteCommand(RpcClient, wshrpc.CommandDeleteFileData{Path: conn.Path, Recursive: recursive}, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) } func Join(ctx context.Context, path string, parts ...string) (*wshrpc.FileInfo, error) { log.Printf("Join: %v", path) conn, err := parseConnection(ctx, path) if err != nil { return nil, err } return wshclient.RemoteFileJoinCommand(RpcClient, append([]string{conn.Path}, parts...), &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) } func moveInternal(srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) error { if srcConn.Host != destConn.Host { return fmt.Errorf("move internal, src and dest hosts do not match") } if opts == nil { opts = &wshrpc.FileCopyOpts{} } timeout := opts.Timeout if timeout == 0 { timeout = DefaultTimeout.Milliseconds() } return wshclient.RemoteFileMoveCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcConn.GetFullURI(), DestUri: destConn.GetFullURI(), Opts: opts}, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(destConn.Host), Timeout: timeout}) } func copyInternal(srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) (bool, error) { if opts == nil { opts = &wshrpc.FileCopyOpts{} } timeout := opts.Timeout if timeout == 0 { timeout = DefaultTimeout.Milliseconds() } return wshclient.RemoteFileCopyCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcConn.GetFullURI(), DestUri: destConn.GetFullURI(), Opts: opts}, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(destConn.Host), Timeout: timeout}) } ================================================ FILE: pkg/remote/sshagent_unix.go ================================================ //go:build !windows package remote import "net" // dialIdentityAgent connects to a Unix domain socket identity agent. func dialIdentityAgent(agentPath string) (net.Conn, error) { return net.Dial("unix", agentPath) } ================================================ FILE: pkg/remote/sshagent_unix_test.go ================================================ //go:build !windows package remote import ( "net" "path/filepath" "testing" ) func TestDialIdentityAgentUnix(t *testing.T) { socketPath := filepath.Join(t.TempDir(), "agent.sock") ln, err := net.Listen("unix", socketPath) if err != nil { t.Fatalf("listen unix socket: %v", err) } defer ln.Close() acceptDone := make(chan struct{}) go func() { conn, _ := ln.Accept() if conn != nil { conn.Close() } close(acceptDone) }() conn, err := dialIdentityAgent(socketPath) if err != nil { t.Fatalf("dialIdentityAgent: %v", err) } conn.Close() <-acceptDone } ================================================ FILE: pkg/remote/sshagent_windows.go ================================================ //go:build windows package remote import ( "net" "time" "github.com/Microsoft/go-winio" ) // dialIdentityAgent connects to the Windows OpenSSH agent named pipe. func dialIdentityAgent(agentPath string) (net.Conn, error) { timeout := 500 * time.Millisecond return winio.DialPipe(agentPath, &timeout) } ================================================ FILE: pkg/remote/sshagent_windows_test.go ================================================ //go:build windows package remote import ( "testing" "time" ) func TestDialIdentityAgentWindowsTimeout(t *testing.T) { start := time.Now() _, err := dialIdentityAgent(`\\.\\pipe\\waveterm-nonexistent-agent`) if err == nil { t.Skip("unexpectedly connected to a test pipe; skipping") } // Optionally verify error indicates connection/timeout failure t.Logf("dialIdentityAgent returned expected error: %v", err) if time.Since(start) > 3*time.Second { t.Fatalf("dialIdentityAgent exceeded expected timeout window") } } ================================================ FILE: pkg/remote/sshclient.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package remote import ( "bytes" "context" "crypto/rand" "crypto/rsa" "encoding/base64" "errors" "fmt" "log" "math" "net" "os" "os/exec" "os/user" "path/filepath" "runtime" "strings" "sync" "time" "github.com/kevinburke/ssh_config" "github.com/skeema/knownhosts" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/trimquotes" "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wconfig" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" xknownhosts "golang.org/x/crypto/ssh/knownhosts" ) const SshProxyJumpMaxDepth = 10 const ( ConnErrCode_ConfigParse = "config-parse" ConnErrCode_ConfigDefault = "config-default" ConnErrCode_ProxyDepth = "proxy-depth" ConnErrCode_ProxyParse = "proxy-parse" ConnErrCode_SecretStore = "secret-error" ConnErrCode_SecretNotFound = "secret-notfound" ConnErrCode_KnownHostsNone = "knownhosts-none" ConnErrCode_KnownHostsFmt = "knownhosts-format" ConnErrCode_Dial = "dial-error" ConnErrCode_ProxyJumpDial = "dial-proxy-jump" ConnErrCode_HostKeyRevoked = "hostkey-revoked" ConnErrCode_HostKeyChanged = "hostkey-changed" ConnErrCode_HostKeyVerify = "hostkey-verify" ConnErrCode_UserCancelled = "user-cancelled" ConnErrCode_UserTimeout = "user-timeout" ConnErrCode_AuthFailed = "auth-failed" ConnErrCode_Unknown = "unknown" ) // Dial error subcodes for more granular classification const ( DialSubCode_DNS = "dns" DialSubCode_Refused = "refused" DialSubCode_Timeout = "timeout" DialSubCode_ContextCanceled = "context-canceled" DialSubCode_NoRoute = "no-route" DialSubCode_HostUnreach = "host-unreachable" DialSubCode_NetUnreach = "net-unreachable" DialSubCode_ConnReset = "conn-reset" DialSubCode_PermDenied = "perm-denied" DialSubCode_ProxyJump = "proxy-jump" DialSubCode_Other = "other" ) // Auth error subcodes for more granular classification const ( AuthSubCode_UnableToAuth = "unable-to-auth" AuthSubCode_HandshakeFailed = "handshake-failed" ) var waveSshConfigUserSettingsInternal *ssh_config.UserSettings var configUserSettingsOnce = &sync.Once{} func WaveSshConfigUserSettings() *ssh_config.UserSettings { configUserSettingsOnce.Do(func() { waveSshConfigUserSettingsInternal = ssh_config.DefaultUserSettings waveSshConfigUserSettingsInternal.IgnoreMatchDirective = true }) return waveSshConfigUserSettingsInternal } type UserInputCancelError struct { Err error } type HostKeyAlgorithms = func(hostWithPort string) (algos []string) func (uice UserInputCancelError) Error() string { return uice.Err.Error() } func (uice UserInputCancelError) Unwrap() error { return uice.Err } type ConnectionDebugInfo struct { CurrentClient *ssh.Client NextOpts *SSHOpts JumpNum int32 } type ConnectionError struct { *ConnectionDebugInfo Err error } func (ce ConnectionError) Error() string { if ce.CurrentClient == nil { return fmt.Sprintf("Connecting to %s, Error: %v", ce.NextOpts, ce.Err) } return fmt.Sprintf("Connecting from %v to %s (jump number %d), Error: %v", ce.CurrentClient, ce.NextOpts, ce.JumpNum, ce.Err) } func (ce ConnectionError) Unwrap() error { return ce.Err } func SimpleMessageFromPossibleConnectionError(err error) string { if err == nil { return "" } if ce, ok := err.(ConnectionError); ok { return ce.Err.Error() } return err.Error() } func ClassifyConnError(err error) (string, string) { code := utilds.GetErrorCode(err) subCode := utilds.GetErrorSubCode(err) if code != "" { return code, subCode } var dnsErr *net.DNSError if errors.As(err, &dnsErr) { return ConnErrCode_Dial, ClassifyDialErrorSubCode(err) } var opErr *net.OpError if errors.As(err, &opErr) { return ConnErrCode_Dial, ClassifyDialErrorSubCode(err) } errStr := err.Error() if strings.Contains(errStr, "unable to authenticate") { return ConnErrCode_AuthFailed, AuthSubCode_UnableToAuth } if strings.Contains(errStr, "handshake failed") { return ConnErrCode_AuthFailed, AuthSubCode_HandshakeFailed } if strings.Contains(errStr, "connection refused") { return ConnErrCode_Dial, ClassifyDialErrorSubCode(err) } if strings.Contains(errStr, "timed out") || strings.Contains(errStr, "timeout") { return ConnErrCode_Dial, ClassifyDialErrorSubCode(err) } return ConnErrCode_Unknown, "" } // ClassifyDialErrorSubCode provides more granular classification of dial errors // to help identify root causes (DNS, VPN, timeouts, etc.) func ClassifyDialErrorSubCode(err error) string { if err == nil { return "" } // Check for context cancellation first if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return DialSubCode_ContextCanceled } // Check if it's a DNS error var dnsErr *net.DNSError if errors.As(err, &dnsErr) { return DialSubCode_DNS } // Check if it's a network operation error var opErr *net.OpError if errors.As(err, &opErr) { // Check the underlying error for more details if opErr.Err != nil { errStr := opErr.Err.Error() if strings.Contains(errStr, "connection refused") { return DialSubCode_Refused } if strings.Contains(errStr, "no route to host") { return DialSubCode_NoRoute } if strings.Contains(errStr, "host is unreachable") || strings.Contains(errStr, "host unreachable") { return DialSubCode_HostUnreach } if strings.Contains(errStr, "network is unreachable") || strings.Contains(errStr, "network unreachable") { return DialSubCode_NetUnreach } if strings.Contains(errStr, "connection reset") { return DialSubCode_ConnReset } if strings.Contains(errStr, "permission denied") { return DialSubCode_PermDenied } } // Generic timeout detection in OpError if opErr.Timeout() { return DialSubCode_Timeout } } // Check error string for common patterns errStr := err.Error() if strings.Contains(errStr, "connection refused") { return DialSubCode_Refused } if strings.Contains(errStr, "timed out") || strings.Contains(errStr, "timeout") || strings.Contains(errStr, "i/o timeout") { return DialSubCode_Timeout } if strings.Contains(errStr, "no route to host") { return DialSubCode_NoRoute } if strings.Contains(errStr, "host is unreachable") || strings.Contains(errStr, "host unreachable") { return DialSubCode_HostUnreach } if strings.Contains(errStr, "network is unreachable") || strings.Contains(errStr, "network unreachable") { return DialSubCode_NetUnreach } if strings.Contains(errStr, "connection reset") { return DialSubCode_ConnReset } if strings.Contains(errStr, "permission denied") { return DialSubCode_PermDenied } return DialSubCode_Other } // This exists to trick the ssh library into continuing to try // different public keys even when the current key cannot be // properly parsed func createDummySigner() ([]ssh.Signer, error) { dummyKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, err } dummySigner, err := ssh.NewSignerFromKey(dummyKey) if err != nil { return nil, err } return []ssh.Signer{dummySigner}, nil } // This is a workaround to only process one identity file at a time, // even if they have passphrases. It must be combined with retryable // authentication to work properly // // Despite returning an array of signers, we only ever provide one since // it allows proper user interaction in between attempts // // A significant number of errors end up returning dummy values as if // they were successes. An error in this function prevents any other // keys from being attempted. But if there's an error because of a dummy // file, the library can still try again with a new key. func createPublicKeyCallback(connCtx context.Context, sshKeywords *wconfig.ConnKeywords, authSockSignersExt []ssh.Signer, agentClient agent.ExtendedAgent, debugInfo *ConnectionDebugInfo) func() ([]ssh.Signer, error) { var identityFiles []string existingKeys := make(map[string][]byte) // checking the file early prevents us from needing to send a // dummy signer if there's a problem with the signer for _, identityFile := range sshKeywords.SshIdentityFile { filePath, err := wavebase.ExpandHomeDir(identityFile) if err != nil { continue } privateKey, err := os.ReadFile(filePath) if err != nil { // skip this key and try with the next continue } existingKeys[identityFile] = privateKey identityFiles = append(identityFiles, identityFile) } // require pointer to modify list in closure identityFilesPtr := &identityFiles var authSockSigners []ssh.Signer authSockSigners = append(authSockSigners, authSockSignersExt...) authSockSignersPtr := &authSockSigners return func() (outSigner []ssh.Signer, outErr error) { defer func() { panicErr := panichandler.PanicHandler("sshclient:publickey-callback", recover()) if panicErr != nil { outErr = panicErr } }() // try auth sock if len(*authSockSignersPtr) != 0 { authSockSigner := (*authSockSignersPtr)[0] *authSockSignersPtr = (*authSockSignersPtr)[1:] return []ssh.Signer{authSockSigner}, nil } if len(*identityFilesPtr) == 0 { return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: fmt.Errorf("no identity files remaining")} } identityFile := (*identityFilesPtr)[0] blocklogger.Infof(connCtx, "[conndebug] trying keyfile %q...\n", identityFile) *identityFilesPtr = (*identityFilesPtr)[1:] privateKey, ok := existingKeys[identityFile] if !ok { log.Printf("error with existingKeys, this should never happen") // skip this key and try with the next return createDummySigner() } unencryptedPrivateKey, err := ssh.ParseRawPrivateKey(privateKey) if err == nil { signer, err := ssh.NewSignerFromKey(unencryptedPrivateKey) if err == nil { if utilfn.SafeDeref(sshKeywords.SshAddKeysToAgent) && agentClient != nil { agentClient.Add(agent.AddedKey{ PrivateKey: unencryptedPrivateKey, }) } return []ssh.Signer{signer}, nil } } if _, ok := err.(*ssh.PassphraseMissingError); !ok { // skip this key and try with the next return createDummySigner() } // batch mode deactivates user input if utilfn.SafeDeref(sshKeywords.SshBatchMode) { // skip this key and try with the next return createDummySigner() } request := &userinput.UserInputRequest{ ResponseType: "text", QueryText: fmt.Sprintf("Enter passphrase for the SSH key: %s", identityFile), Title: "Publickey Auth + Passphrase", } ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) defer cancelFn() response, err := userinput.GetUserInput(ctx, request) if err != nil { // this is an error where we actually do want to stop // trying keys return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_UserCancelled, UserInputCancelError{Err: err})} } unencryptedPrivateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(privateKey, []byte([]byte(response.Text))) if err != nil { // skip this key and try with the next return createDummySigner() } signer, err := ssh.NewSignerFromKey(unencryptedPrivateKey) if err != nil { // skip this key and try with the next return createDummySigner() } if utilfn.SafeDeref(sshKeywords.SshAddKeysToAgent) && agentClient != nil { agentClient.Add(agent.AddedKey{ PrivateKey: unencryptedPrivateKey, }) } return []ssh.Signer{signer}, nil } } func createPasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string, password *string, debugInfo *ConnectionDebugInfo) func() (secret string, err error) { return func() (secret string, outErr error) { defer func() { panicErr := panichandler.PanicHandler("sshclient:password-callback", recover()) if panicErr != nil { outErr = panicErr } }() blocklogger.Infof(connCtx, "[conndebug] Password Authentication requested from connection %s...\n", remoteDisplayName) if password != nil { blocklogger.Infof(connCtx, "[conndebug] using password from secret store, sending to ssh\n") return *password, nil } ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) defer cancelFn() queryText := fmt.Sprintf( "Password Authentication requested from connection \n"+ "%s\n\n"+ "Password:", remoteDisplayName) request := &userinput.UserInputRequest{ ResponseType: "text", QueryText: queryText, Markdown: true, Title: "Password Authentication", } response, err := userinput.GetUserInput(ctx, request) if err != nil { blocklogger.Infof(connCtx, "[conndebug] ERROR Password Authentication failed: %v\n", SimpleMessageFromPossibleConnectionError(err)) return "", ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} } blocklogger.Infof(connCtx, "[conndebug] got password from user, sending to ssh\n") return response.Text, nil } } func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteName string, debugInfo *ConnectionDebugInfo) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { return func(name, instruction string, questions []string, echos []bool) (answers []string, outErr error) { defer func() { panicErr := panichandler.PanicHandler("sshclient:kbdinteractive-callback", recover()) if panicErr != nil { outErr = panicErr } }() if len(questions) != len(echos) { return nil, fmt.Errorf("bad response from server: questions has len %d, echos has len %d", len(questions), len(echos)) } for i, question := range questions { echo := echos[i] answer, err := promptChallengeQuestion(connCtx, question, echo, remoteName) if err != nil { return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_UserCancelled, err)} } answers = append(answers, answer) } return answers, nil } } func promptChallengeQuestion(connCtx context.Context, question string, echo bool, remoteName string) (answer string, err error) { // limited to 15 seconds for some reason. this should be investigated more // in the future ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) defer cancelFn() queryText := fmt.Sprintf( "Keyboard Interactive Authentication requested from connection \n"+ "%s\n\n"+ "%s", remoteName, question) request := &userinput.UserInputRequest{ ResponseType: "text", QueryText: queryText, Markdown: true, Title: "Keyboard Interactive Authentication", PublicText: echo, } response, err := userinput.GetUserInput(ctx, request) if err != nil { return "", err } return response.Text, nil } func openKnownHostsForEdit(knownHostsFilename string) (*os.File, error) { path, _ := filepath.Split(knownHostsFilename) err := os.MkdirAll(path, 0700) if err != nil { return nil, err } return os.OpenFile(knownHostsFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) } func writeToKnownHosts(knownHostsFile string, newLine string, getUserVerification func() (*userinput.UserInputResponse, error)) error { if getUserVerification == nil { getUserVerification = func() (*userinput.UserInputResponse, error) { return &userinput.UserInputResponse{ Type: "confirm", Confirm: true, }, nil } } path, _ := filepath.Split(knownHostsFile) err := os.MkdirAll(path, 0700) if err != nil { return err } f, err := os.OpenFile(knownHostsFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) if err != nil { return err } // do not close writeable files with defer // this file works, so let's ask the user for permission response, err := getUserVerification() if err != nil { f.Close() return UserInputCancelError{Err: err} } if !response.Confirm { f.Close() return UserInputCancelError{Err: fmt.Errorf("canceled by the user")} } _, err = f.WriteString(newLine + "\n") if err != nil { f.Close() return err } return f.Close() } func createUnknownKeyVerifier(ctx context.Context, knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponse, error) { base64Key := base64.StdEncoding.EncodeToString(key.Marshal()) queryText := fmt.Sprintf( "The authenticity of host '%s (%s)' can't be established "+ "as it **does not exist in any checked known_hosts files**. "+ "The host you are attempting to connect to provides this %s key: \n"+ "%s.\n\n"+ "**Would you like to continue connecting?** If so, the key will be permanently "+ "added to the file %s "+ "to protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile) request := &userinput.UserInputRequest{ ResponseType: "confirm", QueryText: queryText, Markdown: true, Title: "Known Hosts Key Missing", } return func() (*userinput.UserInputResponse, error) { ctx, cancelFn := context.WithTimeout(ctx, 60*time.Second) defer cancelFn() resp, err := userinput.GetUserInput(ctx, request) if err != nil { return nil, err } if !resp.Confirm { return nil, fmt.Errorf("user selected no") } return resp, nil } } func createMissingKnownHostsVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponse, error) { base64Key := base64.StdEncoding.EncodeToString(key.Marshal()) queryText := fmt.Sprintf( "The authenticity of host '%s (%s)' can't be established "+ "as **no known_hosts files could be found**. "+ "The host you are attempting to connect to provides this %s key: \n"+ "%s.\n\n"+ "**Would you like to continue connecting?** If so: \n"+ "- %s will be created \n"+ "- the key will be added to %s\n\n"+ "This will protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile, knownHostsFile) request := &userinput.UserInputRequest{ ResponseType: "confirm", QueryText: queryText, Markdown: true, Title: "Known Hosts File Missing", } return func() (*userinput.UserInputResponse, error) { ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second) defer cancelFn() resp, err := userinput.GetUserInput(ctx, request) if err != nil { return nil, err } if !resp.Confirm { return nil, fmt.Errorf("user selected no") } return resp, nil } } func lineContainsMatch(line []byte, matches [][]byte) bool { for _, match := range matches { if bytes.Contains(line, match) { return true } } return false } func createHostKeyCallback(ctx context.Context, sshKeywords *wconfig.ConnKeywords) (ssh.HostKeyCallback, HostKeyAlgorithms, error) { globalKnownHostsFiles := sshKeywords.SshGlobalKnownHostsFile userKnownHostsFiles := sshKeywords.SshUserKnownHostsFile osUser, err := user.Current() if err != nil { return nil, nil, utilds.MakeCodedError(ConnErrCode_ConfigParse, err) } var unexpandedKnownHostsFiles []string if osUser.Username == "root" { unexpandedKnownHostsFiles = globalKnownHostsFiles } else { unexpandedKnownHostsFiles = append(userKnownHostsFiles, globalKnownHostsFiles...) } var knownHostsFiles []string for _, filename := range unexpandedKnownHostsFiles { filePath, err := wavebase.ExpandHomeDir(filename) if err != nil { continue } knownHostsFiles = append(knownHostsFiles, filePath) } // there are no good known hosts files if len(knownHostsFiles) == 0 { return nil, nil, utilds.Errorf(ConnErrCode_KnownHostsNone, "no known_hosts files provided by ssh. defaults are overridden") } var unreadableFiles []string // the library we use isn't very forgiving about files that are formatted // incorrectly. if a problem file is found, it is removed from our list // and we try again var basicCallback ssh.HostKeyCallback var hostKeyAlgorithms HostKeyAlgorithms for basicCallback == nil && len(knownHostsFiles) > 0 { keyDb, err := knownhosts.NewDB(knownHostsFiles...) if serr, ok := err.(*os.PathError); ok { badFile := serr.Path unreadableFiles = append(unreadableFiles, badFile) var okFiles []string for _, filename := range knownHostsFiles { if filename != badFile { okFiles = append(okFiles, filename) } } if len(okFiles) >= len(knownHostsFiles) { return nil, nil, utilds.Errorf(ConnErrCode_KnownHostsFmt, "problem file (%s) doesn't exist. this should not be possible", badFile) } knownHostsFiles = okFiles } else if err != nil { return nil, nil, utilds.Errorf(ConnErrCode_KnownHostsFmt, "known_hosts formatting error: %w", err) } else { basicCallback = keyDb.HostKeyCallback() hostKeyAlgorithms = keyDb.HostKeyAlgorithms } } if basicCallback == nil { basicCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error { return &xknownhosts.KeyError{} } // need to return nil here to avoid null pointer from attempting to call // the one provided by the db if nothing was found hostKeyAlgorithms = func(hostWithPort string) (algos []string) { return nil } } waveHostKeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) (outErr error) { defer func() { panicErr := panichandler.PanicHandler("sshclient:wave-hostkey-callback", recover()) if panicErr != nil { outErr = panicErr } }() err := basicCallback(hostname, remote, key) if err == nil { // success return nil } else if _, ok := err.(*xknownhosts.RevokedError); ok { return utilds.MakeCodedError(ConnErrCode_HostKeyRevoked, err) } else if _, ok := err.(*xknownhosts.KeyError); !ok { // this is an unknown error (note the !ok is opposite of usual) return err } serr, _ := err.(*xknownhosts.KeyError) if len(serr.Want) == 0 { // the key was not found // try to write to a file that could be read err := fmt.Errorf("placeholder, should not be returned") // a null value here can cause problems with empty slice for _, filename := range knownHostsFiles { newLine := xknownhosts.Line([]string{xknownhosts.Normalize(hostname)}, key) getUserVerification := createUnknownKeyVerifier(ctx, filename, hostname, remote.String(), key) err = writeToKnownHosts(filename, newLine, getUserVerification) if err == nil { break } if serr, ok := err.(UserInputCancelError); ok { return utilds.MakeCodedError(ConnErrCode_UserCancelled, serr) } } // try to write to a file that could not be read (file likely doesn't exist) // should catch cases where there is no known_hosts file if err != nil { for _, filename := range unreadableFiles { newLine := xknownhosts.Line([]string{xknownhosts.Normalize(hostname)}, key) getUserVerification := createMissingKnownHostsVerifier(filename, hostname, remote.String(), key) err = writeToKnownHosts(filename, newLine, getUserVerification) if err == nil { knownHostsFiles = []string{filename} break } if serr, ok := err.(UserInputCancelError); ok { return utilds.MakeCodedError(ConnErrCode_UserCancelled, serr) } } } if err != nil { return utilds.Errorf(ConnErrCode_HostKeyVerify, "unable to create new knownhost key: %w", err) } } else { // the key changed correctKeyFingerprint := base64.StdEncoding.EncodeToString(key.Marshal()) var bulletListKnownHosts []string for _, knownHostName := range knownHostsFiles { withBulletPoint := "- " + knownHostName bulletListKnownHosts = append(bulletListKnownHosts, withBulletPoint) } var offendingKeysFmt []string for _, badKey := range serr.Want { formattedKey := "- " + base64.StdEncoding.EncodeToString(badKey.Key.Marshal()) offendingKeysFmt = append(offendingKeysFmt, formattedKey) } // todo errorMsg := fmt.Sprintf("**WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!**\n\n"+ "If this is not expected, it is possible that someone could be trying to "+ "eavesdrop on you via a man-in-the-middle attack. "+ "Alternatively, the host you are connecting to may have changed its key. "+ "The %s key sent by the remote hist has the fingerprint: \n"+ "%s\n\n"+ "If you are sure this is correct, please update your known_hosts files to "+ "remove the lines with the offending before trying to connect again. \n"+ "**Known Hosts Files** \n"+ "%s\n\n"+ "**Offending Keys** \n"+ "%s", key.Type(), correctKeyFingerprint, strings.Join(bulletListKnownHosts, " \n"), strings.Join(offendingKeysFmt, " \n")) log.Print(errorMsg) //update := scbus.MakeUpdatePacket() // create update into alert message //send update via bus? return utilds.Errorf(ConnErrCode_HostKeyChanged, "remote host identification has changed") } updatedCallback, err := xknownhosts.New(knownHostsFiles...) if err != nil { return err } // try one final time return updatedCallback(hostname, remote, key) } return waveHostKeyCallback, hostKeyAlgorithms, nil } func createClientConfig(connCtx context.Context, sshKeywords *wconfig.ConnKeywords, debugInfo *ConnectionDebugInfo) (*ssh.ClientConfig, error) { chosenUser := utilfn.SafeDeref(sshKeywords.SshUser) chosenHostName := utilfn.SafeDeref(sshKeywords.SshHostName) chosenPort := utilfn.SafeDeref(sshKeywords.SshPort) remoteName := xknownhosts.Normalize(chosenHostName + ":" + chosenPort) if chosenUser != "" { remoteName = chosenUser + "@" + remoteName } var authSockSigners []ssh.Signer var agentClient agent.ExtendedAgent // IdentitiesOnly indicates that only the keys listed in the identity and certificate files or passed as arguments should be used, even if there are matches in the SSH Agent, PKCS11Provider, or SecurityKeyProvider. See https://man.openbsd.org/ssh_config#IdentitiesOnly // TODO: Update if we decide to support PKCS11Provider and SecurityKeyProvider agentPath := strings.TrimSpace(utilfn.SafeDeref(sshKeywords.SshIdentityAgent)) if !utilfn.SafeDeref(sshKeywords.SshIdentitiesOnly) && agentPath != "" { conn, err := dialIdentityAgent(agentPath) if err != nil { log.Printf("Failed to open Identity Agent Socket %q: %v", agentPath, err) } else { agentClient = agent.NewClient(conn) authSockSigners, _ = agentClient.Signers() } } var sshPassword *string if sshKeywords.SshPasswordSecretName != nil && *sshKeywords.SshPasswordSecretName != "" { secretName := *sshKeywords.SshPasswordSecretName password, exists, err := secretstore.GetSecret(secretName) if err != nil { return nil, utilds.Errorf(ConnErrCode_SecretStore, "error retrieving ssh:passwordsecretname %q: %w", secretName, err) } if !exists { return nil, utilds.Errorf(ConnErrCode_SecretNotFound, "ssh:passwordsecretname %q not found in secret store", secretName) } blocklogger.Infof(connCtx, "[conndebug] successfully retrieved ssh:passwordsecretname %q from secret store\n", secretName) sshPassword = &password } publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, authSockSigners, agentClient, debugInfo)) keyboardInteractive := ssh.KeyboardInteractive(createInteractiveKbdInteractiveChallenge(connCtx, remoteName, debugInfo)) passwordCallback := ssh.PasswordCallback(createPasswordCallbackPrompt(connCtx, remoteName, sshPassword, debugInfo)) // exclude gssapi-with-mic and hostbased until implemented authMethodMap := map[string]ssh.AuthMethod{ "publickey": ssh.RetryableAuthMethod(publicKeyCallback, len(sshKeywords.SshIdentityFile)+len(authSockSigners)), "keyboard-interactive": ssh.RetryableAuthMethod(keyboardInteractive, 1), "password": ssh.RetryableAuthMethod(passwordCallback, 1), } // note: batch mode turns off interactive input authMethodActiveMap := map[string]bool{ "publickey": utilfn.SafeDeref(sshKeywords.SshPubkeyAuthentication), "keyboard-interactive": utilfn.SafeDeref(sshKeywords.SshKbdInteractiveAuthentication) && !utilfn.SafeDeref(sshKeywords.SshBatchMode), "password": utilfn.SafeDeref(sshKeywords.SshPasswordAuthentication) && !utilfn.SafeDeref(sshKeywords.SshBatchMode), } var authMethods []ssh.AuthMethod for _, authMethodName := range sshKeywords.SshPreferredAuthentications { authMethodActive, ok := authMethodActiveMap[authMethodName] if !ok || !authMethodActive { continue } authMethod, ok := authMethodMap[authMethodName] if !ok { continue } authMethods = append(authMethods, authMethod) } hostKeyCallback, hostKeyAlgorithms, err := createHostKeyCallback(connCtx, sshKeywords) if err != nil { return nil, err } networkAddr := chosenHostName + ":" + chosenPort return &ssh.ClientConfig{ User: chosenUser, Auth: authMethods, HostKeyCallback: hostKeyCallback, HostKeyAlgorithms: hostKeyAlgorithms(networkAddr), }, nil } func connectInternal(ctx context.Context, networkAddr string, clientConfig *ssh.ClientConfig, currentClient *ssh.Client) (*ssh.Client, error) { var clientConn net.Conn var err error if currentClient == nil { d := net.Dialer{Timeout: clientConfig.Timeout} blocklogger.Infof(ctx, "[conndebug] ssh dial %s\n", networkAddr) clientConn, err = d.DialContext(ctx, "tcp", networkAddr) if err != nil { subCode := ClassifyDialErrorSubCode(err) blocklogger.Infof(ctx, "[conndebug] ERROR dial error [%s]: %v\n", subCode, err) return nil, utilds.MakeSubCodedError(ConnErrCode_Dial, subCode, err) } } else { blocklogger.Infof(ctx, "[conndebug] ssh dial (from client) %s\n", networkAddr) clientConn, err = currentClient.DialContext(ctx, "tcp", networkAddr) if err != nil { subCode := ClassifyDialErrorSubCode(err) blocklogger.Infof(ctx, "[conndebug] ERROR dial error [%s]: %v\n", subCode, err) return nil, utilds.MakeSubCodedError(ConnErrCode_ProxyJumpDial, subCode, err) } } c, chans, reqs, err := ssh.NewClientConn(clientConn, networkAddr, clientConfig) if err != nil { blocklogger.Infof(ctx, "[conndebug] ERROR ssh auth/negotiation: %s\n", SimpleMessageFromPossibleConnectionError(err)) return nil, err } blocklogger.Infof(ctx, "[conndebug] successful ssh connection to %s\n", networkAddr) return ssh.NewClient(c, chans, reqs), nil } func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.Client, jumpNum int32, connFlags *wconfig.ConnKeywords) (*ssh.Client, int32, error) { blocklogger.Infof(connCtx, "[conndebug] ConnectToClient %s (jump:%d)...\n", opts.String(), jumpNum) debugInfo := &ConnectionDebugInfo{ CurrentClient: currentClient, NextOpts: opts, JumpNum: jumpNum, } if jumpNum > SshProxyJumpMaxDepth { return nil, jumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.Errorf(ConnErrCode_ProxyDepth, "ProxyJump %d exceeds Wave's max depth of %d", jumpNum, SshProxyJumpMaxDepth)} } rawName := opts.String() fullConfig := wconfig.GetWatcher().GetFullConfig() internalSshConfigKeywords, ok := fullConfig.Connections[rawName] if !ok { internalSshConfigKeywords = wconfig.ConnKeywords{} } var sshConfigKeywords *wconfig.ConnKeywords if utilfn.SafeDeref(internalSshConfigKeywords.ConnIgnoreSshConfig) { var err error sshConfigKeywords, err = findSshDefaults(opts.SSHHost) if err != nil { err = utilds.MakeCodedError(ConnErrCode_ConfigDefault, fmt.Errorf("cannot determine default config keywords: %w", err)) return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} } } else { var err error sshConfigKeywords, err = findSshConfigKeywords(opts.SSHHost) if err != nil { err = utilds.MakeCodedError(ConnErrCode_ConfigParse, fmt.Errorf("cannot determine config keywords: %w", err)) return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} } } parsedKeywords := &wconfig.ConnKeywords{} if opts.SSHUser != "" { parsedKeywords.SshUser = &opts.SSHUser } if opts.SSHPort != "" { parsedKeywords.SshPort = &opts.SSHPort } // cascade order: // ssh config -> (optional) internal config -> specified flag keywords -> parsed keywords partialMerged := sshConfigKeywords partialMerged = mergeKeywords(partialMerged, &internalSshConfigKeywords) partialMerged = mergeKeywords(partialMerged, connFlags) sshKeywords := mergeKeywords(partialMerged, parsedKeywords) // handle these separately since // - they append // - since they append, the order is reversed // - there is no reason to not include the internal config // - they are never part of the parsedKeywords sshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, connFlags.SshIdentityFile...) sshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, internalSshConfigKeywords.SshIdentityFile...) sshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, sshConfigKeywords.SshIdentityFile...) for _, proxyName := range sshKeywords.SshProxyJump { proxyOpts, err := ParseOpts(proxyName) if err != nil { return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_ProxyParse, err)} } // ensure no overflow (this will likely never happen) if jumpNum < math.MaxInt32 { jumpNum += 1 } // do not apply supplied keywords to proxies - ssh config must be used for that debugInfo.CurrentClient, jumpNum, err = ConnectToClient(connCtx, proxyOpts, debugInfo.CurrentClient, jumpNum, &wconfig.ConnKeywords{}) if err != nil { // do not add a context on a recursive call // (this can cause a recursive nested context that's arbitrarily deep) return nil, jumpNum, err } } clientConfig, err := createClientConfig(connCtx, sshKeywords, debugInfo) if err != nil { return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} } networkAddr := utilfn.SafeDeref(sshKeywords.SshHostName) + ":" + utilfn.SafeDeref(sshKeywords.SshPort) client, err := connectInternal(connCtx, networkAddr, clientConfig, debugInfo.CurrentClient) if err != nil { return client, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} } return client, debugInfo.JumpNum, nil } // note that a `var == "yes"` will default to false // but `var != "no"` will default to true // when given unexpected strings func findSshConfigKeywords(hostPattern string) (connKeywords *wconfig.ConnKeywords, outErr error) { defer func() { panicErr := panichandler.PanicHandler("sshclient:find-ssh-config-keywords", recover()) if panicErr != nil { outErr = panicErr } }() WaveSshConfigUserSettings().ReloadConfigs() sshKeywords := &wconfig.ConnKeywords{} var err error userRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "User") if err != nil { return nil, err } userClean := trimquotes.TryTrimQuotes(userRaw) if userClean == "" { userDetails, err := user.Current() if err != nil { return nil, err } userClean = userDetails.Username } sshKeywords.SshUser = &userClean hostNameRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "HostName") if err != nil { return nil, err } // manually implementing default HostName here as it is not handled by ssh_config library hostNameProcessed := trimquotes.TryTrimQuotes(hostNameRaw) if hostNameProcessed == "" { sshKeywords.SshHostName = &hostPattern } else { sshKeywords.SshHostName = &hostNameRaw } portRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "Port") if err != nil { return nil, err } sshKeywords.SshPort = utilfn.Ptr(trimquotes.TryTrimQuotes(portRaw)) identityFileRaw := WaveSshConfigUserSettings().GetAll(hostPattern, "IdentityFile") for i := 0; i < len(identityFileRaw); i++ { identityFileRaw[i] = trimquotes.TryTrimQuotes(identityFileRaw[i]) } sshKeywords.SshIdentityFile = identityFileRaw batchModeRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "BatchMode") if err != nil { return nil, err } sshKeywords.SshBatchMode = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(batchModeRaw)) == "yes") // we currently do not support host-bound or unbound but will use yes when they are selected pubkeyAuthenticationRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "PubkeyAuthentication") if err != nil { return nil, err } sshKeywords.SshPubkeyAuthentication = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(pubkeyAuthenticationRaw)) != "no") passwordAuthenticationRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "PasswordAuthentication") if err != nil { return nil, err } sshKeywords.SshPasswordAuthentication = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(passwordAuthenticationRaw)) != "no") kbdInteractiveAuthenticationRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "KbdInteractiveAuthentication") if err != nil { return nil, err } sshKeywords.SshKbdInteractiveAuthentication = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(kbdInteractiveAuthenticationRaw)) != "no") // these are parsed as a single string and must be separated // these are case sensitive in openssh so they are here too preferredAuthenticationsRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "PreferredAuthentications") if err != nil { return nil, err } sshKeywords.SshPreferredAuthentications = strings.Split(trimquotes.TryTrimQuotes(preferredAuthenticationsRaw), ",") addKeysToAgentRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "AddKeysToAgent") if err != nil { return nil, err } sshKeywords.SshAddKeysToAgent = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(addKeysToAgentRaw)) == "yes") identitiesOnly, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "IdentitiesOnly") if err != nil { return nil, err } sshKeywords.SshIdentitiesOnly = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(identitiesOnly)) == "yes") identityAgentRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "IdentityAgent") if err != nil { return nil, err } if identityAgentRaw == "" { if runtime.GOOS == "windows" { sshKeywords.SshIdentityAgent = utilfn.Ptr(`\\.\pipe\openssh-ssh-agent`) } else { shellPath := shellutil.DetectLocalShellPath() authSockCommand := exec.Command(shellPath, "-c", "echo ${SSH_AUTH_SOCK}") sshAuthSock, err := authSockCommand.Output() if err == nil { trimmedSock := strings.TrimSpace(string(sshAuthSock)) if trimmedSock == "" { log.Printf("SSH_AUTH_SOCK is empty in shell environment") } else { agentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(trimmedSock)) if err != nil { return nil, err } sshKeywords.SshIdentityAgent = utilfn.Ptr(agentPath) } } else { log.Printf("unable to find SSH_AUTH_SOCK: %v\n", err) } } } else { agentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(identityAgentRaw)) if err != nil { return nil, err } sshKeywords.SshIdentityAgent = utilfn.Ptr(agentPath) } proxyJumpRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "ProxyJump") if err != nil { return nil, err } proxyJumpSplit := strings.Split(proxyJumpRaw, ",") for _, proxyJumpName := range proxyJumpSplit { proxyJumpName = strings.TrimSpace(proxyJumpName) if proxyJumpName == "" || strings.ToLower(proxyJumpName) == "none" { continue } sshKeywords.SshProxyJump = append(sshKeywords.SshProxyJump, proxyJumpName) } rawUserKnownHostsFile, _ := WaveSshConfigUserSettings().GetStrict(hostPattern, "UserKnownHostsFile") sshKeywords.SshUserKnownHostsFile = strings.Fields(rawUserKnownHostsFile) // TODO - smarter splitting escaped spaces and quotes rawGlobalKnownHostsFile, _ := WaveSshConfigUserSettings().GetStrict(hostPattern, "GlobalKnownHostsFile") sshKeywords.SshGlobalKnownHostsFile = strings.Fields(rawGlobalKnownHostsFile) // TODO - smarter splitting escaped spaces and quotes return sshKeywords, nil } func findSshDefaults(hostPattern string) (connKeywords *wconfig.ConnKeywords, outErr error) { sshKeywords := &wconfig.ConnKeywords{} userDetails, err := user.Current() if err != nil { return nil, err } sshKeywords.SshUser = &userDetails.Username sshKeywords.SshHostName = &hostPattern sshKeywords.SshPort = utilfn.Ptr(ssh_config.Default("Port")) sshKeywords.SshIdentityFile = ssh_config.DefaultAll("IdentityFile", hostPattern, ssh_config.DefaultUserSettings) // use the sshconfig here. should be different later sshKeywords.SshBatchMode = utilfn.Ptr(false) sshKeywords.SshPubkeyAuthentication = utilfn.Ptr(true) sshKeywords.SshPasswordAuthentication = utilfn.Ptr(true) sshKeywords.SshKbdInteractiveAuthentication = utilfn.Ptr(true) sshKeywords.SshPreferredAuthentications = strings.Split(ssh_config.Default("PreferredAuthentications"), ",") sshKeywords.SshAddKeysToAgent = utilfn.Ptr(false) sshKeywords.SshIdentitiesOnly = utilfn.Ptr(false) sshKeywords.SshIdentityAgent = utilfn.Ptr(ssh_config.Default("IdentityAgent")) sshKeywords.SshProxyJump = []string{} sshKeywords.SshUserKnownHostsFile = strings.Fields(ssh_config.Default("UserKnownHostsFile")) sshKeywords.SshGlobalKnownHostsFile = strings.Fields(ssh_config.Default("GlobalKnownHostsFile")) return sshKeywords, nil } type SSHOpts struct { SSHHost string `json:"sshhost"` SSHUser string `json:"sshuser"` SSHPort string `json:"sshport,omitempty"` } func (opts SSHOpts) String() string { stringRepr := "" if opts.SSHUser != "" { stringRepr = opts.SSHUser + "@" } stringRepr = stringRepr + opts.SSHHost if opts.SSHPort != "22" && opts.SSHPort != "" { stringRepr = stringRepr + ":" + fmt.Sprint(opts.SSHPort) } return stringRepr } func mergeKeywords(oldKeywords *wconfig.ConnKeywords, newKeywords *wconfig.ConnKeywords) *wconfig.ConnKeywords { if oldKeywords == nil { oldKeywords = &wconfig.ConnKeywords{} } if newKeywords == nil { return oldKeywords } outKeywords := *oldKeywords if newKeywords.SshHostName != nil { outKeywords.SshHostName = newKeywords.SshHostName } if newKeywords.SshUser != nil { outKeywords.SshUser = newKeywords.SshUser } if newKeywords.SshPort != nil { outKeywords.SshPort = newKeywords.SshPort } // skip identityfile (handled separately due to different behavior) if newKeywords.SshBatchMode != nil { outKeywords.SshBatchMode = newKeywords.SshBatchMode } if newKeywords.SshPubkeyAuthentication != nil { outKeywords.SshPubkeyAuthentication = newKeywords.SshPubkeyAuthentication } if newKeywords.SshPasswordAuthentication != nil { outKeywords.SshPasswordAuthentication = newKeywords.SshPasswordAuthentication } if newKeywords.SshKbdInteractiveAuthentication != nil { outKeywords.SshKbdInteractiveAuthentication = newKeywords.SshKbdInteractiveAuthentication } if newKeywords.SshPreferredAuthentications != nil { outKeywords.SshPreferredAuthentications = newKeywords.SshPreferredAuthentications } if newKeywords.SshAddKeysToAgent != nil { outKeywords.SshAddKeysToAgent = newKeywords.SshAddKeysToAgent } if newKeywords.SshIdentityAgent != nil { outKeywords.SshIdentityAgent = newKeywords.SshIdentityAgent } if newKeywords.SshIdentitiesOnly != nil { outKeywords.SshIdentitiesOnly = newKeywords.SshIdentitiesOnly } if newKeywords.SshProxyJump != nil { outKeywords.SshProxyJump = newKeywords.SshProxyJump } if newKeywords.SshUserKnownHostsFile != nil { outKeywords.SshUserKnownHostsFile = newKeywords.SshUserKnownHostsFile } if newKeywords.SshGlobalKnownHostsFile != nil { outKeywords.SshGlobalKnownHostsFile = newKeywords.SshGlobalKnownHostsFile } if newKeywords.SshPasswordSecretName != nil { outKeywords.SshPasswordSecretName = newKeywords.SshPasswordSecretName } return &outKeywords } ================================================ FILE: pkg/schema/schema.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package schema import ( "log" "net/http" "os" "path/filepath" "github.com/wavetermdev/waveterm/pkg/wavebase" ) var schemaHandler http.Handler func GetSchemaHandler() http.Handler { schemaStaticPath := filepath.Join(wavebase.GetWaveAppPath(), "schema") stat, err := os.Stat(schemaStaticPath) if schemaHandler == nil { log.Println("Schema is nil, initializing") if err == nil && stat.IsDir() { log.Printf("Found static site at %s, serving\n", schemaStaticPath) schemaHandler = http.FileServer(JsonDir{http.Dir(schemaStaticPath)}) } else { log.Printf("Did not find static site at %s, serving not found handler. stat: %v, err: %v\n", schemaStaticPath, stat, err) schemaHandler = http.NotFoundHandler() } } return addHeaders(schemaHandler) } func addHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/schema+json") next.ServeHTTP(w, r) }) } type JsonDir struct { d http.Dir } func (d JsonDir) Open(name string) (http.File, error) { // Try name as supplied f, err := d.d.Open(name) if os.IsNotExist(err) { // Not found, try with .json if f, err := d.d.Open(name + ".json"); err == nil { return f, nil } } return f, err } ================================================ FILE: pkg/secretstore/secretstore.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package secretstore import ( "context" "encoding/json" "fmt" "log" "os" "path/filepath" "regexp" "runtime" "strings" "sync" "time" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) const ( SecretsFileName = "secrets.enc" WriteDebounceMs = 1000 EncryptionTimeout = 5000 InitRetryMs = 1000 SecretNamePattern = `^[A-Za-z][A-Za-z0-9_]*$` WriteTsKey = "wave:writets" ) var lock sync.Mutex var secrets = make(map[string]string) var writeRequestChan chan struct{} var initialized bool var lastInitTryTime time.Time var lastInitErr error var secretNameRegexp = regexp.MustCompile(SecretNamePattern) var linuxStorageBackend string // must hold lock func getLinuxStorageBackend() error { if runtime.GOOS != "linux" { return nil } rpcClient := wshclient.GetBareRpcClient() ctx, cancel := context.WithTimeout(context.Background(), EncryptionTimeout*time.Millisecond) defer cancel() encryptData := wshrpc.CommandElectronEncryptData{ PlainText: "hello", } rpcOpts := &wshrpc.RpcOpts{ Route: wshutil.ElectronRoute, Timeout: EncryptionTimeout, } result, err := wshclient.ElectronEncryptCommand(rpcClient, encryptData, rpcOpts) if err != nil { return fmt.Errorf("failed to get storage backend: %w", err) } if ctx.Err() != nil { return fmt.Errorf("encryption timeout: %w", ctx.Err()) } if result.StorageBackend != "" { linuxStorageBackend = result.StorageBackend } return nil } // must hold lock func readSecretsFromFile() (map[string]string, error) { configDir := wavebase.GetWaveConfigDir() secretsPath := filepath.Join(configDir, SecretsFileName) encryptedData, err := os.ReadFile(secretsPath) if err != nil { if !os.IsNotExist(err) { log.Printf("secretstore: could not read secrets file: %v\n", err) } if err := getLinuxStorageBackend(); err != nil { log.Printf("secretstore: could not get linux storage backend: %v\n", err) } return make(map[string]string), nil } rpcClient := wshclient.GetBareRpcClient() ctx, cancel := context.WithTimeout(context.Background(), EncryptionTimeout*time.Millisecond) defer cancel() decryptData := wshrpc.CommandElectronDecryptData{ CipherText: string(encryptedData), } rpcOpts := &wshrpc.RpcOpts{ Route: wshutil.ElectronRoute, Timeout: EncryptionTimeout, } result, err := wshclient.ElectronDecryptCommand(rpcClient, decryptData, rpcOpts) if err != nil { return nil, fmt.Errorf("failed to decrypt secrets: %w", err) } if ctx.Err() != nil { return nil, fmt.Errorf("decryption timeout: %w", ctx.Err()) } if result.StorageBackend != "" { linuxStorageBackend = result.StorageBackend } var decryptedSecrets map[string]string if err := json.Unmarshal([]byte(result.PlainText), &decryptedSecrets); err != nil { return nil, fmt.Errorf("failed to parse secrets: %w", err) } return decryptedSecrets, nil } func initSecretStore() error { lock.Lock() defer lock.Unlock() if initialized { return nil } now := time.Now() if !lastInitTryTime.IsZero() && now.Sub(lastInitTryTime) < InitRetryMs*time.Millisecond { return lastInitErr } lastInitTryTime = now loadedSecrets, err := readSecretsFromFile() if err != nil { lastInitErr = err return err } secrets = loadedSecrets writeRequestChan = make(chan struct{}, 1) initialized = true lastInitErr = nil go writerLoop() return nil } func writerLoop() { var timer *time.Timer for range writeRequestChan { if timer != nil { timer.Stop() } timer = time.AfterFunc(WriteDebounceMs*time.Millisecond, func() { if err := writeSecretsToFile(); err != nil { log.Printf("secretstore: error writing secrets: %v\n", err) } }) } } func writeSecretsToFile() error { lock.Lock() secretsCopy := make(map[string]string, len(secrets)+1) for k, v := range secrets { secretsCopy[k] = v } secretsCopy[WriteTsKey] = time.Now().UTC().Format(time.RFC3339) lock.Unlock() jsonData, err := json.Marshal(secretsCopy) if err != nil { return fmt.Errorf("failed to marshal secrets: %w", err) } rpcClient := wshclient.GetBareRpcClient() ctx, cancel := context.WithTimeout(context.Background(), EncryptionTimeout*time.Millisecond) defer cancel() encryptData := wshrpc.CommandElectronEncryptData{ PlainText: string(jsonData), } rpcOpts := &wshrpc.RpcOpts{ Route: wshutil.ElectronRoute, Timeout: EncryptionTimeout, } result, err := wshclient.ElectronEncryptCommand(rpcClient, encryptData, rpcOpts) if err != nil { return fmt.Errorf("failed to encrypt secrets: %w", err) } if ctx.Err() != nil { return fmt.Errorf("encryption timeout: %w", ctx.Err()) } configDir := wavebase.GetWaveConfigDir() secretsPath := filepath.Join(configDir, SecretsFileName) if err := os.WriteFile(secretsPath, []byte(result.CipherText), 0600); err != nil { return fmt.Errorf("failed to write secrets file: %w", err) } return nil } func requestWrite() { select { case writeRequestChan <- struct{}{}: default: } } func SetSecret(name string, value string) error { if name == "" { return fmt.Errorf("secret name cannot be empty") } if !secretNameRegexp.MatchString(name) { return fmt.Errorf("secret name must start with a letter and contain only letters, numbers, and underscores") } if err := initSecretStore(); err != nil { return err } lock.Lock() defer lock.Unlock() secrets[name] = strings.TrimRight(value, "\r\n") requestWrite() return nil } func DeleteSecret(name string) error { if name == "" { return fmt.Errorf("secret name cannot be empty") } if err := initSecretStore(); err != nil { return err } lock.Lock() defer lock.Unlock() delete(secrets, name) requestWrite() return nil } func GetSecret(name string) (string, bool, error) { if name == WriteTsKey { return "", false, nil } if err := initSecretStore(); err != nil { return "", false, err } lock.Lock() defer lock.Unlock() value, exists := secrets[name] return value, exists, nil } func GetSecretNames() ([]string, error) { if err := initSecretStore(); err != nil { return nil, err } lock.Lock() defer lock.Unlock() names := make([]string, 0, len(secrets)) for name := range secrets { if name == WriteTsKey { continue } names = append(names, name) } return names, nil } func CountSecrets() (int, error) { lock.Lock() defer lock.Unlock() if !initialized { return 0, fmt.Errorf("secret store not initialized") } count := 0 for name := range secrets { if name == WriteTsKey { continue } count++ } return count, nil } func GetLinuxStorageBackend() (string, error) { if runtime.GOOS != "linux" { return "", nil } lock.Lock() defer lock.Unlock() if linuxStorageBackend != "" { return linuxStorageBackend, nil } if err := getLinuxStorageBackend(); err != nil { return "", err } if linuxStorageBackend == "" { return "", fmt.Errorf("failed to determine linux storage backend") } return linuxStorageBackend, nil } ================================================ FILE: pkg/service/blockservice/blockservice.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package blockservice import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" ) type BlockService struct{} const DefaultTimeout = 2 * time.Second var BlockServiceInstance = &BlockService{} func (bs *BlockService) SendCommand_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ Desc: "send command to block", ArgNames: []string{"blockid", "cmd"}, } } func (bs *BlockService) GetControllerStatus(ctx context.Context, blockId string) (*blockcontroller.BlockControllerRuntimeStatus, error) { return blockcontroller.GetBlockControllerRuntimeStatus(blockId), nil } func (*BlockService) SaveTerminalState_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ Desc: "save the terminal state to a blockfile", ArgNames: []string{"ctx", "blockId", "state", "stateType", "ptyOffset", "termSize"}, } } func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, state string, stateType string, ptyOffset int64, termSize waveobj.TermSize) error { _, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { return err } if stateType != "full" && stateType != "preview" { return fmt.Errorf("invalid state type: %q", stateType) } // ignore MakeFile error (already exists is ok) filestore.WFS.MakeFile(ctx, blockId, "cache:term:"+stateType, nil, wshrpc.FileOpts{}) err = filestore.WFS.WriteFile(ctx, blockId, "cache:term:"+stateType, []byte(state)) if err != nil { return fmt.Errorf("cannot save terminal state: %w", err) } fileMeta := wshrpc.FileMeta{ "ptyoffset": ptyOffset, "termsize": termSize, } err = filestore.WFS.WriteMeta(ctx, blockId, "cache:term:"+stateType, fileMeta, true) if err != nil { return fmt.Errorf("cannot save terminal state meta: %w", err) } return nil } func (bs *BlockService) SaveWaveAiData(ctx context.Context, blockId string, history []wshrpc.WaveAIPromptMessageType) error { block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { return err } viewName := block.Meta.GetString(waveobj.MetaKey_View, "") if viewName != "waveai" { return fmt.Errorf("invalid view type: %s", viewName) } historyBytes, err := json.Marshal(history) if err != nil { return fmt.Errorf("unable to serialize ai history: %v", err) } // ignore MakeFile error (already exists is ok) filestore.WFS.MakeFile(ctx, blockId, "aidata", nil, wshrpc.FileOpts{}) err = filestore.WFS.WriteFile(ctx, blockId, "aidata", historyBytes) if err != nil { return fmt.Errorf("cannot save terminal state: %w", err) } return nil } func (*BlockService) CleanupOrphanedBlocks_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ Desc: "queue a layout action to cleanup orphaned blocks in the tab", ArgNames: []string{"ctx", "tabId"}, } } func (bs *BlockService) CleanupOrphanedBlocks(ctx context.Context, tabId string) (waveobj.UpdatesRtnType, error) { ctx = waveobj.ContextWithUpdates(ctx) layoutAction := waveobj.LayoutActionData{ ActionType: wcore.LayoutActionDataType_CleanupOrphaned, ActionId: uuid.NewString(), } err := wcore.QueueLayoutActionForTab(ctx, tabId, layoutAction) if err != nil { return nil, fmt.Errorf("error queuing cleanup layout action: %w", err) } return waveobj.ContextGetUpdatesRtn(ctx), nil } ================================================ FILE: pkg/service/clientservice/clientservice.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package clientservice import ( "context" "fmt" "log" "time" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wslconn" "github.com/wavetermdev/waveterm/pkg/wstore" ) type ClientService struct{} const DefaultTimeout = 2 * time.Second func (cs *ClientService) GetClientData() (*waveobj.Client, error) { log.Println("GetClientData") ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() return wcore.GetClientData(ctx) } func (cs *ClientService) GetTab(tabId string) (*waveobj.Tab, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() tab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) if err != nil { return nil, fmt.Errorf("error getting tab: %w", err) } return tab, nil } func (cs *ClientService) GetAllConnStatus(ctx context.Context) ([]wshrpc.ConnStatus, error) { sshStatuses := conncontroller.GetAllConnStatus() wslStatuses := wslconn.GetAllConnStatus() return append(sshStatuses, wslStatuses...), nil } // moves the window to the front of the windowId stack func (cs *ClientService) FocusWindow(ctx context.Context, windowId string) error { return wcore.FocusWindow(ctx, windowId) } func (cs *ClientService) AgreeTos(ctx context.Context) (waveobj.UpdatesRtnType, error) { ctx = waveobj.ContextWithUpdates(ctx) clientData, err := wstore.DBGetSingleton[*waveobj.Client](ctx) if err != nil { return nil, fmt.Errorf("error getting client data: %w", err) } timestamp := time.Now().UnixMilli() clientData.TosAgreed = timestamp err = wstore.DBUpdate(ctx, clientData) if err != nil { return nil, fmt.Errorf("error updating client data: %w", err) } wcore.BootstrapStarterLayout(ctx) return waveobj.ContextGetUpdatesRtn(ctx), nil } func (cs *ClientService) TelemetryUpdate(ctx context.Context, telemetryEnabled bool) error { meta := waveobj.MetaMapType{ wconfig.ConfigKey_TelemetryEnabled: telemetryEnabled, } err := wconfig.SetBaseConfigValue(meta) if err != nil { return fmt.Errorf("error setting telemetry value: %w", err) } return nil } ================================================ FILE: pkg/service/objectservice/objectservice.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package objectservice import ( "context" "fmt" "strings" "time" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) type ObjectService struct{} const DefaultTimeout = 2 * time.Second const ConnContextTimeout = 60 * time.Second func parseORef(oref string) (*waveobj.ORef, error) { fields := strings.Split(oref, ":") if len(fields) != 2 { return nil, fmt.Errorf("invalid object reference: %q", oref) } return &waveobj.ORef{OType: fields[0], OID: fields[1]}, nil } func (svc *ObjectService) GetObject_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ Desc: "get wave object by oref", ArgNames: []string{"oref"}, } } func (svc *ObjectService) GetObject(orefStr string) (waveobj.WaveObj, error) { oref, err := parseORef(orefStr) if err != nil { return nil, err } ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() obj, err := wstore.DBGetORef(ctx, *oref) if err != nil { return nil, fmt.Errorf("error getting object: %w", err) } return obj, nil } func (svc *ObjectService) GetObjects_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"orefs"}, ReturnDesc: "objects", } } func (svc *ObjectService) GetObjects(orefStrArr []string) ([]waveobj.WaveObj, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() var orefArr []waveobj.ORef for _, orefStr := range orefStrArr { orefObj, err := parseORef(orefStr) if err != nil { return nil, err } orefArr = append(orefArr, *orefObj) } return wstore.DBSelectORefs(ctx, orefArr) } func (svc *ObjectService) CreateBlock_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"uiContext", "blockDef", "rtOpts"}, ReturnDesc: "blockId", } } func (svc *ObjectService) CreateBlock(uiContext waveobj.UIContext, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (string, waveobj.UpdatesRtnType, error) { if uiContext.ActiveTabId == "" { return "", nil, fmt.Errorf("no active tab") } ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) blockData, err := wcore.CreateBlock(ctx, uiContext.ActiveTabId, blockDef, rtOpts) if err != nil { return "", nil, err } return blockData.OID, waveobj.ContextGetUpdatesRtn(ctx), nil } func (svc *ObjectService) DeleteBlock_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"uiContext", "blockId"}, } } func (svc *ObjectService) DeleteBlock(uiContext waveobj.UIContext, blockId string) (waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) err := wcore.DeleteBlock(ctx, blockId, true) if err != nil { return nil, fmt.Errorf("error deleting block: %w", err) } return waveobj.ContextGetUpdatesRtn(ctx), nil } func (svc *ObjectService) UpdateObjectMeta_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"uiContext", "oref", "meta"}, } } func (svc *ObjectService) UpdateObjectMeta(uiContext waveobj.UIContext, orefStr string, meta waveobj.MetaMapType) (waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) oref, err := parseORef(orefStr) if err != nil { return nil, fmt.Errorf("error parsing object reference: %w", err) } err = wstore.UpdateObjectMeta(ctx, *oref, meta, false) if err != nil { return nil, fmt.Errorf("error updating %q meta: %w", orefStr, err) } return waveobj.ContextGetUpdatesRtn(ctx), nil } func (svc *ObjectService) UpdateObject_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"uiContext", "waveObj", "returnUpdates"}, } } func (svc *ObjectService) UpdateObject(uiContext waveobj.UIContext, waveObj waveobj.WaveObj, returnUpdates bool) (waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) if waveObj == nil { return nil, fmt.Errorf("update wavobj is nil") } oref := waveobj.ORefFromWaveObj(waveObj) found, err := wstore.DBExistsORef(ctx, *oref) if err != nil { return nil, fmt.Errorf("error getting object: %w", err) } if !found { return nil, fmt.Errorf("object not found: %s", oref) } err = wstore.DBUpdate(ctx, waveObj) if err != nil { return nil, fmt.Errorf("error updating object: %w", err) } if (waveObj.GetOType() == waveobj.OType_Workspace) && (waveObj.(*waveobj.Workspace).Name != "") { wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WorkspaceUpdate}) } if returnUpdates { return waveobj.ContextGetUpdatesRtn(ctx), nil } return nil, nil } ================================================ FILE: pkg/service/service.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package service import ( "context" "fmt" "reflect" "strings" "github.com/wavetermdev/waveterm/pkg/service/blockservice" "github.com/wavetermdev/waveterm/pkg/service/clientservice" "github.com/wavetermdev/waveterm/pkg/service/objectservice" "github.com/wavetermdev/waveterm/pkg/service/userinputservice" "github.com/wavetermdev/waveterm/pkg/service/windowservice" "github.com/wavetermdev/waveterm/pkg/service/workspaceservice" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/web/webcmd" ) var ServiceMap = map[string]any{ "block": blockservice.BlockServiceInstance, "object": &objectservice.ObjectService{}, "client": &clientservice.ClientService{}, "window": &windowservice.WindowService{}, "workspace": &workspaceservice.WorkspaceService{}, "userinput": &userinputservice.UserInputService{}, } var contextRType = reflect.TypeOf((*context.Context)(nil)).Elem() var errorRType = reflect.TypeOf((*error)(nil)).Elem() var updatesRType = reflect.TypeOf(([]waveobj.WaveObjUpdate{})) var waveObjRType = reflect.TypeOf((*waveobj.WaveObj)(nil)).Elem() var waveObjSliceRType = reflect.TypeOf([]waveobj.WaveObj{}) var waveObjMapRType = reflect.TypeOf(map[string]waveobj.WaveObj{}) var methodMetaRType = reflect.TypeOf(tsgenmeta.MethodMeta{}) var waveObjUpdateRType = reflect.TypeOf(waveobj.WaveObjUpdate{}) var uiContextRType = reflect.TypeOf((*waveobj.UIContext)(nil)).Elem() var wsCommandRType = reflect.TypeOf((*webcmd.WSCommandType)(nil)).Elem() var orefRType = reflect.TypeOf((*waveobj.ORef)(nil)).Elem() type WebCallType struct { Service string `json:"service"` Method string `json:"method"` UIContext *waveobj.UIContext `json:"uicontext,omitempty"` Args []any `json:"args"` } type WebReturnType struct { Success bool `json:"success,omitempty"` Error string `json:"error,omitempty"` Data any `json:"data,omitempty"` Updates []waveobj.WaveObjUpdate `json:"updates,omitempty"` } func convertNumber(argType reflect.Type, jsonArg float64) (any, error) { switch argType.Kind() { case reflect.Int: return int(jsonArg), nil case reflect.Int8: return int8(jsonArg), nil case reflect.Int16: return int16(jsonArg), nil case reflect.Int32: return int32(jsonArg), nil case reflect.Int64: return int64(jsonArg), nil case reflect.Uint: return uint(jsonArg), nil case reflect.Uint8: return uint8(jsonArg), nil case reflect.Uint16: return uint16(jsonArg), nil case reflect.Uint32: return uint32(jsonArg), nil case reflect.Uint64: return uint64(jsonArg), nil case reflect.Float32: return float32(jsonArg), nil case reflect.Float64: return jsonArg, nil } return nil, fmt.Errorf("invalid number type %s", argType) } func convertComplex(argType reflect.Type, jsonArg any) (any, error) { nativeArgVal := reflect.New(argType) err := utilfn.DoMapStructure(nativeArgVal.Interface(), jsonArg) if err != nil { return nil, err } return nativeArgVal.Elem().Interface(), nil } func isSpecialWaveArgType(argType reflect.Type) bool { return argType == waveObjRType || argType == waveObjSliceRType || argType == waveObjMapRType || argType == wsCommandRType } func convertWSCommand(argType reflect.Type, jsonArg any) (any, error) { if _, ok := jsonArg.(map[string]any); !ok { return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) } cmd, err := webcmd.ParseWSCommandMap(jsonArg.(map[string]any)) if err != nil { return nil, fmt.Errorf("error parsing command map: %w", err) } return cmd, nil } func convertSpecial(argType reflect.Type, jsonArg any) (any, error) { jsonType := reflect.TypeOf(jsonArg) if argType == orefRType { if jsonType.Kind() != reflect.String { return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) } oref, err := waveobj.ParseORef(jsonArg.(string)) if err != nil { return nil, fmt.Errorf("invalid oref string: %v", err) } return oref, nil } else if argType == wsCommandRType { return convertWSCommand(argType, jsonArg) } else if argType == waveObjRType { if jsonType.Kind() != reflect.Map { return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) } return waveobj.FromJsonMap(jsonArg.(map[string]any)) } else if argType == waveObjSliceRType { if jsonType.Kind() != reflect.Slice { return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) } sliceArg := jsonArg.([]any) nativeSlice := make([]waveobj.WaveObj, len(sliceArg)) for idx, elem := range sliceArg { elemMap, ok := elem.(map[string]any) if !ok { return nil, fmt.Errorf("cannot convert %T to %s (idx %d is not a map, is %T)", jsonArg, waveObjSliceRType, idx, elem) } nativeObj, err := waveobj.FromJsonMap(elemMap) if err != nil { return nil, fmt.Errorf("cannot convert %T to %s (idx %d) error: %v", jsonArg, waveObjSliceRType, idx, err) } nativeSlice[idx] = nativeObj } return nativeSlice, nil } else if argType == waveObjMapRType { if jsonType.Kind() != reflect.Map { return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) } mapArg := jsonArg.(map[string]any) nativeMap := make(map[string]waveobj.WaveObj) for key, elem := range mapArg { elemMap, ok := elem.(map[string]any) if !ok { return nil, fmt.Errorf("cannot convert %T to %s (key %s is not a map, is %T)", jsonArg, waveObjMapRType, key, elem) } nativeObj, err := waveobj.FromJsonMap(elemMap) if err != nil { return nil, fmt.Errorf("cannot convert %T to %s (key %s) error: %v", jsonArg, waveObjMapRType, key, err) } nativeMap[key] = nativeObj } return nativeMap, nil } else { return nil, fmt.Errorf("invalid special wave argument type %s", argType) } } func convertSpecialForReturn(argType reflect.Type, nativeArg any) (any, error) { if argType == waveObjRType { return waveobj.ToJsonMap(nativeArg.(waveobj.WaveObj)) } else if argType == waveObjSliceRType { nativeSlice := nativeArg.([]waveobj.WaveObj) jsonSlice := make([]map[string]any, len(nativeSlice)) for idx, elem := range nativeSlice { elemMap, err := waveobj.ToJsonMap(elem) if err != nil { return nil, err } jsonSlice[idx] = elemMap } return jsonSlice, nil } else if argType == waveObjMapRType { nativeMap := nativeArg.(map[string]waveobj.WaveObj) jsonMap := make(map[string]map[string]any) for key, elem := range nativeMap { elemMap, err := waveobj.ToJsonMap(elem) if err != nil { return nil, err } jsonMap[key] = elemMap } return jsonMap, nil } else { return nil, fmt.Errorf("invalid special wave argument type %s", argType) } } func convertArgument(argType reflect.Type, jsonArg any) (any, error) { if jsonArg == nil { return reflect.Zero(argType).Interface(), nil } if isSpecialWaveArgType(argType) { return convertSpecial(argType, jsonArg) } jsonType := reflect.TypeOf(jsonArg) switch argType.Kind() { case reflect.String: if jsonType.Kind() == reflect.String { return jsonArg, nil } return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) case reflect.Bool: if jsonType.Kind() == reflect.Bool { return jsonArg, nil } return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64: if jsonType.Kind() == reflect.Float64 { return convertNumber(argType, jsonArg.(float64)) } return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) case reflect.Map: if argType.Key().Kind() != reflect.String { return nil, fmt.Errorf("invalid map key type %s", argType.Key()) } if jsonType.Kind() != reflect.Map { return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) } return convertComplex(argType, jsonArg) case reflect.Slice: if jsonType.Kind() != reflect.Slice { return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) } return convertComplex(argType, jsonArg) case reflect.Struct: if jsonType.Kind() != reflect.Map { return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) } return convertComplex(argType, jsonArg) case reflect.Ptr: if argType.Elem().Kind() != reflect.Struct { return nil, fmt.Errorf("invalid pointer type %s", argType) } if jsonType.Kind() != reflect.Map { return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) } return convertComplex(argType, jsonArg) default: return nil, fmt.Errorf("invalid argument type %s", argType) } } func isNilable(val reflect.Value) bool { switch val.Kind() { case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Interface, reflect.Chan, reflect.Func: return true } return false } func convertReturnValues(rtnVals []reflect.Value) *WebReturnType { rtn := &WebReturnType{} if len(rtnVals) == 0 { return rtn } for _, val := range rtnVals { if isNilable(val) && val.IsNil() { continue } valType := val.Type() if valType == errorRType { rtn.Error = val.Interface().(error).Error() continue } if valType == updatesRType { // has a special MarshalJSON method rtn.Updates = val.Interface().([]waveobj.WaveObjUpdate) continue } if isSpecialWaveArgType(valType) { jsonVal, err := convertSpecialForReturn(valType, val.Interface()) if err != nil { rtn.Error = fmt.Errorf("cannot convert special return value: %v", err).Error() continue } rtn.Data = jsonVal continue } rtn.Data = val.Interface() } if rtn.Error == "" { rtn.Success = true } return rtn } func webErrorRtn(err error) *WebReturnType { return &WebReturnType{ Error: err.Error(), } } func CallService(ctx context.Context, webCall WebCallType) *WebReturnType { svcObj := ServiceMap[webCall.Service] if svcObj == nil { return webErrorRtn(fmt.Errorf("invalid service: %q", webCall.Service)) } method := reflect.ValueOf(svcObj).MethodByName(webCall.Method) if !method.IsValid() { return webErrorRtn(fmt.Errorf("invalid method: %s.%s", webCall.Service, webCall.Method)) } var valueArgs []reflect.Value argIdx := 0 for idx := 0; idx < method.Type().NumIn(); idx++ { argType := method.Type().In(idx) if idx == 0 && argType == contextRType { valueArgs = append(valueArgs, reflect.ValueOf(ctx)) continue } if argType == uiContextRType { if webCall.UIContext == nil { return webErrorRtn(fmt.Errorf("missing UIContext for %s.%s", webCall.Service, webCall.Method)) } valueArgs = append(valueArgs, reflect.ValueOf(*webCall.UIContext)) continue } if argIdx >= len(webCall.Args) { return webErrorRtn(fmt.Errorf("not enough arguments passed %s.%s idx:%d (type %T)", webCall.Service, webCall.Method, idx, argType)) } nativeArg, err := convertArgument(argType, webCall.Args[argIdx]) if err != nil { return webErrorRtn(fmt.Errorf("cannot convert argument %s.%s type:%T idx:%d error:%v", webCall.Service, webCall.Method, argType, idx, err)) } valueArgs = append(valueArgs, reflect.ValueOf(nativeArg)) argIdx++ } retValArr := method.Call(valueArgs) return convertReturnValues(retValArr) } // ValidateServiceArg validates the argument type for a service method // does not allow interfaces (and the obvious invalid types) // arguments + return values have special handling for wave objects func baseValidateServiceArg(argType reflect.Type) error { if argType == waveObjUpdateRType { // has special MarshalJSON method, so it is safe return nil } switch argType.Kind() { case reflect.Ptr, reflect.Slice, reflect.Array: return baseValidateServiceArg(argType.Elem()) case reflect.Map: if argType.Key().Kind() != reflect.String { return fmt.Errorf("invalid map key type %s", argType.Key()) } return baseValidateServiceArg(argType.Elem()) case reflect.Struct: for idx := 0; idx < argType.NumField(); idx++ { if err := baseValidateServiceArg(argType.Field(idx).Type); err != nil { return err } } case reflect.Interface: return fmt.Errorf("invalid argument type %s: contains interface", argType) case reflect.Chan, reflect.Func, reflect.Complex128, reflect.Complex64, reflect.Invalid, reflect.Uintptr, reflect.UnsafePointer: return fmt.Errorf("invalid argument type %s", argType) } return nil } func validateMethodReturnArg(retType reflect.Type) error { // specifically allow waveobj.WaveObj, []waveobj.WaveObj, map[string]waveobj.WaveObj, and error if isSpecialWaveArgType(retType) || retType == errorRType { return nil } return baseValidateServiceArg(retType) } func validateMethodArg(argType reflect.Type) error { // specifically allow waveobj.WaveObj, []waveobj.WaveObj, map[string]waveobj.WaveObj, and context.Context if isSpecialWaveArgType(argType) || argType == contextRType { return nil } return baseValidateServiceArg(argType) } func validateServiceMethod(service string, method reflect.Method) error { for idx := 0; idx < method.Type.NumOut(); idx++ { if err := validateMethodReturnArg(method.Type.Out(idx)); err != nil { return fmt.Errorf("invalid return type %s.%s %s: %v", service, method.Name, method.Type.Out(idx), err) } } for idx := 1; idx < method.Type.NumIn(); idx++ { // skip the first argument which is the receiver if err := validateMethodArg(method.Type.In(idx)); err != nil { return fmt.Errorf("invalid argument type %s.%s %s: %v", service, method.Name, method.Type.In(idx), err) } } return nil } func validateServiceMetaMethod(service string, method reflect.Method) error { if method.Type.NumIn() != 1 { return fmt.Errorf("invalid number of arguments %s.%s: got:%d, expected just the receiver", service, method.Name, method.Type.NumIn()) } if method.Type.NumOut() != 1 && method.Type.Out(0) != methodMetaRType { return fmt.Errorf("invalid return type %s.%s: got:%s, expected servicemeta.MethodMeta", service, method.Name, method.Type.Out(0)) } return nil } func ValidateService(serviceName string, svcObj any) error { svcType := reflect.TypeOf(svcObj) if svcType.Kind() != reflect.Ptr { return fmt.Errorf("service object %q must be a pointer", serviceName) } svcType = svcType.Elem() if svcType.Kind() != reflect.Struct { return fmt.Errorf("service object %q must be a ptr to struct", serviceName) } for idx := 0; idx < svcType.NumMethod(); idx++ { method := svcType.Method(idx) if strings.HasSuffix(method.Name, "_Meta") { err := validateServiceMetaMethod(serviceName, method) if err != nil { return err } } if err := validateServiceMethod(serviceName, method); err != nil { return err } } return nil } func ValidateServiceMap() error { for svcName, svcObj := range ServiceMap { if err := ValidateService(svcName, svcObj); err != nil { return err } } return nil } ================================================ FILE: pkg/service/userinputservice/userinputservice.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package userinputservice import ( "github.com/wavetermdev/waveterm/pkg/userinput" ) type UserInputService struct { } func (uis *UserInputService) SendUserInputResponse(response *userinput.UserInputResponse) { select { case userinput.MainUserInputHandler.Channels[response.RequestId] <- response: default: } } ================================================ FILE: pkg/service/windowservice/windowservice.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package windowservice import ( "context" "fmt" "time" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) const DefaultTimeout = 2 * time.Second type WindowService struct{} func (svc *WindowService) GetWindow_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"windowId"}, } } func (svc *WindowService) GetWindow(windowId string) (*waveobj.Window, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() window, err := wstore.DBGet[*waveobj.Window](ctx, windowId) if err != nil { return nil, fmt.Errorf("error getting window: %w", err) } return window, nil } func (svc *WindowService) CreateWindow_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"ctx", "winSize", "workspaceId"}, } } func (svc *WindowService) CreateWindow(ctx context.Context, winSize *waveobj.WinSize, workspaceId string) (*waveobj.Window, error) { window, err := wcore.CreateWindow(ctx, winSize, workspaceId) if err != nil { return nil, fmt.Errorf("error creating window: %w", err) } return window, nil } func (svc *WindowService) SetWindowPosAndSize_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ Desc: "set window position and size", ArgNames: []string{"ctx", "windowId", "pos", "size"}, } } func (ws *WindowService) SetWindowPosAndSize(ctx context.Context, windowId string, pos *waveobj.Point, size *waveobj.WinSize) (waveobj.UpdatesRtnType, error) { if pos == nil && size == nil { return nil, nil } ctx = waveobj.ContextWithUpdates(ctx) win, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) if err != nil { return nil, err } if pos != nil { win.Pos = *pos } if size != nil { win.WinSize = *size } win.IsNew = false err = wstore.DBUpdate(ctx, win) if err != nil { return nil, err } return waveobj.ContextGetUpdatesRtn(ctx), nil } func (svc *WindowService) SwitchWorkspace_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"ctx", "windowId", "workspaceId"}, } } func (svc *WindowService) SwitchWorkspace(ctx context.Context, windowId string, workspaceId string) (*waveobj.Workspace, error) { ctx = waveobj.ContextWithUpdates(ctx) ws, err := wcore.SwitchWorkspace(ctx, windowId, workspaceId) updates := waveobj.ContextGetUpdatesRtn(ctx) go func() { defer func() { panichandler.PanicHandler("WindowService:SwitchWorkspace:SendUpdateEvents", recover()) }() wps.Broker.SendUpdateEvents(updates) }() return ws, err } func (svc *WindowService) CloseWindow_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"ctx", "windowId", "fromElectron"}, } } func (svc *WindowService) CloseWindow(ctx context.Context, windowId string, fromElectron bool) error { ctx = waveobj.ContextWithUpdates(ctx) return wcore.CloseWindow(ctx, windowId, fromElectron) } ================================================ FILE: pkg/service/workspaceservice/workspaceservice.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package workspaceservice import ( "context" "fmt" "time" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) const DefaultTimeout = 2 * time.Second type WorkspaceService struct{} func (svc *WorkspaceService) CreateWorkspace_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"ctx", "name", "icon", "color", "applyDefaults"}, ReturnDesc: "workspaceId", } } func (svc *WorkspaceService) CreateWorkspace(ctx context.Context, name string, icon string, color string, applyDefaults bool) (string, error) { newWS, err := wcore.CreateWorkspace(ctx, name, icon, color, applyDefaults, false) if err != nil { return "", fmt.Errorf("error creating workspace: %w", err) } return newWS.OID, nil } func (svc *WorkspaceService) UpdateWorkspace_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"ctx", "workspaceId", "name", "icon", "color", "applyDefaults"}, } } func (svc *WorkspaceService) UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, applyDefaults bool) (waveobj.UpdatesRtnType, error) { ctx = waveobj.ContextWithUpdates(ctx) _, updated, err := wcore.UpdateWorkspace(ctx, workspaceId, name, icon, color, applyDefaults) if err != nil { return nil, fmt.Errorf("error updating workspace: %w", err) } if !updated { return nil, nil } wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WorkspaceUpdate, }) updates := waveobj.ContextGetUpdatesRtn(ctx) go func() { defer func() { panichandler.PanicHandler("WorkspaceService:UpdateWorkspace:SendUpdateEvents", recover()) }() wps.Broker.SendUpdateEvents(updates) }() return updates, nil } func (svc *WorkspaceService) GetWorkspace_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"workspaceId"}, ReturnDesc: "workspace", } } func (svc *WorkspaceService) GetWorkspace(workspaceId string) (*waveobj.Workspace, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ws, err := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId) if err != nil { return nil, fmt.Errorf("error getting workspace: %w", err) } return ws, nil } func (svc *WorkspaceService) DeleteWorkspace_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"workspaceId"}, } } func (svc *WorkspaceService) DeleteWorkspace(workspaceId string) (waveobj.UpdatesRtnType, string, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) deleted, claimableWorkspace, err := wcore.DeleteWorkspace(ctx, workspaceId, true) if claimableWorkspace != "" { return nil, claimableWorkspace, nil } if err != nil { return nil, claimableWorkspace, fmt.Errorf("error deleting workspace: %w", err) } if !deleted { return nil, claimableWorkspace, nil } updates := waveobj.ContextGetUpdatesRtn(ctx) go func() { defer func() { panichandler.PanicHandler("WorkspaceService:DeleteWorkspace:SendUpdateEvents", recover()) }() wps.Broker.SendUpdateEvents(updates) }() return updates, claimableWorkspace, nil } func (svc *WorkspaceService) ListWorkspaces() (waveobj.WorkspaceList, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() return wcore.ListWorkspaces(ctx) } func (svc *WorkspaceService) CreateTab_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"workspaceId", "tabName", "activateTab"}, ReturnDesc: "tabId", } } func (svc *WorkspaceService) GetColors_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ReturnDesc: "colors", } } func (svc *WorkspaceService) GetColors() []string { return wcore.WorkspaceColors[:] } func (svc *WorkspaceService) GetIcons_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ReturnDesc: "icons", } } func (svc *WorkspaceService) GetIcons() []string { return wcore.WorkspaceIcons[:] } func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab, false) if err != nil { return "", nil, fmt.Errorf("error creating tab: %w", err) } updates := waveobj.ContextGetUpdatesRtn(ctx) go func() { defer func() { panichandler.PanicHandler("WorkspaceService:CreateTab:SendUpdateEvents", recover()) }() wps.Broker.SendUpdateEvents(updates) }() return tabId, updates, nil } func (svc *WorkspaceService) SetActiveTab_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"workspaceId", "tabId"}, } } func (svc *WorkspaceService) SetActiveTab(workspaceId string, tabId string) (waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) err := wcore.SetActiveTab(ctx, workspaceId, tabId) if err != nil { return nil, fmt.Errorf("error setting active tab: %w", err) } // check all blocks in tab and start controllers (if necessary) tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId) if err != nil { return nil, fmt.Errorf("error getting tab: %w", err) } blockORefs := tab.GetBlockORefs() blocks, err := wstore.DBSelectORefs(ctx, blockORefs) if err != nil { return nil, fmt.Errorf("error getting tab blocks: %w", err) } updates := waveobj.ContextGetUpdatesRtn(ctx) go func() { defer func() { panichandler.PanicHandler("WorkspaceService:SetActiveTab:SendUpdateEvents", recover()) }() wps.Broker.SendUpdateEvents(updates) }() var extraUpdates waveobj.UpdatesRtnType extraUpdates = append(extraUpdates, updates...) extraUpdates = append(extraUpdates, waveobj.MakeUpdate(tab)) extraUpdates = append(extraUpdates, waveobj.MakeUpdates(blocks)...) return extraUpdates, nil } type CloseTabRtnType struct { CloseWindow bool `json:"closewindow,omitempty"` NewActiveTabId string `json:"newactivetabid,omitempty"` } func (svc *WorkspaceService) CloseTab_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"ctx", "workspaceId", "tabId", "fromElectron"}, ReturnDesc: "CloseTabRtn", } } // returns the new active tabid func (svc *WorkspaceService) CloseTab(ctx context.Context, workspaceId string, tabId string, fromElectron bool) (*CloseTabRtnType, waveobj.UpdatesRtnType, error) { ctx = waveobj.ContextWithUpdates(ctx) tab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) if err == nil && tab != nil { go func() { for _, blockId := range tab.BlockIds { blockcontroller.DestroyBlockController(blockId) } }() } newActiveTabId, err := wcore.DeleteTab(ctx, workspaceId, tabId, true) if err != nil { return nil, nil, fmt.Errorf("error closing tab: %w", err) } rtn := &CloseTabRtnType{} if newActiveTabId == "" { rtn.CloseWindow = true } else { rtn.NewActiveTabId = newActiveTabId } updates := waveobj.ContextGetUpdatesRtn(ctx) go func() { defer func() { panichandler.PanicHandler("WorkspaceService:CloseTab:SendUpdateEvents", recover()) }() wps.Broker.SendUpdateEvents(updates) }() return rtn, updates, nil } ================================================ FILE: pkg/shellexec/conninterface.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package shellexec import ( "io" "os" "os/exec" "runtime" "sync" "syscall" "time" "github.com/creack/pty" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/util/unixutil" "github.com/wavetermdev/waveterm/pkg/wsl" "golang.org/x/crypto/ssh" ) type ConnInterface interface { Kill() KillGraceful(time.Duration) Wait() error Start() error ExitCode() int ExitSignal() string StdinPipe() (io.WriteCloser, error) StdoutPipe() (io.ReadCloser, error) StderrPipe() (io.ReadCloser, error) SetSize(w int, h int) error pty.Pty } type CmdWrap struct { Cmd *exec.Cmd IsShell bool WaitOnce *sync.Once WaitErr error pty.Pty } func MakeCmdWrap(cmd *exec.Cmd, cmdPty pty.Pty, isShell bool) CmdWrap { return CmdWrap{ Cmd: cmd, IsShell: isShell, WaitOnce: &sync.Once{}, Pty: cmdPty, } } func (cw CmdWrap) Kill() { cw.Cmd.Process.Kill() } func (cw CmdWrap) Wait() error { cw.WaitOnce.Do(func() { cw.WaitErr = cw.Cmd.Wait() }) return cw.WaitErr } // only valid once Wait() has returned (or you know Cmd is done) func (cw CmdWrap) ExitCode() int { state := cw.Cmd.ProcessState if state == nil { return -1 } return state.ExitCode() } func (cw CmdWrap) ExitSignal() string { state := cw.Cmd.ProcessState if state == nil { return "" } if ws, ok := state.Sys().(syscall.WaitStatus); ok { if ws.Signaled() { return unixutil.GetSignalName(ws.Signal()) } } return "" } func (cw CmdWrap) KillGraceful(timeout time.Duration) { if cw.Cmd.Process == nil { return } if cw.Cmd.ProcessState != nil && cw.Cmd.ProcessState.Exited() { return } if runtime.GOOS == "windows" { cw.Cmd.Process.Kill() return } if cw.IsShell { unixutil.SignalHup(cw.Cmd.Process.Pid) } else { unixutil.SignalTerm(cw.Cmd.Process.Pid) } go func() { defer func() { panichandler.PanicHandler("KillGraceful:Kill", recover()) }() time.Sleep(timeout) if cw.Cmd.ProcessState == nil || !cw.Cmd.ProcessState.Exited() { cw.Cmd.Process.Kill() // force kill if it is already not exited } }() } func (cw CmdWrap) Start() error { defer func() { for _, extraFile := range cw.Cmd.ExtraFiles { if extraFile != nil { extraFile.Close() } } }() return cw.Cmd.Start() } func (cw CmdWrap) StdinPipe() (io.WriteCloser, error) { return cw.Cmd.StdinPipe() } func (cw CmdWrap) StdoutPipe() (io.ReadCloser, error) { return cw.Cmd.StdoutPipe() } func (cw CmdWrap) StderrPipe() (io.ReadCloser, error) { return cw.Cmd.StderrPipe() } func (cw CmdWrap) SetSize(w int, h int) error { err := pty.Setsize(cw.Pty, &pty.Winsize{Rows: uint16(w), Cols: uint16(h)}) if err != nil { return err } return nil } type SessionWrap struct { Session *ssh.Session StartCmd string Tty pty.Tty WaitOnce *sync.Once WaitErr error pty.Pty } func MakeSessionWrap(session *ssh.Session, startCmd string, sessionPty pty.Pty) SessionWrap { return SessionWrap{ Session: session, StartCmd: startCmd, Tty: sessionPty, WaitOnce: &sync.Once{}, Pty: sessionPty, } } func (sw SessionWrap) Kill() { sw.Tty.Close() sw.Session.Close() } func (sw SessionWrap) KillGraceful(timeout time.Duration) { sw.Kill() } func (sw SessionWrap) ExitCode() int { waitErr := sw.WaitErr if waitErr == nil { return -1 } return ExitCodeFromWaitErr(waitErr) } func (sw SessionWrap) ExitSignal() string { if sw.WaitErr == nil { return "" } if exitErr, ok := sw.WaitErr.(*ssh.ExitError); ok { signal := exitErr.Signal() if signal != "" { return signal } } return "" } func (sw SessionWrap) Wait() error { sw.WaitOnce.Do(func() { sw.WaitErr = sw.Session.Wait() }) return sw.WaitErr } func (sw SessionWrap) Start() error { return sw.Session.Start(sw.StartCmd) } func (sw SessionWrap) StdinPipe() (io.WriteCloser, error) { return sw.Session.StdinPipe() } func (sw SessionWrap) StdoutPipe() (io.ReadCloser, error) { stdoutReader, err := sw.Session.StdoutPipe() if err != nil { return nil, err } return io.NopCloser(stdoutReader), nil } func (sw SessionWrap) StderrPipe() (io.ReadCloser, error) { stderrReader, err := sw.Session.StderrPipe() if err != nil { return nil, err } return io.NopCloser(stderrReader), nil } func (sw SessionWrap) SetSize(h int, w int) error { return sw.Session.WindowChange(h, w) } type WslCmdWrap struct { *wsl.WslCmd Tty pty.Tty pty.Pty } func (wcw WslCmdWrap) Kill() { wcw.Tty.Close() wcw.Close() } func (wcw WslCmdWrap) KillGraceful(timeout time.Duration) { process := wcw.WslCmd.GetProcess() if process == nil { return } processState := wcw.WslCmd.GetProcessState() if processState != nil && processState.Exited() { return } process.Signal(os.Interrupt) go func() { defer func() { panichandler.PanicHandler("KillGraceful-wsl:Kill", recover()) }() time.Sleep(timeout) process := wcw.WslCmd.GetProcess() processState := wcw.WslCmd.GetProcessState() if processState == nil || !processState.Exited() { process.Kill() // force kill if it is already not exited } }() } /** * SetSize does nothing for WslCmdWrap as there * is no pty to manage. **/ func (wcw WslCmdWrap) SetSize(w int, h int) error { return nil } ================================================ FILE: pkg/shellexec/shellexec.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package shellexec import ( "bytes" "context" "fmt" "io" "log" "os" "os/exec" "runtime" "strings" "sync" "syscall" "time" "maps" "github.com/creack/pty" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/util/pamparse" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wslconn" ) const DefaultGracefulKillWait = 400 * time.Millisecond type CommandOptsType struct { Interactive bool `json:"interactive,omitempty"` Login bool `json:"login,omitempty"` Cwd string `json:"cwd,omitempty"` ShellPath string `json:"shellPath,omitempty"` ShellOpts []string `json:"shellOpts,omitempty"` SwapToken *shellutil.TokenSwapEntry `json:"swapToken,omitempty"` ForceJwt bool `json:"forcejwt,omitempty"` } type ShellProc struct { ConnName string Cmd ConnInterface CloseOnce *sync.Once DoneCh chan any // closed after proc.Wait() returns WaitErr error // WaitErr is synchronized by DoneCh (written before DoneCh is closed) and CloseOnce } func (sp *ShellProc) Close() { sp.Cmd.KillGraceful(DefaultGracefulKillWait) go func() { defer func() { panichandler.PanicHandler("ShellProc.Close", recover()) }() waitErr := sp.Cmd.Wait() sp.SetWaitErrorAndSignalDone(waitErr) // windows cannot handle the pty being // closed twice, so we let the pty // close itself instead if runtime.GOOS != "windows" { sp.Cmd.Close() } }() } func (sp *ShellProc) SetWaitErrorAndSignalDone(waitErr error) { sp.CloseOnce.Do(func() { sp.WaitErr = waitErr close(sp.DoneCh) }) } func (sp *ShellProc) Wait() error { <-sp.DoneCh return sp.WaitErr } // returns (done, waitError) func (sp *ShellProc) WaitNB() (bool, error) { select { case <-sp.DoneCh: return true, sp.WaitErr default: return false, nil } } func ExitCodeFromWaitErr(err error) int { if err == nil { return 0 } if exitErr, ok := err.(*exec.ExitError); ok { if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { return status.ExitStatus() } } return -1 } func checkCwd(cwd string) error { if cwd == "" { return fmt.Errorf("cwd is empty") } if _, err := os.Stat(cwd); err != nil { return fmt.Errorf("error statting cwd %q: %w", cwd, err) } return nil } type PipePty struct { remoteStdinWrite *os.File remoteStdoutRead *os.File } func (pp *PipePty) Fd() uintptr { return pp.remoteStdinWrite.Fd() } func (pp *PipePty) Name() string { return "pipe-pty" } func (pp *PipePty) Read(p []byte) (n int, err error) { return pp.remoteStdoutRead.Read(p) } func (pp *PipePty) Write(p []byte) (n int, err error) { return pp.remoteStdinWrite.Write(p) } func (pp *PipePty) Close() error { err1 := pp.remoteStdinWrite.Close() err2 := pp.remoteStdoutRead.Close() if err1 != nil { return err1 } return err2 } func (pp *PipePty) WriteString(s string) (n int, err error) { return pp.Write([]byte(s)) } func StartWslShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wslconn.WslConn) (*ShellProc, error) { client := conn.GetClient() conn.Infof(ctx, "WSL-NEWSESSION (StartWslShellProcNoWsh)") ecmd := exec.Command("wsl.exe", "~", "-d", client.Name()) if termSize.Rows == 0 || termSize.Cols == 0 { termSize.Rows = shellutil.DefaultTermRows termSize.Cols = shellutil.DefaultTermCols } if termSize.Rows <= 0 || termSize.Cols <= 0 { return nil, fmt.Errorf("invalid term size: %v", termSize) } cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)}) if err != nil { return nil, err } cmdWrap := MakeCmdWrap(ecmd, cmdPty, true) return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wslconn.WslConn) (*ShellProc, error) { if cmdOpts.SwapToken == nil { return nil, fmt.Errorf("SwapToken is required in CommandOptsType") } client := conn.GetClient() conn.Infof(ctx, "WSL-NEWSESSION (StartWslShellProc)") connRoute := wshutil.MakeConnectionRouteId(conn.GetName()) rpcClient := wshclient.GetBareRpcClient() remoteInfo, err := wshclient.RemoteGetInfoCommand(rpcClient, &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000}) if err != nil { return nil, fmt.Errorf("unable to obtain client info: %w", err) } log.Printf("client info collected: %+#v", remoteInfo) var shellPath string if cmdOpts.ShellPath != "" { conn.Infof(ctx, "using shell path from command opts: %s\n", cmdOpts.ShellPath) shellPath = cmdOpts.ShellPath } configShellPath := conn.GetConfigShellPath() if shellPath == "" && configShellPath != "" { conn.Infof(ctx, "using shell path from config (conn:shellpath): %s\n", configShellPath) shellPath = configShellPath } if shellPath == "" && remoteInfo.Shell != "" { conn.Infof(ctx, "using shell path detected on remote machine: %s\n", remoteInfo.Shell) shellPath = remoteInfo.Shell } if shellPath == "" { conn.Infof(ctx, "no shell path detected, using default (/bin/bash)\n") shellPath = "/bin/bash" } var shellOpts []string var cmdCombined string log.Printf("detected shell %q for conn %q\n", shellPath, conn.GetName()) err = wshclient.RemoteInstallRcFilesCommand(rpcClient, &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000}) if err != nil { log.Printf("error installing rc files: %v", err) return nil, err } shellOpts = append(shellOpts, cmdOpts.ShellOpts...) shellType := shellutil.GetShellTypeFromShellPath(shellPath) conn.Infof(ctx, "detected shell type: %s\n", shellType) conn.Debugf(ctx, "cmdStr: %q\n", cmdStr) if cmdStr == "" { /* transform command in order to inject environment vars */ if shellType == shellutil.ShellType_bash { // add --rcfile // cant set -l or -i with --rcfile bashPath := fmt.Sprintf("~/.waveterm/%s/.bashrc", shellutil.BashIntegrationDir) shellOpts = append(shellOpts, "--rcfile", bashPath) } else if shellType == shellutil.ShellType_fish { if cmdOpts.Login { shellOpts = append(shellOpts, "-l") } // source the wave.fish file waveFishPath := fmt.Sprintf("~/.waveterm/%s/wave.fish", shellutil.FishIntegrationDir) carg := fmt.Sprintf(`"source %s"`, waveFishPath) shellOpts = append(shellOpts, "-C", carg) } else if shellType == shellutil.ShellType_pwsh { pwshPath := fmt.Sprintf("~/.waveterm/%s/wavepwsh.ps1", shellutil.PwshIntegrationDir) // powershell is weird about quoted path executables and requires an ampersand first shellPath = "& " + shellPath shellOpts = append(shellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", pwshPath) } else { if cmdOpts.Login { shellOpts = append(shellOpts, "-l") } if cmdOpts.Interactive { shellOpts = append(shellOpts, "-i") } // zdotdir setting moved to after session is created } cmdCombined = fmt.Sprintf("%s %s", shellPath, strings.Join(shellOpts, " ")) } else { // TODO check quoting of cmdStr shellOpts = append(shellOpts, "-c", cmdStr) cmdCombined = fmt.Sprintf("%s %s", shellPath, strings.Join(shellOpts, " ")) } conn.Infof(ctx, "starting shell, using command: %s\n", cmdCombined) conn.Infof(ctx, "WSL-NEWSESSION (StartWslShellProc)\n") if shellType == shellutil.ShellType_zsh { zshDir := fmt.Sprintf("~/.waveterm/%s", shellutil.ZshIntegrationDir) conn.Infof(ctx, "setting ZDOTDIR to %s\n", zshDir) cmdCombined = fmt.Sprintf(`ZDOTDIR=%s %s`, zshDir, cmdCombined) } packedToken, err := cmdOpts.SwapToken.PackForClient() if err != nil { conn.Infof(ctx, "error packing swap token: %v", err) } else { conn.Debugf(ctx, "packed swaptoken %s\n", packedToken) cmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveSwapTokenVarName, packedToken, cmdCombined) } jwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName] if jwtToken != "" && cmdOpts.ForceJwt { conn.Debugf(ctx, "adding JWT token to environment\n") cmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveJwtTokenVarName, jwtToken, cmdCombined) } log.Printf("full combined command: %s", cmdCombined) ecmd := exec.Command("wsl.exe", "~", "-d", client.Name(), "--", "sh", "-c", cmdCombined) if termSize.Rows == 0 || termSize.Cols == 0 { termSize.Rows = shellutil.DefaultTermRows termSize.Cols = shellutil.DefaultTermCols } if termSize.Rows <= 0 || termSize.Cols <= 0 { return nil, fmt.Errorf("invalid term size: %v", termSize) } shellutil.AddTokenSwapEntry(cmdOpts.SwapToken) cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)}) if err != nil { return nil, err } cmdWrap := MakeCmdWrap(ecmd, cmdPty, true) return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } func StartRemoteShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { client := conn.GetClient() conn.Infof(ctx, "SSH-NEWSESSION (StartRemoteShellProcNoWsh)") session, err := client.NewSession() if err != nil { return nil, err } remoteStdinRead, remoteStdinWriteOurs, err := os.Pipe() if err != nil { return nil, err } remoteStdoutReadOurs, remoteStdoutWrite, err := os.Pipe() if err != nil { return nil, err } pipePty := &PipePty{ remoteStdinWrite: remoteStdinWriteOurs, remoteStdoutRead: remoteStdoutReadOurs, } if termSize.Rows == 0 || termSize.Cols == 0 { termSize.Rows = shellutil.DefaultTermRows termSize.Cols = shellutil.DefaultTermCols } if termSize.Rows <= 0 || termSize.Cols <= 0 { return nil, fmt.Errorf("invalid term size: %v", termSize) } session.Stdin = remoteStdinRead session.Stdout = remoteStdoutWrite session.Stderr = remoteStdoutWrite session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) sessionWrap := MakeSessionWrap(session, "", pipePty) err = session.Shell() if err != nil { pipePty.Close() return nil, err } return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { if cmdOpts.SwapToken == nil { return nil, fmt.Errorf("SwapToken is required in CommandOptsType") } client := conn.GetClient() connRoute := wshutil.MakeConnectionRouteId(conn.GetName()) rpcClient := wshclient.GetBareRpcClient() remoteInfo, err := wshclient.RemoteGetInfoCommand(rpcClient, &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000}) if err != nil { return nil, fmt.Errorf("unable to obtain client info: %w", err) } if remoteInfo.HomeDir == "" { return nil, fmt.Errorf("unable to obtain home directory from remote machine") } log.Printf("client info collected: %+#v", remoteInfo) var shellPath string if cmdOpts.ShellPath != "" { conn.Infof(logCtx, "using shell path from command opts: %s\n", cmdOpts.ShellPath) shellPath = cmdOpts.ShellPath } configShellPath := conn.GetConfigShellPath() if shellPath == "" && configShellPath != "" { conn.Infof(logCtx, "using shell path from config (conn:shellpath): %s\n", configShellPath) shellPath = configShellPath } if shellPath == "" && remoteInfo.Shell != "" { conn.Infof(logCtx, "using shell path detected on remote machine: %s\n", remoteInfo.Shell) shellPath = remoteInfo.Shell } if shellPath == "" { conn.Infof(logCtx, "no shell path detected, using default (/bin/bash)\n") shellPath = "/bin/bash" } var shellOpts []string var cmdCombined string log.Printf("detected shell %q for conn %q\n", shellPath, conn.GetName()) shellOpts = append(shellOpts, cmdOpts.ShellOpts...) shellType := shellutil.GetShellTypeFromShellPath(shellPath) conn.Infof(logCtx, "detected shell type: %s\n", shellType) conn.Infof(logCtx, "swaptoken: %s\n", cmdOpts.SwapToken.Token) conn.Debugf(logCtx, "cmdStr: %q\n", cmdStr) if cmdStr == "" { /* transform command in order to inject environment vars */ if shellType == shellutil.ShellType_bash { // add --rcfile // cant set -l or -i with --rcfile bashPath := fmt.Sprintf("%s/.waveterm/%s/.bashrc", remoteInfo.HomeDir, shellutil.BashIntegrationDir) shellOpts = append(shellOpts, "--rcfile", bashPath) } else if shellType == shellutil.ShellType_fish { if cmdOpts.Login { shellOpts = append(shellOpts, "-l") } // source the wave.fish file waveFishPath := fmt.Sprintf("%s/.waveterm/%s/wave.fish", remoteInfo.HomeDir, shellutil.FishIntegrationDir) carg := fmt.Sprintf(`"source %s"`, waveFishPath) shellOpts = append(shellOpts, "-C", carg) } else if shellType == shellutil.ShellType_pwsh { pwshPath := fmt.Sprintf("%s/.waveterm/%s/wavepwsh.ps1", remoteInfo.HomeDir, shellutil.PwshIntegrationDir) // powershell is weird about quoted path executables and requires an ampersand first shellPath = "& " + shellPath shellOpts = append(shellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", pwshPath) } else { if cmdOpts.Login { shellOpts = append(shellOpts, "-l") } if cmdOpts.Interactive { shellOpts = append(shellOpts, "-i") } // zdotdir setting moved to after session is created } cmdCombined = fmt.Sprintf("%s %s", shellPath, strings.Join(shellOpts, " ")) } else { // TODO check quoting of cmdStr shellOpts = append(shellOpts, "-c", cmdStr) cmdCombined = fmt.Sprintf("%s %s", shellPath, strings.Join(shellOpts, " ")) } conn.Infof(logCtx, "starting shell, using command: %s\n", cmdCombined) conn.Infof(logCtx, "SSH-NEWSESSION (StartRemoteShellProc)\n") session, err := client.NewSession() if err != nil { return nil, err } remoteStdinRead, remoteStdinWriteOurs, err := os.Pipe() if err != nil { return nil, err } remoteStdoutReadOurs, remoteStdoutWrite, err := os.Pipe() if err != nil { return nil, err } pipePty := &PipePty{ remoteStdinWrite: remoteStdinWriteOurs, remoteStdoutRead: remoteStdoutReadOurs, } if termSize.Rows == 0 || termSize.Cols == 0 { termSize.Rows = shellutil.DefaultTermRows termSize.Cols = shellutil.DefaultTermCols } if termSize.Rows <= 0 || termSize.Cols <= 0 { return nil, fmt.Errorf("invalid term size: %v", termSize) } session.Stdin = remoteStdinRead session.Stdout = remoteStdoutWrite session.Stderr = remoteStdoutWrite if shellType == shellutil.ShellType_zsh { zshDir := fmt.Sprintf("~/.waveterm/%s", shellutil.ZshIntegrationDir) conn.Infof(logCtx, "setting ZDOTDIR to %s\n", zshDir) cmdCombined = fmt.Sprintf(`ZDOTDIR=%s %s`, zshDir, cmdCombined) } packedToken, err := cmdOpts.SwapToken.PackForClient() if err != nil { conn.Infof(logCtx, "error packing swap token: %v", err) } else { conn.Debugf(logCtx, "packed swaptoken %s\n", packedToken) cmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveSwapTokenVarName, packedToken, cmdCombined) } jwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName] if jwtToken != "" && cmdOpts.ForceJwt { conn.Debugf(logCtx, "adding JWT token to environment\n") cmdCombined = fmt.Sprintf(`%s=%s %s`, wavebase.WaveJwtTokenVarName, jwtToken, cmdCombined) } shellutil.AddTokenSwapEntry(cmdOpts.SwapToken) session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) sessionWrap := MakeSessionWrap(session, cmdCombined, pipePty) err = sessionWrap.Start() if err != nil { pipePty.Close() return nil, err } return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn, optBlockId string) (string, error) { connRoute := wshutil.MakeConnectionRouteId(conn.GetName()) rpcClient := wshclient.GetBareRpcClient() remoteInfo, err := wshclient.RemoteGetInfoCommand(rpcClient, &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000}) if err != nil { return "", fmt.Errorf("unable to obtain client info: %w", err) } if remoteInfo.HomeDir == "" { return "", fmt.Errorf("unable to obtain home directory from remote machine") } log.Printf("client info collected: %+#v", remoteInfo) var shellPath string if cmdOpts.ShellPath != "" { conn.Infof(logCtx, "using shell path from command opts: %s\n", cmdOpts.ShellPath) shellPath = cmdOpts.ShellPath } configShellPath := conn.GetConfigShellPath() if shellPath == "" && configShellPath != "" { conn.Infof(logCtx, "using shell path from config (conn:shellpath): %s\n", configShellPath) shellPath = configShellPath } if shellPath == "" && remoteInfo.Shell != "" { conn.Infof(logCtx, "using shell path detected on remote machine: %s\n", remoteInfo.Shell) shellPath = remoteInfo.Shell } if shellPath == "" { conn.Infof(logCtx, "no shell path detected, using default (/bin/bash)\n") shellPath = "/bin/bash" } var shellOpts []string log.Printf("detected shell %q for conn %q\n", shellPath, conn.GetName()) shellOpts = append(shellOpts, cmdOpts.ShellOpts...) shellType := shellutil.GetShellTypeFromShellPath(shellPath) conn.Infof(logCtx, "detected shell type: %s\n", shellType) conn.Debugf(logCtx, "cmdStr: %q\n", cmdStr) if cmdStr == "" { if shellType == shellutil.ShellType_bash { bashPath := fmt.Sprintf("%s/.waveterm/%s/.bashrc", remoteInfo.HomeDir, shellutil.BashIntegrationDir) shellOpts = append(shellOpts, "--rcfile", bashPath) } else if shellType == shellutil.ShellType_fish { if cmdOpts.Login { shellOpts = append(shellOpts, "-l") } waveFishPath := fmt.Sprintf("%s/.waveterm/%s/wave.fish", remoteInfo.HomeDir, shellutil.FishIntegrationDir) carg := fmt.Sprintf(`source %s`, waveFishPath) shellOpts = append(shellOpts, "-C", carg) } else if shellType == shellutil.ShellType_pwsh { pwshPath := fmt.Sprintf("%s/.waveterm/%s/wavepwsh.ps1", remoteInfo.HomeDir, shellutil.PwshIntegrationDir) shellOpts = append(shellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", pwshPath) } else { if cmdOpts.Login { shellOpts = append(shellOpts, "-l") } if cmdOpts.Interactive { shellOpts = append(shellOpts, "-i") } } } else { shellOpts = append(shellOpts, "-c", cmdStr) } conn.Infof(logCtx, "starting shell job, using command: %s %s\n", shellPath, strings.Join(shellOpts, " ")) if termSize.Rows == 0 || termSize.Cols == 0 { termSize.Rows = shellutil.DefaultTermRows termSize.Cols = shellutil.DefaultTermCols } if termSize.Rows <= 0 || termSize.Cols <= 0 { return "", fmt.Errorf("invalid term size: %v", termSize) } env := make(map[string]string) env["TERM"] = shellutil.DefaultTermType if shellType == shellutil.ShellType_zsh { zshDir := fmt.Sprintf("%s/.waveterm/%s", remoteInfo.HomeDir, shellutil.ZshIntegrationDir) conn.Infof(logCtx, "setting ZDOTDIR to %s\n", zshDir) env["ZDOTDIR"] = zshDir } if cmdOpts.SwapToken != nil { packedToken, err := cmdOpts.SwapToken.PackForClient() if err != nil { conn.Infof(logCtx, "error packing swap token: %v", err) } else { conn.Debugf(logCtx, "packed swaptoken %s\n", packedToken) env[wavebase.WaveSwapTokenVarName] = packedToken } jwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName] if jwtToken != "" && cmdOpts.ForceJwt { conn.Debugf(logCtx, "adding JWT token to environment\n") env[wavebase.WaveJwtTokenVarName] = jwtToken } shellutil.AddTokenSwapEntry(cmdOpts.SwapToken) } jobParams := jobcontroller.StartJobParams{ ConnName: conn.GetName(), JobKind: jobcontroller.JobKind_Shell, Cmd: shellPath, Args: shellOpts, Env: env, TermSize: &termSize, BlockId: optBlockId, } jobId, err := jobcontroller.StartJob(ctx, jobParams) if err != nil { return "", fmt.Errorf("failed to start job: %w", err) } conn.Infof(logCtx, "started job: %s\n", jobId) return jobId, nil } func StartLocalShellProc(logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, connName string) (*ShellProc, error) { if cmdOpts.SwapToken == nil { return nil, fmt.Errorf("SwapToken is required in CommandOptsType") } shellutil.InitCustomShellStartupFiles() var ecmd *exec.Cmd var shellOpts []string shellPath := cmdOpts.ShellPath if shellPath == "" { shellPath = shellutil.DetectLocalShellPath() } shellType := shellutil.GetShellTypeFromShellPath(shellPath) shellOpts = append(shellOpts, cmdOpts.ShellOpts...) var isShell bool if cmdStr == "" { isShell = true if shellType == shellutil.ShellType_bash { // add --rcfile // cant set -l or -i with --rcfile shellOpts = append(shellOpts, "--rcfile", shellutil.GetLocalBashRcFileOverride()) } else if shellType == shellutil.ShellType_fish { if cmdOpts.Login { shellOpts = append(shellOpts, "-l") } waveFishPath := shellutil.GetLocalWaveFishFilePath() carg := fmt.Sprintf("source %s", shellutil.HardQuoteFish(waveFishPath)) shellOpts = append(shellOpts, "-C", carg) } else if shellType == shellutil.ShellType_pwsh { shellOpts = append(shellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", shellutil.GetLocalWavePowershellEnv()) } else { if cmdOpts.Login { shellOpts = append(shellOpts, "-l") } if cmdOpts.Interactive { shellOpts = append(shellOpts, "-i") } } blocklogger.Debugf(logCtx, "[conndebug] shell:%s shellOpts:%v\n", shellPath, shellOpts) ecmd = exec.Command(shellPath, shellOpts...) ecmd.Env = os.Environ() if shellType == shellutil.ShellType_zsh { shellutil.UpdateCmdEnv(ecmd, map[string]string{"ZDOTDIR": shellutil.GetLocalZshZDotDir()}) } } else { isShell = false shellOpts = append(shellOpts, "-c", cmdStr) ecmd = exec.Command(shellPath, shellOpts...) ecmd.Env = os.Environ() } packedToken, err := cmdOpts.SwapToken.PackForClient() if err != nil { blocklogger.Infof(logCtx, "error packing swap token: %v", err) } else { blocklogger.Debugf(logCtx, "packed swaptoken %s\n", packedToken) shellutil.UpdateCmdEnv(ecmd, map[string]string{wavebase.WaveSwapTokenVarName: packedToken}) } jwtToken := cmdOpts.SwapToken.Env[wavebase.WaveJwtTokenVarName] if jwtToken != "" && cmdOpts.ForceJwt { blocklogger.Debugf(logCtx, "adding JWT token to environment\n") shellutil.UpdateCmdEnv(ecmd, map[string]string{wavebase.WaveJwtTokenVarName: jwtToken}) } /* For Snap installations, we need to correct the XDG environment variables as Snap overrides them to point to snap directories. We will get the correct values, if set, from the PAM environment. If the XDG variables are set in profile or in an RC file, it will be overridden when the shell initializes. */ if os.Getenv("SNAP") != "" { log.Printf("Detected Snap installation, correcting XDG environment variables") varsToReplace := map[string]string{"XDG_CONFIG_HOME": "", "XDG_DATA_HOME": "", "XDG_CACHE_HOME": "", "XDG_RUNTIME_DIR": "", "XDG_CONFIG_DIRS": "", "XDG_DATA_DIRS": ""} pamEnvs := tryGetPamEnvVars() if len(pamEnvs) > 0 { // We only want to set the XDG variables from the PAM environment, all others should already be correct or may have been overridden by something else out of our control for k := range pamEnvs { if _, ok := varsToReplace[k]; ok { varsToReplace[k] = pamEnvs[k] } } } log.Printf("Setting XDG environment variables to: %v", varsToReplace) shellutil.UpdateCmdEnv(ecmd, varsToReplace) } if cmdOpts.Cwd != "" { ecmd.Dir = cmdOpts.Cwd } if cwdErr := checkCwd(ecmd.Dir); cwdErr != nil { ecmd.Dir = wavebase.GetHomeDir() } envToAdd := shellutil.WaveshellLocalEnvVars(shellutil.DefaultTermType) if os.Getenv("LANG") == "" { envToAdd["LANG"] = wavebase.DetermineLang() } shellutil.UpdateCmdEnv(ecmd, envToAdd) if termSize.Rows == 0 || termSize.Cols == 0 { termSize.Rows = shellutil.DefaultTermRows termSize.Cols = shellutil.DefaultTermCols } if termSize.Rows <= 0 || termSize.Cols <= 0 { return nil, fmt.Errorf("invalid term size: %v", termSize) } shellutil.AddTokenSwapEntry(cmdOpts.SwapToken) cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)}) if err != nil { return nil, err } cmdWrap := MakeCmdWrap(ecmd, cmdPty, isShell) return &ShellProc{Cmd: cmdWrap, ConnName: connName, CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } func RunSimpleCmdInPty(ecmd *exec.Cmd, termSize waveobj.TermSize) ([]byte, error) { ecmd.Env = os.Environ() shellutil.UpdateCmdEnv(ecmd, shellutil.WaveshellLocalEnvVars(shellutil.DefaultTermType)) if termSize.Rows == 0 || termSize.Cols == 0 { termSize.Rows = shellutil.DefaultTermRows termSize.Cols = shellutil.DefaultTermCols } if termSize.Rows <= 0 || termSize.Cols <= 0 { return nil, fmt.Errorf("invalid term size: %v", termSize) } cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)}) if err != nil { cmdPty.Close() return nil, err } if runtime.GOOS != "windows" { defer cmdPty.Close() } ioDone := make(chan bool) var outputBuf bytes.Buffer go func() { panichandler.PanicHandler("RunSimpleCmdInPty:ioCopy", recover()) // ignore error (/dev/ptmx has read error when process is done) defer close(ioDone) io.Copy(&outputBuf, cmdPty) }() exitErr := ecmd.Wait() if exitErr != nil { return nil, exitErr } <-ioDone return outputBuf.Bytes(), nil } const etcEnvironmentPath = "/etc/environment" const etcSecurityPath = "/etc/security/pam_env.conf" const userEnvironmentPath = "~/.pam_environment" var pamParseOpts *pamparse.PamParseOpts = pamparse.ParsePasswdSafe() /* tryGetPamEnvVars tries to get the environment variables from /etc/environment, /etc/security/pam_env.conf, and ~/.pam_environment. It then returns a map of the environment variables, overriding duplicates with the following order of precedence: 1. /etc/environment 2. /etc/security/pam_env.conf 3. ~/.pam_environment */ func tryGetPamEnvVars() map[string]string { envVars, err := pamparse.ParseEnvironmentFile(etcEnvironmentPath) if err != nil { log.Printf("error parsing %s: %v", etcEnvironmentPath, err) } envVars2, err := pamparse.ParseEnvironmentConfFile(etcSecurityPath, pamParseOpts) if err != nil { log.Printf("error parsing %s: %v", etcSecurityPath, err) } envVars3, err := pamparse.ParseEnvironmentConfFile(wavebase.ExpandHomeDirSafe(userEnvironmentPath), pamParseOpts) if err != nil { log.Printf("error parsing %s: %v", userEnvironmentPath, err) } maps.Copy(envVars, envVars2) maps.Copy(envVars, envVars3) if runtime_dir, ok := envVars["XDG_RUNTIME_DIR"]; !ok || runtime_dir == "" { envVars["XDG_RUNTIME_DIR"] = "/run/user/" + fmt.Sprint(os.Getuid()) } return envVars } ================================================ FILE: pkg/streamclient/stream_test.go ================================================ package streamclient import ( "bytes" "encoding/base64" "io" "testing" "time" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type fakeTransport struct { dataChan chan wshrpc.CommandStreamData ackChan chan wshrpc.CommandStreamAckData } func newFakeTransport() *fakeTransport { return &fakeTransport{ dataChan: make(chan wshrpc.CommandStreamData, 10), ackChan: make(chan wshrpc.CommandStreamAckData, 10), } } func (ft *fakeTransport) SendData(dataPk wshrpc.CommandStreamData) { ft.dataChan <- dataPk } func (ft *fakeTransport) SendAck(ackPk wshrpc.CommandStreamAckData) { ft.ackChan <- ackPk } func TestBasicReadWrite(t *testing.T) { transport := newFakeTransport() reader := NewReader("1", 1024, transport) writer := NewWriter("1", 1024, transport) go func() { for dataPk := range transport.dataChan { reader.RecvData(dataPk) } }() go func() { for ackPk := range transport.ackChan { writer.RecvAck(ackPk) } }() testData := []byte("Hello, World!") n, err := writer.Write(testData) if err != nil { t.Fatalf("Write failed: %v", err) } if n != len(testData) { t.Fatalf("Write returned %d, expected %d", n, len(testData)) } buf := make([]byte, 1024) n, err = reader.Read(buf) if err != nil { t.Fatalf("Read failed: %v", err) } if n != len(testData) { t.Fatalf("Read returned %d, expected %d", n, len(testData)) } if !bytes.Equal(buf[:n], testData) { t.Fatalf("Read data %q doesn't match written data %q", buf[:n], testData) } } func TestEOF(t *testing.T) { transport := newFakeTransport() reader := NewReader("1", 1024, transport) writer := NewWriter("1", 1024, transport) go func() { for dataPk := range transport.dataChan { reader.RecvData(dataPk) } }() go func() { for ackPk := range transport.ackChan { writer.RecvAck(ackPk) } }() testData := []byte("Test data") writer.Write(testData) writer.Close() buf := make([]byte, 1024) n, err := reader.Read(buf) if err != nil { t.Fatalf("First read failed: %v", err) } if !bytes.Equal(buf[:n], testData) { t.Fatalf("Read data doesn't match") } _, err = reader.Read(buf) if err != io.EOF { t.Fatalf("Expected EOF, got %v", err) } } func TestFlowControl(t *testing.T) { smallWindow := int64(10) transport := newFakeTransport() reader := NewReader("1", smallWindow, transport) writer := NewWriter("1", smallWindow, transport) go func() { for dataPk := range transport.dataChan { reader.RecvData(dataPk) } }() go func() { for ackPk := range transport.ackChan { writer.RecvAck(ackPk) } }() largeData := make([]byte, 100) for i := range largeData { largeData[i] = byte(i) } writeDone := make(chan error) go func() { _, err := writer.Write(largeData) writeDone <- err }() received := make([]byte, 0, 100) buf := make([]byte, 20) for len(received) < len(largeData) { n, err := reader.Read(buf) if err != nil { t.Fatalf("Read failed: %v", err) } received = append(received, buf[:n]...) } select { case err := <-writeDone: if err != nil { t.Fatalf("Write failed: %v", err) } case <-time.After(2 * time.Second): t.Fatal("Write didn't complete in time") } if !bytes.Equal(received, largeData) { t.Fatal("Received data doesn't match sent data") } } func TestError(t *testing.T) { transport := newFakeTransport() reader := NewReader("1", 1024, transport) writer := NewWriter("1", 1024, transport) go func() { for dataPk := range transport.dataChan { reader.RecvData(dataPk) } }() go func() { for ackPk := range transport.ackChan { writer.RecvAck(ackPk) } }() testErr := io.ErrUnexpectedEOF writer.CloseWithError(testErr) buf := make([]byte, 1024) _, err := reader.Read(buf) if err == nil { t.Fatal("Expected error from read") } if err.Error() != "stream error: unexpected EOF" { t.Fatalf("Expected stream error, got: %v", err) } } func TestCancel(t *testing.T) { transport := newFakeTransport() reader := NewReader("1", 1024, transport) writer := NewWriter("1", 1024, transport) go func() { for dataPk := range transport.dataChan { reader.RecvData(dataPk) } }() go func() { for ackPk := range transport.ackChan { writer.RecvAck(ackPk) } }() reader.Close() select { case <-writer.GetCanceledChan(): // Success case <-time.After(1 * time.Second): t.Fatal("Writer not notified of cancellation") } _, _, canceled := writer.GetAckState() if !canceled { t.Fatal("Writer should be in canceled state") } } func TestMultipleWrites(t *testing.T) { transport := newFakeTransport() reader := NewReader("1", 1024, transport) writer := NewWriter("1", 1024, transport) go func() { for dataPk := range transport.dataChan { reader.RecvData(dataPk) } }() go func() { for ackPk := range transport.ackChan { writer.RecvAck(ackPk) } }() messages := []string{"First", "Second", "Third"} for _, msg := range messages { _, err := writer.Write([]byte(msg)) if err != nil { t.Fatalf("Write failed: %v", err) } } expected := "FirstSecondThird" buf := make([]byte, len(expected)) totalRead := 0 for totalRead < len(expected) { n, err := reader.Read(buf[totalRead:]) if err != nil { t.Fatalf("Read failed: %v", err) } totalRead += n } if string(buf) != expected { t.Fatalf("Expected %q, got %q", expected, string(buf)) } } func TestOutOfOrderPackets(t *testing.T) { transport := newFakeTransport() reader := NewReader("test-ooo", 1024, transport) packet0 := wshrpc.CommandStreamData{ Id: "test-ooo", Seq: 0, Data64: base64.StdEncoding.EncodeToString([]byte("AAAAA")), } packet5 := wshrpc.CommandStreamData{ Id: "test-ooo", Seq: 5, Data64: base64.StdEncoding.EncodeToString([]byte("BBBBB")), } packet10 := wshrpc.CommandStreamData{ Id: "test-ooo", Seq: 10, Data64: base64.StdEncoding.EncodeToString([]byte("CCCCC")), } packet15 := wshrpc.CommandStreamData{ Id: "test-ooo", Seq: 15, Data64: base64.StdEncoding.EncodeToString([]byte("DDDDD")), } // Send packets out of order: 0, 10, 15, 5 reader.RecvData(packet0) reader.RecvData(packet10) // OOO - should be buffered reader.RecvData(packet15) // OOO - should be buffered reader.RecvData(packet5) // fills the gap - should trigger processing // Read all data buf := make([]byte, 1024) totalRead := 0 expectedLen := 20 // 4 packets * 5 bytes each readDone := make(chan struct{}) go func() { for totalRead < expectedLen { n, err := reader.Read(buf[totalRead:]) if err != nil { t.Errorf("Read failed: %v", err) return } totalRead += n } close(readDone) }() select { case <-readDone: // Success case <-time.After(2 * time.Second): t.Fatalf("Read didn't complete in time. Read %d bytes, expected %d", totalRead, expectedLen) } if totalRead != expectedLen { t.Fatalf("Expected to read %d bytes, got %d", expectedLen, totalRead) } } func TestOutOfOrderWithDuplicates(t *testing.T) { transport := newFakeTransport() reader := NewReader("test-dup", 1024, transport) packet0 := wshrpc.CommandStreamData{ Id: "test-dup", Seq: 0, Data64: base64.StdEncoding.EncodeToString([]byte("aaaaa")), } packet10 := wshrpc.CommandStreamData{ Id: "test-dup", Seq: 10, Data64: base64.StdEncoding.EncodeToString([]byte("ccccc")), } packet5First := wshrpc.CommandStreamData{ Id: "test-dup", Seq: 5, Data64: base64.StdEncoding.EncodeToString([]byte("xxxxx")), } packet5Second := wshrpc.CommandStreamData{ Id: "test-dup", Seq: 5, Data64: base64.StdEncoding.EncodeToString([]byte("bbbbb")), } reader.RecvData(packet0) reader.RecvData(packet10) // OOO - buffered reader.RecvData(packet5First) // OOO - buffered reader.RecvData(packet5First) // Duplicate - should be ignored reader.RecvData(packet5Second) // Duplicate with different data - should be ignored // Read all data - should get all 3 packets in order buf := make([]byte, 20) n, err := reader.Read(buf) if err != nil { t.Fatalf("Read failed: %v", err) } // Should get all 15 bytes (3 packets * 5 bytes) if n != 15 { t.Fatalf("Expected to read 15 bytes, got %d", n) } // Should be "aaaaaxxxxxccccc" (first packet received for each seq wins) expected := "aaaaaxxxxxccccc" if string(buf[:n]) != expected { t.Fatalf("Expected %q, got %q", expected, string(buf[:n])) } } func TestOutOfOrderWithGaps(t *testing.T) { transport := newFakeTransport() reader := NewReader("test-gaps", 1024, transport) packet0 := wshrpc.CommandStreamData{ Id: "test-gaps", Seq: 0, Data64: base64.StdEncoding.EncodeToString([]byte("aaaaa")), } packet20 := wshrpc.CommandStreamData{ Id: "test-gaps", Seq: 20, Data64: base64.StdEncoding.EncodeToString([]byte("eeeee")), } packet40 := wshrpc.CommandStreamData{ Id: "test-gaps", Seq: 40, Data64: base64.StdEncoding.EncodeToString([]byte("iiiii")), } packet5 := wshrpc.CommandStreamData{ Id: "test-gaps", Seq: 5, Data64: base64.StdEncoding.EncodeToString([]byte("bbbbb")), } reader.RecvData(packet0) reader.RecvData(packet40) // Way ahead - should be buffered reader.RecvData(packet20) // Still ahead - should be buffered // Read first packet buf := make([]byte, 10) n, err := reader.Read(buf) if err != nil { t.Fatalf("Read failed: %v", err) } if n != 5 || string(buf[:n]) != "aaaaa" { t.Fatalf("Expected 'aaaaa', got %q", string(buf[:n])) } // Send packet to partially fill gap reader.RecvData(packet5) // Should be able to read it now n, err = reader.Read(buf) if err != nil { t.Fatalf("Second read failed: %v", err) } if n != 5 || string(buf[:n]) != "bbbbb" { t.Fatalf("Expected 'bbbbb', got %q", string(buf[:n])) } packet10 := wshrpc.CommandStreamData{ Id: "test-gaps", Seq: 10, Data64: base64.StdEncoding.EncodeToString([]byte("ccccc")), } packet15 := wshrpc.CommandStreamData{ Id: "test-gaps", Seq: 15, Data64: base64.StdEncoding.EncodeToString([]byte("ddddd")), } packet25 := wshrpc.CommandStreamData{ Id: "test-gaps", Seq: 25, Data64: base64.StdEncoding.EncodeToString([]byte("fffff")), } packet30 := wshrpc.CommandStreamData{ Id: "test-gaps", Seq: 30, Data64: base64.StdEncoding.EncodeToString([]byte("ggggg")), } packet35 := wshrpc.CommandStreamData{ Id: "test-gaps", Seq: 35, Data64: base64.StdEncoding.EncodeToString([]byte("hhhhh")), } reader.RecvData(packet10) reader.RecvData(packet15) reader.RecvData(packet25) reader.RecvData(packet30) reader.RecvData(packet35) // Read all remaining data at once allData := make([]byte, 100) totalRead := 0 for totalRead < 35 { n, err = reader.Read(allData[totalRead:]) if err != nil { t.Fatalf("Read failed: %v", err) } totalRead += n } expected := "cccccdddddeeeeefffffggggghhhhhiiiii" if string(allData[:totalRead]) != expected { t.Fatalf("Expected %q, got %q", expected, string(allData[:totalRead])) } } func TestOutOfOrderWithEOF(t *testing.T) { transport := newFakeTransport() reader := NewReader("test-eof", 1024, transport) packet0 := wshrpc.CommandStreamData{ Id: "test-eof", Seq: 0, Data64: base64.StdEncoding.EncodeToString([]byte("first")), } packet11 := wshrpc.CommandStreamData{ Id: "test-eof", Seq: 11, Data64: base64.StdEncoding.EncodeToString([]byte("third")), Eof: true, } packet5 := wshrpc.CommandStreamData{ Id: "test-eof", Seq: 5, Data64: base64.StdEncoding.EncodeToString([]byte("second")), } reader.RecvData(packet0) reader.RecvData(packet11) // OOO with EOF reader.RecvData(packet5) // Fill the gap // Read all data buf := make([]byte, 20) n, err := reader.Read(buf) if err != nil { t.Fatalf("Read failed: %v", err) } expected := "firstsecondthird" if string(buf[:n]) != expected { t.Fatalf("Expected %q, got %q", expected, string(buf[:n])) } // Should get EOF now _, err = reader.Read(buf) if err != io.EOF { t.Fatalf("Expected EOF, got %v", err) } } ================================================ FILE: pkg/streamclient/streambroker.go ================================================ package streamclient import ( "fmt" "sync" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type workItem struct { workType string ackPk wshrpc.CommandStreamAckData dataPk wshrpc.CommandStreamData } type StreamWriter interface { RecvAck(ackPk wshrpc.CommandStreamAckData) } type StreamRpcInterface interface { StreamDataAckCommand(data wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error } type Broker struct { lock sync.Mutex rpcClient StreamRpcInterface readers map[string]*Reader writers map[string]StreamWriter readerRoutes map[string]string writerRoutes map[string]string readerErrorSentTime map[string]time.Time sendQueue *utilds.WorkQueue[workItem] recvQueue *utilds.WorkQueue[workItem] } func NewBroker(rpcClient StreamRpcInterface) *Broker { b := &Broker{ rpcClient: rpcClient, readers: make(map[string]*Reader), writers: make(map[string]StreamWriter), readerRoutes: make(map[string]string), writerRoutes: make(map[string]string), readerErrorSentTime: make(map[string]time.Time), } b.sendQueue = utilds.NewWorkQueue(b.processSendWork) b.recvQueue = utilds.NewWorkQueue(b.processRecvWork) return b } func (b *Broker) CreateStreamReader(readerRoute string, writerRoute string, rwnd int64) (*Reader, *wshrpc.StreamMeta) { return b.CreateStreamReaderWithSeq(readerRoute, writerRoute, rwnd, 0) } func (b *Broker) CreateStreamReaderWithSeq(readerRoute string, writerRoute string, rwnd int64, startSeq int64) (*Reader, *wshrpc.StreamMeta) { b.lock.Lock() defer b.lock.Unlock() streamId := uuid.New().String() reader := NewReaderWithSeq(streamId, rwnd, startSeq, b) b.readers[streamId] = reader b.readerRoutes[streamId] = readerRoute b.writerRoutes[streamId] = writerRoute meta := &wshrpc.StreamMeta{ Id: streamId, RWnd: rwnd, ReaderRouteId: readerRoute, WriterRouteId: writerRoute, } return reader, meta } func (b *Broker) AttachStreamWriter(meta *wshrpc.StreamMeta, writer StreamWriter) error { b.lock.Lock() defer b.lock.Unlock() if _, exists := b.writers[meta.Id]; exists { return fmt.Errorf("writer already registered for stream id %s", meta.Id) } b.writers[meta.Id] = writer b.readerRoutes[meta.Id] = meta.ReaderRouteId b.writerRoutes[meta.Id] = meta.WriterRouteId return nil } func (b *Broker) DetachStreamWriter(streamId string) { b.lock.Lock() defer b.lock.Unlock() delete(b.writers, streamId) delete(b.writerRoutes, streamId) } func (b *Broker) CreateStreamWriter(meta *wshrpc.StreamMeta) (*Writer, error) { writer := NewWriter(meta.Id, meta.RWnd, b) err := b.AttachStreamWriter(meta, writer) if err != nil { return nil, err } return writer, nil } func (b *Broker) SendAck(ackPk wshrpc.CommandStreamAckData) { b.sendQueue.Enqueue(workItem{workType: "sendack", ackPk: ackPk}) } func (b *Broker) SendData(dataPk wshrpc.CommandStreamData) { b.sendQueue.Enqueue(workItem{workType: "senddata", dataPk: dataPk}) } // RecvData and RecvAck are designed to be non-blocking and must remain so to prevent deadlock. // They only enqueue work items to be processed asynchronously by the work queue's goroutine. // These methods are called from the main RPC runServer loop, so blocking here would stall all RPC processing. func (b *Broker) RecvData(dataPk wshrpc.CommandStreamData) { b.recvQueue.Enqueue(workItem{workType: "recvdata", dataPk: dataPk}) } func (b *Broker) RecvAck(ackPk wshrpc.CommandStreamAckData) { b.recvQueue.Enqueue(workItem{workType: "recvack", ackPk: ackPk}) } func (b *Broker) processSendWork(item workItem) { switch item.workType { case "sendack": b.processSendAck(item.ackPk) case "senddata": b.processSendData(item.dataPk) } } func (b *Broker) processRecvWork(item workItem) { switch item.workType { case "recvdata": b.processRecvData(item.dataPk) case "recvack": b.processRecvAck(item.ackPk) } } func (b *Broker) processSendAck(ackPk wshrpc.CommandStreamAckData) { b.lock.Lock() route, ok := b.writerRoutes[ackPk.Id] b.lock.Unlock() if !ok { return } opts := &wshrpc.RpcOpts{ Route: route, NoResponse: true, } b.rpcClient.StreamDataAckCommand(ackPk, opts) if ackPk.Fin || ackPk.Cancel { b.cleanupReader(ackPk.Id) } } func (b *Broker) processSendData(dataPk wshrpc.CommandStreamData) { b.lock.Lock() route := b.readerRoutes[dataPk.Id] b.lock.Unlock() opts := &wshrpc.RpcOpts{ Route: route, NoResponse: true, } b.rpcClient.StreamDataCommand(dataPk, opts) } func (b *Broker) processRecvData(dataPk wshrpc.CommandStreamData) { b.lock.Lock() reader, ok := b.readers[dataPk.Id] if !ok { lastSent := b.readerErrorSentTime[dataPk.Id] now := time.Now() if now.Sub(lastSent) < time.Second { b.lock.Unlock() return } b.readerErrorSentTime[dataPk.Id] = now } b.lock.Unlock() if !ok { ackPk := wshrpc.CommandStreamAckData{ Id: dataPk.Id, Seq: dataPk.Seq, Cancel: true, Error: "stream reader not found", } b.SendAck(ackPk) return } reader.RecvData(dataPk) } func (b *Broker) processRecvAck(ackPk wshrpc.CommandStreamAckData) { b.lock.Lock() writer, ok := b.writers[ackPk.Id] b.lock.Unlock() if !ok { return } writer.RecvAck(ackPk) if ackPk.Fin || ackPk.Cancel { b.cleanupWriter(ackPk.Id) } } func (b *Broker) Close() { b.sendQueue.Close(false) b.recvQueue.Close(false) b.sendQueue.Wait() b.recvQueue.Wait() } func (b *Broker) cleanupReader(streamId string) { b.lock.Lock() defer b.lock.Unlock() delete(b.readers, streamId) delete(b.readerRoutes, streamId) delete(b.readerErrorSentTime, streamId) } func (b *Broker) cleanupWriter(streamId string) { b.lock.Lock() defer b.lock.Unlock() delete(b.writers, streamId) delete(b.writerRoutes, streamId) } ================================================ FILE: pkg/streamclient/streambroker_test.go ================================================ package streamclient import ( "bytes" "io" "testing" "time" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type mockRpcInterface struct { dataChan chan wshrpc.CommandStreamData ackChan chan wshrpc.CommandStreamAckData } func (m *mockRpcInterface) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error { m.dataChan <- data return nil } func (m *mockRpcInterface) StreamDataAckCommand(data wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error { m.ackChan <- data return nil } func setupBrokerPair() (*Broker, *Broker) { rpc1 := &mockRpcInterface{ dataChan: make(chan wshrpc.CommandStreamData, 10), ackChan: make(chan wshrpc.CommandStreamAckData, 10), } rpc2 := &mockRpcInterface{ dataChan: make(chan wshrpc.CommandStreamData, 10), ackChan: make(chan wshrpc.CommandStreamAckData, 10), } broker1 := NewBroker(rpc1) broker2 := NewBroker(rpc2) go func() { for data := range rpc1.dataChan { broker2.RecvData(data) } }() go func() { for ack := range rpc1.ackChan { broker2.RecvAck(ack) } }() go func() { for data := range rpc2.dataChan { broker1.RecvData(data) } }() go func() { for ack := range rpc2.ackChan { broker1.RecvAck(ack) } }() return broker1, broker2 } func TestBrokerBasicReadWrite(t *testing.T) { broker1, broker2 := setupBrokerPair() reader, meta := broker1.CreateStreamReader("reader1", "writer1", 1024) writer, err := broker2.CreateStreamWriter(meta) if err != nil { t.Fatalf("CreateStreamWriter failed: %v", err) } testData := []byte("Hello, World!") n, err := writer.Write(testData) if err != nil { t.Fatalf("Write failed: %v", err) } if n != len(testData) { t.Fatalf("Write returned %d, expected %d", n, len(testData)) } buf := make([]byte, 1024) n, err = reader.Read(buf) if err != nil { t.Fatalf("Read failed: %v", err) } if n != len(testData) { t.Fatalf("Read returned %d, expected %d", n, len(testData)) } if !bytes.Equal(buf[:n], testData) { t.Fatalf("Read data %q doesn't match written data %q", buf[:n], testData) } writer.Close() _, err = reader.Read(buf) if err != io.EOF { t.Fatalf("Expected EOF, got %v", err) } } func TestBrokerEOF(t *testing.T) { broker1, broker2 := setupBrokerPair() reader, meta := broker1.CreateStreamReader("reader1", "writer1", 1024) writer, err := broker2.CreateStreamWriter(meta) if err != nil { t.Fatalf("CreateStreamWriter failed: %v", err) } testData := []byte("Test data") writer.Write(testData) writer.Close() buf := make([]byte, 1024) n, err := reader.Read(buf) if err != nil { t.Fatalf("First read failed: %v", err) } if !bytes.Equal(buf[:n], testData) { t.Fatalf("Read data doesn't match") } _, err = reader.Read(buf) if err != io.EOF { t.Fatalf("Expected EOF, got %v", err) } } func TestBrokerFlowControl(t *testing.T) { broker1, broker2 := setupBrokerPair() smallWindow := int64(10) reader, meta := broker1.CreateStreamReader("reader1", "writer1", smallWindow) writer, err := broker2.CreateStreamWriter(meta) if err != nil { t.Fatalf("CreateStreamWriter failed: %v", err) } largeData := make([]byte, 100) for i := range largeData { largeData[i] = byte(i) } writeDone := make(chan error) go func() { _, err := writer.Write(largeData) writeDone <- err }() received := make([]byte, 0, 100) buf := make([]byte, 20) for len(received) < len(largeData) { n, err := reader.Read(buf) if err != nil { t.Fatalf("Read failed: %v", err) } received = append(received, buf[:n]...) } select { case err := <-writeDone: if err != nil { t.Fatalf("Write failed: %v", err) } case <-time.After(2 * time.Second): t.Fatal("Write didn't complete in time") } if !bytes.Equal(received, largeData) { t.Fatal("Received data doesn't match sent data") } writer.Close() } func TestBrokerError(t *testing.T) { broker1, broker2 := setupBrokerPair() reader, meta := broker1.CreateStreamReader("reader1", "writer1", 1024) writer, err := broker2.CreateStreamWriter(meta) if err != nil { t.Fatalf("CreateStreamWriter failed: %v", err) } testErr := io.ErrUnexpectedEOF writer.CloseWithError(testErr) buf := make([]byte, 1024) _, err = reader.Read(buf) if err == nil { t.Fatal("Expected error from read") } if err.Error() != "stream error: unexpected EOF" { t.Fatalf("Expected stream error, got: %v", err) } } func TestBrokerCancel(t *testing.T) { broker1, broker2 := setupBrokerPair() reader, meta := broker1.CreateStreamReader("reader1", "writer1", 1024) writer, err := broker2.CreateStreamWriter(meta) if err != nil { t.Fatalf("CreateStreamWriter failed: %v", err) } reader.Close() select { case <-writer.GetCanceledChan(): // Success case <-time.After(1 * time.Second): t.Fatal("Writer not notified of cancellation") } _, _, canceled := writer.GetAckState() if !canceled { t.Fatal("Writer should be in canceled state") } } func TestBrokerMultipleWrites(t *testing.T) { broker1, broker2 := setupBrokerPair() reader, meta := broker1.CreateStreamReader("reader1", "writer1", 1024) writer, err := broker2.CreateStreamWriter(meta) if err != nil { t.Fatalf("CreateStreamWriter failed: %v", err) } messages := []string{"First", "Second", "Third"} for _, msg := range messages { _, err := writer.Write([]byte(msg)) if err != nil { t.Fatalf("Write failed: %v", err) } } expected := "FirstSecondThird" buf := make([]byte, len(expected)) totalRead := 0 for totalRead < len(expected) { n, err := reader.Read(buf[totalRead:]) if err != nil { t.Fatalf("Read failed: %v", err) } totalRead += n } if string(buf) != expected { t.Fatalf("Expected %q, got %q", expected, string(buf)) } writer.Close() } func TestBrokerCleanup(t *testing.T) { broker1, broker2 := setupBrokerPair() reader, meta := broker1.CreateStreamReader("reader1", "writer1", 1024) writer, err := broker2.CreateStreamWriter(meta) if err != nil { t.Fatalf("CreateStreamWriter failed: %v", err) } testData := []byte("cleanup test") writer.Write(testData) buf := make([]byte, 1024) reader.Read(buf) writer.Close() time.Sleep(100 * time.Millisecond) broker1.lock.Lock() _, readerExists := broker1.readers[meta.Id] broker1.lock.Unlock() if readerExists { t.Fatal("Reader should have been cleaned up") } broker2.lock.Lock() _, writerExists := broker2.writers[meta.Id] broker2.lock.Unlock() if writerExists { t.Fatal("Writer should have been cleaned up") } } ================================================ FILE: pkg/streamclient/streamreader.go ================================================ package streamclient import ( "encoding/base64" "fmt" "io" "sort" "sync" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type AckSender interface { SendAck(ackPk wshrpc.CommandStreamAckData) } type Reader struct { lock sync.Mutex cond *sync.Cond id string ackSender AckSender readWindow int64 nextSeq int64 buffer []byte eof bool err error closed bool lastRwndSent int64 oooPackets []wshrpc.CommandStreamData // out-of-order packets awaiting delivery } func NewReader(id string, readWindow int64, ackSender AckSender) *Reader { return NewReaderWithSeq(id, readWindow, 0, ackSender) } func NewReaderWithSeq(id string, readWindow int64, startSeq int64, ackSender AckSender) *Reader { r := &Reader{ id: id, readWindow: readWindow, ackSender: ackSender, nextSeq: startSeq, lastRwndSent: readWindow, } r.cond = sync.NewCond(&r.lock) return r } func (r *Reader) RecvData(dataPk wshrpc.CommandStreamData) { r.lock.Lock() defer r.lock.Unlock() if r.closed || r.eof || r.err != nil { return } if dataPk.Id != r.id { return } // error packets can be sent without a valid Seq, so check for errors before validating sequence if dataPk.Error != "" { r.err = fmt.Errorf("stream error: %s", dataPk.Error) r.cond.Broadcast() r.sendAckLocked(true, false, "") return } if dataPk.Seq < r.nextSeq { return } if dataPk.Seq > r.nextSeq { r.addOOOPacketLocked(dataPk) return } r.recvDataOrderedLocked(dataPk) r.processOOOPacketsLocked() r.cond.Broadcast() r.sendAckLocked(r.eof, false, "") } func (r *Reader) recvDataOrderedLocked(dataPk wshrpc.CommandStreamData) { if dataPk.Data64 != "" { data, err := base64.StdEncoding.DecodeString(dataPk.Data64) if err != nil { r.err = err r.sendAckLocked(false, true, "base64 decode error") return } r.buffer = append(r.buffer, data...) r.nextSeq += int64(len(data)) } if dataPk.Eof { r.eof = true } } func (r *Reader) addOOOPacketLocked(dataPk wshrpc.CommandStreamData) { for _, pkt := range r.oooPackets { if pkt.Seq == dataPk.Seq { // this handles duplicates return } } r.oooPackets = append(r.oooPackets, dataPk) } func (r *Reader) processOOOPacketsLocked() { if len(r.oooPackets) == 0 { return } sort.Slice(r.oooPackets, func(i, j int) bool { return r.oooPackets[i].Seq < r.oooPackets[j].Seq }) consumed := 0 for _, pkt := range r.oooPackets { if r.eof || r.err != nil { // we're done, so we can clear any pending ooo packets r.oooPackets = nil return } if pkt.Seq != r.nextSeq { break } r.recvDataOrderedLocked(pkt) consumed++ } r.oooPackets = r.oooPackets[consumed:] } func (r *Reader) sendAckLocked(fin bool, cancel bool, errStr string) { rwnd := r.readWindow - int64(len(r.buffer)) if rwnd < 0 { rwnd = 0 } ack := wshrpc.CommandStreamAckData{ Id: r.id, Seq: r.nextSeq, Fin: fin, Cancel: cancel, RWnd: rwnd, Error: errStr, } r.ackSender.SendAck(ack) r.lastRwndSent = rwnd } func (r *Reader) Read(p []byte) (int, error) { r.lock.Lock() defer r.lock.Unlock() for len(r.buffer) == 0 && !r.eof && r.err == nil && !r.closed { r.cond.Wait() } if r.closed { return 0, io.ErrClosedPipe } if r.err != nil { return 0, r.err } if len(r.buffer) == 0 && r.eof { return 0, io.EOF } n := copy(p, r.buffer) r.buffer = r.buffer[n:] if n > 0 { currentRwnd := r.readWindow - int64(len(r.buffer)) if currentRwnd < 0 { currentRwnd = 0 } threshold := r.readWindow / 5 rwndDiff := currentRwnd - r.lastRwndSent if len(r.buffer) == 0 || rwndDiff >= threshold { r.sendAckLocked(false, false, "") } } return n, nil } func (r *Reader) UpdateNextSeq(newSeq int64) { r.lock.Lock() defer r.lock.Unlock() r.nextSeq = newSeq } func (r *Reader) Close() error { r.lock.Lock() defer r.lock.Unlock() if r.closed { if r.err != nil { return r.err } return io.ErrClosedPipe } r.closed = true if r.err == nil { r.err = io.ErrClosedPipe } r.cond.Broadcast() r.sendAckLocked(false, true, "") return r.err } ================================================ FILE: pkg/streamclient/streamwriter.go ================================================ package streamclient import ( "encoding/base64" "fmt" "io" "sync" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type DataSender interface { SendData(dataPk wshrpc.CommandStreamData) } type Writer struct { lock sync.Mutex cond *sync.Cond id string dataSender DataSender readWindow int64 nextSeq int64 buffer []byte sentNotAcked int64 maxAckedSeq int64 maxAckedRwnd int64 finAcked bool canceled bool canceledChan chan struct{} eof bool err error closed bool } func NewWriter(id string, readWindow int64, dataSender DataSender) *Writer { w := &Writer{ id: id, readWindow: readWindow, dataSender: dataSender, nextSeq: 0, sentNotAcked: 0, maxAckedSeq: 0, canceledChan: make(chan struct{}), } w.cond = sync.NewCond(&w.lock) return w } func (w *Writer) RecvAck(ackPk wshrpc.CommandStreamAckData) { w.lock.Lock() defer w.lock.Unlock() if ackPk.Id != w.id { return } ackedSeq := ackPk.Seq rwnd := ackPk.RWnd if ackPk.Fin { w.finAcked = true w.maxAckedSeq = ackedSeq return } if ackPk.Cancel && !w.canceled { w.canceled = true close(w.canceledChan) if !w.closed { w.err = fmt.Errorf("stream cancelled") w.cond.Broadcast() } return } // Ignore stale ACKs using tuple comparison (seq, rwnd) if ackedSeq < w.maxAckedSeq || (ackedSeq == w.maxAckedSeq && rwnd <= w.maxAckedRwnd) { return } // Update max acked tuple w.maxAckedSeq = ackedSeq w.maxAckedRwnd = rwnd if !w.closed { if ackedSeq > (w.nextSeq - w.sentNotAcked) { ackedBytes := ackedSeq - (w.nextSeq - w.sentNotAcked) w.sentNotAcked -= ackedBytes if w.sentNotAcked < 0 { w.sentNotAcked = 0 } } w.readWindow = rwnd w.cond.Broadcast() } } func (w *Writer) GetAckState() (maxAckedSeq int64, finAcked bool, canceled bool) { w.lock.Lock() defer w.lock.Unlock() return w.maxAckedSeq, w.finAcked, w.canceled } func (w *Writer) GetCanceledChan() <-chan struct{} { return w.canceledChan } func (w *Writer) Write(p []byte) (int, error) { w.lock.Lock() defer w.lock.Unlock() if w.closed { return 0, io.ErrClosedPipe } if w.err != nil { return 0, w.err } w.buffer = append(w.buffer, p...) n := len(p) for len(w.buffer) > 0 { if w.closed { return 0, io.ErrClosedPipe } if w.err != nil { return 0, w.err } sent := w.trySendDataLocked() if !sent { w.cond.Wait() } } return n, nil } func (w *Writer) trySendDataLocked() bool { availWindow := w.readWindow - w.sentNotAcked if availWindow <= 0 { return false } toSend := len(w.buffer) if int64(toSend) > availWindow { toSend = int(availWindow) } data := w.buffer[:toSend] w.buffer = w.buffer[toSend:] dataStr := base64.StdEncoding.EncodeToString(data) dataPk := wshrpc.CommandStreamData{ Id: w.id, Seq: w.nextSeq, Data64: dataStr, } w.dataSender.SendData(dataPk) w.nextSeq += int64(toSend) w.sentNotAcked += int64(toSend) return toSend > 0 } // If Close() is called while a Write is blocked, the Write will return an error and buffered data may be discarded. func (w *Writer) Close() error { return w.CloseWithError(nil) } // If CloseWithError() is called while a Write is blocked, the Write will return an error and buffered data may be discarded. func (w *Writer) CloseWithError(err error) error { w.lock.Lock() defer w.lock.Unlock() if w.closed { return nil } w.closed = true if w.err == nil { w.err = io.ErrClosedPipe } w.cond.Broadcast() var dataPk wshrpc.CommandStreamData if err == nil || err == io.EOF { dataPk = wshrpc.CommandStreamData{ Id: w.id, Seq: w.nextSeq, Eof: true, } } else { dataPk = wshrpc.CommandStreamData{ Id: w.id, Seq: w.nextSeq, Error: err.Error(), } } w.dataSender.SendData(dataPk) return nil } ================================================ FILE: pkg/suggestion/filewalk.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package suggestion import ( "container/list" "context" "fmt" "io/fs" "os" "path/filepath" "strings" "sync" "time" "golang.org/x/sync/singleflight" ) const ListDirChanSize = 50 // cache settings const ( maxCacheEntries = 20 cacheTTL = 60 * time.Second ) type cacheEntry struct { key string value []DirEntryResult expiration time.Time lruElement *list.Element } var ( cache = make(map[string]*cacheEntry) cacheLRU = list.New() cacheMu sync.Mutex // group ensures only one listing per key is executed concurrently. group singleflight.Group ) func init() { go func() { ticker := time.NewTicker(60 * time.Second) defer ticker.Stop() for range ticker.C { cleanCache() } }() } func cleanCache() { cacheMu.Lock() defer cacheMu.Unlock() now := time.Now() for key, entry := range cache { if now.After(entry.expiration) { cacheLRU.Remove(entry.lruElement) delete(cache, key) } } } func getCache(key string) ([]DirEntryResult, bool) { cacheMu.Lock() defer cacheMu.Unlock() entry, ok := cache[key] if !ok { return nil, false } if time.Now().After(entry.expiration) { // expired cacheLRU.Remove(entry.lruElement) delete(cache, key) return nil, false } // update LRU order cacheLRU.MoveToFront(entry.lruElement) return entry.value, true } func setCache(key string, value []DirEntryResult) { cacheMu.Lock() defer cacheMu.Unlock() // if already exists, update it if entry, ok := cache[key]; ok { entry.value = value entry.expiration = time.Now().Add(cacheTTL) cacheLRU.MoveToFront(entry.lruElement) return } // evict if at capacity if cacheLRU.Len() >= maxCacheEntries { oldest := cacheLRU.Back() if oldest != nil { oldestKey := oldest.Value.(string) if oldEntry, ok := cache[oldestKey]; ok { cacheLRU.Remove(oldEntry.lruElement) delete(cache, oldestKey) } } } // add new entry elem := cacheLRU.PushFront(key) cache[key] = &cacheEntry{ key: key, value: value, expiration: time.Now().Add(cacheTTL), lruElement: elem, } } // cacheDispose clears all cache entries for the provided widgetId. func cacheDispose(widgetId string) { cacheMu.Lock() defer cacheMu.Unlock() prefix := widgetId + "|" for key, entry := range cache { if strings.HasPrefix(key, prefix) { cacheLRU.Remove(entry.lruElement) delete(cache, key) } } } type DirEntryResult struct { Entry fs.DirEntry Err error } func listDirectory(ctx context.Context, widgetId string, dir string, maxFiles int) (<-chan DirEntryResult, error) { key := widgetId + "|" + dir if cached, ok := getCache(key); ok { ch := make(chan DirEntryResult, ListDirChanSize) go func() { defer close(ch) for _, r := range cached { select { case ch <- r: case <-ctx.Done(): return } } }() return ch, nil } // Use singleflight to ensure only one listing operation occurs per key. value, err, _ := group.Do(key, func() (interface{}, error) { f, err := os.Open(dir) if err != nil { return nil, err } defer f.Close() fi, err := f.Stat() if err != nil { return nil, err } if !fi.IsDir() { return nil, fmt.Errorf("%s is not a directory", dir) } entries, err := f.ReadDir(maxFiles) if err != nil { return nil, err } var results []DirEntryResult for _, entry := range entries { results = append(results, DirEntryResult{Entry: entry}) } // Add parent directory (“..”) entry if not at the filesystem root. if filepath.Dir(dir) != dir { mockDir := &MockDirEntry{ NameStr: "..", IsDirVal: true, FileMode: fs.ModeDir | 0755, } results = append(results, DirEntryResult{Entry: mockDir}) } return results, nil }) if err != nil { return nil, err } results := value.([]DirEntryResult) setCache(key, results) ch := make(chan DirEntryResult, ListDirChanSize) go func() { defer close(ch) for _, r := range results { select { case ch <- r: case <-ctx.Done(): return } } }() return ch, nil } ================================================ FILE: pkg/suggestion/suggestion.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package suggestion import ( "container/heap" "context" "fmt" "io/fs" "net/url" "os" "path/filepath" "sort" "strings" "github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/util" "github.com/wavetermdev/waveterm/pkg/faviconcache" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const MaxSuggestions = 50 type MockDirEntry struct { NameStr string IsDirVal bool FileMode fs.FileMode } func (m *MockDirEntry) Name() string { return m.NameStr } func (m *MockDirEntry) IsDir() bool { return m.IsDirVal } func (m *MockDirEntry) Type() fs.FileMode { return m.FileMode } func (m *MockDirEntry) Info() (fs.FileInfo, error) { return nil, fs.ErrInvalid } var PathSepStr = string(os.PathSeparator) // ensureTrailingSlash makes sure s ends with a slash. func ensureTrailingSlash(s string) string { if s == "" { return s } if !strings.HasSuffix(s, PathSepStr) { return s + PathSepStr } return s } // resolveFileQuery returns (baseDir, queryPrefix, searchTerm, error). // // Our approach is to use the presence of a trailing slash to decide whether // to treat the query as a directory listing (searchTerm is empty) or a search // filter. (This means that a query of exactly "." or ".." is treated as a // search filter, so that files with a dot in their name––including ".."––will // be returned.) // // In addition, if there is a slash anywhere in the query (but not at the end), // we treat everything before the last slash as a relative directory to search // in, and the portion after the last slash as the search term. func resolveFileQuery(cwd string, query string) (string, string, string, error) { // If no current working directory, default to "~". if cwd == "" { cwd = "~" } var err error cwd, err = wavebase.ExpandHomeDir(cwd) if err != nil { return "", "", "", fmt.Errorf("error expanding home dir: %w", err) } if query == "" { return cwd, "", "", nil } // Expand home if needed. tildeSlash := "~" + PathSepStr if query == "~" || strings.HasPrefix(query, tildeSlash) { ogQuery := query query, err = wavebase.ExpandHomeDir(query) if err != nil { return "", "", "", fmt.Errorf("error expanding query home dir: %w", err) } if ogQuery == "~" || ogQuery == tildeSlash { return query, tildeSlash, "", nil } } // Handle absolute queries. if filepath.IsAbs(query) { if filepath.Dir(query) == query { return query, query, "", nil } if strings.HasSuffix(query, PathSepStr) { // Remove trailing slash for canonical directory path. baseDir := strings.TrimRight(query, PathSepStr) // But keep the trailing slash in the queryPrefix for display. queryPrefix := query return baseDir, queryPrefix, "", nil } // Otherwise, e.g. "/var/f" baseDir := filepath.Dir(query) queryPrefix := filepath.Dir(query) searchTerm := filepath.Base(query) return baseDir, queryPrefix, searchTerm, nil } // For relative queries: // If the query ends with a slash (e.g. "./" or "waveterm/"), then treat it // as a directory listing. if strings.HasSuffix(query, PathSepStr) { fullPath := filepath.Join(cwd, query) baseDir := strings.TrimRight(fullPath, PathSepStr) queryPrefix := query return baseDir, queryPrefix, "", nil } // If there is a slash in the query, split into directory part and search term. if idx := strings.LastIndex(query, PathSepStr); idx != -1 { dirPart := query[:idx] term := query[idx+1:] baseDir := filepath.Join(cwd, dirPart) // For display purposes, set queryPrefix to the dirPart with a trailing slash. queryPrefix := "" if dirPart != "" { queryPrefix = ensureTrailingSlash(dirPart) } return baseDir, queryPrefix, term, nil } // No slash in query: search in the cwd. return cwd, "", query, nil } func DisposeSuggestions(ctx context.Context, widgetId string) { cacheDispose(widgetId) } func FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { if data.SuggestionType == "file" { return fetchFileSuggestions(ctx, data) } if data.SuggestionType == "bookmark" { return fetchBookmarkSuggestions(ctx, data) } return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType) } func filterBookmarksForValid(bookmarks map[string]wconfig.WebBookmark) map[string]wconfig.WebBookmark { validBookmarks := make(map[string]wconfig.WebBookmark) for k, v := range bookmarks { if v.Url == "" { continue } u, err := url.ParseRequestURI(v.Url) if err != nil || u.Scheme == "" || u.Host == "" { continue } validBookmarks[k] = v } return validBookmarks } func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { if data.SuggestionType != "bookmark" { return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType) } // scoredEntry holds a bookmark along with its computed score, the match positions for the // field that will be used for display, the positions for the secondary field (if any), // and its original index in the Bookmarks list. type scoredEntry struct { bookmark wconfig.WebBookmark score int matchPos []int // positions for the field that's used as Display subMatchPos []int // positions for the other field (if any) origIndex int } bookmarks := wconfig.GetWatcher().GetFullConfig().Bookmarks bookmarks = filterBookmarksForValid(bookmarks) searchTerm := data.Query var patternRunes []rune if searchTerm != "" { patternRunes = []rune(strings.ToLower(searchTerm)) } var scoredEntries []scoredEntry var slab util.Slab bookmarkKeys := utilfn.GetMapKeys(bookmarks) // sort by display:order and then by key sort.Slice(bookmarkKeys, func(i, j int) bool { bookmarkA := bookmarks[bookmarkKeys[i]] bookmarkB := bookmarks[bookmarkKeys[j]] if bookmarkA.DisplayOrder != bookmarkB.DisplayOrder { return bookmarkA.DisplayOrder < bookmarkB.DisplayOrder } return bookmarkKeys[i] < bookmarkKeys[j] }) for i, bmkey := range bookmarkKeys { bookmark := bookmarks[bmkey] // If no search term, include all bookmarks (score 0, no positions). if searchTerm == "" { scoredEntries = append(scoredEntries, scoredEntry{ bookmark: bookmark, score: 0, origIndex: i, }) continue } // For bookmarks with a title, Display is set to the title and SubText to the URL. // We perform fuzzy matching on both fields. if bookmark.Title != "" { // Fuzzy match against the title. candidateTitle := strings.ToLower(bookmark.Title) textTitle := util.ToChars([]byte(candidateTitle)) resultTitle, titlePositionsPtr := algo.FuzzyMatchV2(false, true, true, &textTitle, patternRunes, true, &slab) var titleScore int var titlePositions []int if titlePositionsPtr != nil { titlePositions = *titlePositionsPtr } titleScore = resultTitle.Score // Fuzzy match against the URL. candidateUrl := strings.ToLower(bookmark.Url) textUrl := util.ToChars([]byte(candidateUrl)) resultUrl, urlPositionsPtr := algo.FuzzyMatchV2(false, true, true, &textUrl, patternRunes, true, &slab) var urlScore int var urlPositions []int if urlPositionsPtr != nil { urlPositions = *urlPositionsPtr } urlScore = resultUrl.Score // Compute the overall score as the higher of the two. maxScore := titleScore if urlScore > maxScore { maxScore = urlScore } // If neither field produced a positive match, skip this bookmark. if maxScore <= 0 { continue } // Since Display is title, we use the title match positions as MatchPos and the URL match positions as SubMatchPos. scoredEntries = append(scoredEntries, scoredEntry{ bookmark: bookmark, score: maxScore, matchPos: titlePositions, subMatchPos: urlPositions, origIndex: i, }) } else { // For bookmarks with no title, Display is set to the URL. // Only perform fuzzy matching against the URL. candidateUrl := strings.ToLower(bookmark.Url) textUrl := util.ToChars([]byte(candidateUrl)) resultUrl, urlPositionsPtr := algo.FuzzyMatchV2(false, true, true, &textUrl, patternRunes, true, &slab) urlScore := resultUrl.Score var urlPositions []int if urlPositionsPtr != nil { urlPositions = *urlPositionsPtr } // Skip this bookmark if the URL doesn't match. if urlScore <= 0 { continue } scoredEntries = append(scoredEntries, scoredEntry{ bookmark: bookmark, score: urlScore, matchPos: urlPositions, // match positions come from the URL, since that's what is displayed. subMatchPos: nil, origIndex: i, }) } } // Sort the scored entries in descending order by score. // For equal scores, preserve the original order from the Bookmarks list. sort.Slice(scoredEntries, func(i, j int) bool { if scoredEntries[i].score != scoredEntries[j].score { return scoredEntries[i].score > scoredEntries[j].score } return scoredEntries[i].origIndex < scoredEntries[j].origIndex }) // Build up to MaxSuggestions suggestions. var suggestions []wshrpc.SuggestionType for _, entry := range scoredEntries { var display, subText string if entry.bookmark.Title != "" { display = entry.bookmark.Title subText = entry.bookmark.Url } else { display = entry.bookmark.Url subText = "" } suggestion := wshrpc.SuggestionType{ Type: "url", SuggestionId: utilfn.QuickHashString(entry.bookmark.Url), Display: display, SubText: subText, MatchPos: entry.matchPos, // These positions correspond to the field in Display. SubMatchPos: entry.subMatchPos, // For bookmarks with a title, this is the URL match positions. Score: entry.score, UrlUrl: entry.bookmark.Url, } suggestion.IconSrc = faviconcache.GetFavicon(entry.bookmark.Url) suggestions = append(suggestions, suggestion) if len(suggestions) >= MaxSuggestions { break } } return &wshrpc.FetchSuggestionsResponse{ Suggestions: suggestions, ReqNum: data.ReqNum, }, nil } // Define a scored entry for fuzzy matching. type scoredEntry struct { ent fs.DirEntry score int fileName string positions []int } // We'll use a heap to only keep the top MaxSuggestions when a search term is provided. // Define a min-heap so that the worst (lowest scoring) candidate is at the top. type scoredEntryHeap []scoredEntry // Less: lower score is “less”. For equal scores, a candidate with a longer filename is considered worse. func (h scoredEntryHeap) Len() int { return len(h) } func (h scoredEntryHeap) Less(i, j int) bool { if h[i].score != h[j].score { return h[i].score < h[j].score } return len(h[i].fileName) > len(h[j].fileName) } func (h scoredEntryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } func (h *scoredEntryHeap) Push(x interface{}) { *h = append(*h, x.(scoredEntry)) } func (h *scoredEntryHeap) Pop() interface{} { old := *h n := len(old) x := old[n-1] *h = old[0 : n-1] return x } func fetchFileSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { // Only support file suggestions. if data.SuggestionType != "file" { return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType) } // Resolve the base directory, query prefix (for display) and search term. baseDir, queryPrefix, searchTerm, err := resolveFileQuery(data.FileCwd, data.Query) if err != nil { return nil, fmt.Errorf("error resolving base dir: %w", err) } // Use a cancellable context for directory listing. listingCtx, cancelFn := context.WithCancel(ctx) defer cancelFn() entriesCh, err := listDirectory(listingCtx, data.WidgetId, baseDir, 1000) if err != nil { return nil, fmt.Errorf("error listing directory: %w", err) } const maxEntries = MaxSuggestions // top-k entries // Always use a heap. var topHeap scoredEntryHeap heap.Init(&topHeap) var patternRunes []rune if searchTerm != "" { patternRunes = []rune(strings.ToLower(searchTerm)) } var slab util.Slab var index int // used for ordering when searchTerm is empty // Process each directory entry. for result := range entriesCh { if result.Err != nil { return nil, fmt.Errorf("error reading directory: %w", result.Err) } de := result.Entry fileName := de.Name() var score int var candidatePositions []int if searchTerm != "" { // Perform fuzzy matching. candidate := strings.ToLower(fileName) text := util.ToChars([]byte(candidate)) matchResult, positions := algo.FuzzyMatchV2(false, true, true, &text, patternRunes, true, &slab) if matchResult.Score <= 0 { index++ continue } score = matchResult.Score if positions != nil { candidatePositions = *positions } } else { // Use ordering: first entry gets highest score. score = maxEntries - index } index++ se := scoredEntry{ ent: de, score: score, fileName: fileName, positions: candidatePositions, } if topHeap.Len() < maxEntries { heap.Push(&topHeap, se) } else { // Replace the worst candidate if this one is better. worst := topHeap[0] if se.score > worst.score || (se.score == worst.score && len(se.fileName) < len(worst.fileName)) { heap.Pop(&topHeap) heap.Push(&topHeap, se) } } if searchTerm == "" && topHeap.Len() >= maxEntries { break } } // Extract and sort the scored entries (highest score first). scoredEntries := make([]scoredEntry, topHeap.Len()) copy(scoredEntries, topHeap) sort.Slice(scoredEntries, func(i, j int) bool { if scoredEntries[i].score != scoredEntries[j].score { return scoredEntries[i].score > scoredEntries[j].score } return len(scoredEntries[i].fileName) < len(scoredEntries[j].fileName) }) // Build suggestions from the scored entries. var suggestions []wshrpc.SuggestionType for _, candidate := range scoredEntries { fileName := candidate.ent.Name() fullPath := filepath.Join(baseDir, fileName) suggestionFileName := filepath.Join(queryPrefix, fileName) offset := len(suggestionFileName) - len(fileName) if offset > 0 && len(candidate.positions) > 0 { // Adjust match positions to account for the query prefix. for j := range candidate.positions { candidate.positions[j] += offset } } s := wshrpc.SuggestionType{ Type: "file", FilePath: fullPath, SuggestionId: utilfn.QuickHashString(fullPath), Display: suggestionFileName, FileName: suggestionFileName, FileMimeType: fileutil.DetectMimeTypeWithDirEnt(fullPath, candidate.ent), MatchPos: candidate.positions, Score: candidate.score, } suggestions = append(suggestions, s) if len(suggestions) >= MaxSuggestions { break } } return &wshrpc.FetchSuggestionsResponse{ Suggestions: suggestions, ReqNum: data.ReqNum, }, nil } ================================================ FILE: pkg/telemetry/telemetry.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package telemetry import ( "context" "database/sql/driver" "encoding/json" "fmt" "log" "sync/atomic" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/daystr" "github.com/wavetermdev/waveterm/pkg/util/dbutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" ) const MaxTzNameLen = 50 const ActivityEventName = "app:activity" var cachedTosAgreedTs atomic.Int64 func GetTosAgreedTs() int64 { cached := cachedTosAgreedTs.Load() if cached != 0 { return cached } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) if err != nil || client == nil || client.TosAgreed == 0 { return 0 } cachedTosAgreedTs.Store(client.TosAgreed) return client.TosAgreed } type ActivityType struct { Day string `json:"day"` Uploaded bool `json:"-"` TData TelemetryData `json:"tdata"` TzName string `json:"tzname"` TzOffset int `json:"tzoffset"` ClientVersion string `json:"clientversion"` ClientArch string `json:"clientarch"` BuildTime string `json:"buildtime"` OSRelease string `json:"osrelease"` } type TelemetryData struct { ActiveMinutes int `json:"activeminutes"` FgMinutes int `json:"fgminutes"` OpenMinutes int `json:"openminutes"` WaveAIActiveMinutes int `json:"waveaiactiveminutes,omitempty"` WaveAIFgMinutes int `json:"waveaifgminutes,omitempty"` NumTabs int `json:"numtabs"` NumBlocks int `json:"numblocks,omitempty"` NumWindows int `json:"numwindows,omitempty"` NumWS int `json:"numws,omitempty"` NumWSNamed int `json:"numwsnamed,omitempty"` NumSSHConn int `json:"numsshconn,omitempty"` NumWSLConn int `json:"numwslconn,omitempty"` NumMagnify int `json:"nummagnify,omitempty"` NewTab int `json:"newtab"` NumStartup int `json:"numstartup,omitempty"` NumShutdown int `json:"numshutdown,omitempty"` NumPanics int `json:"numpanics,omitempty"` NumAIReqs int `json:"numaireqs,omitempty"` SetTabTheme int `json:"settabtheme,omitempty"` Displays []wshrpc.ActivityDisplayType `json:"displays,omitempty"` Renderers map[string]int `json:"renderers,omitempty"` Blocks map[string]int `json:"blocks,omitempty"` WshCmds map[string]int `json:"wshcmds,omitempty"` Conn map[string]int `json:"conn,omitempty"` } func (tdata TelemetryData) Value() (driver.Value, error) { return dbutil.QuickValueJson(tdata) } func (tdata *TelemetryData) Scan(val interface{}) error { return dbutil.QuickScanJson(tdata, val) } func IsTelemetryEnabled() bool { settings := wconfig.GetWatcher().GetFullConfig() return settings.Settings.TelemetryEnabled } func IsAutoUpdateEnabled() bool { settings := wconfig.GetWatcher().GetFullConfig() return settings.Settings.AutoUpdateEnabled } func AutoUpdateChannel() string { settings := wconfig.GetWatcher().GetFullConfig() return settings.Settings.AutoUpdateChannel } // Wraps UpdateCurrentActivity, spawns goroutine, and logs errors func GoUpdateActivityWrap(update wshrpc.ActivityUpdate, debugStr string) { go func() { defer func() { panichandler.PanicHandlerNoTelemetry("GoUpdateActivityWrap", recover()) }() ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() err := UpdateActivity(ctx, update) if err != nil { // ignore error, just log, since this is not critical log.Printf("error updating current activity (%s): %v\n", debugStr, err) } }() } func insertTEvent(ctx context.Context, event *telemetrydata.TEvent) error { if event.Uuid == "" { return fmt.Errorf("cannot insert TEvent: uuid is empty") } if event.Ts == 0 { return fmt.Errorf("cannot insert TEvent: ts is 0") } if event.TsLocal == "" { return fmt.Errorf("cannot insert TEvent: tslocal is empty") } if event.Event == "" { return fmt.Errorf("cannot insert TEvent: event is empty") } return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { query := `INSERT INTO db_tevent (uuid, ts, tslocal, event, props) VALUES (?, ?, ?, ?, ?)` tx.Exec(query, event.Uuid, event.Ts, event.TsLocal, event.Event, dbutil.QuickJson(event.Props)) return nil }) } // merges newActivity into curActivity, returns curActivity func mergeActivity(curActivity *telemetrydata.TEventProps, newActivity telemetrydata.TEventProps) { curActivity.ActiveMinutes += newActivity.ActiveMinutes curActivity.FgMinutes += newActivity.FgMinutes curActivity.OpenMinutes += newActivity.OpenMinutes curActivity.WaveAIActiveMinutes += newActivity.WaveAIActiveMinutes curActivity.WaveAIFgMinutes += newActivity.WaveAIFgMinutes curActivity.TermCommandsRun += newActivity.TermCommandsRun curActivity.TermCommandsRemote += newActivity.TermCommandsRemote curActivity.TermCommandsDurable += newActivity.TermCommandsDurable curActivity.TermCommandsWsl += newActivity.TermCommandsWsl if newActivity.AppFirstDay { curActivity.AppFirstDay = true } } // ignores the timestamp in tevent, and uses the current time func updateActivityTEvent(ctx context.Context, tevent *telemetrydata.TEvent) error { eventTs := time.Now() // compute to 1-hour boundary, and round up to next 1-hour boundary eventTs = eventTs.Truncate(time.Hour).Add(time.Hour) return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { // find event that matches this timestamp with event name "app:activity" var hasRow bool var curActivity telemetrydata.TEventProps uuidStr := tx.GetString(`SELECT uuid FROM db_tevent WHERE ts = ? AND event = ?`, eventTs.UnixMilli(), ActivityEventName) if uuidStr != "" { hasRow = true rawProps := tx.GetString(`SELECT props FROM db_tevent WHERE uuid = ?`, uuidStr) err := json.Unmarshal([]byte(rawProps), &curActivity) if err != nil { // ignore, curActivity will just be 0 log.Printf("error unmarshalling activity props: %v\n", err) } } mergeActivity(&curActivity, tevent.Props) if hasRow { query := `UPDATE db_tevent SET props = ? WHERE uuid = ?` tx.Exec(query, dbutil.QuickJson(curActivity), uuidStr) } else { query := `INSERT INTO db_tevent (uuid, ts, tslocal, event, props) VALUES (?, ?, ?, ?, ?)` tsLocal := utilfn.ConvertToWallClockPT(eventTs).Format(time.RFC3339) tx.Exec(query, uuid.New().String(), eventTs.UnixMilli(), tsLocal, ActivityEventName, dbutil.QuickJson(curActivity)) } return nil }) } func TruncateActivityTEventForShutdown(ctx context.Context) error { nowTs := time.Now() eventTs := nowTs.Truncate(time.Hour).Add(time.Hour) return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { // find event that matches this timestamp with event name "app:activity" uuidStr := tx.GetString(`SELECT uuid FROM db_tevent WHERE ts = ? AND event = ?`, eventTs.UnixMilli(), ActivityEventName) if uuidStr == "" { return nil } // we're going to update this app:activity event back to nowTs tsLocal := utilfn.ConvertToWallClockPT(nowTs).Format(time.RFC3339) query := `UPDATE db_tevent SET ts = ?, tslocal = ? WHERE uuid = ?` tx.Exec(query, nowTs.UnixMilli(), tsLocal, uuidStr) return nil }) } func GoRecordTEventWrap(tevent *telemetrydata.TEvent) { if tevent == nil || tevent.Event == "" { return } go func() { defer func() { panichandler.PanicHandlerNoTelemetry("GoRecordTEventWrap", recover()) }() ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() err := RecordTEvent(ctx, tevent) if err != nil { // ignore error, just log, since this is not critical log.Printf("error recording %q telemetry event: %v\n", tevent.Event, err) } }() } func RecordTEvent(ctx context.Context, tevent *telemetrydata.TEvent) error { if tevent == nil { return nil } if tevent.Uuid == "" { tevent.Uuid = uuid.New().String() } err := tevent.Validate(true) if err != nil { return err } tevent.EnsureTimestamps() // Set AppFirstDay if on same calendar day as TOS agreement tosAgreedTs := GetTosAgreedTs() if tosAgreedTs == 0 { tevent.Props.AppFirstDay = true } else { tosYear, tosMonth, tosDay := time.UnixMilli(tosAgreedTs).Date() nowYear, nowMonth, nowDay := time.Now().Date() if tosYear == nowYear && tosMonth == nowMonth && tosDay == nowDay { tevent.Props.AppFirstDay = true } } if tevent.Event == ActivityEventName { return updateActivityTEvent(ctx, tevent) } return insertTEvent(ctx, tevent) } func CleanOldTEvents(ctx context.Context) error { daysToKeep := 7 if !IsTelemetryEnabled() { daysToKeep = 1 } olderThan := time.Now().AddDate(0, 0, -daysToKeep).UnixMilli() return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { query := `DELETE FROM db_tevent WHERE ts < ?` tx.Exec(query, olderThan) return nil }) } func GetNonUploadedTEvents(ctx context.Context, maxEvents int) ([]*telemetrydata.TEvent, error) { now := time.Now() return wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) ([]*telemetrydata.TEvent, error) { var rtn []*telemetrydata.TEvent query := `SELECT uuid, ts, tslocal, event, props, uploaded FROM db_tevent WHERE uploaded = 0 AND ts <= ? ORDER BY ts LIMIT ?` tx.Select(&rtn, query, now.UnixMilli(), maxEvents) for _, event := range rtn { if err := event.ConvertRawJSON(); err != nil { return nil, fmt.Errorf("scan json for event %s: %w", event.Uuid, err) } } return rtn, nil }) } func MarkTEventsAsUploaded(ctx context.Context, events []*telemetrydata.TEvent) error { return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { ids := make([]string, 0, len(events)) for _, event := range events { ids = append(ids, event.Uuid) } query := `UPDATE db_tevent SET uploaded = 1 WHERE uuid IN (SELECT value FROM json_each(?))` tx.Exec(query, dbutil.QuickJson(ids)) return nil }) } func UpdateActivity(ctx context.Context, update wshrpc.ActivityUpdate) error { now := time.Now() dayStr := daystr.GetCurDayStr() txErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { var tdata TelemetryData query := `SELECT tdata FROM db_activity WHERE day = ?` found := tx.Get(&tdata, query, dayStr) if !found { query = `INSERT INTO db_activity (day, uploaded, tdata, tzname, tzoffset, clientversion, clientarch, buildtime, osrelease) VALUES ( ?, 0, ?, ?, ?, ?, ?, ?, ?)` tzName, tzOffset := now.Zone() if len(tzName) > MaxTzNameLen { tzName = tzName[0:MaxTzNameLen] } tx.Exec(query, dayStr, tdata, tzName, tzOffset, wavebase.WaveVersion, wavebase.ClientArch(), wavebase.BuildTime, wavebase.UnameKernelRelease()) } tdata.FgMinutes += update.FgMinutes tdata.ActiveMinutes += update.ActiveMinutes tdata.OpenMinutes += update.OpenMinutes tdata.WaveAIFgMinutes += update.WaveAIFgMinutes tdata.WaveAIActiveMinutes += update.WaveAIActiveMinutes tdata.NewTab += update.NewTab tdata.NumStartup += update.Startup tdata.NumShutdown += update.Shutdown tdata.SetTabTheme += update.SetTabTheme tdata.NumMagnify += update.NumMagnify tdata.NumPanics += update.NumPanics tdata.NumAIReqs += update.NumAIReqs if update.NumTabs > 0 { tdata.NumTabs = update.NumTabs } if update.NumBlocks > 0 { tdata.NumBlocks = update.NumBlocks } if update.NumWindows > 0 { tdata.NumWindows = update.NumWindows } if update.NumWS > 0 { tdata.NumWS = update.NumWS } if update.NumWSNamed > 0 { tdata.NumWSNamed = update.NumWSNamed } if update.NumSSHConn > 0 && update.NumSSHConn > tdata.NumSSHConn { tdata.NumSSHConn = update.NumSSHConn } if update.NumWSLConn > 0 && update.NumWSLConn > tdata.NumWSLConn { tdata.NumWSLConn = update.NumWSLConn } if len(update.Renderers) > 0 { if tdata.Renderers == nil { tdata.Renderers = make(map[string]int) } for key, val := range update.Renderers { tdata.Renderers[key] += val } } if len(update.WshCmds) > 0 { if tdata.WshCmds == nil { tdata.WshCmds = make(map[string]int) } for key, val := range update.WshCmds { tdata.WshCmds[key] += val } } if len(update.Conn) > 0 { if tdata.Conn == nil { tdata.Conn = make(map[string]int) } for key, val := range update.Conn { tdata.Conn[key] += val } } if len(update.Displays) > 0 { tdata.Displays = update.Displays } if len(update.Blocks) > 0 { tdata.Blocks = update.Blocks } query = `UPDATE db_activity SET tdata = ?, clientversion = ?, buildtime = ? WHERE day = ?` tx.Exec(query, tdata, wavebase.WaveVersion, wavebase.BuildTime, dayStr) return nil }) if txErr != nil { return txErr } return nil } func GetNonUploadedActivity(ctx context.Context) ([]*ActivityType, error) { var rtn []*ActivityType txErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { query := `SELECT * FROM db_activity WHERE uploaded = 0 ORDER BY day DESC LIMIT 30` tx.Select(&rtn, query) return nil }) if txErr != nil { return nil, txErr } return rtn, nil } func MarkActivityAsUploaded(ctx context.Context, activityArr []*ActivityType) error { dayStr := daystr.GetCurDayStr() txErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { query := `UPDATE db_activity SET uploaded = 1 WHERE day = ?` for _, activity := range activityArr { if activity.Day == dayStr { continue } tx.Exec(query, activity.Day) } return nil }) return txErr } ================================================ FILE: pkg/telemetry/telemetrydata/telemetrydata.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package telemetrydata import ( "encoding/json" "fmt" "regexp" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) var ValidEventNames = map[string]bool{ "app:startup": true, "app:shutdown": true, "app:activity": true, "app:display": true, "app:counts": true, "action:magnify": true, "action:settabtheme": true, "action:runaicmd": true, "action:createtab": true, "action:createblock": true, "action:openwaveai": true, "action:other": true, "action:term": true, "action:termdurable": true, "action:link": true, "wsh:run": true, "debug:panic": true, "conn:connect": true, "conn:connecterror": true, "conn:nowsh": true, "waveai:enabletelemetry": true, "waveai:post": true, "waveai:feedback": true, "waveai:showdiff": true, "waveai:revertfile": true, "onboarding:start": true, "onboarding:skip": true, "onboarding:fire": true, "onboarding:githubstar": true, "job:start": true, "job:reconnect": true, "job:done": true, } type TEvent struct { Uuid string `json:"uuid,omitempty" db:"uuid"` Ts int64 `json:"ts,omitempty" db:"ts"` TsLocal string `json:"tslocal,omitempty" db:"tslocal"` // iso8601 format (wall clock converted to PT) Event string `json:"event" db:"event"` Props TEventProps `json:"props" db:"-"` // Don't scan directly to map // DB fields Uploaded bool `json:"-" db:"uploaded"` // For database scanning RawProps string `json:"-" db:"props"` } type TEventUserProps struct { ClientArch string `json:"client:arch,omitempty"` ClientVersion string `json:"client:version,omitempty"` ClientInitialVersion string `json:"client:initial_version,omitempty"` ClientBuildTime string `json:"client:buildtime,omitempty"` ClientOSRelease string `json:"client:osrelease,omitempty"` ClientIsDev bool `json:"client:isdev,omitempty"` ClientPackageType string `json:"client:packagetype,omitempty"` ClientMacOSVersion string `json:"client:macos,omitempty"` CohortMonth string `json:"cohort:month,omitempty"` CohortISOWeek string `json:"cohort:isoweek,omitempty"` AutoUpdateChannel string `json:"autoupdate:channel,omitempty"` AutoUpdateEnabled bool `json:"autoupdate:enabled,omitempty"` LocalShellType string `json:"localshell:type,omitempty"` LocalShellVersion string `json:"localshell:version,omitempty"` LocCountryCode string `json:"loc:countrycode,omitempty"` LocRegionCode string `json:"loc:regioncode,omitempty"` SettingsCustomWidgets int `json:"settings:customwidgets,omitempty"` SettingsCustomAIPresets int `json:"settings:customaipresets,omitempty"` SettingsCustomSettings int `json:"settings:customsettings,omitempty"` SettingsCustomAIModes int `json:"settings:customaimodes,omitempty"` SettingsSecretsCount int `json:"settings:secretscount,omitempty"` SettingsTransparent bool `json:"settings:transparent,omitempty"` } type TEventProps struct { TEventUserProps `tstype:"-"` // generally don't need to set these since they will be automatically copied over ActiveMinutes int `json:"activity:activeminutes,omitempty"` FgMinutes int `json:"activity:fgminutes,omitempty"` OpenMinutes int `json:"activity:openminutes,omitempty"` WaveAIActiveMinutes int `json:"activity:waveaiactiveminutes,omitempty"` WaveAIFgMinutes int `json:"activity:waveaifgminutes,omitempty"` TermCommandsRun int `json:"activity:termcommandsrun,omitempty"` TermCommandsRemote int `json:"activity:termcommands:remote,omitempty"` TermCommandsDurable int `json:"activity:termcommands:durable,omitempty"` TermCommandsWsl int `json:"activity:termcommands:wsl,omitempty"` AppFirstDay bool `json:"app:firstday,omitempty"` AppFirstLaunch bool `json:"app:firstlaunch,omitempty"` ActionInitiator string `json:"action:initiator,omitempty" tstype:"\"keyboard\" | \"mouse\""` ActionType string `json:"action:type,omitempty"` PanicType string `json:"debug:panictype,omitempty"` BlockView string `json:"block:view,omitempty"` BlockController string `json:"block:controller,omitempty"` AiBackendType string `json:"ai:backendtype,omitempty"` AiLocal bool `json:"ai:local,omitempty"` WshCmd string `json:"wsh:cmd,omitempty"` WshHadError bool `json:"wsh:haderror,omitempty"` ConnType string `json:"conn:conntype,omitempty"` ConnWshErrorCode string `json:"conn:wsherrorcode,omitempty"` ConnErrorCode string `json:"conn:errorcode,omitempty"` ConnSubErrorCode string `json:"conn:suberrorcode,omitempty"` ConnContextError bool `json:"conn:contexterror,omitempty"` OnboardingFeature string `json:"onboarding:feature,omitempty" tstype:"\"waveai\" | \"durable\" | \"magnify\" | \"wsh\""` OnboardingVersion string `json:"onboarding:version,omitempty"` OnboardingGithubStar string `json:"onboarding:githubstar,omitempty" tstype:"\"already\" | \"star\" | \"later\""` OnboardingPage string `json:"onboarding:page,omitempty"` DisplayHeight int `json:"display:height,omitempty"` DisplayWidth int `json:"display:width,omitempty"` DisplayDPR float64 `json:"display:dpr,omitempty"` DisplayCount int `json:"display:count,omitempty"` DisplayAll interface{} `json:"display:all,omitempty"` CountBlocks int `json:"count:blocks,omitempty"` CountTabs int `json:"count:tabs,omitempty"` CountWindows int `json:"count:windows,omitempty"` CountWorkspaces int `json:"count:workspaces,omitempty"` CountSSHConn int `json:"count:sshconn,omitempty"` CountWSLConn int `json:"count:wslconn,omitempty"` CountJobs int `json:"count:jobs,omitempty"` CountJobsConnected int `json:"count:jobsconnected,omitempty"` CountViews map[string]int `json:"count:views,omitempty"` WaveAIAPIType string `json:"waveai:apitype,omitempty"` WaveAIModel string `json:"waveai:model,omitempty"` WaveAIChatId string `json:"waveai:chatid,omitempty"` WaveAIStepNum int `json:"waveai:stepnum,omitempty"` WaveAIInputTokens int `json:"waveai:inputtokens,omitempty"` WaveAIOutputTokens int `json:"waveai:outputtokens,omitempty"` WaveAINativeWebSearchCount int `json:"waveai:nativewebsearchcount,omitempty"` WaveAIRequestCount int `json:"waveai:requestcount,omitempty"` WaveAIToolUseCount int `json:"waveai:toolusecount,omitempty"` WaveAIToolUseErrorCount int `json:"waveai:tooluseerrorcount,omitempty"` WaveAIToolDetail map[string]int `json:"waveai:tooldetail,omitempty"` WaveAIPremiumReq int `json:"waveai:premiumreq,omitempty"` WaveAIProxyReq int `json:"waveai:proxyreq,omitempty"` WaveAIHadError bool `json:"waveai:haderror,omitempty"` WaveAIImageCount int `json:"waveai:imagecount,omitempty"` WaveAIPDFCount int `json:"waveai:pdfcount,omitempty"` WaveAITextDocCount int `json:"waveai:textdoccount,omitempty"` WaveAITextLen int `json:"waveai:textlen,omitempty"` WaveAIFirstByteMs int `json:"waveai:firstbytems,omitempty"` // ms WaveAIRequestDurMs int `json:"waveai:requestdurms,omitempty"` // ms WaveAIWidgetAccess bool `json:"waveai:widgetaccess,omitempty"` WaveAIThinkingLevel string `json:"waveai:thinkinglevel,omitempty"` WaveAIMode string `json:"waveai:mode,omitempty"` WaveAIProvider string `json:"waveai:provider,omitempty"` WaveAIIsLocal bool `json:"waveai:islocal,omitempty"` WaveAIFeedback string `json:"waveai:feedback,omitempty" tstype:"\"good\" | \"bad\""` WaveAIAction string `json:"waveai:action,omitempty"` JobDoneReason string `json:"job:donereason,omitempty"` JobKind string `json:"job:kind,omitempty"` UserSet *TEventUserProps `json:"$set,omitempty"` UserSetOnce *TEventUserProps `json:"$set_once,omitempty"` } func MakeTEvent(event string, props TEventProps) *TEvent { now := time.Now() // TsLocal gets set in EnsureTimestamps() return &TEvent{ Uuid: uuid.New().String(), Ts: now.UnixMilli(), Event: event, Props: props, } } func MakeUntypedTEvent(event string, propsMap map[string]any) (*TEvent, error) { if event == "" { return nil, fmt.Errorf("event name must be non-empty") } var props TEventProps err := utilfn.ReUnmarshal(&props, propsMap) if err != nil { return nil, fmt.Errorf("error re-marshalling TEvent props: %w", err) } return MakeTEvent(event, props), nil } func (t *TEvent) EnsureTimestamps() { if t.Ts == 0 { t.Ts = time.Now().UnixMilli() } gtime := time.UnixMilli(t.Ts) t.TsLocal = utilfn.ConvertToWallClockPT(gtime).Format(time.RFC3339) } func (t *TEvent) UserSetProps() *TEventUserProps { if t.Props.UserSet == nil { t.Props.UserSet = &TEventUserProps{} } return t.Props.UserSet } func (t *TEvent) UserSetOnceProps() *TEventUserProps { if t.Props.UserSetOnce == nil { t.Props.UserSetOnce = &TEventUserProps{} } return t.Props.UserSetOnce } func (t *TEvent) ConvertRawJSON() error { if t.RawProps != "" { return json.Unmarshal([]byte(t.RawProps), &t.Props) } return nil } var eventNameRe = regexp.MustCompile(`^[a-zA-Z0-9.:_/-]+$`) // validates a tevent that was just created (not for validating out of the DB, or an uploaded TEvent) // checks that TS is pretty current (or unset) func (te *TEvent) Validate(current bool) error { if te == nil { return fmt.Errorf("TEvent cannot be nil") } if te.Event == "" { return fmt.Errorf("TEvent.Event cannot be empty") } if !eventNameRe.MatchString(te.Event) { return fmt.Errorf("TEvent.Event invalid: %q", te.Event) } if !ValidEventNames[te.Event] { return fmt.Errorf("TEvent.Event not valid: %q", te.Event) } if te.Uuid == "" { return fmt.Errorf("TEvent.Uuid cannot be empty") } _, err := uuid.Parse(te.Uuid) if err != nil { return fmt.Errorf("TEvent.Uuid invalid: %v", err) } if current { if te.Ts != 0 { now := time.Now().UnixMilli() if te.Ts > now+60000 || te.Ts < now-60000 { return fmt.Errorf("TEvent.Ts is not current: %d", te.Ts) } } } else { if te.Ts == 0 { return fmt.Errorf("TEvent.Ts must be set") } if te.TsLocal == "" { return fmt.Errorf("TEvent.TsLocal must be set") } t, err := time.Parse(time.RFC3339, te.TsLocal) if err != nil { return fmt.Errorf("TEvent.TsLocal parse error: %v", err) } now := time.Now() if t.Before(now.Add(-30*24*time.Hour)) || t.After(now.Add(2*24*time.Hour)) { return fmt.Errorf("tslocal out of valid range") } } barr, err := json.Marshal(te.Props) if err != nil { return fmt.Errorf("TEvent.Props JSON error: %v", err) } if len(barr) > 20000 { return fmt.Errorf("TEvent.Props too large: %d", len(barr)) } return nil } ================================================ FILE: pkg/trimquotes/trimquotes.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package trimquotes import ( "strconv" ) func TrimQuotes(s string) (string, bool) { if len(s) > 2 && s[0] == '"' { trimmed, err := strconv.Unquote(s) if err != nil { return s, false } return trimmed, true } return s, false } func TryTrimQuotes(s string) string { trimmed, _ := TrimQuotes(s) return trimmed } func ReplaceQuotes(s string, shouldReplace bool) string { if shouldReplace { return strconv.Quote(s) } return s } ================================================ FILE: pkg/tsgen/tsgen.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package tsgen import ( "bytes" "context" "fmt" "reflect" "strings" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/eventbus" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/service" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/web/webcmd" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" ) // add extra types to generate here var ExtraTypes = []any{ waveobj.ORef{}, (*waveobj.WaveObj)(nil), map[string]any{}, service.WebCallType{}, service.WebReturnType{}, waveobj.UIContext{}, eventbus.WSEventType{}, wps.WSFileEventData{}, waveobj.LayoutActionData{}, filestore.WaveFile{}, wconfig.FullConfigType{}, wconfig.WatcherUpdate{}, wshutil.RpcMessage{}, wshrpc.WshServerCommandMeta{}, userinput.UserInputRequest{}, vdom.VDomCreateContext{}, vdom.VDomElem{}, vdom.VDomFunc{}, vdom.VDomRef{}, vdom.VDomBinding{}, vdom.VDomFrontendUpdate{}, vdom.VDomBackendUpdate{}, waveobj.MetaTSType{}, waveobj.ObjRTInfo{}, uctypes.RateLimitInfo{}, wconfig.AIModeConfigUpdate{}, wshrpc.BlockJobStatusData{}, } // add extra type unions to generate here var TypeUnions = []tsgenmeta.TypeUnionMeta{ webcmd.WSCommandTypeUnionMeta(), } var contextRType = reflect.TypeOf((*context.Context)(nil)).Elem() var errorRType = reflect.TypeOf((*error)(nil)).Elem() var anyRType = reflect.TypeOf((*interface{})(nil)).Elem() var metaRType = reflect.TypeOf((*waveobj.MetaMapType)(nil)).Elem() var metaSettingsType = reflect.TypeOf((*wshrpc.MetaSettingsType)(nil)).Elem() var uiContextRType = reflect.TypeOf((*waveobj.UIContext)(nil)).Elem() var waveObjRType = reflect.TypeOf((*waveobj.WaveObj)(nil)).Elem() var updatesRtnRType = reflect.TypeOf(waveobj.UpdatesRtnType{}) var orefRType = reflect.TypeOf((*waveobj.ORef)(nil)).Elem() var wshRpcInterfaceRType = reflect.TypeOf((*wshrpc.WshRpcInterface)(nil)).Elem() func generateTSMethodTypes(method reflect.Method, tsTypesMap map[reflect.Type]string, skipFirstArg bool) error { for idx := 0; idx < method.Type.NumIn(); idx++ { if skipFirstArg && idx == 0 { continue } inType := method.Type.In(idx) GenerateTSType(inType, tsTypesMap) } for idx := 0; idx < method.Type.NumOut(); idx++ { outType := method.Type.Out(idx) GenerateTSType(outType, tsTypesMap) } return nil } func getTSFieldName(field reflect.StructField) string { tsFieldTag := field.Tag.Get("tsfield") if tsFieldTag != "" { if tsFieldTag == "-" { return "" } return tsFieldTag } jsonTag := utilfn.GetJsonTag(field) if jsonTag == "-" { return "" } if strings.Contains(jsonTag, ":") { return "\"" + jsonTag + "\"" } if jsonTag != "" { return jsonTag } return field.Name } func isFieldOmitEmpty(field reflect.StructField) bool { jsonTag := field.Tag.Get("json") if jsonTag != "" { parts := strings.Split(jsonTag, ",") if len(parts) > 1 { for _, part := range parts[1:] { if part == "omitempty" { return true } } } } return false } func TypeToTSType(t reflect.Type, tsTypesMap map[reflect.Type]string) (string, []reflect.Type) { switch t.Kind() { case reflect.String: return "string", nil case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64: return "number", nil case reflect.Bool: return "boolean", nil case reflect.Slice, reflect.Array: // special case for byte slice, marshals to base64 encoded string if t.Elem().Kind() == reflect.Uint8 { return "string", nil } elemType, subTypes := TypeToTSType(t.Elem(), tsTypesMap) if elemType == "" { return "", nil } return fmt.Sprintf("%s[]", elemType), subTypes case reflect.Map: if t.Key().Kind() != reflect.String { return "", nil } if t == metaRType { return "MetaType", nil } elemType, subTypes := TypeToTSType(t.Elem(), tsTypesMap) if elemType == "" { return "", nil } return fmt.Sprintf("{[key: string]: %s}", elemType), subTypes case reflect.Struct: name := t.Name() if tsRename := tsRenameMap[name]; tsRename != "" { name = tsRename } return name, []reflect.Type{t} case reflect.Ptr: return TypeToTSType(t.Elem(), tsTypesMap) case reflect.Interface: if _, ok := tsTypesMap[t]; ok { return t.Name(), nil } return "any", nil default: return "", nil } } var tsRenameMap = map[string]string{ "Window": "WaveWindow", "Elem": "VDomElem", "MetaTSType": "MetaType", "MetaSettingsType": "SettingsType", } func generateTSTypeInternal(rtype reflect.Type, tsTypesMap map[reflect.Type]string, embedded bool) (string, []reflect.Type) { var buf bytes.Buffer tsTypeName := rtype.Name() if tsRename, ok := tsRenameMap[tsTypeName]; ok { tsTypeName = tsRename } var isWaveObj bool if !embedded { if rtype.Implements(waveObjRType) || reflect.PointerTo(rtype).Implements(waveObjRType) { isWaveObj = true } } var fieldsBuf bytes.Buffer var subTypes []reflect.Type for i := 0; i < rtype.NumField(); i++ { field := rtype.Field(i) if field.PkgPath != "" { continue } if field.Anonymous { embeddedBuf, embeddedTypes := generateTSTypeInternal(field.Type, tsTypesMap, true) fieldsBuf.WriteString(embeddedBuf) subTypes = append(subTypes, embeddedTypes...) continue } fieldName := getTSFieldName(field) if fieldName == "" { continue } if isWaveObj && (fieldName == waveobj.OTypeKeyName || fieldName == waveobj.OIDKeyName || fieldName == waveobj.VersionKeyName || fieldName == waveobj.MetaKeyName) { continue } optMarker := "" if isFieldOmitEmpty(field) { optMarker = "?" } tsTypeTag := field.Tag.Get("tstype") if tsTypeTag != "" { if tsTypeTag == "-" { continue } fieldsBuf.WriteString(fmt.Sprintf(" %s%s: %s;\n", fieldName, optMarker, tsTypeTag)) continue } tsType, fieldSubTypes := TypeToTSType(field.Type, tsTypesMap) if tsType == "" { continue } subTypes = append(subTypes, fieldSubTypes...) if tsType == "UIContext" { optMarker = "?" } fieldsBuf.WriteString(fmt.Sprintf(" %s%s: %s;\n", fieldName, optMarker, tsType)) } if !embedded { buf.WriteString(fmt.Sprintf("// %s\n", rtype.String())) if fieldsBuf.Len() == 0 && !isWaveObj { // empty struct - use "object" instead of "{}" to satisfy linter buf.WriteString(fmt.Sprintf("type %s = object;\n", tsTypeName)) } else if isWaveObj { buf.WriteString(fmt.Sprintf("type %s = WaveObj & {\n", tsTypeName)) buf.Write(fieldsBuf.Bytes()) buf.WriteString("};\n") } else { buf.WriteString(fmt.Sprintf("type %s = {\n", tsTypeName)) buf.Write(fieldsBuf.Bytes()) buf.WriteString("};\n") } } else { buf.Write(fieldsBuf.Bytes()) } return buf.String(), subTypes } func GenerateWaveObjTSType() string { var buf bytes.Buffer buf.WriteString("// waveobj.WaveObj\n") buf.WriteString("type WaveObj = {\n") buf.WriteString(" otype: string;\n") buf.WriteString(" oid: string;\n") buf.WriteString(" version: number;\n") buf.WriteString(" meta: MetaType;\n") buf.WriteString("};\n") return buf.String() } func GenerateTSTypeUnion(unionMeta tsgenmeta.TypeUnionMeta, tsTypeMap map[reflect.Type]string) { rtn := generateTSTypeUnionInternal(unionMeta) tsTypeMap[unionMeta.BaseType] = rtn for _, rtype := range unionMeta.Types { GenerateTSType(rtype, tsTypeMap) } } func generateTSTypeUnionInternal(unionMeta tsgenmeta.TypeUnionMeta) string { var buf bytes.Buffer if unionMeta.Desc != "" { buf.WriteString(fmt.Sprintf("// %s\n", unionMeta.Desc)) } buf.WriteString(fmt.Sprintf("type %s = {\n", unionMeta.BaseType.Name())) buf.WriteString(fmt.Sprintf(" %s: string;\n", unionMeta.TypeFieldName)) buf.WriteString("} & ( ") for idx, rtype := range unionMeta.Types { if idx > 0 { buf.WriteString(" | ") } buf.WriteString(rtype.Name()) } buf.WriteString(" );\n") return buf.String() } func GenerateTSType(rtype reflect.Type, tsTypesMap map[reflect.Type]string) { if rtype == nil { return } if rtype.Kind() == reflect.Chan { rtype = rtype.Elem() } if rtype == contextRType || rtype == errorRType || rtype == anyRType { return } if rtype.Kind() == reflect.Slice { rtype = rtype.Elem() } if rtype.Kind() == reflect.Map { rtype = rtype.Elem() } if rtype.Kind() == reflect.Ptr { rtype = rtype.Elem() } if _, ok := tsTypesMap[rtype]; ok { return } if rtype == orefRType { tsTypesMap[orefRType] = "// waveobj.ORef\ntype ORef = string;\n" return } if rtype == waveObjRType { tsTypesMap[rtype] = GenerateWaveObjTSType() return } if rtype == metaSettingsType { return } if rtype.Kind() != reflect.Struct { return } tsType, subTypes := generateTSTypeInternal(rtype, tsTypesMap, false) tsTypesMap[rtype] = tsType for _, subType := range subTypes { GenerateTSType(subType, tsTypesMap) } } func hasUpdatesReturn(method reflect.Method) bool { for idx := 0; idx < method.Type.NumOut(); idx++ { outType := method.Type.Out(idx) if outType == updatesRtnRType { return true } } return false } func GenerateMethodSignature(serviceName string, method reflect.Method, meta tsgenmeta.MethodMeta, isFirst bool, tsTypesMap map[reflect.Type]string) string { var sb strings.Builder mayReturnUpdates := hasUpdatesReturn(method) if (meta.Desc != "" || meta.ReturnDesc != "" || mayReturnUpdates) && !isFirst { sb.WriteString("\n") } if meta.Desc != "" { sb.WriteString(fmt.Sprintf(" // %s\n", meta.Desc)) } if mayReturnUpdates || meta.ReturnDesc != "" { if mayReturnUpdates && meta.ReturnDesc != "" { sb.WriteString(fmt.Sprintf(" // @returns %s (and object updates)\n", meta.ReturnDesc)) } else if mayReturnUpdates { sb.WriteString(" // @returns object updates\n") } else { sb.WriteString(fmt.Sprintf(" // @returns %s\n", meta.ReturnDesc)) } } sb.WriteString(" ") sb.WriteString(method.Name) sb.WriteString("(") wroteArg := false // skip first arg, which is the receiver for idx := 1; idx < method.Type.NumIn(); idx++ { if wroteArg { sb.WriteString(", ") } inType := method.Type.In(idx) if inType == contextRType || inType == uiContextRType { continue } tsTypeName, _ := TypeToTSType(inType, tsTypesMap) var argName string if idx-1 < len(meta.ArgNames) { argName = meta.ArgNames[idx-1] // subtract 1 for receiver } else { argName = fmt.Sprintf("arg%d", idx) } sb.WriteString(fmt.Sprintf("%s: %s", argName, tsTypeName)) wroteArg = true } sb.WriteString("): ") rtnTypes := []string{} for idx := 0; idx < method.Type.NumOut(); idx++ { outType := method.Type.Out(idx) if outType == errorRType { continue } if outType == updatesRtnRType { continue } tsTypeName, _ := TypeToTSType(outType, tsTypesMap) rtnTypes = append(rtnTypes, tsTypeName) } if len(rtnTypes) == 0 { sb.WriteString("Promise<void>") } else if len(rtnTypes) == 1 { sb.WriteString(fmt.Sprintf("Promise<%s>", rtnTypes[0])) } else { sb.WriteString(fmt.Sprintf("Promise<[%s]>", strings.Join(rtnTypes, ", "))) } sb.WriteString(" {\n") return sb.String() } func GenerateMethodBody(serviceName string, method reflect.Method, meta tsgenmeta.MethodMeta) string { return fmt.Sprintf(" return callBackendService(this?.waveEnv, %q, %q, Array.from(arguments))\n", serviceName, method.Name) } func GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[reflect.Type]string) string { serviceType := reflect.TypeOf(serviceObj) var sb strings.Builder tsServiceName := serviceType.Elem().Name() sb.WriteString(fmt.Sprintf("// %s (%s)\n", serviceType.Elem().String(), serviceName)) sb.WriteString("export class ") sb.WriteString(tsServiceName + "Type") sb.WriteString(" {\n") sb.WriteString(" waveEnv: WaveEnv;\n\n") sb.WriteString(" constructor(waveEnv?: WaveEnv) {\n") sb.WriteString(" this.waveEnv = waveEnv;\n") sb.WriteString(" }\n\n") isFirst := true for midx := 0; midx < serviceType.NumMethod(); midx++ { method := serviceType.Method(midx) if strings.HasSuffix(method.Name, "_Meta") { continue } var meta tsgenmeta.MethodMeta metaMethod, found := serviceType.MethodByName(method.Name + "_Meta") if found { serviceObjVal := reflect.ValueOf(serviceObj) metaVal := metaMethod.Func.Call([]reflect.Value{serviceObjVal}) meta = metaVal[0].Interface().(tsgenmeta.MethodMeta) } sb.WriteString(GenerateMethodSignature(serviceName, method, meta, isFirst, tsTypesMap)) sb.WriteString(GenerateMethodBody(serviceName, method, meta)) sb.WriteString(" }\n") isFirst = false } sb.WriteString("}\n\n") sb.WriteString(fmt.Sprintf("export const %s = new %sType();\n", tsServiceName, tsServiceName)) return sb.String() } func GenerateWshClientApiMethod(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) string { if methodDecl.CommandType == wshrpc.RpcType_ResponseStream { return generateWshClientApiMethod_ResponseStream(methodDecl, tsTypesMap) } else if methodDecl.CommandType == wshrpc.RpcType_Call { return generateWshClientApiMethod_Call(methodDecl, tsTypesMap) } else { panic(fmt.Sprintf("cannot generate wshserver commandtype %q", methodDecl.CommandType)) } } func generateWshClientApiMethod_ResponseStream(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) string { var sb strings.Builder sb.WriteString(fmt.Sprintf(" // command %q [%s]\n", methodDecl.Command, methodDecl.CommandType)) respType := "any" if methodDecl.DefaultResponseDataType != nil { respType, _ = TypeToTSType(methodDecl.DefaultResponseDataType, tsTypesMap) } methodSigDataParams, dataName := getTsWshMethodDataParamsAndExpr(methodDecl, tsTypesMap) genRespType := fmt.Sprintf("AsyncGenerator<%s, void, boolean>", respType) if methodSigDataParams == "" { sb.WriteString(fmt.Sprintf(" %s(client: WshClient, opts?: RpcOpts): %s {\n", methodDecl.MethodName, genRespType)) } else { sb.WriteString(fmt.Sprintf(" %s(client: WshClient, %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, methodSigDataParams, genRespType)) } sb.WriteString(fmt.Sprintf(" if (this.mockClient) return this.mockClient.mockWshRpcStream(client, %q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(fmt.Sprintf(" return client.wshRpcStream(%q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(" }\n") return sb.String() } func generateWshClientApiMethod_Call(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) string { var sb strings.Builder sb.WriteString(fmt.Sprintf(" // command %q [%s]\n", methodDecl.Command, methodDecl.CommandType)) rtnType := "Promise<void>" if methodDecl.DefaultResponseDataType != nil { rtnTypeName, _ := TypeToTSType(methodDecl.DefaultResponseDataType, tsTypesMap) rtnType = fmt.Sprintf("Promise<%s>", rtnTypeName) } methodSigDataParams, dataName := getTsWshMethodDataParamsAndExpr(methodDecl, tsTypesMap) if methodSigDataParams == "" { sb.WriteString(fmt.Sprintf(" %s(client: WshClient, opts?: RpcOpts): %s {\n", methodDecl.MethodName, rtnType)) } else { sb.WriteString(fmt.Sprintf(" %s(client: WshClient, %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, methodSigDataParams, rtnType)) } sb.WriteString(fmt.Sprintf(" if (this.mockClient) return this.mockClient.mockWshRpcCall(client, %q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(fmt.Sprintf(" return client.wshRpcCall(%q, %s, opts);\n", methodDecl.Command, dataName)) sb.WriteString(" }\n") return sb.String() } func getTsWshMethodDataParamsAndExpr(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) (string, string) { dataTypes := methodDecl.GetCommandDataTypes() if len(dataTypes) == 0 { return "", "null" } if len(dataTypes) == 1 { cmdDataTsName, _ := TypeToTSType(dataTypes[0], tsTypesMap) return fmt.Sprintf("data: %s", cmdDataTsName), "data" } var methodParamBuilder strings.Builder var argBuilder strings.Builder for idx, dataType := range dataTypes { if idx > 0 { methodParamBuilder.WriteString(", ") argBuilder.WriteString(", ") } argName := fmt.Sprintf("arg%d", idx+1) cmdDataTsName, _ := TypeToTSType(dataType, tsTypesMap) methodParamBuilder.WriteString(fmt.Sprintf("%s: %s", argName, cmdDataTsName)) argBuilder.WriteString(argName) } return methodParamBuilder.String(), fmt.Sprintf("{ args: [%s] }", argBuilder.String()) } func GenerateWaveObjTypes(tsTypesMap map[reflect.Type]string) { for _, typeUnion := range TypeUnions { GenerateTSTypeUnion(typeUnion, tsTypesMap) } for _, extraType := range ExtraTypes { GenerateTSType(reflect.TypeOf(extraType), tsTypesMap) } for _, rtype := range waveobj.AllWaveObjTypes() { if rtype.String() == "*waveobj.MainServer" { continue } GenerateTSType(rtype, tsTypesMap) } } func GenerateServiceTypes(tsTypesMap map[reflect.Type]string) error { for _, serviceObj := range service.ServiceMap { serviceType := reflect.TypeOf(serviceObj) for midx := 0; midx < serviceType.NumMethod(); midx++ { method := serviceType.Method(midx) err := generateTSMethodTypes(method, tsTypesMap, true) if err != nil { return fmt.Errorf("error generating TS method types for %s.%s: %v", serviceType, method.Name, err) } } } return nil } func GenerateWshServerTypes(tsTypesMap map[reflect.Type]string) error { GenerateTSType(reflect.TypeOf(wshrpc.RpcOpts{}), tsTypesMap) rtype := wshRpcInterfaceRType for midx := 0; midx < rtype.NumMethod(); midx++ { method := rtype.Method(midx) err := generateTSMethodTypes(method, tsTypesMap, false) if err != nil { return fmt.Errorf("error generating TS method types for %s.%s: %v", rtype, method.Name, err) } } return nil } ================================================ FILE: pkg/tsgen/tsgen_wshclientapi_test.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package tsgen import ( "reflect" "strings" "testing" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) func TestGenerateWshClientApiMethodCall_MultiArg(t *testing.T) { methodDecl := &wshrpc.WshRpcMethodDecl{ Command: "test", CommandType: wshrpc.RpcType_Call, MethodName: "TestCommand", CommandDataTypes: []reflect.Type{reflect.TypeOf(""), reflect.TypeOf(0)}, } out := GenerateWshClientApiMethod(methodDecl, map[reflect.Type]string{}) if !strings.Contains(out, "TestCommand(client: WshClient, arg1: string, arg2: number, opts?: RpcOpts): Promise<void> {") { t.Fatalf("generated method missing multi-arg signature:\n%s", out) } if !strings.Contains(out, "return client.wshRpcCall(\"test\", { args: [arg1, arg2] }, opts);") { t.Fatalf("generated method missing MultiArg payload:\n%s", out) } } ================================================ FILE: pkg/tsgen/tsgenevent.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package tsgen import ( "bytes" "fmt" "reflect" "strconv" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) var waveEventRType = reflect.TypeOf(wps.WaveEvent{}) var WaveEventDataTypes = map[string]reflect.Type{ wps.Event_BlockClose: reflect.TypeOf(""), wps.Event_ConnChange: reflect.TypeOf(wshrpc.ConnStatus{}), wps.Event_SysInfo: reflect.TypeOf(wshrpc.TimeSeriesData{}), wps.Event_ControllerStatus: reflect.TypeOf((*blockcontroller.BlockControllerRuntimeStatus)(nil)), wps.Event_BuilderStatus: reflect.TypeOf(wshrpc.BuilderStatusData{}), wps.Event_BuilderOutput: reflect.TypeOf(map[string]any{}), wps.Event_WaveObjUpdate: reflect.TypeOf(waveobj.WaveObjUpdate{}), wps.Event_BlockFile: reflect.TypeOf((*wps.WSFileEventData)(nil)), wps.Event_Config: reflect.TypeOf(wconfig.WatcherUpdate{}), wps.Event_UserInput: reflect.TypeOf((*userinput.UserInputRequest)(nil)), wps.Event_RouteDown: nil, wps.Event_RouteUp: nil, wps.Event_WorkspaceUpdate: nil, wps.Event_WaveAIRateLimit: reflect.TypeOf((*uctypes.RateLimitInfo)(nil)), wps.Event_WaveAppAppGoUpdated: nil, wps.Event_TsunamiUpdateMeta: reflect.TypeOf(wshrpc.AppMeta{}), wps.Event_AIModeConfig: reflect.TypeOf(wconfig.AIModeConfigUpdate{}), wps.Event_BlockJobStatus: reflect.TypeOf(wshrpc.BlockJobStatusData{}), wps.Event_Badge: reflect.TypeOf(baseds.BadgeEvent{}), } func getWaveEventDataTSType(eventName string, tsTypesMap map[reflect.Type]string) string { rtype, found := WaveEventDataTypes[eventName] if !found { return "any" } if rtype == nil { return "null" } tsType, _ := TypeToTSType(rtype, tsTypesMap) if tsType == "" { return "any" } return tsType } func GenerateWaveEventTypes(tsTypesMap map[reflect.Type]string) string { for _, rtype := range WaveEventDataTypes { GenerateTSType(rtype, tsTypesMap) } // suppress default struct generation, this type is custom generated tsTypesMap[waveEventRType] = "" var buf bytes.Buffer buf.WriteString("// wps.WaveEvent\n") buf.WriteString("type WaveEventName =\n") for _, eventName := range wps.AllEvents { buf.WriteString(fmt.Sprintf(" | %s\n", strconv.Quote(eventName))) } buf.WriteString(";\n\n") buf.WriteString("type WaveEvent = {\n") buf.WriteString(" event: WaveEventName;\n") buf.WriteString(" scopes?: string[];\n") buf.WriteString(" sender?: string;\n") buf.WriteString(" persist?: number;\n") buf.WriteString(" data?: unknown;\n") buf.WriteString("} & (\n") for idx, eventName := range wps.AllEvents { if idx > 0 { buf.WriteString(" | \n") } buf.WriteString(fmt.Sprintf(" { event: %s; data?: %s; }", strconv.Quote(eventName), getWaveEventDataTSType(eventName, tsTypesMap))) } buf.WriteString("\n);\n") return buf.String() } ================================================ FILE: pkg/tsgen/tsgenevent_test.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package tsgen import ( "reflect" "strings" "testing" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) func TestGenerateWaveEventTypes(t *testing.T) { tsTypesMap := make(map[reflect.Type]string) waveEventTypeDecl := GenerateWaveEventTypes(tsTypesMap) if !strings.Contains(waveEventTypeDecl, `type WaveEventName = "blockclose"`) { t.Fatalf("expected WaveEventName declaration, got:\n%s", waveEventTypeDecl) } if !strings.Contains(waveEventTypeDecl, `{ event: "block:jobstatus"; data?: BlockJobStatusData; }`) { t.Fatalf("expected typed block:jobstatus event, got:\n%s", waveEventTypeDecl) } if !strings.Contains(waveEventTypeDecl, `{ event: "route:up"; data?: null; }`) { t.Fatalf("expected null for known no-data event, got:\n%s", waveEventTypeDecl) } if got := getWaveEventDataTSType("unmapped:event", tsTypesMap); got != "any" { t.Fatalf("expected any for unmapped event fallback, got: %q", got) } if _, found := tsTypesMap[reflect.TypeOf(wps.WaveEvent{})]; !found { t.Fatalf("expected WaveEvent type to be seeded in tsTypesMap") } if _, found := tsTypesMap[reflect.TypeOf(wshrpc.BlockJobStatusData{})]; !found { t.Fatalf("expected mapped data types to be generated into tsTypesMap") } } ================================================ FILE: pkg/tsgen/tsgenmeta/tsgenmeta.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package tsgenmeta import "reflect" type MethodMeta struct { Desc string ArgNames []string ReturnDesc string } type TypeUnionMeta struct { BaseType reflect.Type Desc string TypeFieldName string Types []reflect.Type } ================================================ FILE: pkg/tsunamiutil/tsunamiutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package tsunamiutil import ( "fmt" "path/filepath" "strings" "github.com/wavetermdev/waveterm/pkg/wavebase" ) const DevModeCorsOrigins = "http://localhost:5173,http://localhost:5174" func GetTsunamiAppCachePath(scope string, appName string, osArch string) (string, error) { cachesDir := wavebase.GetWaveCachesDir() tsunamiCacheDir := filepath.Join(cachesDir, "tsunami-build-cache") fullAppName := appName + "." + osArch if strings.HasPrefix(osArch, "windows") { fullAppName = fullAppName + ".exe" } fullPath := filepath.Join(tsunamiCacheDir, scope, fullAppName) dirPath := filepath.Dir(fullPath) err := wavebase.TryMkdirs(dirPath, 0755, "tsunami cache directory") if err != nil { return "", fmt.Errorf("failed to create tsunami cache directory: %w", err) } return fullPath, nil } ================================================ FILE: pkg/userinput/userinput.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package userinput import ( "context" "errors" "fmt" "log" "sync" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) var MainUserInputHandler = UserInputHandler{Channels: make(map[string](chan *UserInputResponse), 1)} var defaultProvider UserInputProvider = &FrontendProvider{} type UserInputProvider interface { GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) } type UserInputRequest struct { RequestId string `json:"requestid"` QueryText string `json:"querytext"` ResponseType string `json:"responsetype"` Title string `json:"title"` Markdown bool `json:"markdown"` TimeoutMs int `json:"timeoutms"` CheckBoxMsg string `json:"checkboxmsg"` PublicText bool `json:"publictext"` OkLabel string `json:"oklabel,omitempty"` CancelLabel string `json:"cancellabel,omitempty"` } type UserInputResponse struct { Type string `json:"type"` RequestId string `json:"requestid"` Text string `json:"text,omitempty"` Confirm bool `json:"confirm,omitempty"` ErrorMsg string `json:"errormsg,omitempty"` CheckboxStat bool `json:"checkboxstat,omitempty"` } type UserInputHandler struct { Lock sync.Mutex Channels map[string](chan *UserInputResponse) } type FrontendProvider struct{} func (ui *UserInputHandler) registerChannel() (string, chan *UserInputResponse) { ui.Lock.Lock() defer ui.Lock.Unlock() id := uuid.New().String() uich := make(chan *UserInputResponse, 1) ui.Channels[id] = uich return id, uich } func (ui *UserInputHandler) unregisterChannel(id string) { ui.Lock.Lock() defer ui.Lock.Unlock() delete(ui.Channels, id) } func (ui *UserInputHandler) sendRequestToFrontend(request *UserInputRequest, scopes []string) { wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_UserInput, Data: request, Scopes: scopes, }) } func determineScopes(ctx context.Context) ([]string, error) { connData := genconn.GetConnData(ctx) if connData == nil { return nil, fmt.Errorf("context did not contain connection info") } // resolve windowId from blockId tabId, err := wstore.DBFindTabForBlockId(ctx, connData.BlockId) if err != nil { return nil, fmt.Errorf("unabled to determine tab for route: %w", err) } workspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId) if err != nil { return nil, fmt.Errorf("unabled to determine workspace for route: %w", err) } windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId) if err != nil { return nil, fmt.Errorf("unabled to determine window for route: %w", err) } return []string{windowId}, nil } func (p *FrontendProvider) GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) { id, uiCh := MainUserInputHandler.registerChannel() defer MainUserInputHandler.unregisterChannel(id) request.RequestId = id request.TimeoutMs = int(utilfn.TimeoutFromContext(ctx, 30*time.Second).Milliseconds()) scopes, scopesErr := determineScopes(ctx) if scopesErr != nil { log.Printf("user input scopes could not be found: %v", scopesErr) blocklogger.Infof(ctx, "user input scopes could not be found: %v", scopesErr) allWindows, err := wstore.DBGetAllOIDsByType(ctx, "window") if err != nil { blocklogger.Infof(ctx, "unable to find windows for user input: %v", err) return nil, fmt.Errorf("unable to find windows for user input: %v", err) } scopes = allWindows } MainUserInputHandler.sendRequestToFrontend(request, scopes) var response *UserInputResponse var err error select { case resp := <-uiCh: log.Printf("checking received: %v", resp.RequestId) response = resp case <-ctx.Done(): return nil, fmt.Errorf("timed out waiting for user input") } if response.ErrorMsg != "" { err = errors.New(response.ErrorMsg) } return response, err } func GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) { return defaultProvider.GetUserInput(ctx, request) } func SetUserInputProvider(provider UserInputProvider) { defaultProvider = provider } ================================================ FILE: pkg/util/daystr/daystr.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package daystr import ( "fmt" "regexp" "strconv" "time" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) var customDayStrRe = regexp.MustCompile(`^((?:\d{4}-\d{2}-\d{2})|today|yesterday|bom|bow)?((?:[+-]\d+[dwm])*)$`) var daystrRe = regexp.MustCompile(`^(\d{4})-(\d{2})-(\d{2})$`) func GetCurDayStr() string { now := time.Now() dayStr := now.Format("2006-01-02") return dayStr } func GetRelDayStr(relDays int) string { now := time.Now() dayStr := now.AddDate(0, 0, relDays).Format("2006-01-02") return dayStr } // accepts a custom format string to return a daystr // can be either a prefix, a delta, or a prefix w/ a delta // if no prefix is given, "today" is assumed // examples: today-2d, bow, bom+1m-1d (that's end of the month), 2025-04-01+1w // // prefixes: // // yyyy-mm-dd // today // yesterday // bom (beginning of month) // bow (beginning of week -- sunday) // // deltas: // // +[n]d, -[n]d (e.g. +1d, -5d) // +[n]w, -[n]w (e.g. +2w) // +[n]m, -[n]m (e.g. -1m) // deltas can be combined e.g. +1w-2d func GetCustomDayStr(format string) (string, error) { m := customDayStrRe.FindStringSubmatch(format) if m == nil { return "", fmt.Errorf("invalid daystr format") } prefix, deltas := m[1], m[2] if prefix == "" { prefix = "today" } var rtnTime time.Time now := time.Now() switch prefix { case "today": rtnTime = now case "yesterday": rtnTime = now.AddDate(0, 0, -1) case "bom": rtnTime = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) case "bow": weekday := now.Weekday() if weekday == time.Sunday { rtnTime = now } else { rtnTime = now.AddDate(0, 0, -int(weekday)) } default: m = daystrRe.FindStringSubmatch(prefix) if m == nil { return "", fmt.Errorf("invalid prefix format") } year, month, day := m[1], m[2], m[3] yearInt, monthInt, dayInt := utilfn.AtoiNoErr(year), utilfn.AtoiNoErr(month), utilfn.AtoiNoErr(day) if yearInt == 0 || monthInt == 0 || dayInt == 0 { return "", fmt.Errorf("invalid prefix format") } rtnTime = time.Date(yearInt, time.Month(monthInt), dayInt, 0, 0, 0, 0, now.Location()) } for _, delta := range regexp.MustCompile(`[+-]\d+[dwm]`).FindAllString(deltas, -1) { deltaVal, err := strconv.Atoi(delta[1 : len(delta)-1]) if err != nil { return "", fmt.Errorf("invalid delta format") } if delta[0] == '-' { deltaVal = -deltaVal } switch delta[len(delta)-1] { case 'd': rtnTime = rtnTime.AddDate(0, 0, deltaVal) case 'w': rtnTime = rtnTime.AddDate(0, 0, deltaVal*7) case 'm': rtnTime = rtnTime.AddDate(0, deltaVal, 0) } } return rtnTime.Format("2006-01-02"), nil } ================================================ FILE: pkg/util/daystr/daystr_test.go ================================================ package daystr import ( "testing" "time" ) func TestGetCurDayStr(t *testing.T) { expected := time.Now().Format("2006-01-02") result := GetCurDayStr() if result != expected { t.Errorf("GetCurDayStr() = %v; want %v", result, expected) } } func TestGetRelDayStr(t *testing.T) { expected := time.Now().AddDate(0, 0, 5).Format("2006-01-02") result := GetRelDayStr(5) if result != expected { t.Errorf("GetRelDayStr(5) = %v; want %v", result, expected) } expected = time.Now().AddDate(0, 0, -5).Format("2006-01-02") result = GetRelDayStr(-5) if result != expected { t.Errorf("GetRelDayStr(-5) = %v; want %v", result, expected) } } func TestGetCustomDayStr(t *testing.T) { tests := []struct { format string expected string }{ {"today", time.Now().Format("2006-01-02")}, {"yesterday", time.Now().AddDate(0, 0, -1).Format("2006-01-02")}, {"bom", time.Date(time.Now().Year(), time.Now().Month(), 1, 0, 0, 0, 0, time.Now().Location()).Format("2006-01-02")}, {"bow", time.Now().AddDate(0, 0, -int(time.Now().Weekday())).Format("2006-01-02")}, {"2025-04-01", "2025-04-01"}, {"2025-04-01+1w", "2025-04-08"}, {"2025-04-01+1w-1d", "2025-04-07"}, {"2025-04-01+1m", "2025-05-01"}, {"2025-04-01+1m-1d", "2025-04-30"}, } for _, test := range tests { result, err := GetCustomDayStr(test.format) if err != nil { t.Errorf("GetCustomDayStr(%v) returned error: %v", test.format, err) } if result != test.expected { t.Errorf("GetCustomDayStr(%v) = %v; want %v", test.format, result, test.expected) } } invalidTests := []string{ "invalid", "2025-04-01+1x", "2025-04-01+1m-1x", } for _, test := range invalidTests { _, err := GetCustomDayStr(test) if err == nil { t.Errorf("GetCustomDayStr(%v) expected error, got nil", test) } } } ================================================ FILE: pkg/util/dbutil/dbmappable.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package dbutil import ( "fmt" "reflect" "strings" "github.com/sawka/txwrap" ) type DBMappable interface { UseDBMap() } type MapEntry[T any] struct { Key string Val T } type MapConverter interface { ToMap() map[string]interface{} FromMap(map[string]interface{}) bool } type HasSimpleKey interface { GetSimpleKey() string } type HasSimpleInt64Key interface { GetSimpleKey() int64 } type MapConverterPtr[T any] interface { MapConverter *T } type DBMappablePtr[T any] interface { DBMappable *T } func FromMap[PT MapConverterPtr[T], T any](m map[string]any) PT { if len(m) == 0 { return nil } rtn := PT(new(T)) ok := rtn.FromMap(m) if !ok { return nil } return rtn } func GetMapGen[PT MapConverterPtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) PT { m := tx.GetMap(query, args...) return FromMap[PT](m) } func GetMappable[PT DBMappablePtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) PT { m := tx.GetMap(query, args...) if len(m) == 0 { return nil } rtn := PT(new(T)) FromDBMap(rtn, m) return rtn } func SelectMappable[PT DBMappablePtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) []PT { var rtn []PT marr := tx.SelectMaps(query, args...) for _, m := range marr { if len(m) == 0 { continue } val := PT(new(T)) FromDBMap(val, m) rtn = append(rtn, val) } return rtn } func SelectMapsGen[PT MapConverterPtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) []PT { var rtn []PT marr := tx.SelectMaps(query, args...) for _, m := range marr { val := FromMap[PT](m) if val != nil { rtn = append(rtn, val) } } return rtn } func SelectSimpleMap[T any](tx *txwrap.TxWrap, query string, args ...interface{}) map[string]T { var rtn []MapEntry[T] tx.Select(&rtn, query, args...) if len(rtn) == 0 { return nil } rtnMap := make(map[string]T) for _, entry := range rtn { rtnMap[entry.Key] = entry.Val } return rtnMap } func MakeGenMap[T HasSimpleKey](arr []T) map[string]T { rtn := make(map[string]T) for _, val := range arr { rtn[val.GetSimpleKey()] = val } return rtn } func MakeGenMapInt64[T HasSimpleInt64Key](arr []T) map[int64]T { rtn := make(map[int64]T) for _, val := range arr { rtn[val.GetSimpleKey()] = val } return rtn } func isStructType(rt reflect.Type) bool { if rt.Kind() == reflect.Struct { return true } if rt.Kind() == reflect.Pointer && rt.Elem().Kind() == reflect.Struct { return true } return false } func isByteArrayType(t reflect.Type) bool { return t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 } func isStringMapType(t reflect.Type) bool { return t.Kind() == reflect.Map && t.Key().Kind() == reflect.String } func ToDBMap(v DBMappable, useBytes bool) map[string]interface{} { if CheckNil(v) { return nil } rv := reflect.ValueOf(v) if rv.Kind() == reflect.Pointer { rv = rv.Elem() } if rv.Kind() != reflect.Struct { panic(fmt.Sprintf("invalid type %T (non-struct) passed to StructToDBMap", v)) } rt := rv.Type() m := make(map[string]interface{}) numFields := rt.NumField() for i := 0; i < numFields; i++ { field := rt.Field(i) fieldVal := rv.FieldByIndex(field.Index) dbName := field.Tag.Get("dbmap") if dbName == "" { dbName = strings.ToLower(field.Name) } if dbName == "-" { continue } if isByteArrayType(field.Type) { m[dbName] = fieldVal.Interface() } else if field.Type.Kind() == reflect.Slice { if useBytes { m[dbName] = QuickJsonArrBytes(fieldVal.Interface()) } else { m[dbName] = QuickJsonArr(fieldVal.Interface()) } } else if isStructType(field.Type) || isStringMapType(field.Type) { if useBytes { m[dbName] = QuickJsonBytes(fieldVal.Interface()) } else { m[dbName] = QuickJson(fieldVal.Interface()) } } else { m[dbName] = fieldVal.Interface() } } return m } func FromDBMap(v DBMappable, m map[string]interface{}) { if CheckNil(v) { panic("StructFromDBMap, v cannot be nil") } rv := reflect.ValueOf(v) if rv.Kind() == reflect.Pointer { rv = rv.Elem() } if rv.Kind() != reflect.Struct { panic(fmt.Sprintf("invalid type %T (non-struct) passed to StructFromDBMap", v)) } rt := rv.Type() numFields := rt.NumField() for i := 0; i < numFields; i++ { field := rt.Field(i) fieldVal := rv.FieldByIndex(field.Index) dbName := field.Tag.Get("dbmap") if dbName == "" { dbName = strings.ToLower(field.Name) } if dbName == "-" { continue } if isByteArrayType(field.Type) { barrVal := fieldVal.Addr().Interface() QuickSetBytes(barrVal.(*[]byte), m, dbName) } else if field.Type.Kind() == reflect.Slice { QuickSetJsonArr(fieldVal.Addr().Interface(), m, dbName) } else if isStructType(field.Type) || isStringMapType(field.Type) { QuickSetJson(fieldVal.Addr().Interface(), m, dbName) } else if field.Type.Kind() == reflect.String { strVal := fieldVal.Addr().Interface() QuickSetStr(strVal.(*string), m, dbName) } else if field.Type.Kind() == reflect.Int64 { intVal := fieldVal.Addr().Interface() QuickSetInt64(intVal.(*int64), m, dbName) } else if field.Type.Kind() == reflect.Int { intVal := fieldVal.Addr().Interface() QuickSetInt(intVal.(*int), m, dbName) } else if field.Type.Kind() == reflect.Bool { boolVal := fieldVal.Addr().Interface() QuickSetBool(boolVal.(*bool), m, dbName) } else { panic(fmt.Sprintf("StructFromDBMap invalid field type %v in %T", fieldVal.Type(), v)) } } } ================================================ FILE: pkg/util/dbutil/dbutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package dbutil import ( "database/sql/driver" "encoding/json" "fmt" "reflect" "strconv" ) func QuickSetStr(strVal *string, m map[string]any, name string) { v, ok := m[name] if !ok { return } ival, ok := v.(int64) if ok { *strVal = strconv.FormatInt(ival, 10) return } str, ok := v.(string) if !ok { return } *strVal = str } func QuickSetInt(ival *int, m map[string]any, name string) { v, ok := m[name] if !ok { return } sqlInt, ok := v.(int) if ok { *ival = sqlInt return } sqlInt64, ok := v.(int64) if ok { *ival = int(sqlInt64) return } } func QuickSetNullableInt64(ival **int64, m map[string]any, name string) { v, ok := m[name] if !ok { // set to nil return } sqlInt64, ok := v.(int64) if ok { *ival = &sqlInt64 return } sqlInt, ok := v.(int) if ok { sqlInt64 = int64(sqlInt) *ival = &sqlInt64 return } } func QuickSetInt64(ival *int64, m map[string]any, name string) { v, ok := m[name] if !ok { // leave as zero return } sqlInt64, ok := v.(int64) if ok { *ival = sqlInt64 return } sqlInt, ok := v.(int) if ok { *ival = int64(sqlInt) return } } func QuickSetBool(bval *bool, m map[string]any, name string) { v, ok := m[name] if !ok { return } sqlInt, ok := v.(int64) if ok { if sqlInt > 0 { *bval = true } return } sqlBool, ok := v.(bool) if ok { *bval = sqlBool } } func QuickSetBytes(bval *[]byte, m map[string]any, name string) { v, ok := m[name] if !ok { return } sqlBytes, ok := v.([]byte) if ok { *bval = sqlBytes } } func getByteArr(m map[string]any, name string, def string) ([]byte, bool) { v, ok := m[name] if !ok { return nil, false } barr, ok := v.([]byte) if !ok { str, ok := v.(string) if !ok { return nil, false } barr = []byte(str) } if len(barr) == 0 { barr = []byte(def) } return barr, true } func QuickSetJson(ptr any, m map[string]any, name string) { barr, ok := getByteArr(m, name, "{}") if !ok { return } json.Unmarshal(barr, ptr) } func QuickSetNullableJson(ptr any, m map[string]any, name string) { barr, ok := getByteArr(m, name, "null") if !ok { return } json.Unmarshal(barr, ptr) } func QuickSetJsonArr(ptr any, m map[string]any, name string) { barr, ok := getByteArr(m, name, "[]") if !ok { return } json.Unmarshal(barr, ptr) } func CheckNil(v any) bool { rv := reflect.ValueOf(v) if !rv.IsValid() { return true } switch rv.Kind() { case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: return rv.IsNil() default: return false } } func QuickNullableJson(v any) string { if CheckNil(v) { return "null" } barr, _ := json.Marshal(v) return string(barr) } func QuickJson(v any) string { if CheckNil(v) { return "{}" } barr, _ := json.Marshal(v) return string(barr) } func QuickJsonBytes(v any) []byte { if CheckNil(v) { return []byte("{}") } barr, _ := json.Marshal(v) return barr } func QuickJsonArr(v any) string { if CheckNil(v) { return "[]" } barr, _ := json.Marshal(v) return string(barr) } func QuickJsonArrBytes(v any) []byte { if CheckNil(v) { return []byte("[]") } barr, _ := json.Marshal(v) return barr } func QuickScanJson(ptr any, val any) error { barrVal, ok := val.([]byte) if !ok { strVal, ok := val.(string) if !ok { return fmt.Errorf("cannot scan '%T' into '%T'", val, ptr) } barrVal = []byte(strVal) } if len(barrVal) == 0 { barrVal = []byte("{}") } return json.Unmarshal(barrVal, ptr) } func QuickValueJson(v any) (driver.Value, error) { if CheckNil(v) { return "{}", nil } barr, err := json.Marshal(v) if err != nil { return nil, err } return string(barr), nil } // on error will return nil unless forceMake is set, in which case it returns make(map[string]any) func ParseJsonMap(val string, forceMake bool) map[string]any { var noRtn map[string]any if forceMake { noRtn = make(map[string]any) } if val == "" { return noRtn } var m map[string]any err := json.Unmarshal([]byte(val), &m) if err != nil { return noRtn } return m } func ParseJsonArr[T any](val string) []T { if val == "" { return nil } var arr []T err := json.Unmarshal([]byte(val), &arr) if err != nil { return nil } return arr } ================================================ FILE: pkg/util/ds/expmap.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package ds import ( "sync" "time" "github.com/emirpasic/gods/trees/binaryheap" ) // an ExpMap has "expiring" keys, which are automatically deleted after a certain time type ExpMap[T any] struct { lock *sync.Mutex expHeap *binaryheap.Heap // heap of expEntries (sorted by time) m map[string]expMapEntry[T] } type expMapEntry[T any] struct { Val T Exp time.Time } type expEntry struct { Key string Exp time.Time } func heapComparator(aArg, bArg any) int { a := aArg.(expEntry) b := bArg.(expEntry) if a.Exp.Before(b.Exp) { return -1 } else if a.Exp.After(b.Exp) { return 1 } return 0 } func MakeExpMap[T any]() *ExpMap[T] { return &ExpMap[T]{ lock: &sync.Mutex{}, expHeap: binaryheap.NewWith(heapComparator), m: make(map[string]expMapEntry[T]), } } func (em *ExpMap[T]) Set(key string, value T, exp time.Time) { em.lock.Lock() defer em.lock.Unlock() oldEntry, ok := em.m[key] em.m[key] = expMapEntry[T]{Val: value, Exp: exp} if !ok || oldEntry.Exp != exp { em.expHeap.Push(expEntry{Key: key, Exp: exp}) // this might create duplicates. that's ok. } } func (em *ExpMap[T]) expireItems_nolock() { // should already hold the lock now := time.Now() for { if em.expHeap.Empty() { break } // we know it isn't empty, so we ignore "ok" topI, _ := em.expHeap.Peek() top := topI.(expEntry) if top.Exp.After(now) { break } em.expHeap.Pop() entry, ok := em.m[top.Key] if ok && (entry.Exp.Before(now) || entry.Exp.Equal(now)) { delete(em.m, top.Key) } } } func (em *ExpMap[T]) Get(key string) (T, bool) { em.lock.Lock() defer em.lock.Unlock() em.expireItems_nolock() v, ok := em.m[key] return v.Val, ok } ================================================ FILE: pkg/util/ds/syncmap.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package ds import "sync" type SyncMap[T any] struct { lock *sync.Mutex m map[string]T } func MakeSyncMap[T any]() *SyncMap[T] { return &SyncMap[T]{ lock: &sync.Mutex{}, m: make(map[string]T), } } func (sm *SyncMap[T]) Set(key string, value T) { sm.lock.Lock() defer sm.lock.Unlock() sm.m[key] = value } func (sm *SyncMap[T]) Get(key string) T { sm.lock.Lock() defer sm.lock.Unlock() return sm.m[key] } func (sm *SyncMap[T]) GetEx(key string) (T, bool) { sm.lock.Lock() defer sm.lock.Unlock() v, ok := sm.m[key] return v, ok } func (sm *SyncMap[T]) Delete(key string) { sm.lock.Lock() defer sm.lock.Unlock() delete(sm.m, key) } func (sm *SyncMap[T]) SetUnless(key string, value T) bool { sm.lock.Lock() defer sm.lock.Unlock() if _, exists := sm.m[key]; exists { return false } sm.m[key] = value return true } func (sm *SyncMap[T]) TestAndSet(key string, newValue T, testFn func(T, bool) bool) bool { sm.lock.Lock() defer sm.lock.Unlock() currentValue, exists := sm.m[key] if testFn(currentValue, exists) { sm.m[key] = newValue return true } return false } func (sm *SyncMap[T]) GetOrCreate(key string, createFn func() T) T { sm.lock.Lock() defer sm.lock.Unlock() if v, ok := sm.m[key]; ok { return v } v := createFn() sm.m[key] = v return v } ================================================ FILE: pkg/util/ds/syncmap_test.go ================================================ package ds import ( "testing" ) func TestSyncMap_Set(t *testing.T) { sm := MakeSyncMap[int]() sm.Set("key1", 1) if sm.Get("key1") != 1 { t.Errorf("expected 1, got %d", sm.Get("key1")) } } func TestSyncMap_Get(t *testing.T) { sm := MakeSyncMap[int]() sm.Set("key1", 1) if sm.Get("key1") != 1 { t.Errorf("expected 1, got %d", sm.Get("key1")) } if sm.Get("key2") != 0 { t.Errorf("expected 0, got %d", sm.Get("key2")) } } func TestSyncMap_GetEx(t *testing.T) { sm := MakeSyncMap[int]() sm.Set("key1", 1) value, ok := sm.GetEx("key1") if !ok || value != 1 { t.Errorf("expected 1, got %d", value) } value, ok = sm.GetEx("key2") if ok || value != 0 { t.Errorf("expected 0, got %d", value) } } func TestSyncMap_Delete(t *testing.T) { sm := MakeSyncMap[int]() sm.Set("key1", 1) sm.Delete("key1") if sm.Get("key1") != 0 { t.Errorf("expected 0, got %d", sm.Get("key1")) } } ================================================ FILE: pkg/util/envutil/envutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package envutil import ( "fmt" "strings" ) const MaxEnvSize = 1024 * 1024 // env format: // KEY=VALUE\0 // keys cannot have '=' or '\0' in them // values can have '=' but not '\0' func EnvToMap(envStr string) map[string]string { rtn := make(map[string]string) envLines := strings.Split(envStr, "\x00") for _, line := range envLines { if len(line) == 0 { continue } parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { rtn[parts[0]] = parts[1] } } return rtn } func MapToEnv(envMap map[string]string) string { var sb strings.Builder for key, val := range envMap { sb.WriteString(key) sb.WriteByte('=') sb.WriteString(val) sb.WriteByte('\x00') } return sb.String() } func GetEnv(envStr string, key string) string { envMap := EnvToMap(envStr) return envMap[key] } func SetEnv(envStr string, key string, val string) (string, error) { if strings.ContainsAny(key, "=\x00") { return "", fmt.Errorf("key cannot contain '=' or '\\x00'") } if strings.Contains(val, "\x00") { return "", fmt.Errorf("value cannot contain '\\x00'") } if len(key)+len(val)+2+len(envStr) > MaxEnvSize { return "", fmt.Errorf("env string too large (max %d bytes)", MaxEnvSize) } envMap := EnvToMap(envStr) envMap[key] = val rtnStr := MapToEnv(envMap) return rtnStr, nil } func RmEnv(envStr string, key string) string { envMap := EnvToMap(envStr) delete(envMap, key) return MapToEnv(envMap) } func SliceToEnv(env []string) string { var sb strings.Builder for _, envVar := range env { if len(envVar) == 0 { continue } sb.WriteString(envVar) sb.WriteByte('\x00') } return sb.String() } func EnvToSlice(envStr string) []string { envLines := strings.Split(envStr, "\x00") result := make([]string, 0, len(envLines)) for _, line := range envLines { if len(line) == 0 { continue } result = append(result, line) } return result } func SliceToMap(env []string) map[string]string { envMap := make(map[string]string) for _, envVar := range env { parts := strings.SplitN(envVar, "=", 2) if len(parts) == 2 { envMap[parts[0]] = parts[1] } } return envMap } func CopyAndAddToEnvMap(envMap map[string]string, key string, val string) map[string]string { newMap := make(map[string]string, len(envMap)+1) for k, v := range envMap { newMap[k] = v } newMap[key] = val return newMap } func PruneInitialEnv(envMap map[string]string) map[string]string { pruned := make(map[string]string) for key, value := range envMap { if strings.HasPrefix(key, "WAVETERM_") || strings.HasPrefix(key, "BASH_FUNC_") { continue } if key == "XDG_SESSION_ID" || key == "SHLVL" || key == "S_COLORS" || key == "SSH_CONNECTION" || key == "SSH_CLIENT" || key == "LESSOPEN" || key == "which_declare" { continue } pruned[key] = value } return pruned } ================================================ FILE: pkg/util/fileutil/fileutil.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package fileutil import ( "bytes" "errors" "fmt" "io" "io/fs" "mime" "net/http" "os" "path/filepath" "regexp" "strings" "github.com/wavetermdev/waveterm/pkg/wavebase" ) type ByteRangeType struct { All bool Start int64 End int64 // inclusive; only valid when OpenEnd is false OpenEnd bool // true when range is "N-" (read from Start to EOF) } func ParseByteRange(rangeStr string) (ByteRangeType, error) { if rangeStr == "" { return ByteRangeType{All: true}, nil } // handle open-ended range "N-" if len(rangeStr) > 0 && rangeStr[len(rangeStr)-1] == '-' { var start int64 _, err := fmt.Sscanf(rangeStr, "%d-", &start) if err != nil || start < 0 { return ByteRangeType{}, errors.New("invalid byte range") } return ByteRangeType{Start: start, OpenEnd: true}, nil } var start, end int64 _, err := fmt.Sscanf(rangeStr, "%d-%d", &start, &end) if err != nil { return ByteRangeType{}, errors.New("invalid byte range") } if start < 0 || end < 0 || start > end { return ByteRangeType{}, errors.New("invalid byte range") } // End is inclusive (HTTP byte range semantics: bytes=0-999 means 1000 bytes) return ByteRangeType{Start: start, End: end}, nil } func FixPath(path string) (string, error) { origPath := path var err error if strings.HasPrefix(path, "~") { path = filepath.Join(wavebase.GetHomeDir(), path[1:]) } else if !filepath.IsAbs(path) { path, err = filepath.Abs(path) if err != nil { return "", err } } if strings.HasSuffix(origPath, "/") && !strings.HasSuffix(path, "/") { path += "/" } return path, nil } const ( winFlagSoftlink = uint32(0x8000) // FILE_ATTRIBUTE_REPARSE_POINT winFlagJunction = uint32(0x80) // FILE_ATTRIBUTE_JUNCTION ) func WinSymlinkDir(path string, bits os.FileMode) bool { // Windows compatibility layer doesn't expose symlink target type through fileInfo // so we need to check file attributes and extension patterns isFileSymlink := func(filepath string) bool { if len(filepath) == 0 { return false } return strings.LastIndex(filepath, ".") > strings.LastIndex(filepath, "/") } flags := uint32(bits >> 12) if flags == winFlagSoftlink { return !isFileSymlink(path) } else if flags == winFlagJunction { return true } else { return false } } // on error just returns "" // does not return "application/octet-stream" as this is considered a detection failure // can pass an existing fileInfo to avoid re-statting the file // falls back to text/plain for 0 byte files func DetectMimeType(path string, fileInfo fs.FileInfo, extended bool) string { if fileInfo == nil { statRtn, err := os.Stat(path) if err != nil { return "" } fileInfo = statRtn } if fileInfo.IsDir() || WinSymlinkDir(path, fileInfo.Mode()) { return "directory" } if fileInfo.Mode()&os.ModeNamedPipe == os.ModeNamedPipe { return "pipe" } charDevice := os.ModeDevice | os.ModeCharDevice if fileInfo.Mode()&charDevice == charDevice { return "character-special" } if fileInfo.Mode()&os.ModeDevice == os.ModeDevice { return "block-special" } ext := strings.ToLower(filepath.Ext(path)) if mimeType, ok := StaticMimeTypeMap[ext]; ok { return mimeType } if mimeType := mime.TypeByExtension(ext); mimeType != "" { return mimeType } if fileInfo.Size() == 0 { return "text/plain" } if !extended { return "" } fd, err := os.Open(path) if err != nil { return "" } defer fd.Close() buf := make([]byte, 512) // ignore the error (EOF / UnexpectedEOF is fine, just process how much we got back) n, _ := io.ReadAtLeast(fd, buf, 512) if n == 0 { return "" } buf = buf[:n] rtn := http.DetectContentType(buf) if rtn == "application/octet-stream" { return "" } return rtn } func DetectMimeTypeWithDirEnt(path string, dirEnt fs.DirEntry) string { if dirEnt != nil { if dirEnt.IsDir() { return "directory" } mode := dirEnt.Type() if mode&os.ModeNamedPipe == os.ModeNamedPipe { return "pipe" } charDevice := os.ModeDevice | os.ModeCharDevice if mode&charDevice == charDevice { return "character-special" } if mode&os.ModeDevice == os.ModeDevice { return "block-special" } } ext := strings.ToLower(filepath.Ext(path)) if mimeType, ok := StaticMimeTypeMap[ext]; ok { return mimeType } return "" } func AtomicWriteFile(fileName string, data []byte, perm os.FileMode) error { tmpFileName := fileName + TempFileSuffix if err := os.WriteFile(tmpFileName, data, perm); err != nil { if removeErr := os.Remove(tmpFileName); removeErr != nil && !os.IsNotExist(removeErr) { return fmt.Errorf("failed to write temp file %q: %w (also failed to remove temp file: %v)", tmpFileName, err, removeErr) } return err } if err := os.Rename(tmpFileName, fileName); err != nil { if removeErr := os.Remove(tmpFileName); removeErr != nil && !os.IsNotExist(removeErr) { return fmt.Errorf("failed to rename temp file %q to %q: %w (also failed to remove temp file: %v)", tmpFileName, fileName, err, removeErr) } return err } return nil } var ( systemBinDirs = []string{ "/bin/", "/usr/bin/", "/usr/local/bin/", "/opt/bin/", "/sbin/", "/usr/sbin/", } suspiciousPattern = regexp.MustCompile(`[:;#!&$\t%="|>{}]`) flagPattern = regexp.MustCompile(` --?[a-zA-Z0-9]`) ) // IsInitScriptPath tries to determine if the input string is a path to a script // rather than an inline script content. func IsInitScriptPath(input string) bool { if len(input) == 0 || strings.Contains(input, "\n") { return false } if suspiciousPattern.MatchString(input) { return false } if flagPattern.MatchString(input) { return false } // Check for home directory path if strings.HasPrefix(input, "~/") { return true } // Path must be absolute (if not home directory) if !filepath.IsAbs(input) { return false } // Check if path starts with system binary directories normalizedPath := filepath.ToSlash(input) for _, binDir := range systemBinDirs { if strings.HasPrefix(normalizedPath, binDir) { return false } } return true } const ( TempFileSuffix = ".tmp" MaxEditFileSize = 5 * 1024 * 1024 // 5MB ) type EditSpec struct { OldStr string `json:"old_str"` NewStr string `json:"new_str"` Desc string `json:"desc,omitempty"` } type EditResult struct { Applied bool `json:"applied"` Desc string `json:"desc"` Error string `json:"error,omitempty"` } // applyEdit applies a single edit to the content and returns the modified content and result. func applyEdit(content []byte, edit EditSpec, index int) ([]byte, EditResult) { result := EditResult{ Desc: edit.Desc, } if result.Desc == "" { result.Desc = fmt.Sprintf("Edit %d", index+1) } if edit.OldStr == "" { result.Applied = false result.Error = "old_str cannot be empty" return content, result } oldBytes := []byte(edit.OldStr) count := bytes.Count(content, oldBytes) if count == 0 { result.Applied = false result.Error = "old_str not found in file" return content, result } if count > 1 { result.Applied = false result.Error = fmt.Sprintf("old_str appears %d times, must appear exactly once", count) return content, result } modifiedContent := bytes.Replace(content, oldBytes, []byte(edit.NewStr), 1) result.Applied = true return modifiedContent, result } // ApplyEdits applies a series of edits to the given content and returns the modified content. // This is atomic - all edits succeed or all fail. func ApplyEdits(originalContent []byte, edits []EditSpec) ([]byte, error) { modifiedContents := originalContent for i, edit := range edits { var result EditResult modifiedContents, result = applyEdit(modifiedContents, edit, i) if !result.Applied { return nil, fmt.Errorf("edit %d (%s): %s", i, result.Desc, result.Error) } } return modifiedContents, nil } // ApplyEditsPartial applies edits incrementally, continuing until the first failure. // Returns the modified content (potentially partially applied) and results for each edit. func ApplyEditsPartial(originalContent []byte, edits []EditSpec) ([]byte, []EditResult) { modifiedContents := originalContent results := make([]EditResult, len(edits)) failed := false for i, edit := range edits { if failed { results[i].Desc = edit.Desc if results[i].Desc == "" { results[i].Desc = fmt.Sprintf("Edit %d", i+1) } results[i].Applied = false results[i].Error = "previous edit failed" continue } modifiedContents, results[i] = applyEdit(modifiedContents, edit, i) if !results[i].Applied { failed = true } } return modifiedContents, results } func ReplaceInFile(filePath string, edits []EditSpec) error { fileInfo, err := os.Stat(filePath) if err != nil { return fmt.Errorf("failed to stat file: %w", err) } if !fileInfo.Mode().IsRegular() { return fmt.Errorf("not a regular file: %s", filePath) } if fileInfo.Size() > MaxEditFileSize { return fmt.Errorf("file too large for editing: %d bytes (max: %d)", fileInfo.Size(), MaxEditFileSize) } contents, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("failed to read file: %w", err) } modifiedContents, err := ApplyEdits(contents, edits) if err != nil { return err } if err := os.WriteFile(filePath, modifiedContents, fileInfo.Mode()); err != nil { return fmt.Errorf("failed to write file: %w", err) } return nil } // ReplaceInFilePartial applies edits incrementally up to the first failure. // Returns the results for each edit and writes the partially modified content. func ReplaceInFilePartial(filePath string, edits []EditSpec) ([]EditResult, error) { fileInfo, err := os.Stat(filePath) if err != nil { return nil, fmt.Errorf("failed to stat file: %w", err) } if !fileInfo.Mode().IsRegular() { return nil, fmt.Errorf("not a regular file: %s", filePath) } if fileInfo.Size() > MaxEditFileSize { return nil, fmt.Errorf("file too large for editing: %d bytes (max: %d)", fileInfo.Size(), MaxEditFileSize) } contents, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } modifiedContents, results := ApplyEditsPartial(contents, edits) if err := os.WriteFile(filePath, modifiedContents, fileInfo.Mode()); err != nil { return nil, fmt.Errorf("failed to write file: %w", err) } return results, nil } ================================================ FILE: pkg/util/fileutil/fileutil_test.go ================================================ package fileutil import ( "os" "path/filepath" "testing" ) func TestAtomicWriteFile(t *testing.T) { tmpDir := t.TempDir() fileName := filepath.Join(tmpDir, "settings.json") err := AtomicWriteFile(fileName, []byte(`{"key":"value"}`), 0644) if err != nil { t.Fatalf("AtomicWriteFile failed: %v", err) } data, err := os.ReadFile(fileName) if err != nil { t.Fatalf("ReadFile failed: %v", err) } if string(data) != `{"key":"value"}` { t.Fatalf("unexpected file contents: %q", string(data)) } if _, err := os.Stat(fileName + TempFileSuffix); !os.IsNotExist(err) { t.Fatalf("temporary file should not exist, stat err: %v", err) } } func TestAtomicWriteFileRenameErrorCleansTempFile(t *testing.T) { tmpDir := t.TempDir() fileName := filepath.Join(tmpDir, "settings.json") if err := os.Mkdir(fileName, 0755); err != nil { t.Fatalf("Mkdir failed: %v", err) } err := AtomicWriteFile(fileName, []byte(`{"key":"value"}`), 0644) if err == nil { t.Fatalf("AtomicWriteFile expected error") } if _, statErr := os.Stat(fileName + TempFileSuffix); !os.IsNotExist(statErr) { t.Fatalf("temporary file should be removed on rename error, stat err: %v", statErr) } } ================================================ FILE: pkg/util/fileutil/mimetypes.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package fileutil var StaticMimeTypeMap = map[string]string{ ".a2l": "application/A2L", ".aml": "application/AML", ".ez": "application/andrew-inset", ".anx": "application/annodex", ".atf": "application/ATF", ".atfx": "application/ATFX", ".atom": "application/atom+xml", ".atomcat": "application/atomcat+xml", ".atomdeleted": "application/atomdeleted+xml", ".atomsrv": "application/atomserv+xml", ".atomsvc": "application/atomsvc+xml", ".dwd": "application/atsc-dwd+xml", ".held": "application/atsc-held+xml", ".rsat": "application/atsc-rsat+xml", ".atxml": "application/ATXML", ".apxml": "application/auth-policy+xml", ".amlx": "application/automationml-amlx+zip", ".xdd": "application/bacnet-xdd+zip", ".lin": "application/bbolin", ".xcs": "application/calendar+xml", ".cbor": "application/cbor", ".c3ex": "application/cccex", ".ccmp": "application/ccmp+xml", ".ccxml": "application/ccxml+xml", ".cdfx": "application/CDFX+XML", ".cdmia": "application/cdmi-capability", ".cdmic": "application/cdmi-container", ".cdmid": "application/cdmi-domain", ".cdmio": "application/cdmi-object", ".cdmiq": "application/cdmi-queue", ".cea": "application/CEA", ".cellml": "application/cellml+xml", ".1clr": "application/clr", ".clue": "application/clue_info+xml", ".cmsc": "application/cms", ".cpl": "application/cpl+xml", ".csrattrs": "application/csrattrs", ".cu": "application/cu-seeme", ".cwl": "application/cwl", ".cwl.json": "application/cwl+json", ".mpd": "application/dash+xml", ".mpdd": "application/dashdelta", ".davmount": "application/davmount+xml", ".dcd": "application/DCD", ".dcm": "application/dicom", ".dii": "application/DII", ".dit": "application/DIT", ".xmls": "application/dskpp+xml", ".tsp": "application/dsptype", ".dssc": "application/dssc+der", ".xdssc": "application/dssc+xml", ".dvc": "application/dvcs", ".efi": "application/efi", ".emma": "application/emma+xml", ".emotionml": "application/emotionml+xml", ".epub": "application/epub+zip", ".exi": "application/exi", ".exp": "application/express", ".finf": "application/fastinfoset", ".fdf": "application/fdf", ".fdt": "application/fdt+xml", ".pfr": "application/font-tdpfr", ".spl": "application/futuresplash", ".geojson": "application/geo+json", ".gpkg": "application/geopackage+sqlite3", ".glbin": "application/gltf-buffer", ".gml": "application/gml+xml", ".gql": "application/graphql", ".graphql": "application/graphql", ".gz": "application/gzip", ".hta": "application/hta", ".stk": "application/hyperstudio", ".ink": "application/inkml+xml", ".ipfix": "application/ipfix", ".its": "application/its+xml", ".jar": "application/java-archive", ".ser": "application/java-serialized-object", ".class": "application/java-vm", ".jrd": "application/jrd+json", ".json": "application/json", ".json-patch": "application/json-patch+json", ".jsonld": "application/ld+json", ".lgr": "application/lgr+xml", ".wlnk": "application/link-format", ".liquid": "application/liquid", ".lostxml": "application/lost+xml", ".lostsyncxml": "application/lostsync+xml", ".lpf": "application/lpf+zip", ".lxf": "application/LXF", ".m3g": "application/m3g", ".hqx": "application/mac-binhex40", ".cpt": "application/mac-compactpro", ".mads": "application/mads+xml", ".webmanifest": "application/manifest+json", ".mrc": "application/marc", ".mrcx": "application/marcxml+xml", ".ma": "application/mathematica", ".mml": "application/mathml+xml", ".mbox": "application/mbox", ".meta4": "application/metalink4+xml", ".mets": "application/mets+xml", ".mf4": "application/MF4", ".maei": "application/mmt-aei+xml", ".musd": "application/mmt-usd+xml", ".mods": "application/mods+xml", ".m21": "application/mp21", ".mdb": "application/msaccess", ".doc": "application/msword", ".mxf": "application/mxf", ".nq": "application/n-quads", ".nt": "application/n-triples", ".orq": "application/ocsp-request", ".ors": "application/ocsp-response", ".bin": "application/octet-stream", ".oda": "application/ODA", ".odx": "application/ODX", ".opf": "application/oebps-package+xml", ".ogx": "application/ogg", ".one": "application/onenote", ".oxps": "application/oxps", ".p21": "application/p21", ".relo": "application/p2p-overlay+xml", ".pdf": "application/pdf", ".pdx": "application/PDX", ".pgp": "application/pgp-encrypted", ".asc": "application/pgp-keys", ".sig": "application/pgp-signature", ".prf": "application/pics-rules", ".p10": "application/pkcs10", ".p12": "application/pkcs12", ".p7m": "application/pkcs7-mime", ".p7s": "application/pkcs7-signature", ".p8": "application/pkcs8", ".p8e": "application/pkcs8-encrypted", ".ac": "application/pkix-attr-cert", ".cer": "application/pkix-cert", ".crl": "application/pkix-crl", ".pkipath": "application/pkix-pkipath", ".pki": "application/pkixcmp", ".ps": "application/postscript", ".provx": "application/provenance+xml", ".cw": "application/prs.cww", ".hpub": "application/prs.hpub+zip", ".rnd": "application/prs.nprend", ".rdf-crypt": "application/prs.rdf-xml-crypt", ".xsf": "application/prs.xsf+xml", ".pskcxml": "application/pskc+xml", ".rdf": "application/rdf+xml", ".rif": "application/reginfo+xml", ".rnc": "application/relax-ng-compact-syntax", ".rl": "application/resource-lists+xml", ".rld": "application/resource-lists-diff+xml", ".rfcxml": "application/rfc+xml", ".rapd": "application/route-apd+xml", ".sls": "application/route-s-tsid+xml", ".rusd": "application/route-usd+xml", ".gbr": "application/rpki-ghostbusters", ".mft": "application/rpki-manifest", ".roa": "application/rpki-roa", ".rtf": "application/rtf", ".sarif": "application/sarif+json", ".sarif-external-properties": "application/sarif-external-properties+json", ".scim": "application/scim+json", ".scq": "application/scvp-cv-request", ".scs": "application/scvp-cv-response", ".spq": "application/scvp-vp-request", ".spp": "application/scvp-vp-response", ".sdp": "application/sdp", ".senmlc": "application/senml+cbor", ".senml": "application/senml+json", ".senmlx": "application/senml+xml", ".senml-etchc": "application/senml-etch+cbor", ".senml-etchj": "application/senml-etch+json", ".senmle": "application/senml-exi", ".sensmlc": "application/sensml+cbor", ".sensml": "application/sensml+json", ".sensmlx": "application/sensml+xml", ".sensmle": "application/sensml-exi", ".soc": "application/sgml-open-catalog", ".shf": "application/shf+xml", ".siv": "application/sieve", ".smil": "application/smil+xml", ".rq": "application/sparql-query", ".srx": "application/sparql-results+xml", ".spdx.json": "application/spdx+json", ".sql": "application/sql", ".gram": "application/srgs", ".grxml": "application/srgs+xml", ".sru": "application/sru+xml", ".ssml": "application/ssml+xml", ".stix": "application/stix+json", ".coswid": "application/swid+cbor", ".swidtag": "application/swid+xml", ".tau": "application/tamp-apex-update", ".auc": "application/tamp-apex-update-confirm", ".tcu": "application/tamp-community-update", ".cuc": "application/tamp-community-update-confirm", ".ter": "application/tamp-error", ".tsa": "application/tamp-sequence-adjust", ".sac": "application/tamp-sequence-adjust-confirm", ".tur": "application/tamp-update", ".tuc": "application/tamp-update-confirm", ".jsontd": "application/td+json", ".tei": "application/tei+xml", ".tfi": "application/thraud+xml", ".tsq": "application/timestamp-query", ".tsr": "application/timestamp-reply", ".tsd": "application/timestamped-data", ".tm.jsonld": "application/tm+json", ".toml": "application/toml", ".trig": "application/trig", ".ttml": "application/ttml+xml", ".gsheet": "application/urc-grpsheet+xml", ".rsheet": "application/urc-ressheet+xml", ".td": "application/urc-targetdesc+xml", ".uis": "application/urc-uisocketdesc+xml", ".1km": "application/vnd.1000minds.decision-model+xml", ".ob": "application/vnd.1ob", ".plb": "application/vnd.3gpp.pic-bw-large", ".psb": "application/vnd.3gpp.pic-bw-small", ".pvb": "application/vnd.3gpp.pic-bw-var", ".sms": "application/vnd.3gpp2.sms", ".tcap": "application/vnd.3gpp2.tcap", ".imgcal": "application/vnd.3lightssoftware.imagescal", ".pwn": "application/vnd.3M.Post-it-Notes", ".aso": "application/vnd.accpac.simply.aso", ".imp": "application/vnd.accpac.simply.imp", ".acu": "application/vnd.acucobol", ".atc": "application/vnd.acucorp", ".swf": "application/vnd.adobe.flash.movie", ".fcdt": "application/vnd.adobe.formscentral.fcdt", ".fxp": "application/vnd.adobe.fxp", ".xdp": "application/vnd.adobe.xdp+xml", ".list3820": "application/vnd.afpc.modca", ".ovl": "application/vnd.afpc.modca-overlay", ".psg": "application/vnd.afpc.modca-pagesegment", ".age": "application/vnd.age", ".ahead": "application/vnd.ahead.space", ".azf": "application/vnd.airzip.filesecure.azf", ".azs": "application/vnd.airzip.filesecure.azs", ".azw3": "application/vnd.amazon.mobi8-ebook", ".acc": "application/vnd.americandynamics.acc", ".ami": "application/vnd.amiga.ami", ".ota": "application/vnd.android.ota", ".apk": "application/vnd.android.package-archive", ".apkg": "application/vnd.anki", ".cii": "application/vnd.anser-web-certificate-issue-initiation", ".fti": "application/vnd.anser-web-funds-transfer-initiation", ".arrow": "application/vnd.apache.arrow.file", ".arrows": "application/vnd.apache.arrow.stream", ".apexlang": "application/vnd.apexlang", ".dist": "application/vnd.apple.installer+xml", ".keynote": "application/vnd.apple.keynote", ".m3u8": "application/vnd.apple.mpegurl", ".numbers": "application/vnd.apple.numbers", ".pages": "application/vnd.apple.pages", ".swi": "application/vnd.aristanetworks.swi", ".artisan": "application/vnd.artisan+json", ".iota": "application/vnd.astraea-software.iota", ".aep": "application/vnd.audiograph", ".package": "application/vnd.autopackage", ".bmml": "application/vnd.balsamiq.bmml+xml", ".bmpr": "application/vnd.balsamiq.bmpr", ".ac2": "application/vnd.banana-accounting", ".lhzd": "application/vnd.belightsoft.lhzd+zip", ".lhzl": "application/vnd.belightsoft.lhzl+zip", ".mpm": "application/vnd.blueice.multipass", ".ep": "application/vnd.bluetooth.ep.oob", ".le": "application/vnd.bluetooth.le.oob", ".bmi": "application/vnd.bmi", ".rep": "application/vnd.businessobjects", ".tlclient": "application/vnd.cendio.thinlinc.clientconf", ".cdxml": "application/vnd.chemdraw+xml", ".pgn": "application/vnd.chess-pgn", ".mmd": "application/vnd.chipnuts.karaoke-mmd", ".cdy": "application/vnd.cinderella", ".csl": "application/vnd.citationstyles.style+xml", ".cla": "application/vnd.claymore", ".rp9": "application/vnd.cloanto.rp9", ".c4g": "application/vnd.clonk.c4group", ".c11amc": "application/vnd.cluetrust.cartomobile-config", ".c11amz": "application/vnd.cluetrust.cartomobile-config-pkg", ".coffee": "application/vnd.coffeescript", ".xodt": "application/vnd.collabio.xodocuments.document", ".xott": "application/vnd.collabio.xodocuments.document-template", ".xodp": "application/vnd.collabio.xodocuments.presentation", ".xotp": "application/vnd.collabio.xodocuments.presentation-template", ".xods": "application/vnd.collabio.xodocuments.spreadsheet", ".xots": "application/vnd.collabio.xodocuments.spreadsheet-template", ".cbz": "application/vnd.comicbook+zip", ".cbr": "application/vnd.comicbook-rar", ".icf": "application/vnd.commerce-battelle", ".csp": "application/vnd.commonspace", ".cdbcmsg": "application/vnd.contact.cmsg", ".ign": "application/vnd.coreos.ignition+json", ".cmc": "application/vnd.cosmocaller", ".clkx": "application/vnd.crick.clicker", ".clkk": "application/vnd.crick.clicker.keyboard", ".clkp": "application/vnd.crick.clicker.palette", ".clkt": "application/vnd.crick.clicker.template", ".clkw": "application/vnd.crick.clicker.wordbank", ".wbs": "application/vnd.criticaltools.wbs+xml", ".ssvc": "application/vnd.crypto-shade-file", ".c9r": "application/vnd.cryptomator.encrypted", ".cryptomator": "application/vnd.cryptomator.vault", ".pml": "application/vnd.ctc-posml", ".ppd": "application/vnd.cups-ppd", ".rdz": "application/vnd.data-vision.rdz", ".dl": "application/vnd.datalog", ".dbf": "application/vnd.dbf", ".deb": "application/vnd.debian.binary-package", ".uvf": "application/vnd.dece.data", ".uvt": "application/vnd.dece.ttml+xml", ".uvx": "application/vnd.dece.unspecified", ".uvz": "application/vnd.dece.zip", ".fe_launch": "application/vnd.denovo.fcselayout-link", ".dsm": "application/vnd.desmume.movie", ".dna": "application/vnd.dna", ".docjson": "application/vnd.document+json", ".scld": "application/vnd.doremir.scorecloud-binary-document", ".dpg": "application/vnd.dpgraph", ".dfac": "application/vnd.dreamfactory", ".fla": "application/vnd.dtg.local.flash", ".ait": "application/vnd.dvb.ait", ".svc": "application/vnd.dvb.service", ".geo": "application/vnd.dynageo", ".dzr": "application/vnd.dzr", ".mag": "application/vnd.ecowin.chart", ".ELN": "application/vnd.eln+zip", ".nml": "application/vnd.enliven", ".esf": "application/vnd.epson.esf", ".msf": "application/vnd.epson.msf", ".qam": "application/vnd.epson.quickanime", ".slt": "application/vnd.epson.salt", ".ssf": "application/vnd.epson.ssf", ".qcall": "application/vnd.ericsson.quickcall", ".espass": "application/vnd.espass-espass+zip", ".es3": "application/vnd.eszigno3+xml", ".asice": "application/vnd.etsi.asic-e+zip", ".asics": "application/vnd.etsi.asic-s+zip", ".tst": "application/vnd.etsi.timestamp-token", ".carjson": "application/vnd.eu.kasparian.car+json", ".ecigprofile": "application/vnd.evolv.ecig.profile", ".ecig": "application/vnd.evolv.ecig.settings", ".ecigtheme": "application/vnd.evolv.ecig.theme", ".mpw": "application/vnd.exstream-empower+zip", ".ez2": "application/vnd.ezpix-album", ".ez3": "application/vnd.ezpix-package", ".gdz": "application/vnd.familysearch.gedcom+zip", ".dim": "application/vnd.fastcopy-disk-image", ".msd": "application/vnd.fdsn.mseed", ".seed": "application/vnd.fdsn.seed", ".flb": "application/vnd.ficlab.flb+zip", ".zfc": "application/vnd.filmit.zfc", ".gph": "application/vnd.FloGraphIt", ".ftc": "application/vnd.fluxtime.clip", ".sfd": "application/vnd.font-fontforge-sfd", ".fm": "application/vnd.framemaker", ".fsc": "application/vnd.fsc.weblaunch", ".oas": "application/vnd.fujitsu.oasys", ".oa2": "application/vnd.fujitsu.oasys2", ".oa3": "application/vnd.fujitsu.oasys3", ".fg5": "application/vnd.fujitsu.oasysgp", ".bh2": "application/vnd.fujitsu.oasysprs", ".ddd": "application/vnd.fujixerox.ddd", ".xdw": "application/vnd.fujixerox.docuworks", ".xbd": "application/vnd.fujixerox.docuworks.binder", ".xct": "application/vnd.fujixerox.docuworks.container", ".fzs": "application/vnd.fuzzysheet", ".txd": "application/vnd.genomatix.tuxedo", ".genozip": "application/vnd.genozip", ".grd": "application/vnd.gentics.grd+json", ".ebuild": "application/vnd.gentoo.ebuild", ".eclass": "application/vnd.gentoo.eclass", ".gpkg.tar": "application/vnd.gentoo.gpkg", ".xpak": "application/vnd.gentoo.xpak", ".ggb": "application/vnd.geogebra.file", ".ggs": "application/vnd.geogebra.slides", ".ggt": "application/vnd.geogebra.tool", ".gex": "application/vnd.geometry-explorer", ".gxt": "application/vnd.geonext", ".g2w": "application/vnd.geoplan", ".g3w": "application/vnd.geospace", ".kml": "application/vnd.google-earth.kml+xml", ".kmz": "application/vnd.google-earth.kmz", ".gqf": "application/vnd.grafeq", ".gac": "application/vnd.groove-account", ".ghf": "application/vnd.groove-help", ".gim": "application/vnd.groove-identity-message", ".grv": "application/vnd.groove-injector", ".gtm": "application/vnd.groove-tool-message", ".tpl": "application/vnd.groove-tool-template", ".vcg": "application/vnd.groove-vcard", ".hal": "application/vnd.hal+xml", ".zmm": "application/vnd.HandHeld-Entertainment+xml", ".hbci": "application/vnd.hbci", ".hdt": "application/vnd.hdt", ".les": "application/vnd.hhe.lesson-player", ".hpgl": "application/vnd.hp-HPGL", ".hpi": "application/vnd.hp-hpid", ".hps": "application/vnd.hp-hps", ".jlt": "application/vnd.hp-jlyt", ".pcl": "application/vnd.hp-PCL", ".hsl": "application/vnd.hsl", ".sfd-hdstx": "application/vnd.hydrostatix.sof-data", ".emm": "application/vnd.ibm.electronic-media", ".mpy": "application/vnd.ibm.MiniPay", ".irm": "application/vnd.ibm.rights-management", ".icc": "application/vnd.iccprofile", ".1905.1": "application/vnd.ieee.1905", ".igl": "application/vnd.igloader", ".imf": "application/vnd.imagemeter.folder+zip", ".imi": "application/vnd.imagemeter.image+zip", ".ivp": "application/vnd.immervision-ivp", ".ivu": "application/vnd.immervision-ivu", ".imscc": "application/vnd.ims.imsccv1p1", ".igm": "application/vnd.insors.igm", ".xpw": "application/vnd.intercon.formnet", ".i2g": "application/vnd.intergeo", ".qbo": "application/vnd.intu.qbo", ".qfx": "application/vnd.intu.qfx", ".ipns-record": "application/vnd.ipfs.ipns-record", ".car": "application/vnd.ipld.car", ".rcprofile": "application/vnd.ipunplugged.rcprofile", ".irp": "application/vnd.irepository.package+xml", ".xpr": "application/vnd.is-xpr", ".fcs": "application/vnd.isac.fcs", ".jam": "application/vnd.jam", ".rms": "application/vnd.jcp.javame.midlet-rms", ".jisp": "application/vnd.jisp", ".joda": "application/vnd.joost.joda-archive", ".ktz": "application/vnd.kahootz", ".karbon": "application/vnd.kde.karbon", ".chrt": "application/vnd.kde.kchart", ".kfo": "application/vnd.kde.kformula", ".flw": "application/vnd.kde.kivio", ".kon": "application/vnd.kde.kontour", ".kpr": "application/vnd.kde.kpresenter", ".ksp": "application/vnd.kde.kspread", ".kwd": "application/vnd.kde.kword", ".htke": "application/vnd.kenameaapp", ".kia": "application/vnd.kidspiration", ".kne": "application/vnd.Kinar", ".skp": "application/vnd.koan", ".sse": "application/vnd.kodak-descriptor", ".las": "application/vnd.las", ".lasjson": "application/vnd.las.las+json", ".lasxml": "application/vnd.las.las+xml", ".lbd": "application/vnd.llamagraphics.life-balance.desktop", ".lbe": "application/vnd.llamagraphics.life-balance.exchange+xml", ".lcs": "application/vnd.logipipe.circuit+zip", ".loom": "application/vnd.loom", ".123": "application/vnd.lotus-1-2-3", ".apr": "application/vnd.lotus-approach", ".prz": "application/vnd.lotus-freelance", ".nsf": "application/vnd.lotus-notes", ".or3": "application/vnd.lotus-organizer", ".lwp": "application/vnd.lotus-wordpro", ".portpkg": "application/vnd.macports.portpkg", ".mvt": "application/vnd.mapbox-vector-tile", ".mdc": "application/vnd.marlin.drm.mdcf", ".3tz": "application/vnd.maxar.archive.3tz+zip", ".mmdb": "application/vnd.maxmind.maxmind-db", ".mcd": "application/vnd.mcd", ".mdl": "application/vnd.mdl", ".mbsdf": "application/vnd.mdl-mbsdf", ".mc1": "application/vnd.medcalcdata", ".cdkey": "application/vnd.mediastation.cdkey", ".rxt": "application/vnd.medicalholodeck.recordxr", ".mwf": "application/vnd.MFER", ".mfm": "application/vnd.mfmp", ".flo": "application/vnd.micrografx.flo", ".igx": "application/vnd.micrografx.igx", ".mif": "application/vnd.mif", ".daf": "application/vnd.Mobius.DAF", ".dis": "application/vnd.Mobius.DIS", ".mbk": "application/vnd.Mobius.MBK", ".mqy": "application/vnd.Mobius.MQY", ".msl": "application/vnd.Mobius.MSL", ".plc": "application/vnd.Mobius.PLC", ".txf": "application/vnd.Mobius.TXF", ".modl": "application/vnd.modl", ".mpn": "application/vnd.mophun.application", ".mpc": "application/vnd.mophun.certificate", ".xul": "application/vnd.mozilla.xul+xml", ".3mf": "application/vnd.ms-3mfdocument", ".cil": "application/vnd.ms-artgalry", ".asf": "application/vnd.ms-asf", ".cab": "application/vnd.ms-cab-compressed", ".xls": "application/vnd.ms-excel", ".xlam": "application/vnd.ms-excel.addin.macroEnabled.12", ".xlsb": "application/vnd.ms-excel.sheet.binary.macroEnabled.12", ".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12", ".xltm": "application/vnd.ms-excel.template.macroEnabled.12", ".eot": "application/vnd.ms-fontobject", ".chm": "application/vnd.ms-htmlhelp", ".ims": "application/vnd.ms-ims", ".lrm": "application/vnd.ms-lrm", ".thmx": "application/vnd.ms-officetheme", ".cat": "application/vnd.ms-pki.seccat", ".ppt": "application/vnd.ms-powerpoint", ".ppam": "application/vnd.ms-powerpoint.addin.macroEnabled.12", ".pptm": "application/vnd.ms-powerpoint.presentation.macroEnabled.12", ".sldm": "application/vnd.ms-powerpoint.slide.macroEnabled.12", ".ppsm": "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", ".potm": "application/vnd.ms-powerpoint.template.macroEnabled.12", ".mpp": "application/vnd.ms-project", ".tnef": "application/vnd.ms-tnef", ".docm": "application/vnd.ms-word.document.macroEnabled.12", ".dotm": "application/vnd.ms-word.template.macroEnabled.12", ".wcm": "application/vnd.ms-works", ".wpl": "application/vnd.ms-wpl", ".xps": "application/vnd.ms-xpsdocument", ".msa": "application/vnd.msa-disk-image", ".mseq": "application/vnd.mseq", ".crtr": "application/vnd.multiad.creator", ".cif": "application/vnd.multiad.creator.cif", ".mus": "application/vnd.musician", ".msty": "application/vnd.muvee.style", ".taglet": "application/vnd.mynfc", ".nebul": "application/vnd.nebumind.line", ".entity": "application/vnd.nervana", ".nlu": "application/vnd.neurolanguage.nlu", ".nimn": "application/vnd.nimn", ".nds": "application/vnd.nintendo.nitro.rom", ".sfc": "application/vnd.nintendo.snes.rom", ".nitf": "application/vnd.nitf", ".nnd": "application/vnd.noblenet-directory", ".nns": "application/vnd.noblenet-sealer", ".nnw": "application/vnd.noblenet-web", ".ngdat": "application/vnd.nokia.n-gage.data", ".rpst": "application/vnd.nokia.radio-preset", ".rpss": "application/vnd.nokia.radio-presets", ".edm": "application/vnd.novadigm.EDM", ".edx": "application/vnd.novadigm.EDX", ".ext": "application/vnd.novadigm.EXT", ".odb": "application/vnd.oasis.opendocument.base", ".odc": "application/vnd.oasis.opendocument.chart", ".otc": "application/vnd.oasis.opendocument.chart-template", ".odf": "application/vnd.oasis.opendocument.formula", ".odg": "application/vnd.oasis.opendocument.graphics", ".otg": "application/vnd.oasis.opendocument.graphics-template", ".odi": "application/vnd.oasis.opendocument.image", ".oti": "application/vnd.oasis.opendocument.image-template", ".odp": "application/vnd.oasis.opendocument.presentation", ".otp": "application/vnd.oasis.opendocument.presentation-template", ".ods": "application/vnd.oasis.opendocument.spreadsheet", ".ots": "application/vnd.oasis.opendocument.spreadsheet-template", ".odt": "application/vnd.oasis.opendocument.text", ".odm": "application/vnd.oasis.opendocument.text-master", ".otm": "application/vnd.oasis.opendocument.text-master-template", ".ott": "application/vnd.oasis.opendocument.text-template", ".oth": "application/vnd.oasis.opendocument.text-web", ".xo": "application/vnd.olpc-sugar", ".dd2": "application/vnd.oma.dd2+xml", ".tam": "application/vnd.onepager", ".tamp": "application/vnd.onepagertamp", ".tamx": "application/vnd.onepagertamx", ".tat": "application/vnd.onepagertat", ".tatp": "application/vnd.onepagertatp", ".tatx": "application/vnd.onepagertatx", ".obgx": "application/vnd.openblox.game+xml", ".obg": "application/vnd.openblox.game-binary", ".oeb": "application/vnd.openeye.oeb", ".oxt": "application/vnd.openofficeorg.extension", ".osm": "application/vnd.openstreetmap.data+xml", ".exe": "application/vnd.microsoft.portable-executable", ".dll": "application/vnd.microsoft.portable-executable", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide", ".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow", ".potx": "application/vnd.openxmlformats-officedocument.presentationml.template", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template", ".ndc": "application/vnd.osa.netdeploy", ".mgp": "application/vnd.osgeo.mapguide.package", ".dp": "application/vnd.osgi.dp", ".esa": "application/vnd.osgi.subsystem", ".oxlicg": "application/vnd.oxli.countgraph", ".pdb": "application/vnd.palm", ".plp": "application/vnd.panoply", ".dive": "application/vnd.patentdive", ".paw": "application/vnd.pawaafile", ".str": "application/vnd.pg.format", ".ei6": "application/vnd.pg.osasli", ".pil": "application/vnd.piaccess.application-licence", ".efif": "application/vnd.picsel", ".wg": "application/vnd.pmi.widget", ".plf": "application/vnd.pocketlearn", ".pbd": "application/vnd.powerbuilder6", ".preminet": "application/vnd.preminet", ".box": "application/vnd.previewsystems.box", ".mgz": "application/vnd.proteus.magazine", ".psfs": "application/vnd.psfs", ".qps": "application/vnd.publishare-delta-tree", ".ptid": "application/vnd.pvi.ptid1", ".bar": "application/vnd.qualcomm.brew-app-res", ".qxd": "application/vnd.Quark.QuarkXPress", ".quox": "application/vnd.quobject-quoxdocument", ".tree": "application/vnd.rainstor.data", ".rar": "application/vnd.rar", ".bed": "application/vnd.realvnc.bed", ".mxl": "application/vnd.recordare.musicxml", ".rlm": "application/vnd.resilient.logic", ".cryptonote": "application/vnd.rig.cryptonote", ".cod": "application/vnd.rim.cod", ".link66": "application/vnd.route66.link66+xml", ".st": "application/vnd.sailingtracker.track", ".SAR": "application/vnd.sar", ".scd": "application/vnd.scribus", ".s3df": "application/vnd.sealed.3df", ".scsf": "application/vnd.sealed.csf", ".sdoc": "application/vnd.sealed.doc", ".seml": "application/vnd.sealed.eml", ".smht": "application/vnd.sealed.mht", ".sppt": "application/vnd.sealed.ppt", ".stif": "application/vnd.sealed.tiff", ".sxls": "application/vnd.sealed.xls", ".stml": "application/vnd.sealedmedia.softseal.html", ".spdf": "application/vnd.sealedmedia.softseal.pdf", ".see": "application/vnd.seemail", ".sema": "application/vnd.sema", ".semd": "application/vnd.semd", ".semf": "application/vnd.semf", ".ssv": "application/vnd.shade-save-file", ".ifm": "application/vnd.shana.informed.formdata", ".itp": "application/vnd.shana.informed.formtemplate", ".iif": "application/vnd.shana.informed.interchange", ".ipk": "application/vnd.shana.informed.package", ".shp": "application/vnd.shp", ".shx": "application/vnd.shx", ".sr": "application/vnd.sigrok.session", ".twd": "application/vnd.SimTech-MindMapper", ".mmf": "application/vnd.smaf", ".notebook": "application/vnd.smart.notebook", ".teacher": "application/vnd.smart.teacher", ".sipa": "application/vnd.smintio.portals.archive", ".ptrom": "application/vnd.snesdev-page-table", ".fo": "application/vnd.software602.filler.form+xml", ".zfo": "application/vnd.software602.filler.form-xml-zip", ".sdkm": "application/vnd.solent.sdkm+xml", ".dxp": "application/vnd.spotfire.dxp", ".sfs": "application/vnd.spotfire.sfs", ".sqlite": "application/vnd.sqlite3", ".sdc": "application/vnd.stardivision.calc", ".sds": "application/vnd.stardivision.chart", ".sda": "application/vnd.stardivision.draw", ".sdd": "application/vnd.stardivision.impress", ".smf": "application/vnd.stardivision.math", ".sdw": "application/vnd.stardivision.writer", ".sgl": "application/vnd.stardivision.writer-global", ".smzip": "application/vnd.stepmania.package", ".sm": "application/vnd.stepmania.stepchart", ".wadl": "application/vnd.sun.wadl+xml", ".sxc": "application/vnd.sun.xml.calc", ".stc": "application/vnd.sun.xml.calc.template", ".sxd": "application/vnd.sun.xml.draw", ".std": "application/vnd.sun.xml.draw.template", ".sxi": "application/vnd.sun.xml.impress", ".sti": "application/vnd.sun.xml.impress.template", ".sxm": "application/vnd.sun.xml.math", ".sxw": "application/vnd.sun.xml.writer", ".sxg": "application/vnd.sun.xml.writer.global", ".stw": "application/vnd.sun.xml.writer.template", ".sus": "application/vnd.sus-calendar", ".ml2": "application/vnd.sybyl.mol2", ".scl": "application/vnd.sycle+xml", ".syft.json": "application/vnd.syft+json", ".sis": "application/vnd.symbian.install", ".xsm": "application/vnd.syncml+xml", ".bdm": "application/vnd.syncml.dm+wbxml", ".xdm": "application/vnd.syncml.dm+xml", ".ddf": "application/vnd.syncml.dmddf+xml", ".tao": "application/vnd.tao.intent-module-archive", ".pcap": "application/vnd.tcpdump.pcap", ".qvd": "application/vnd.theqvd", ".ppttc": "application/vnd.think-cell.ppttc+json", ".vfr": "application/vnd.tml", ".tmo": "application/vnd.tmobile-livetv", ".tpt": "application/vnd.trid.tpt", ".mxs": "application/vnd.triscape.mxs", ".tra": "application/vnd.trueapp", ".ufdl": "application/vnd.ufdl", ".utz": "application/vnd.uiq.theme", ".umj": "application/vnd.umajin", ".unityweb": "application/vnd.unity", ".uoml": "application/vnd.uoml+xml", ".urim": "application/vnd.uri-map", ".vmt": "application/vnd.valve.source.material", ".vcx": "application/vnd.vcx", ".mxi": "application/vnd.vd-study", ".vwx": "application/vnd.vectorworks", ".aion": "application/vnd.veritone.aion+json", ".istc": "application/vnd.veryant.thin", ".VES": "application/vnd.ves.encrypted", ".vsc": "application/vnd.vidsoft.vidconference", ".vsd": "application/vnd.visio", ".vis": "application/vnd.visionary", ".vsf": "application/vnd.vsf", ".sic": "application/vnd.wap.sic", ".slc": "application/vnd.wap.slc", ".wbxml": "application/vnd.wap.wbxml", ".wmlc": "application/vnd.wap.wmlc", ".wmlsc": "application/vnd.wap.wmlscriptc", ".wafl": "application/vnd.wasmflow.wafl", ".wtb": "application/vnd.webturbo", ".p2p": "application/vnd.wfa.p2p", ".wsc": "application/vnd.wfa.wsc", ".wmc": "application/vnd.wmc", ".nb": "application/vnd.wolfram.mathematica", ".nbp": "application/vnd.wolfram.player", ".wpd": "application/vnd.wordperfect", ".wqd": "application/vnd.wqd", ".stf": "application/vnd.wt.stf", ".wv": "application/vnd.wv.csp+wbxml", ".xar": "application/vnd.xara", ".xfdl": "application/vnd.xfdl", ".cpkg": "application/vnd.xmpie.cpkg", ".dpkg": "application/vnd.xmpie.dpkg", ".ppkg": "application/vnd.xmpie.ppkg", ".xlim": "application/vnd.xmpie.xlim", ".hvd": "application/vnd.yamaha.hv-dic", ".hvs": "application/vnd.yamaha.hv-script", ".hvp": "application/vnd.yamaha.hv-voice", ".osf": "application/vnd.yamaha.openscoreformat", ".saf": "application/vnd.yamaha.smaf-audio", ".spf": "application/vnd.yamaha.smaf-phrase", ".yme": "application/vnd.yaoweme", ".cmp": "application/vnd.yellowriver-custom-menu", ".zir": "application/vnd.zul", ".zaz": "application/vnd.zzazz.deck+xml", ".vxml": "application/voicexml+xml", ".vcj": "application/voucher-cms+json", ".wasm": "application/wasm", ".wif": "application/watcherinfo+xml", ".wgt": "application/widget", ".wsdl": "application/wsdl+xml", ".wspolicy": "application/wspolicy+xml", ".wk": "application/x-123", ".7z": "application/x-7z-compressed", ".abw": "application/x-abiword", ".dmg": "application/x-apple-diskimage", ".bcpio": "application/x-bcpio", ".torrent": "application/x-bittorrent", ".cdf": "application/x-cdf", ".vcd": "application/x-cdlink", ".mph": "application/x-comsol", ".cpio": "application/x-cpio", ".dcr": "application/x-director", ".wad": "application/x-doom", ".dvi": "application/x-dvi", ".pfa": "application/x-font", ".pcf": "application/x-font-pcf", ".mm": "application/x-freemind", ".gan": "application/x-ganttproject", ".gnumeric": "application/x-gnumeric", ".sgf": "application/x-go-sgf", ".gcf": "application/x-graphing-calculator", ".gtar": "application/x-gtar", ".tgz": "application/x-gtar-compressed", ".hdf": "application/x-hdf", ".pem": "application/x-pem-file", ".php": "application/x-php", ".hwp": "application/x-hwp", ".ica": "application/x-ica", ".info": "application/x-info", ".ins": "application/x-internet-signup", ".iii": "application/x-iphone", ".iso": "application/x-iso9660-image", ".jnlp": "application/x-java-jnlp-file", ".jmz": "application/x-jmol", ".kil": "application/x-killustrator", ".latex": "application/x-latex", ".lha": "application/x-lha", ".lyx": "application/x-lyx", ".lzh": "application/x-lzh", ".lzx": "application/x-lzx", ".frm": "application/x-maker", ".wmd": "application/x-ms-wmd", ".wmz": "application/x-ms-wmz", ".com": "application/x-msdos-program", ".msi": "application/x-msi", ".nc": "application/x-netcdf", ".pac": "application/x-ns-proxy-autoconfig", ".nwc": "application/x-nwc", ".o": "application/x-object", ".oza": "application/x-oz-application", ".p7r": "application/x-pkcs7-certreqresp", ".pyc": "application/x-python-code", ".qgs": "application/x-qgis", ".qtl": "application/x-quicktimeplayer", ".rdp": "application/x-rdp", ".rpm": "application/x-redhat-package-manager", ".rss": "application/x-rss+xml", ".rb": "application/x-ruby", ".erb": "application/x-ruby", ".sci": "application/x-scilab", ".xcos": "application/x-scilab-xcos", ".sh": "application/x-sh", ".shar": "application/x-shar", ".scr": "application/x-silverlight", ".sit": "application/x-stuffit", ".sv4cpio": "application/x-sv4cpio", ".sv4crc": "application/x-sv4crc", ".tar": "application/x-tar", ".gf": "application/x-tex-gf", ".pk": "application/x-tex-pk", ".texinfo": "application/x-texinfo", ".~": "application/x-trash", ".man": "application/x-troff-man", ".me": "application/x-troff-me", ".ms": "application/x-troff-ms", ".ustar": "application/x-ustar", ".src": "application/x-wais-source", ".wz": "application/x-wingz", ".crt": "application/x-x509-ca-cert", ".fig": "application/x-xfig", ".xpi": "application/x-xpinstall", ".xz": "application/x-xz", ".xav": "application/xcap-att+xml", ".xca": "application/xcap-caps+xml", ".xdf": "application/xcap-diff+xml", ".xel": "application/xcap-el+xml", ".xer": "application/xcap-error+xml", ".xns": "application/xcap-ns+xml", ".xfdf": "application/xfdf", ".xhtml": "application/xhtml+xml", ".xlf": "application/xliff+xml", ".xml": "application/xml", ".dtd": "application/xml-dtd", ".ent": "application/xml-external-parsed-entity", ".xop": "application/xop+xml", ".xsl": "application/xslt+xml", ".xspf": "application/xspf+xml", ".mxml": "application/xv+xml", ".yaml": "application/x-yaml", ".yml": "application/x-yaml", ".yang": "application/yang", ".yin": "application/yin+xml", ".zip": "application/zip", ".zst": "application/zstd", ".726": "audio/32kadpcm", ".adts": "audio/aac", ".ac3": "audio/ac3", ".amr": "audio/AMR", ".awb": "audio/AMR-WB", ".axa": "audio/annodex", ".acn": "audio/asc", ".aal": "audio/ATRAC-ADVANCED-LOSSLESS", ".atx": "audio/ATRAC-X", ".at3": "audio/ATRAC3", ".au": "audio/basic", ".csd": "audio/csound", ".dls": "audio/dls", ".evc": "audio/EVRC", ".qcp": "audio/EVRC-QCP", ".evb": "audio/EVRCB", ".enw": "audio/EVRCNW", ".evw": "audio/EVRCWB", ".flac": "audio/flac", ".lbc": "audio/iLBC", ".l16": "audio/L16", ".mhas": "audio/mhas", ".mxmf": "audio/mobile-xmf", ".m4a": "audio/mp4", ".mpga": "audio/mpeg", ".m3u": "audio/mpegurl", ".oga": "audio/ogg", ".sid": "audio/prs.sid", ".smv": "audio/SMV", ".sofa": "audio/sofa", ".mid": "audio/sp-midi", ".loas": "audio/usac", ".koz": "audio/vnd.audiokoz", ".uva": "audio/vnd.dece.audio", ".eol": "audio/vnd.digital-winds", ".mlp": "audio/vnd.dolby.mlp", ".dts": "audio/vnd.dts", ".dtshd": "audio/vnd.dts.hd", ".plj": "audio/vnd.everad.plj", ".lvp": "audio/vnd.lucent.voice", ".pya": "audio/vnd.ms-playready.media.pya", ".vbk": "audio/vnd.nortel.vbk", ".ecelp4800": "audio/vnd.nuera.ecelp4800", ".ecelp7470": "audio/vnd.nuera.ecelp7470", ".ecelp9600": "audio/vnd.nuera.ecelp9600", ".multitrack": "audio/vnd.presonus.multitrack", ".rip": "audio/vnd.rip", ".smp3": "audio/vnd.sealedmedia.softseal.mpeg", ".aif": "audio/x-aiff", ".gsm": "audio/x-gsm", ".wax": "audio/x-ms-wax", ".wma": "audio/x-ms-wma", ".ra": "audio/x-pn-realaudio", ".pls": "audio/x-scpls", ".sd2": "audio/x-sd2", ".wav": "audio/x-wav", ".alc": "chemical/x-alchemy", ".cac": "chemical/x-cache", ".csf": "chemical/x-cache-csf", ".cbin": "chemical/x-cactvs-binary", ".cdx": "chemical/x-cdx", ".c3d": "chemical/x-chem3d", ".cmdf": "chemical/x-cmdf", ".cml": "chemical/x-cml", ".cpa": "chemical/x-compass", ".bsd": "chemical/x-crossfire", ".csml": "chemical/x-csml", ".ctx": "chemical/x-ctx", ".cxf": "chemical/x-cxf", ".smi": "#chemical/x-daylight-smiles", ".emb": "chemical/x-embl-dl-nucleotide", ".spc": "chemical/x-galactic-spc", ".inp": "chemical/x-gamess-input", ".fch": "chemical/x-gaussian-checkpoint", ".cub": "chemical/x-gaussian-cube", ".gau": "chemical/x-gaussian-input", ".gal": "chemical/x-gaussian-log", ".gcg": "chemical/x-gcg8-sequence", ".gen": "chemical/x-genbank", ".hin": "chemical/x-hin", ".istr": "chemical/x-isostar", ".jdx": "chemical/x-jcamp-dx", ".kin": "chemical/x-kinemage", ".mcm": "chemical/x-macmolecule", ".mmod": "chemical/x-macromodel-input", ".mol": "chemical/x-mdl-molfile", ".rd": "chemical/x-mdl-rdfile", ".rxn": "chemical/x-mdl-rxnfile", ".sd": "chemical/x-mdl-sdfile", ".tgf": "chemical/x-mdl-tgf", ".mcif": "chemical/x-mmcif", ".b": "chemical/x-molconn-Z", ".gpt": "chemical/x-mopac-graph", ".mop": "chemical/x-mopac-input", ".moo": "chemical/x-mopac-out", ".mvb": "chemical/x-mopac-vib", ".asn": "chemical/x-ncbi-asn1", ".prt": "chemical/x-ncbi-asn1-ascii", ".val": "chemical/x-ncbi-asn1-binary", ".ros": "chemical/x-rosdal", ".sw": "chemical/x-swissprot", ".vms": "chemical/x-vamas-iso14976", ".vmd": "chemical/x-vmd", ".xtel": "chemical/x-xtel", ".xyz": "chemical/x-xyz", ".ttc": "font/collection", ".otf": "font/otf", ".ttf": "font/ttf", ".woff": "font/woff", ".woff2": "font/woff2", ".exr": "image/aces", ".apng": "image/apng", ".avci": "image/avci", ".avcs": "image/avcs", ".avif": "image/avif", ".bmp": "image/bmp", ".cgm": "image/cgm", ".drle": "image/dicom-rle", ".dpx": "image/dpx", ".emf": "image/emf", ".fits": "image/fits", ".gif": "image/gif", ".heic": "image/heic", ".heics": "image/heic-sequence", ".heif": "image/heif", ".heifs": "image/heif-sequence", ".hej2": "image/hej2k", ".hsj2": "image/hsj2", ".ief": "image/ief", ".j2c": "image/j2c", ".jls": "image/jls", ".jp2": "image/jp2", ".jpeg": "image/jpeg", ".jph": "image/jph", ".jhc": "image/jphc", ".jpm": "image/jpm", ".jpx": "image/jpx", ".jxl": "image/jxl", ".jxr": "image/jxr", ".jxra": "image/jxrA", ".jxrs": "image/jxrS", ".jxs": "image/jxs", ".jxsc": "image/jxsc", ".jxsi": "image/jxsi", ".jxss": "image/jxss", ".ktx": "image/ktx", ".ktx2": "image/ktx2", ".png": "image/png", ".btif": "image/prs.btif", ".pti": "image/prs.pti", ".svg": "image/svg+xml", ".tiff": "image/tiff", ".tfx": "image/tiff-fx", ".psd": "image/vnd.adobe.photoshop", ".azv": "image/vnd.airzip.accelerator.azv", ".uvi": "image/vnd.dece.graphic", ".djvu": "image/vnd.djvu", ".dwg": "image/vnd.dwg", ".dxf": "image/vnd.dxf", ".fbs": "image/vnd.fastbidsheet", ".fpx": "image/vnd.fpx", ".fst": "image/vnd.fst", ".mmr": "image/vnd.fujixerox.edmics-mmr", ".rlc": "image/vnd.fujixerox.edmics-rlc", ".PGB": "image/vnd.globalgraphics.pgb", ".ico": "image/vnd.microsoft.icon", ".mdi": "image/vnd.ms-modi", ".b16": "image/vnd.pco.b16", ".hdr": "image/vnd.radiance", ".spng": "image/vnd.sealed.png", ".sgif": "image/vnd.sealedmedia.softseal.gif", ".sjpg": "image/vnd.sealedmedia.softseal.jpg", ".tap": "image/vnd.tencent.tap", ".vtf": "image/vnd.valve.source.texture", ".wbmp": "image/vnd.wap.wbmp", ".xif": "image/vnd.xiff", ".pcx": "image/vnd.zbrush.pcx", ".webp": "image/webp", ".wmf": "image/wmf", ".cr2": "image/x-canon-cr2", ".crw": "image/x-canon-crw", ".ras": "image/x-cmu-raster", ".cdr": "image/x-coreldraw", ".pat": "image/x-coreldrawpattern", ".cdt": "image/x-coreldrawtemplate", ".erf": "image/x-epson-erf", ".art": "image/x-jg", ".jng": "image/x-jng", ".nef": "image/x-nikon-nef", ".orf": "image/x-olympus-orf", ".pnm": "image/x-portable-anymap", ".pbm": "image/x-portable-bitmap", ".pgm": "image/x-portable-graymap", ".ppm": "image/x-portable-pixmap", ".rgb": "image/x-rgb", ".xbm": "image/x-xbitmap", ".xcf": "image/x-xcf", ".xpm": "image/x-xpixmap", ".xwd": "image/x-xwindowdump", ".u8msg": "message/global", ".u8dsn": "message/global-delivery-status", ".u8mdn": "message/global-disposition-notification", ".u8hdr": "message/global-headers", ".eml": "message/rfc822", ".gltf": "model/gltf+json", ".glb": "model/gltf-binary", ".igs": "model/iges", ".jt": "model/JT", ".msh": "model/mesh", ".mtl": "model/mtl", ".obj": "model/obj", ".prc": "model/prc", ".stp": "model/step", ".stpx": "model/step+xml", ".stpz": "model/step+zip", ".stpxz": "model/step-xml+zip", ".stl": "model/stl", ".u3d": "model/u3d", ".bary": "model/vnd.bary", ".cld": "model/vnd.cld", ".dae": "model/vnd.collada+xml", ".dwf": "model/vnd.dwf", ".gdl": "model/vnd.gdl", ".gtw": "model/vnd.gtw", ".moml": "model/vnd.moml+xml", ".mts": "model/vnd.mts", ".ogex": "model/vnd.opengex", ".x_b": "model/vnd.parasolid.transmit.binary", ".x_t": "model/vnd.parasolid.transmit.text", ".pyox": "model/vnd.pytha.pyox", ".vds": "model/vnd.sap.vds", ".usda": "model/vnd.usda", ".usdz": "model/vnd.usdz+zip", ".bsp": "model/vnd.valve.source.compiled-map", ".vtu": "model/vnd.vtu", ".wrl": "model/vrml", ".x3db": "model/x3d+fastinfoset", ".x3d": "model/x3d+xml", ".x3dv": "model/x3d-vrml", ".bmed": "multipart/vnd.bint.med-plus", ".vpm": "multipart/voice-message", "ahk": "text/autohotkey", "au3": "text/autohotkey", ".appcache": "text/cache-manifest", ".ics": "text/calendar", "cof": "text/coffeescript", "coffee": "text/coffeescript", "coffeescript": "text/coffeescript", ".CQL": "text/cql", ".css": "text/css", ".csv": "text/csv", ".csvs": "text/csv-schema", ".soa": "text/dns", ".gff3": "text/gff3", ".htm": "text/html", ".html": "text/html", ".cjs": "text/javascript", ".js": "text/javascript", ".mjs": "text/javascript", ".cnd": "text/jcr-cnd", ".jsx": "text/jsx", ".less": "text/less", ".md": "text/markdown", ".mdx": "text/mdx", ".m": "text/mips", ".miz": "text/mizar", ".n3": "text/n3", ".txt": "text/plain", ".conf": "text/plain", ".pub": "text/plain", ".awk": "text/x-awk", ".provn": "text/provenance-notation", ".rst": "text/prs.fallenstein.rst", ".tag": "text/prs.lines.tag", ".rs": "text/x-rust", ".ini": "text/x-ini", ".sass": "text/scss", ".scss": "text/scss", ".sgml": "text/SGML", ".shaclc": "text/shaclc", ".shex": "text/shex", ".spdx": "text/spdx", ".tsv": "text/tab-separated-values", ".tm": "text/texmacs", ".t": "text/troff", ".tsx": "text/typescript-jsx", ".ttl": "text/turtle", ".ts": "text/typescript", ".uris": "text/uri-list", ".vcf": "text/vcard", ".a": "text/vnd.a", ".abc": "text/vnd.abc", ".ascii": "text/vnd.ascii-art", ".curl": "text/vnd.curl", ".copyright": "text/vnd.debian.copyright", ".dms": "text/vnd.DMClientScript", ".jtd": "text/vnd.esmertec.theme-descriptor", ".VFK": "text/vnd.exchangeable", ".ged": "text/vnd.familysearch.gedcom", ".flt": "text/vnd.ficlab.flt", ".fly": "text/vnd.fly", ".flx": "text/vnd.fmi.flexstor", ".gv": "text/vnd.graphviz", ".hans": "text/vnd.hans", ".hgl": "text/vnd.hgl", ".3dml": "text/vnd.in3d.3dml", ".spot": "text/vnd.in3d.spot", ".mpf": "text/vnd.ms-mediapackage", ".ccc": "text/vnd.net2phone.commcenter.command", ".mc2": "text/vnd.senx.warpscript", ".sos": "text/vnd.sosi", ".jad": "text/vnd.sun.j2me.app-descriptor", ".si": "text/vnd.wap.si", ".sl": "text/vnd.wap.sl", ".wml": "text/vnd.wap.wml", ".wmls": "text/vnd.wap.wmlscript", ".vtt": "text/vtt", ".wgsl": "text/wgsl", ".cls": "text/x-apex", ".asp": "text/x-aspx", ".aspx": "text/x-aspx", ".bib": "text/x-bibtex", ".boo": "text/x-boo", ".h++": "text/x-c++hdr", ".cc": "text/x-c++src", ".cpp": "text/x-c++src", ".c++": "text/x-c++src", ".h": "text/x-chdr", ".clojure": "text/x-clojure", ".htc": "text/x-component", ".csh": "text/x-csh", ".cshtml": "text/x-cshtml", ".c": "text/x-csrc", ".dart": "text/x-dart", ".diff": "text/x-diff", ".d": "text/x-dsrc", ".ex": "text/x-elixir", ".elm": "text/x-elm", ".erl": "text/x-erlang", ".go": "text/x-go", ".handlebars": "text/x-handlebars-template", ".hbs": "text/x-handlebars-template", ".hs": "text/x-haskell", ".java": "text/x-java", ".jl": "text/x-julia", ".kt": "text/x-kotlin", ".kts": "text/x-kotlin", ".ly": "text/x-lilypond", ".cl": "text/x-common-lisp", ".cs": "text/x-c#src", ".l": "text/x-common-lisp", ".lisp": "text/x-common-lisp", ".lsp": "text/x-common-lisp", ".lua": "text/x-lua", ".lhs": "text/x-literate-haskell", ".moc": "text/x-moc", ".p": "text/x-pascal", ".pas": "text/x-pascal", ".pp": "text/x-pascal", ".gcd": "text/x-pcs-gcd", ".pl": "text/x-perl", ".py": "text/x-python", ".r": "text/x-r", ".sbt": "text/x-scala", ".sc": "text/x-scala", ".scala": "text/x-scala", ".scm": "text/x-scheme", ".etx": "text/x-setext", ".sfv": "text/x-sfv", ".swift": "text/swift", ".tcl": "text/x-tcl", ".tex": "text/x-tex", ".twig": "text/x-twig", ".vcs": "text/x-vcalendar", ".axv": "video/annodex", ".dif": "video/dv", ".fli": "video/fli", ".gl": "video/gl", ".m4s": "video/iso.segment", ".mj2": "video/mj2", ".m4v": "video/mp4", ".mkv": "video/mp4", ".mov": "video/mp4", ".mp4": "video/mp4", ".mpeg": "video/mpeg", ".mpg": "video/mpeg", ".ogv": "video/ogg", ".qt": "video/quicktime", ".uvh": "video/vnd.dece.hd", ".uvm": "video/vnd.dece.mobile", ".uvu": "video/vnd.dece.mp4", ".uvp": "video/vnd.dece.pd", ".uvs": "video/vnd.dece.sd", ".uvv": "video/vnd.dece.video", ".dvb": "video/vnd.dvb.file", ".fvt": "video/vnd.fvt", ".mxu": "video/vnd.mpegurl", ".pyv": "video/vnd.ms-playready.media.pyv", ".nim": "video/vnd.nokia.interleaved-multimedia", ".bik": "video/vnd.radgamettools.bink", ".smk": "video/vnd.radgamettools.smacker", ".smpg": "video/vnd.sealed.mpeg1", ".s14": "video/vnd.sealed.mpeg4", ".sswf": "video/vnd.sealed.swf", ".smov": "video/vnd.sealedmedia.softseal.mov", ".viv": "video/vnd.vivo", ".yt": "video/vnd.youtube.yt", ".webm": "video/webm", ".flv": "video/x-flv", ".lsf": "video/x-la-asf", ".mpv": "video/x-matroska", ".mng": "video/x-mng", ".wm": "video/x-ms-wm", ".wmv": "video/x-ms-wmv", ".wmx": "video/x-ms-wmx", ".wvx": "video/x-ms-wvx", ".avi": "video/x-msvideo", ".movie": "video/x-sgi-movie", } ================================================ FILE: pkg/util/fileutil/readdir.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package fileutil import ( "fmt" "io/fs" "os" "path/filepath" "sort" "time" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" ) type DirEntryOut struct { Name string `json:"name"` Dir bool `json:"dir,omitempty"` Symlink bool `json:"symlink,omitempty"` Size int64 `json:"size,omitempty"` Mode string `json:"mode"` Modified string `json:"modified"` ModifiedTime string `json:"modified_time"` } type ReadDirResult struct { Path string `json:"path"` AbsolutePath string `json:"absolute_path"` ParentDir string `json:"parent_dir,omitempty"` Entries []DirEntryOut `json:"entries"` EntryCount int `json:"entry_count"` TotalEntries int `json:"total_entries"` Truncated bool `json:"truncated,omitempty"` } func ReadDir(path string, maxEntries int) (*ReadDirResult, error) { expandedPath, err := wavebase.ExpandHomeDir(path) if err != nil { return nil, fmt.Errorf("failed to expand path: %w", err) } fileInfo, err := os.Stat(expandedPath) if err != nil { return nil, fmt.Errorf("failed to stat path: %w", err) } if !fileInfo.IsDir() { return nil, fmt.Errorf("path is not a directory") } entries, err := os.ReadDir(expandedPath) if err != nil { return nil, fmt.Errorf("failed to read directory: %w", err) } totalEntries := len(entries) isDirMap := make(map[string]bool) symlinkCount := 0 for _, entry := range entries { name := entry.Name() if entry.Type()&fs.ModeSymlink != 0 { if symlinkCount < 1000 { symlinkCount++ fullPath := filepath.Join(expandedPath, name) if info, err := os.Stat(fullPath); err == nil { isDirMap[name] = info.IsDir() } else { isDirMap[name] = entry.IsDir() } } else { isDirMap[name] = entry.IsDir() } } else { isDirMap[name] = entry.IsDir() } } sort.Slice(entries, func(i, j int) bool { iIsDir := isDirMap[entries[i].Name()] jIsDir := isDirMap[entries[j].Name()] if iIsDir != jIsDir { return iIsDir } return entries[i].Name() < entries[j].Name() }) var truncated bool if len(entries) > maxEntries { entries = entries[:maxEntries] truncated = true } var entryList []DirEntryOut for _, entry := range entries { info, err := entry.Info() if err != nil { continue } isDir := isDirMap[entry.Name()] isSymlink := entry.Type()&fs.ModeSymlink != 0 entryData := DirEntryOut{ Name: entry.Name(), Dir: isDir, Symlink: isSymlink, Mode: info.Mode().String(), Modified: utilfn.FormatRelativeTime(info.ModTime()), ModifiedTime: info.ModTime().UTC().Format(time.RFC3339), } if !isDir { entryData.Size = info.Size() } entryList = append(entryList, entryData) } result := &ReadDirResult{ Path: path, AbsolutePath: expandedPath, Entries: entryList, EntryCount: len(entryList), TotalEntries: totalEntries, Truncated: truncated, } parentDir := filepath.Dir(expandedPath) if parentDir != expandedPath { result.ParentDir = parentDir } return result, nil } func ReadDirRecursive(path string, maxEntries int) (*ReadDirResult, error) { expandedPath, err := wavebase.ExpandHomeDir(path) if err != nil { return nil, fmt.Errorf("failed to expand path: %w", err) } fileInfo, err := os.Stat(expandedPath) if err != nil { return nil, fmt.Errorf("failed to stat path: %w", err) } if !fileInfo.IsDir() { return nil, fmt.Errorf("path is not a directory") } var allEntries []DirEntryOut isDirMap := make(map[string]bool) var truncated bool err = filepath.WalkDir(expandedPath, func(fullPath string, d fs.DirEntry, err error) error { if err != nil { return nil } if fullPath == expandedPath { return nil } if len(allEntries) >= maxEntries { truncated = true return fs.SkipAll } relativePath, _ := filepath.Rel(expandedPath, fullPath) isSymlink := d.Type()&fs.ModeSymlink != 0 info, infoErr := d.Info() if infoErr != nil { return nil } isDir := d.IsDir() isDirMap[relativePath] = isDir entryData := DirEntryOut{ Name: relativePath, Dir: isDir, Symlink: isSymlink, Mode: info.Mode().String(), Modified: utilfn.FormatRelativeTime(info.ModTime()), ModifiedTime: info.ModTime().UTC().Format(time.RFC3339), } if !isDir { entryData.Size = info.Size() } allEntries = append(allEntries, entryData) if isSymlink && isDir { return fs.SkipDir } return nil }) if err != nil && err != fs.SkipAll { return nil, fmt.Errorf("failed to walk directory: %w", err) } sort.Slice(allEntries, func(i, j int) bool { iIsDir := isDirMap[allEntries[i].Name] jIsDir := isDirMap[allEntries[j].Name] if iIsDir != jIsDir { return iIsDir } return allEntries[i].Name < allEntries[j].Name }) result := &ReadDirResult{ Path: path, AbsolutePath: expandedPath, Entries: allEntries, EntryCount: len(allEntries), TotalEntries: 0, Truncated: truncated, } parentDir := filepath.Dir(expandedPath) if parentDir != expandedPath { result.ParentDir = parentDir } return result, nil } ================================================ FILE: pkg/util/iochan/iochan.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // allows for streaming an io.Reader to a channel and an io.Writer from a channel package iochan import ( "bytes" "context" "crypto/sha256" "errors" "fmt" "io" "log" "github.com/wavetermdev/waveterm/pkg/util/iochan/iochantypes" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" ) // ReaderChan reads from an io.Reader and sends the data to a channel func ReaderChan(ctx context.Context, r io.Reader, chunkSize int64, callback func()) chan wshrpc.RespOrErrorUnion[iochantypes.Packet] { ch := make(chan wshrpc.RespOrErrorUnion[iochantypes.Packet], 32) go func() { defer func() { log.Printf("Closing ReaderChan\n") close(ch) callback() }() sha256Hash := sha256.New() for { select { case <-ctx.Done(): if ctx.Err() == context.Canceled { return } return default: buf := make([]byte, chunkSize) if n, err := r.Read(buf); err != nil { if errors.Is(err, io.EOF) { ch <- wshrpc.RespOrErrorUnion[iochantypes.Packet]{Response: iochantypes.Packet{Checksum: sha256Hash.Sum(nil)}} // send the checksum return } ch <- wshutil.RespErr[iochantypes.Packet](fmt.Errorf("ReaderChan: read error: %v", err)) return } else if n > 0 { if _, err := sha256Hash.Write(buf[:n]); err != nil { ch <- wshutil.RespErr[iochantypes.Packet](fmt.Errorf("ReaderChan: error writing to sha256 hash: %v", err)) return } ch <- wshrpc.RespOrErrorUnion[iochantypes.Packet]{Response: iochantypes.Packet{Data: buf[:n]}} } } } }() return ch } // WriterChan reads from a channel and writes the data to an io.Writer func WriterChan(ctx context.Context, w io.Writer, ch <-chan wshrpc.RespOrErrorUnion[iochantypes.Packet], callback func(), cancel context.CancelCauseFunc) { go func() { defer func() { if ctx.Err() != nil { utilfn.DrainChannelSafe(ch, "WriterChan") } callback() }() sha256Hash := sha256.New() for { select { case <-ctx.Done(): return case resp, ok := <-ch: if !ok { return } if resp.Error != nil { cancel(resp.Error) return } if _, err := sha256Hash.Write(resp.Response.Data); err != nil { cancel(fmt.Errorf("WriterChan: error writing to sha256 hash: %v", err)) return } // The checksum is sent as the last packet if resp.Response.Checksum != nil { localChecksum := sha256Hash.Sum(nil) if !bytes.Equal(localChecksum, resp.Response.Checksum) { cancel(fmt.Errorf("WriterChan: checksum mismatch")) } return } if _, err := w.Write(resp.Response.Data); err != nil { cancel(fmt.Errorf("WriterChan: write error: %v", err)) return } } } }() } ================================================ FILE: pkg/util/iochan/iochan_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package iochan_test import ( "context" "io" "testing" "time" "github.com/wavetermdev/waveterm/pkg/util/iochan" ) const ( buflen = 1024 ) func TestIochan_Basic(t *testing.T) { // Write the packet to the source pipe from a goroutine srcPipeReader, srcPipeWriter := io.Pipe() packet := []byte("hello world") go func() { srcPipeWriter.Write(packet) srcPipeWriter.Close() }() // Initialize the reader channel readerChanCallbackCalled := false readerChanCallback := func() { srcPipeReader.Close() readerChanCallbackCalled = true } defer readerChanCallback() // Ensure the callback is called ioch := iochan.ReaderChan(context.TODO(), srcPipeReader, buflen, readerChanCallback) // Initialize the destination pipe and the writer channel destPipeReader, destPipeWriter := io.Pipe() writerChanCallbackCalled := false writerChanCallback := func() { destPipeReader.Close() destPipeWriter.Close() writerChanCallbackCalled = true } defer writerChanCallback() // Ensure the callback is called iochan.WriterChan(context.TODO(), destPipeWriter, ioch, writerChanCallback, func(err error) {}) // Read the packet from the destination pipe and compare it to the original packet buf := make([]byte, buflen) n, err := destPipeReader.Read(buf) if err != nil { t.Fatalf("Read failed: %v", err) } if n != len(packet) { t.Fatalf("Read length mismatch: %d != %d", n, len(packet)) } if string(buf[:n]) != string(packet) { t.Fatalf("Read data mismatch: %s != %s", buf[:n], packet) } // Give the callbacks a chance to run before checking if they were called time.Sleep(10 * time.Millisecond) if !readerChanCallbackCalled { t.Fatalf("ReaderChan callback not called") } if !writerChanCallbackCalled { t.Fatalf("WriterChan callback not called") } } ================================================ FILE: pkg/util/iochan/iochantypes/iochantypes.go ================================================ package iochantypes type Packet struct { Data []byte Checksum []byte } ================================================ FILE: pkg/util/iterfn/iterfn.go ================================================ package iterfn import ( "cmp" "iter" "maps" "slices" ) func CollectSeqToSorted[T cmp.Ordered](seq iter.Seq[T]) []T { rtn := []T{} for v := range seq { rtn = append(rtn, v) } slices.Sort(rtn) return rtn } func CollectSeq[T any](seq iter.Seq[T]) []T { rtn := []T{} for v := range seq { rtn = append(rtn, v) } return rtn } func MapKeysToSorted[K cmp.Ordered, V any](m map[K]V) []K { return CollectSeqToSorted(maps.Keys(m)) } ================================================ FILE: pkg/util/iterfn/iterfn_test.go ================================================ package iterfn_test import ( "maps" "slices" "testing" "github.com/wavetermdev/waveterm/pkg/util/iterfn" ) func TestCollectSeqToSorted(t *testing.T) { t.Parallel() // Test code here m := map[int]struct{}{1: {}, 3: {}, 2: {}} got := iterfn.CollectSeqToSorted(maps.Keys(m)) want := []int{1, 2, 3} if !slices.Equal(got, want) { t.Errorf("got %v, want %v", got, want) } } func TestCollectSeq(t *testing.T) { t.Parallel() // Test code here m := map[int]struct{}{1: {}, 3: {}, 2: {}} got := iterfn.CollectSeq(maps.Keys(m)) i := 0 for _, v := range got { if _, ok := m[v]; !ok { t.Errorf("collected value %v not in original map", v) } i++ } if i != len(m) { t.Errorf("collected array length %v, want %v", i, len(m)) } } func TestMapKeysToSorted(t *testing.T) { t.Parallel() // Test code here m := map[int]struct{}{1: {}, 3: {}, 2: {}} got := iterfn.MapKeysToSorted(m) want := []int{1, 2, 3} if !slices.Equal(got, want) { t.Errorf("got %v, want %v", got, want) } } ================================================ FILE: pkg/util/logutil/logutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package logutil import ( "log" "github.com/wavetermdev/waveterm/pkg/wavebase" ) // DevPrintf logs using log.Printf only if running in dev mode func DevPrintf(format string, v ...any) { if wavebase.IsDevMode() { log.Printf(format, v...) } } ================================================ FILE: pkg/util/logview/logview.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package logview import ( "fmt" "io" "os" "regexp" ) const BufSize = 256 * 1024 const MaxLineSize = 1024 type LinePtr struct { Offset int64 RealLineNum int64 LineNum int64 } type LogView struct { File *os.File MultiBuf *MultiBufferByteGetter MatchRe *regexp.Regexp } func MakeLogView(file *os.File) *LogView { return &LogView{ File: file, MultiBuf: MakeMultiBufferByteGetter(file, BufSize), } } func (lv *LogView) Close() { lv.File.Close() } func (lv *LogView) ReadLineData(linePtr *LinePtr) ([]byte, error) { return lv.readLineAt(linePtr.Offset) } func (lv *LogView) readLineAt(offset int64) ([]byte, error) { var rtn []byte for { if len(rtn) > MaxLineSize { break } b, err := lv.MultiBuf.GetByte(offset) if err == io.EOF { break } if err != nil { return nil, err } if b == '\n' { break } rtn = append(rtn, b) offset++ } return rtn, nil } func (lv *LogView) FirstLinePtr() (*LinePtr, error) { linePtr := &LinePtr{Offset: 0, RealLineNum: 1, LineNum: 1} if lv.isLineMatch(0) { return linePtr, nil } return lv.NextLinePtr(linePtr) } func (lv *LogView) isLineMatch(offset int64) bool { if lv.MatchRe == nil { return true } lineData, err := lv.readLineAt(offset) if err != nil { return false } return lv.MatchRe.Match(lineData) } func (lv *LogView) NextLinePtr(linePtr *LinePtr) (*LinePtr, error) { if linePtr == nil { return nil, fmt.Errorf("linePtr is nil") } numLines := int64(0) offset := linePtr.Offset for { var err error nextOffset, err := lv.MultiBuf.NextLine(offset) if err == io.EOF { return nil, nil } if err != nil { return nil, err } numLines++ if lv.isLineMatch(nextOffset) { return &LinePtr{Offset: nextOffset, RealLineNum: linePtr.RealLineNum + numLines, LineNum: linePtr.LineNum + 1}, nil } offset = nextOffset } } func (lv *LogView) PrevLinePtr(linePtr *LinePtr) (*LinePtr, error) { if linePtr == nil { return nil, fmt.Errorf("linePtr is nil") } numLines := int64(0) offset := linePtr.Offset for { var err error prevOffset, err := lv.MultiBuf.PrevLine(offset) if err == ErrBOF { return nil, nil } if err != nil { return nil, err } numLines++ if lv.isLineMatch(prevOffset) { return &LinePtr{Offset: prevOffset, RealLineNum: linePtr.RealLineNum - numLines, LineNum: linePtr.LineNum - 1}, nil } offset = prevOffset } } func (lv *LogView) Move(linePtr *LinePtr, offset int) (int, *LinePtr, error) { var n int if offset > 0 { for { nextLinePtr, err := lv.NextLinePtr(linePtr) if err == io.EOF { break } if err != nil { return 0, nil, err } linePtr = nextLinePtr n++ if n == offset { break } } return n, linePtr, nil } if offset < 0 { for { prevLinePtr, err := lv.PrevLinePtr(linePtr) if err == ErrBOF { break } if err != nil { return 0, nil, err } linePtr = prevLinePtr n-- if n == offset { break } } return n, linePtr, nil } return 0, linePtr, nil } func (lv *LogView) LastLinePtr(linePtr *LinePtr) (*LinePtr, error) { if linePtr == nil { var err error linePtr, err = lv.FirstLinePtr() if err != nil { return nil, err } } if linePtr == nil { return nil, nil } for { nextLinePtr, err := lv.NextLinePtr(linePtr) if err == io.EOF { break } if err != nil { return nil, err } if nextLinePtr == nil { break } linePtr = nextLinePtr } return linePtr, nil } func (lv *LogView) ReadWindow(linePtr *LinePtr, winSize int) ([][]byte, error) { if linePtr == nil { return nil, nil } var rtn [][]byte for len(rtn) < winSize { lineData, err := lv.readLineAt(linePtr.Offset) if err != nil { return nil, err } rtn = append(rtn, lineData) nextLinePtr, err := lv.NextLinePtr(linePtr) if err != nil { return nil, err } if nextLinePtr == nil { break } linePtr = nextLinePtr } return rtn, nil } ================================================ FILE: pkg/util/logview/multibuf.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package logview import ( "errors" "io" "os" ) type MultiBufferByteGetter struct { File *os.File Offset int64 EOF bool Buffers [][]byte BufSize int64 } var ErrBOF = errors.New("beginning of file") func MakeMultiBufferByteGetter(file *os.File, bufSize int64) *MultiBufferByteGetter { return &MultiBufferByteGetter{ File: file, Offset: 0, EOF: false, Buffers: [][]byte{}, BufSize: bufSize, } } func (mb *MultiBufferByteGetter) readFromBuffer(offset int64) (byte, bool) { if offset < mb.Offset || offset >= mb.Offset+int64(mb.bufSize()) { return 0, false } bufIdx := int((offset - mb.Offset) / mb.BufSize) bufOffset := (offset - mb.Offset) % mb.BufSize return mb.Buffers[bufIdx][bufOffset], true } func (mb *MultiBufferByteGetter) bufSize() int { return len(mb.Buffers) * int(mb.BufSize) } func (mb *MultiBufferByteGetter) rebuffer(newOffset int64) error { partNum := int(newOffset / mb.BufSize) partOffset := int64(partNum) * mb.BufSize newBuf := make([]byte, mb.BufSize) n, err := mb.File.ReadAt(newBuf, partOffset) var isEOF bool if err == io.EOF { newBuf = newBuf[:n] isEOF = true } if err != nil { return err } var newBuffers [][]byte if len(mb.Buffers) > 0 { firstBufPartNum := int(mb.Offset / mb.BufSize) lastBufPartNum := int((mb.Offset + int64(mb.bufSize())) / mb.BufSize) if firstBufPartNum == partNum+1 { newBuffers = [][]byte{newBuf, mb.Buffers[0]} } else if lastBufPartNum == partNum-1 { newBuffers = [][]byte{mb.Buffers[0], newBuf} } else { newBuffers = [][]byte{newBuf} } } else { newBuffers = [][]byte{newBuf} } mb.Buffers = newBuffers mb.Offset = partOffset mb.EOF = isEOF return nil } func (mb *MultiBufferByteGetter) GetByte(offset int64) (byte, error) { b, ok := mb.readFromBuffer(offset) if ok { return b, nil } if mb.EOF && offset >= mb.Offset+int64(mb.bufSize()) { return 0, io.EOF } err := mb.rebuffer(offset) if err != nil { return 0, err } b, _ = mb.readFromBuffer(offset) return b, nil } func (mb *MultiBufferByteGetter) NextLine(offset int64) (int64, error) { for { b, err := mb.GetByte(offset) if err != nil { return 0, err } if b == '\n' { break } offset++ } _, lastErr := mb.GetByte(offset + 1) if lastErr == io.EOF { return 0, io.EOF } return offset + 1, nil } func (mb *MultiBufferByteGetter) PrevLine(offset int64) (int64, error) { if offset == 0 { return 0, ErrBOF } offset = offset - 2 for { if offset < 0 { break } b, err := mb.GetByte(offset) if err != nil { return 0, err } if b == '\n' { break } offset-- } return offset + 1, nil } ================================================ FILE: pkg/util/migrateutil/migrateutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package migrateutil import ( "database/sql" "fmt" "io/fs" "log" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/source/iofs" sqlite3migrate "github.com/golang-migrate/migrate/v4/database/sqlite3" ) func GetMigrateVersion(m *migrate.Migrate) (uint, bool, error) { curVersion, dirty, err := m.Version() if err == migrate.ErrNilVersion { return 0, false, nil } return curVersion, dirty, err } func MakeMigrate(storeName string, db *sql.DB, migrationFS fs.FS, migrationsName string) (*migrate.Migrate, error) { fsVar, err := iofs.New(migrationFS, migrationsName) if err != nil { return nil, fmt.Errorf("opening fs: %w", err) } mdriver, err := sqlite3migrate.WithInstance(db, &sqlite3migrate.Config{}) if err != nil { return nil, fmt.Errorf("making %s migration driver: %w", storeName, err) } m, err := migrate.NewWithInstance("iofs", fsVar, "sqlite3", mdriver) if err != nil { return nil, fmt.Errorf("making %s migration: %w", storeName, err) } return m, nil } func Migrate(storeName string, db *sql.DB, migrationFS fs.FS, migrationsName string) error { log.Printf("migrate %s\n", storeName) m, err := MakeMigrate(storeName, db, migrationFS, migrationsName) if err != nil { return err } curVersion, dirty, err := GetMigrateVersion(m) if dirty { return fmt.Errorf("%s, migrate up, database is dirty", storeName) } if err != nil { return fmt.Errorf("%s, cannot get current migration version: %v", storeName, err) } err = m.Up() if err != nil && err != migrate.ErrNoChange { return fmt.Errorf("migrating %s: %w", storeName, err) } newVersion, _, err := GetMigrateVersion(m) if err != nil { return fmt.Errorf("%s, cannot get new migration version: %v", storeName, err) } if newVersion != curVersion { log.Printf("[db] %s migration done, version %d -> %d\n", storeName, curVersion, newVersion) } return nil } ================================================ FILE: pkg/util/packetparser/packetparser.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package packetparser import ( "bufio" "bytes" "fmt" "io" "log" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) type PacketParser struct { Reader io.Reader Ch chan []byte } func ParseWithLinesChan(input chan utilfn.LineOutput, packetCh chan baseds.RpcInputChType, rawCh chan []byte) { defer close(packetCh) defer close(rawCh) for { // note this line doesn't have a trailing newline line, ok := <-input if !ok { return } if line.Error != nil { log.Printf("ParseWithLinesChan: error reading line: %v", line.Error) return } if len(line.Line) <= 1 { // just a blank line continue } if bytes.HasPrefix([]byte(line.Line), []byte{'#', '#', 'N', '{'}) && bytes.HasSuffix([]byte(line.Line), []byte{'}'}) { // strip off the leading "##" packetCh <- baseds.RpcInputChType{MsgBytes: []byte(line.Line[3:len(line.Line)])} } else { rawCh <- []byte(line.Line) } } } func Parse(input io.Reader, packetCh chan baseds.RpcInputChType, rawCh chan []byte) error { bufReader := bufio.NewReader(input) defer close(packetCh) defer close(rawCh) for { // note this line does have a trailing newline line, err := bufReader.ReadBytes('\n') if err == io.EOF { return nil } if err != nil { return err } if len(line) <= 1 { // just a blank line continue } if bytes.HasPrefix(line, []byte{'#', '#', 'N', '{'}) && bytes.HasSuffix(line, []byte{'}', '\n'}) { // strip off the leading "##" and trailing "\n" (single byte) packetCh <- baseds.RpcInputChType{MsgBytes: line[3 : len(line)-1]} } else { rawCh <- line } } } func WritePacket(output io.Writer, packet []byte) error { if len(packet) < 2 { return nil } if packet[0] != '{' || packet[len(packet)-1] != '}' { return fmt.Errorf("invalid packet, must start with '{' and end with '}'") } fullPacket := make([]byte, 0, len(packet)+5) // we add the extra newline to make sure the ## appears at the beginning of the line // since writer isn't buffered, we want to send this all at once fullPacket = append(fullPacket, '\n', '#', '#', 'N') fullPacket = append(fullPacket, packet...) fullPacket = append(fullPacket, '\n') _, err := output.Write(fullPacket) return err } ================================================ FILE: pkg/util/pamparse/pamparse.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // Package pamparse provides functions for parsing environment files in the format of /etc/environment, /etc/security/pam_env.conf, and ~/.pam_environment. package pamparse import ( "bufio" "fmt" "os" "regexp" "strings" ) type PamParseOpts struct { Home string Shell string } // Parses a file in the format of /etc/environment. Accepts a path to the file and returns a map of environment variables. func ParseEnvironmentFile(path string) (map[string]string, error) { rtn := make(map[string]string) file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() key, val := parseEnvironmentLine(line) if key == "" { continue } rtn[key] = val } return rtn, nil } // Parses a file in the format of /etc/security/pam_env.conf or ~/.pam_environment. Accepts a path to the file and returns a map of environment variables. func ParseEnvironmentConfFile(path string, opts *PamParseOpts) (map[string]string, error) { rtn := make(map[string]string) file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() if opts == nil { opts, err = ParsePasswd() if err != nil { return nil, err } } if err != nil { return nil, err } scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() key, val := parseEnvironmentConfLine(line) // Fall back to ParseEnvironmentLine if ParseEnvironmentConfLine fails if key == "" { key, val = parseEnvironmentLine(line) if key == "" { continue } } rtn[key] = replaceHomeAndShell(val, opts.Home, opts.Shell) } return rtn, nil } // Gets the home directory and shell from /etc/passwd for the current user. func ParsePasswd() (*PamParseOpts, error) { file, err := os.Open("/etc/passwd") if err != nil { return nil, err } defer file.Close() userPrefix := fmt.Sprintf("%s:", os.Getenv("USER")) scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, userPrefix) { parts := strings.Split(line, ":") if len(parts) < 7 { return nil, fmt.Errorf("invalid passwd entry: insufficient fields") } return &PamParseOpts{ Home: parts[5], Shell: parts[6], }, nil } } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("error reading passwd file: %w", err) } return nil, nil } /* Gets the home directory and shell from /etc/passwd for the current user and returns a map of environment variables from /etc/security/pam_env.conf or ~/.pam_environment. Returns nil if an error occurs. */ func ParsePasswdSafe() *PamParseOpts { opts, err := ParsePasswd() if err != nil { return nil } return opts } // Replaces @{HOME} and @{SHELL} placeholders in a string with the provided values. Follows guidance from https://wiki.archlinux.org/title/Environment_variables#Using_pam_env func replaceHomeAndShell(val string, home string, shell string) string { val = strings.ReplaceAll(val, "@{HOME}", home) val = strings.ReplaceAll(val, "@{SHELL}", shell) return val } // Regex to parse a line from /etc/environment. Follows the guidance from https://wiki.archlinux.org/title/Environment_variables#Using_pam_env var envFileLineRe = regexp.MustCompile(`^(?:export\s+)?([A-Z0-9_]+[A-Za-z0-9]*)=(.*)$`) func parseEnvironmentLine(line string) (string, string) { m := envFileLineRe.FindStringSubmatch(line) if m == nil { return "", "" } return m[1], sanitizeEnvVarValue(m[2]) } // Regex to parse a line from /etc/security/pam_env.conf or ~/.pam_environment. Follows the guidance from https://wiki.archlinux.org/title/Environment_variables#Using_pam_env var confFileLineRe = regexp.MustCompile(`^([A-Z0-9_]+[A-Za-z0-9]*)\s+(?:(?:DEFAULT=)(\S+(?: \S+)*))\s*(?:(?:OVERRIDE=)(\S+(?: \S+)*))?\s*$`) func parseEnvironmentConfLine(line string) (string, string) { m := confFileLineRe.FindStringSubmatch(line) if m == nil { return "", "" } var vals []string if len(m) > 3 && m[3] != "" { vals = []string{sanitizeEnvVarValue(m[3]), sanitizeEnvVarValue(m[2])} } else { vals = []string{sanitizeEnvVarValue(m[2])} } return m[1], strings.Join(vals, ":") } // Sanitizes an environment variable value by stripping comments and trimming quotes. func sanitizeEnvVarValue(val string) string { return stripComments(trimQuotes(val)) } // Trims quotes as defined by https://unix.stackexchange.com/questions/748790/where-is-the-syntax-for-etc-environment-documented func trimQuotes(val string) string { if strings.HasPrefix(val, "\"") || strings.HasPrefix(val, "'") { val = val[1:] if strings.HasSuffix(val, "\"") || strings.HasSuffix(val, "'") { val = val[0 : len(val)-1] } } return val } // Strips comments as defined by https://unix.stackexchange.com/questions/748790/where-is-the-syntax-for-etc-environment-documented func stripComments(val string) string { commentIdx := strings.Index(val, "#") if commentIdx == -1 { return val } return val[0:commentIdx] } ================================================ FILE: pkg/util/pamparse/pamparse_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package pamparse_test import ( "os" "path/filepath" "testing" "github.com/wavetermdev/waveterm/pkg/util/pamparse" ) // Tests influenced by https://unix.stackexchange.com/questions/748790/where-is-the-syntax-for-etc-environment-documented func TestParseEnvironmentFile(t *testing.T) { const fileContent = ` FOO1=bar FOO2="bar" FOO3="bar FOO4=bar" FOO5='bar' FOO6='bar" export FOO7=bar FOO8=bar bar bar #FOO9=bar FOO10=$PATH FOO11="foo#bar" ` // create a temporary file with the content tempFile := filepath.Join(t.TempDir(), "pam_env") if err := os.WriteFile(tempFile, []byte(fileContent), 0644); err != nil { t.Fatalf("failed to write file: %v", err) } // parse the file got, err := pamparse.ParseEnvironmentFile(tempFile) if err != nil { t.Fatalf("failed to parse pam environment file: %v", err) } want := map[string]string{ "FOO1": "bar", "FOO2": "bar", "FOO3": "bar", "FOO4": "bar\"", "FOO5": "bar", "FOO6": "bar", "FOO7": "bar", "FOO8": "bar bar bar", "FOO10": "$PATH", "FOO11": "foo", } if len(got) != len(want) { t.Fatalf("expected %d environment variables, got %d", len(want), len(got)) } for k, v := range want { if got[k] != v { t.Errorf("expected %q to be %q, got %q", k, v, got[k]) } } } func TestParseEnvironmentConfFile(t *testing.T) { const fileContent = ` TEST DEFAULT=@{HOME}/.config\ state OVERRIDE=./config\ s FOO DEFAULT=@{HOME}/.config\ s STRING DEFAULT="string" STRINGOVERRIDE DEFAULT="string" OVERRIDE="string2" FOO11="foo#bar" ` // create a temporary file with the content tempFile := filepath.Join(t.TempDir(), "pam_env_conf") if err := os.WriteFile(tempFile, []byte(fileContent), 0644); err != nil { t.Fatalf("failed to write file: %v", err) } // parse the file got, err := pamparse.ParseEnvironmentConfFile(tempFile, &pamparse.PamParseOpts{ Home: "/home/user", Shell: "/bin/bash"}, ) if err != nil { t.Fatalf("failed to parse pam environment conf file: %v", err) } want := map[string]string{ "TEST": "./config\\ s:/home/user/.config\\ state", "FOO": "/home/user/.config\\ s", "STRING": "string", "STRINGOVERRIDE": "string2:string", "FOO11": "foo", } if len(got) != len(want) { t.Fatalf("expected %d environment variables, got %d", len(want), len(got)) } for k, v := range want { if got[k] != v { t.Errorf("expected %q to be %q, got %q", k, v, got[k]) } } } ================================================ FILE: pkg/util/readutil/readutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package readutil import ( "bufio" "fmt" "io" "os" ) const ( StopReasonBOF = "bof" StopReasonEOF = "eof" StopReasonReadLimit = "read_limit" ) // ReadLines reads lines from the reader, optionally skipping the first skipLines lines. // If lineCount is 0, no line limit is applied. If readLimit is 0, no byte limit is applied. // Stops when either limit is reached or EOF. // Returns lines (with trailing newlines), stop reason, and error. // Stop reason is StopReasonEOF when EOF is reached, StopReasonReadLimit when byte limit is reached, // or empty string for natural returns (line count limit or no limits applied). func ReadLines(reader io.Reader, lineCount int, skipLines int, readLimit int) ([]string, string, error) { bufReader := bufio.NewReader(reader) lines := make([]string, 0) bytesRead := 0 skippedLines := 0 for { line, err := bufReader.ReadString('\n') if len(line) > 0 { bytesRead += len(line) if skippedLines < skipLines { skippedLines++ } else { lines = append(lines, line) if lineCount > 0 && len(lines) >= lineCount { return lines, "", nil } } if readLimit > 0 && bytesRead >= readLimit { return lines, StopReasonReadLimit, nil } } if err != nil { if err == io.EOF { return lines, StopReasonEOF, nil } return nil, "", err } } } // readLastNLineOffsets reads all line offsets from the reader, keeping only the last maxLines in a sliding window. // keepFirst indicates whether offset 0 should be included (true if starting from file beginning). // Returns the offsets and the total number of lines found. func ReadLastNLineOffsets(rs io.ReadSeeker, maxLines int, keepFirst bool) ([]int64, int, error) { if _, err := rs.Seek(0, io.SeekStart); err != nil { return nil, 0, err } var offsets []int64 reader := bufio.NewReader(rs) var currentPos int64 = 0 totalLines := 0 if keepFirst { offsets = append(offsets, 0) totalLines = 1 } for { line, err := reader.ReadBytes('\n') if len(line) > 0 { currentPos += int64(len(line)) offsets = append(offsets, currentPos) totalLines++ // Keep maxLines+1 for sliding window (extra slot for EOF position) if len(offsets) > maxLines+1 { offsets = offsets[1:] } } if err == io.EOF { break } if err != nil { return nil, 0, err } } // Trim the final EOF offset if we have one if len(offsets) > 0 { offsets = offsets[:len(offsets)-1] totalLines-- } return offsets, totalLines, nil } // readTailLinesInternal reads the last lineCount lines from the reader, excluding the last lineOffset lines. // For example, lineCount=10 and lineOffset=5 would return lines -15 through -6 (the 10 lines before the last 5). // keepFirst indicates whether the first line should be kept (true if starting at file position 0, false otherwise). // Returns the lines (with trailing newlines), a hasMore flag, and any error. // hasMore is true if there are lines before our window (didn't hit BOF), false if we read from the very beginning. func readTailLinesInternal(rs io.ReadSeeker, lineCount int, lineOffset int, keepFirst bool) ([]string, bool, error) { maxOffsets := lineCount + lineOffset offsets, totalLines, err := ReadLastNLineOffsets(rs, maxOffsets, keepFirst) if err != nil { return nil, false, err } if totalLines <= lineOffset { return []string{}, false, nil } linesToRead := lineCount if totalLines-lineOffset < lineCount { linesToRead = totalLines - lineOffset } startIdx := len(offsets) - lineOffset - linesToRead hasMore := totalLines > lineCount+lineOffset if _, err := rs.Seek(offsets[startIdx], io.SeekStart); err != nil { return nil, false, err } lines, _, err := ReadLines(rs, linesToRead, 0, 0) if err != nil { return nil, false, err } return lines, hasMore, nil } // ReadTailLines reads the last lineCount lines from a file, excluding the last lineOffset lines. // It progressively reads larger windows from the end of the file (starting at 1MB, doubling up to readLimit) // until it finds enough lines or reaches the limit. Returns the lines, stop reason, and any error. // Stop reason is StopReasonBOF when beginning of file is reached, StopReasonReadLimit when byte limit is reached, // or empty string for natural completion (found requested line count). func ReadTailLines(file *os.File, lineCount int, lineOffset int, readLimit int64) ([]string, string, error) { if readLimit <= 0 { return nil, "", fmt.Errorf("ReadTailLines readLimit must be positive, got %d", readLimit) } fileInfo, err := file.Stat() if err != nil { return nil, "", err } fileSize := fileInfo.Size() readBytes := int64(1024 * 1024) if readLimit < readBytes { readBytes = readLimit } for { startPos := fileSize - readBytes if startPos < 0 { startPos = 0 readBytes = fileSize } sectionReader := io.NewSectionReader(file, startPos, readBytes) keepFirst := startPos == 0 lines, hasMoreInWindow, err := readTailLinesInternal(sectionReader, lineCount, lineOffset, keepFirst) if err != nil { return nil, "", err } if len(lines) == lineCount { hasMore := startPos > 0 || hasMoreInWindow if !hasMore { return lines, StopReasonBOF, nil } return lines, "", nil } if readBytes >= readLimit || readBytes >= fileSize { if startPos > 0 { return lines, StopReasonReadLimit, nil } return lines, StopReasonBOF, nil } readBytes *= 2 if readBytes > readLimit { readBytes = readLimit } } } ================================================ FILE: pkg/util/shellutil/shellintegration/bash_bashrc.sh ================================================ # Source /etc/profile if it exists if [ -f /etc/profile ]; then . /etc/profile fi WAVETERM_WSHBINDIR={{.WSHBINDIR}} # after /etc/profile which is likely to clobber the path export PATH="$WAVETERM_WSHBINDIR:$PATH" # Source the dynamic script from wsh token eval "$(wsh token "$WAVETERM_SWAPTOKEN" bash 2> /dev/null)" unset WAVETERM_SWAPTOKEN # Source the first of ~/.bash_profile, ~/.bash_login, or ~/.profile that exists if [ -f ~/.bash_profile ]; then . ~/.bash_profile elif [ -f ~/.bash_login ]; then . ~/.bash_login elif [ -f ~/.profile ]; then . ~/.profile fi if [[ ":$PATH:" != *":$WAVETERM_WSHBINDIR:"* ]]; then export PATH="$WAVETERM_WSHBINDIR:$PATH" fi unset WAVETERM_WSHBINDIR if type _init_completion &>/dev/null; then source <(wsh completion bash) fi # extdebug breaks bash-preexec semantics; bail out cleanly if shopt -q extdebug; then # printf 'wave si: disabled (bash extdebug enabled)\n' >&2 printf '\033]16162;M;{"integration":false}\007' return 0 fi # Source bash-preexec for proper preexec/precmd hook support if [ -z "${bash_preexec_imported:-}" ]; then _WAVETERM_SI_BASHRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [ -f "$_WAVETERM_SI_BASHRC_DIR/bash_preexec.sh" ]; then source "$_WAVETERM_SI_BASHRC_DIR/bash_preexec.sh" fi unset _WAVETERM_SI_BASHRC_DIR fi # Check if bash-preexec was successfully imported if [ -z "${bash_preexec_imported:-}" ]; then # bash-preexec failed to import, disable shell integration printf '\033]16162;M;{"integration":false}\007' return 0 fi _WAVETERM_SI_FIRSTPROMPT=1 # Wave Terminal Shell Integration _waveterm_si_blocked() { [[ -n "$TMUX" || -n "$STY" || "$TERM" == tmux* || "$TERM" == screen* ]] } _waveterm_si_urlencode() { local s="$1" s="${s//%/%25}" s="${s// /%20}" s="${s//#/%23}" s="${s//\?/%3F}" s="${s//&/%26}" s="${s//;/%3B}" s="${s//+/%2B}" printf '%s' "$s" } _waveterm_si_osc7() { _waveterm_si_blocked && return local encoded_pwd=$(_waveterm_si_urlencode "$PWD") printf '\033]7;file://localhost%s\007' "$encoded_pwd" } _waveterm_si_precmd() { local _waveterm_si_status=$? _waveterm_si_blocked && return if [ "$_WAVETERM_SI_FIRSTPROMPT" -eq 1 ]; then local uname_info uname_info=$(uname -smr 2>/dev/null) printf '\033]16162;M;{"shell":"bash","shellversion":"%s","uname":"%s","integration":true}\007' "$BASH_VERSION" "$uname_info" else printf '\033]16162;D;{"exitcode":%d}\007' "$_waveterm_si_status" fi # OSC 7 sent on every prompt - bash has no chpwd hook for directory changes _waveterm_si_osc7 printf '\033]16162;A\007' _WAVETERM_SI_FIRSTPROMPT=0 } _waveterm_si_preexec() { _waveterm_si_blocked && return local cmd="$1" local cmd_length=${#cmd} if [ "$cmd_length" -gt 8192 ]; then cmd=$(printf '# command too large (%d bytes)' "$cmd_length") fi local cmd64 cmd64=$(printf '%s' "$cmd" | base64 2>/dev/null | tr -d '\n\r') if [ -n "$cmd64" ]; then printf '\033]16162;C;{"cmd64":"%s"}\007' "$cmd64" else printf '\033]16162;C\007' fi } # Add our functions to the bash-preexec arrays precmd_functions+=(_waveterm_si_precmd) preexec_functions+=(_waveterm_si_preexec) ================================================ FILE: pkg/util/shellutil/shellintegration/bash_preexec.sh ================================================ # License for bash-preexec.sh follows below from https://github.com/rcaloras/bash-preexec v0.6.0 # ----------------------------------------------------------------------------- # The MIT License # # Copyright (c) 2017 Ryan Caloras and contributors (see https://github.com/rcaloras/bash-preexec) # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # # ----------------------------------------------------------------------------- # # bash-preexec.sh -- Bash support for ZSH-like 'preexec' and 'precmd' functions. # https://github.com/rcaloras/bash-preexec # # # 'preexec' functions are executed before each interactive command is # executed, with the interactive command as its argument. The 'precmd' # function is executed before each prompt is displayed. # # Author: Ryan Caloras (ryan@bashhub.com) # Forked from Original Author: Glyph Lefkowitz # # V0.6.0 # # General Usage: # # 1. Source this file at the end of your bash profile so as not to interfere # with anything else that's using PROMPT_COMMAND. # # 2. Add any precmd or preexec functions by appending them to their arrays: # e.g. # precmd_functions+=(my_precmd_function) # precmd_functions+=(some_other_precmd_function) # # preexec_functions+=(my_preexec_function) # # 3. Consider changing anything using the DEBUG trap or PROMPT_COMMAND # to use preexec and precmd instead. Preexisting usages will be # preserved, but doing so manually may be less surprising. # # Note: This module requires two Bash features which you must not otherwise be # using: the "DEBUG" trap, and the "PROMPT_COMMAND" variable. If you override # either of these after bash-preexec has been installed it will most likely break. # Tell shellcheck what kind of file this is. # shellcheck shell=bash # Make sure this is bash that's running and return otherwise. # Use POSIX syntax for this line: if [ -z "${BASH_VERSION-}" ]; then return 1 fi # We only support Bash 3.1+. # Note: BASH_VERSINFO is first available in Bash-2.0. if [[ -z "${BASH_VERSINFO-}" ]] || (( BASH_VERSINFO[0] < 3 || (BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1) )); then return 1 fi # Avoid duplicate inclusion if [[ -n "${bash_preexec_imported:-}" || -n "${__bp_imported:-}" ]]; then return 0 fi bash_preexec_imported="defined" # WARNING: This variable is no longer used and should not be relied upon. # Use ${bash_preexec_imported} instead. # shellcheck disable=SC2034 __bp_imported="${bash_preexec_imported}" # Should be available to each precmd and preexec # functions, should they want it. $? and $_ are available as $? and $_, but # $PIPESTATUS is available only in a copy, $BP_PIPESTATUS. # TODO: Figure out how to restore PIPESTATUS before each precmd or preexec # function. __bp_last_ret_value="$?" BP_PIPESTATUS=("${PIPESTATUS[@]}") __bp_last_argument_prev_command="$_" __bp_inside_precmd=0 __bp_inside_preexec=0 # Initial PROMPT_COMMAND string that is removed from PROMPT_COMMAND post __bp_install __bp_install_string=$'__bp_trap_string="$(trap -p DEBUG)"\ntrap - DEBUG\n__bp_install' # Fails if any of the given variables are readonly # Reference https://stackoverflow.com/a/4441178 __bp_require_not_readonly() { local var for var; do if ! ( unset "$var" 2> /dev/null ); then echo "bash-preexec requires write access to ${var}" >&2 return 1 fi done } # Remove ignorespace and or replace ignoreboth from HISTCONTROL # so we can accurately invoke preexec with a command from our # history even if it starts with a space. __bp_adjust_histcontrol() { local histcontrol histcontrol="${HISTCONTROL:-}" histcontrol="${histcontrol//ignorespace}" # Replace ignoreboth with ignoredups if [[ "$histcontrol" == *"ignoreboth"* ]]; then histcontrol="ignoredups:${histcontrol//ignoreboth}" fi export HISTCONTROL="$histcontrol" } # This variable describes whether we are currently in "interactive mode"; # i.e. whether this shell has just executed a prompt and is waiting for user # input. It documents whether the current command invoked by the trace hook is # run interactively by the user; it's set immediately after the prompt hook, # and unset as soon as the trace hook is run. __bp_preexec_interactive_mode="" # These arrays are used to add functions to be run before, or after, prompts. declare -a precmd_functions declare -a preexec_functions # Trims leading and trailing whitespace from $2 and writes it to the variable # name passed as $1 __bp_trim_whitespace() { local var=${1:?} text=${2:-} text="${text#"${text%%[![:space:]]*}"}" # remove leading whitespace characters text="${text%"${text##*[![:space:]]}"}" # remove trailing whitespace characters printf -v "$var" '%s' "$text" } # Trims whitespace and removes any leading or trailing semicolons from $2 and # writes the resulting string to the variable name passed as $1. Used for # manipulating substrings in PROMPT_COMMAND __bp_sanitize_string() { local var=${1:?} text=${2:-} sanitized __bp_trim_whitespace sanitized "$text" sanitized=${sanitized%;} sanitized=${sanitized#;} __bp_trim_whitespace sanitized "$sanitized" printf -v "$var" '%s' "$sanitized" } # This function is installed as part of the PROMPT_COMMAND; # It sets a variable to indicate that the prompt was just displayed, # to allow the DEBUG trap to know that the next command is likely interactive. __bp_interactive_mode() { __bp_preexec_interactive_mode="on" } # This function is installed as part of the PROMPT_COMMAND. # It will invoke any functions defined in the precmd_functions array. __bp_precmd_invoke_cmd() { # Save the returned value from our last command, and from each process in # its pipeline. Note: this MUST be the first thing done in this function. # BP_PIPESTATUS may be unused, ignore # shellcheck disable=SC2034 __bp_last_ret_value="$?" BP_PIPESTATUS=("${PIPESTATUS[@]}") # Don't invoke precmds if we are inside an execution of an "original # prompt command" by another precmd execution loop. This avoids infinite # recursion. if (( __bp_inside_precmd > 0 )); then return fi local __bp_inside_precmd=1 # Invoke every function defined in our function array. local precmd_function for precmd_function in "${precmd_functions[@]}"; do # Only execute this function if it actually exists. # Test existence of functions with: declare -[Ff] if type -t "$precmd_function" 1>/dev/null; then __bp_set_ret_value "$__bp_last_ret_value" "$__bp_last_argument_prev_command" # Quote our function invocation to prevent issues with IFS "$precmd_function" fi done __bp_set_ret_value "$__bp_last_ret_value" } # Sets a return value in $?. We may want to get access to the $? variable in our # precmd functions. This is available for instance in zsh. We can simulate it in bash # by setting the value here. __bp_set_ret_value() { return ${1:+"$1"} } __bp_in_prompt_command() { local prompt_command_array IFS=$'\n;' read -rd '' -a prompt_command_array <<< "${PROMPT_COMMAND[*]:-}" local trimmed_arg __bp_trim_whitespace trimmed_arg "${1:-}" local command trimmed_command for command in "${prompt_command_array[@]:-}"; do __bp_trim_whitespace trimmed_command "$command" if [[ "$trimmed_command" == "$trimmed_arg" ]]; then return 0 fi done return 1 } # This function is installed as the DEBUG trap. It is invoked before each # interactive prompt display. Its purpose is to inspect the current # environment to attempt to detect if the current command is being invoked # interactively, and invoke 'preexec' if so. __bp_preexec_invoke_exec() { # Save the contents of $_ so that it can be restored later on. # https://stackoverflow.com/questions/40944532/bash-preserve-in-a-debug-trap#40944702 __bp_last_argument_prev_command="${1:-}" # Don't invoke preexecs if we are inside of another preexec. if (( __bp_inside_preexec > 0 )); then return fi local __bp_inside_preexec=1 # Checks if the file descriptor is not standard out (i.e. '1') # __bp_delay_install checks if we're in test. Needed for bats to run. # Prevents preexec from being invoked for functions in PS1 if [[ ! -t 1 && -z "${__bp_delay_install:-}" ]]; then return fi if [[ -n "${COMP_POINT:-}" || -n "${READLINE_POINT:-}" ]]; then # We're in the middle of a completer or a keybinding set up by "bind # -x". This obviously can't be an interactively issued command. return fi if [[ -z "${__bp_preexec_interactive_mode:-}" ]]; then # We're doing something related to displaying the prompt. Let the # prompt set the title instead of me. return else # If we're in a subshell, then the prompt won't be re-displayed to put # us back into interactive mode, so let's not set the variable back. # In other words, if you have a subshell like # (sleep 1; sleep 2) # You want to see the 'sleep 2' as a set_command_title as well. if [[ 0 -eq "${BASH_SUBSHELL:-}" ]]; then __bp_preexec_interactive_mode="" fi fi if __bp_in_prompt_command "${BASH_COMMAND:-}"; then # If we're executing something inside our prompt_command then we don't # want to call preexec. Bash prior to 3.1 can't detect this at all :/ __bp_preexec_interactive_mode="" return fi local this_command this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1) this_command="${this_command#*[[:digit:]][* ] }" # Sanity check to make sure we have something to invoke our function with. if [[ -z "$this_command" ]]; then return fi # Invoke every function defined in our function array. local preexec_function local preexec_function_ret_value local preexec_ret_value=0 for preexec_function in "${preexec_functions[@]:-}"; do # Only execute each function if it actually exists. # Test existence of function with: declare -[fF] if type -t "$preexec_function" 1>/dev/null; then __bp_set_ret_value "${__bp_last_ret_value:-}" # Quote our function invocation to prevent issues with IFS "$preexec_function" "$this_command" preexec_function_ret_value="$?" if [[ "$preexec_function_ret_value" != 0 ]]; then preexec_ret_value="$preexec_function_ret_value" fi fi done # Restore the last argument of the last executed command, and set the return # value of the DEBUG trap to be the return code of the last preexec function # to return an error. # If `extdebug` is enabled a non-zero return value from any preexec function # will cause the user's command not to execute. # Run `shopt -s extdebug` to enable __bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command" } __bp_install() { # Exit if we already have this installed. if [[ "${PROMPT_COMMAND[*]:-}" == *"__bp_precmd_invoke_cmd"* ]]; then return 1 fi trap '__bp_preexec_invoke_exec "$_"' DEBUG # Preserve any prior DEBUG trap as a preexec function eval "local trap_argv=(${__bp_trap_string:-})" local prior_trap=${trap_argv[2]:-} unset __bp_trap_string if [[ -n "$prior_trap" ]]; then eval '__bp_original_debug_trap() { '"$prior_trap"' }' preexec_functions+=(__bp_original_debug_trap) fi # Adjust our HISTCONTROL Variable if needed. __bp_adjust_histcontrol # Issue #25. Setting debug trap for subshells causes sessions to exit for # backgrounded subshell commands (e.g. (pwd)& ). Believe this is a bug in Bash. # # Disabling this by default. It can be enabled by setting this variable. if [[ -n "${__bp_enable_subshells:-}" ]]; then # Set so debug trap will work be invoked in subshells. set -o functrace > /dev/null 2>&1 shopt -s extdebug > /dev/null 2>&1 fi local existing_prompt_command # Remove setting our trap install string and sanitize the existing prompt command string existing_prompt_command="${PROMPT_COMMAND:-}" # Edge case of appending to PROMPT_COMMAND existing_prompt_command="${existing_prompt_command//$__bp_install_string/:}" # no-op existing_prompt_command="${existing_prompt_command//$'\n':$'\n'/$'\n'}" # remove known-token only existing_prompt_command="${existing_prompt_command//$'\n':;/$'\n'}" # remove known-token only __bp_sanitize_string existing_prompt_command "$existing_prompt_command" if [[ "${existing_prompt_command:-:}" == ":" ]]; then existing_prompt_command= fi # Install our hooks in PROMPT_COMMAND to allow our trap to know when we've # actually entered something. PROMPT_COMMAND='__bp_precmd_invoke_cmd' PROMPT_COMMAND+=${existing_prompt_command:+$'\n'$existing_prompt_command} if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then PROMPT_COMMAND+=('__bp_interactive_mode') else # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 PROMPT_COMMAND+=$'\n__bp_interactive_mode' fi # Add two functions to our arrays for convenience # of definition. precmd_functions+=(precmd) preexec_functions+=(preexec) # Invoke our two functions manually that were added to $PROMPT_COMMAND __bp_precmd_invoke_cmd __bp_interactive_mode } # Sets an installation string as part of our PROMPT_COMMAND to install # after our session has started. This allows bash-preexec to be included # at any point in our bash profile. __bp_install_after_session_init() { # bash-preexec needs to modify these variables in order to work correctly # if it can't, just stop the installation __bp_require_not_readonly PROMPT_COMMAND HISTCONTROL HISTTIMEFORMAT || return local sanitized_prompt_command __bp_sanitize_string sanitized_prompt_command "${PROMPT_COMMAND:-}" if [[ -n "$sanitized_prompt_command" ]]; then # shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0 PROMPT_COMMAND=${sanitized_prompt_command}$'\n' fi # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 PROMPT_COMMAND+=${__bp_install_string} } # Run our install so long as we're not delaying it. if [[ -z "${__bp_delay_install:-}" ]]; then __bp_install_after_session_init fi ================================================ FILE: pkg/util/shellutil/shellintegration/fish_wavefish.sh ================================================ # this file is sourced with -C # Add Wave binary directory to PATH set -x PATH {{.WSHBINDIR}} $PATH # Source dynamic script from wsh token (the echo is to prevent fish from complaining about empty input) wsh token "$WAVETERM_SWAPTOKEN" fish 2>/dev/null | source set -e WAVETERM_SWAPTOKEN # Load Wave completions wsh completion fish | source set -g _WAVETERM_SI_FIRSTPROMPT 1 # shell integration function _waveterm_si_blocked # Check if we're in tmux or screen (using fish-native checks) set -q TMUX; or set -q STY; or string match -q 'tmux*' -- $TERM; or string match -q 'screen*' -- $TERM end function _waveterm_si_osc7 _waveterm_si_blocked; and return # Use fish-native URL encoding set -l encoded_pwd (string escape --style=url -- "$PWD") printf '\033]7;file://localhost%s\007' $encoded_pwd end function _waveterm_si_prompt --on-event fish_prompt set -l _waveterm_si_status $status _waveterm_si_blocked; and return if test $_WAVETERM_SI_FIRSTPROMPT -eq 1 set -l uname_info (uname -smr 2>/dev/null) printf '\033]16162;M;{"shell":"fish","shellversion":"%s","uname":"%s","integration":true}\007' $FISH_VERSION "$uname_info" # OSC 7 only sent on first prompt - chpwd hook handles directory changes _waveterm_si_osc7 else printf '\033]16162;D;{"exitcode":%d}\007' $_waveterm_si_status end printf '\033]16162;A\007' set -g _WAVETERM_SI_FIRSTPROMPT 0 end function _waveterm_si_preexec --on-event fish_preexec _waveterm_si_blocked; and return set -l cmd (string join -- ' ' $argv) set -l cmd_length (string length -- "$cmd") if test $cmd_length -gt 8192 set -l cmd64 (printf '# command too large (%d bytes)' $cmd_length | base64 2>/dev/null | string replace -a '\n' '' | string replace -a '\r' '') printf '\033]16162;C;{"cmd64":"%s"}\007' "$cmd64" else set -l cmd64 (printf '%s' "$cmd" | base64 2>/dev/null | string replace -a '\n' '' | string replace -a '\r' '') if test -n "$cmd64" printf '\033]16162;C;{"cmd64":"%s"}\007' "$cmd64" else printf '\033]16162;C\007' end end end # Also update on directory change function _waveterm_si_chpwd --on-variable PWD _waveterm_si_osc7 end ================================================ FILE: pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh ================================================ # We source this file with -NoExit -File $env:PATH = {{.WSHBINDIR_PWSH}} + "{{.PATHSEP}}" + $env:PATH # Source dynamic script from wsh token $waveterm_swaptoken_output = wsh token $env:WAVETERM_SWAPTOKEN pwsh 2>$null | Out-String if ($waveterm_swaptoken_output -and $waveterm_swaptoken_output -ne "") { Invoke-Expression $waveterm_swaptoken_output } Remove-Variable -Name waveterm_swaptoken_output Remove-Item Env:WAVETERM_SWAPTOKEN # Load Wave completions wsh completion powershell | Out-String | Invoke-Expression if ($PSVersionTable.PSVersion.Major -lt 7) { return # skip OSC setup entirely } $Global:_WAVETERM_SI_FIRSTPROMPT = $true # shell integration function Global:_waveterm_si_blocked { # Check if we're in tmux or screen return ($env:TMUX -or $env:STY -or $env:TERM -like "tmux*" -or $env:TERM -like "screen*") } function Global:_waveterm_si_osc7 { if (_waveterm_si_blocked) { return } # Percent-encode the raw path as-is (handles UNC, drive letters, etc.) $encoded_pwd = [System.Uri]::EscapeDataString($PWD.Path) # OSC 7 - current directory Write-Host -NoNewline "`e]7;file://localhost/$encoded_pwd`a" } function Global:_waveterm_si_prompt { if (_waveterm_si_blocked) { return } if ($Global:_WAVETERM_SI_FIRSTPROMPT) { # not sending uname $shellversion = $PSVersionTable.PSVersion.ToString() Write-Host -NoNewline "`e]16162;M;{`"shell`":`"pwsh`",`"shellversion`":`"$shellversion`",`"integration`":false}`a" $Global:_WAVETERM_SI_FIRSTPROMPT = $false } _waveterm_si_osc7 } # Add the OSC 7 call to the prompt function if (Test-Path Function:\prompt) { $global:_waveterm_original_prompt = $function:prompt function Global:prompt { _waveterm_si_prompt & $global:_waveterm_original_prompt } } else { function Global:prompt { _waveterm_si_prompt "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " } } ================================================ FILE: pkg/util/shellutil/shellintegration/zsh_zlogin.sh ================================================ # Source the original zlogin [ -f ~/.zlogin ] && source ~/.zlogin # Unset ZDOTDIR only if it hasn't been modified if [ "$ZDOTDIR" = "$WAVETERM_ZDOTDIR" ]; then unset ZDOTDIR fi ================================================ FILE: pkg/util/shellutil/shellintegration/zsh_zprofile.sh ================================================ # Source the original zprofile [ -f ~/.zprofile ] && source ~/.zprofile ================================================ FILE: pkg/util/shellutil/shellintegration/zsh_zshenv.sh ================================================ # Store the initial ZDOTDIR value WAVETERM_ZDOTDIR="$ZDOTDIR" # Source the original zshenv [ -f ~/.zshenv ] && source ~/.zshenv # Detect if ZDOTDIR has changed if [ "$ZDOTDIR" != "$WAVETERM_ZDOTDIR" ]; then # If changed, manually source your custom zshrc from the original WAVETERM_ZDOTDIR [ -f "$WAVETERM_ZDOTDIR/.zshrc" ] && source "$WAVETERM_ZDOTDIR/.zshrc" fi ================================================ FILE: pkg/util/shellutil/shellintegration/zsh_zshrc.sh ================================================ # add wsh to path, source dynamic script from wsh token WAVETERM_WSHBINDIR={{.WSHBINDIR}} export PATH="$WAVETERM_WSHBINDIR:$PATH" source <(wsh token "$WAVETERM_SWAPTOKEN" zsh 2>/dev/null) unset WAVETERM_SWAPTOKEN # Source the original zshrc only if ZDOTDIR has not been changed if [ "$ZDOTDIR" = "$WAVETERM_ZDOTDIR" ]; then [ -f ~/.zshrc ] && source ~/.zshrc fi if [[ ":$PATH:" != *":$WAVETERM_WSHBINDIR:"* ]]; then export PATH="$WAVETERM_WSHBINDIR:$PATH" fi unset WAVETERM_WSHBINDIR if [[ -n ${_comps+x} ]]; then source <(wsh completion zsh) fi # fix history (macos) if [[ "$HISTFILE" == "$WAVETERM_ZDOTDIR/.zsh_history" ]]; then HISTFILE="$HOME/.zsh_history" fi typeset -g _WAVETERM_SI_FIRSTPRECMD=1 # shell integration _waveterm_si_blocked() { [[ -n "$TMUX" || -n "$STY" || "$TERM" == tmux* || "$TERM" == screen* ]] } _waveterm_si_urlencode() { if (( $+functions[omz_urlencode] )); then omz_urlencode "$1" else local s="$1" # Escape % first s=${s//\%/%25} # Common reserved characters in file paths s=${s//\ /%20} s=${s//\#/%23} s=${s//\?/%3F} s=${s//\&/%26} s=${s//\;/%3B} s=${s//\+/%2B} printf '%s' "$s" fi } _waveterm_si_compmode() { # fzf-based completion wins if typeset -f _fzf_tab_complete >/dev/null 2>&1 || typeset -f _fzf_complete >/dev/null 2>&1; then echo "fzf" return fi # Check zstyle menu setting local _menuval if zstyle -s ':completion:*' menu _menuval 2>/dev/null; then if [[ "$_menuval" == *select* ]]; then echo "menu-select" else echo "menu" fi return fi echo "standard" } _waveterm_si_osc7() { _waveterm_si_blocked && return local encoded_pwd=$(_waveterm_si_urlencode "$PWD") printf '\033]7;file://localhost%s\007' "$encoded_pwd" # OSC 7 - current directory } _waveterm_si_precmd() { local _waveterm_si_status=$? _waveterm_si_blocked && return # D;status for previous command (skip before first prompt) if (( !_WAVETERM_SI_FIRSTPRECMD )); then printf '\033]16162;D;{"exitcode":%d}\007' "$_waveterm_si_status" else local uname_info=$(uname -smr 2>/dev/null) local omz=false local comp=$(_waveterm_si_compmode) [[ -n "$ZSH" && -r "$ZSH/oh-my-zsh.sh" ]] && omz=true printf '\033]16162;M;{"shell":"zsh","shellversion":"%s","uname":"%s","integration":true,"omz":%s,"comp":"%s"}\007' "$ZSH_VERSION" "$uname_info" "$omz" "$comp" # OSC 7 only sent on first prompt - chpwd hook handles directory changes _waveterm_si_osc7 fi printf '\033]16162;A\007' _WAVETERM_SI_FIRSTPRECMD=0 } _waveterm_si_preexec() { _waveterm_si_blocked && return local cmd="$1" local cmd_length=${#cmd} if [ "$cmd_length" -gt 8192 ]; then cmd=$(printf '# command too large (%d bytes)' "$cmd_length") fi local cmd64 cmd64=$(printf '%s' "$cmd" | base64 2>/dev/null | tr -d '\n\r') if [ -n "$cmd64" ]; then printf '\033]16162;C;{"cmd64":"%s"}\007' "$cmd64" else printf '\033]16162;C\007' fi } typeset -g WAVETERM_SI_INPUTEMPTY=1 _waveterm_si_inputempty() { _waveterm_si_blocked && return local current_empty=1 if [[ -n "$BUFFER" ]]; then current_empty=0 fi if (( current_empty != WAVETERM_SI_INPUTEMPTY )); then WAVETERM_SI_INPUTEMPTY=$current_empty if (( current_empty )); then printf '\033]16162;I;{"inputempty":true}\007' else printf '\033]16162;I;{"inputempty":false}\007' fi fi } autoload -Uz add-zle-hook-widget 2>/dev/null if (( $+functions[add-zle-hook-widget] )); then add-zle-hook-widget zle-line-init _waveterm_si_inputempty add-zle-hook-widget zle-line-pre-redraw _waveterm_si_inputempty fi autoload -U add-zsh-hook add-zsh-hook precmd _waveterm_si_precmd add-zsh-hook preexec _waveterm_si_preexec add-zsh-hook chpwd _waveterm_si_osc7 ================================================ FILE: pkg/util/shellutil/shellquote.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package shellutil import ( "log" "regexp" ) const ( MaxQuoteSize = 10000000 // 10MB ) var ( safePattern = regexp.MustCompile(`^[a-zA-Z0-9_@:,+=/.-]+$`) envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) ) func IsValidEnvVarName(name string) bool { return envVarNamePattern.MatchString(name) } func HardQuote(s string) string { if s == "" { return "\"\"" } if safePattern.MatchString(s) { return s } if !checkQuoteSize(s) { return "" } buf := make([]byte, 0, len(s)+5) buf = append(buf, '"') for i := 0; i < len(s); i++ { switch s[i] { case '"', '\\', '$', '`': buf = append(buf, '\\', s[i]) default: buf = append(buf, s[i]) } } buf = append(buf, '"') return string(buf) } // does not encode newlines or backticks func HardQuoteFish(s string) string { if s == "" { return "\"\"" } if safePattern.MatchString(s) { return s } if !checkQuoteSize(s) { return "" } buf := make([]byte, 0, len(s)+5) buf = append(buf, '"') for i := 0; i < len(s); i++ { switch s[i] { case '"', '\\', '$': buf = append(buf, '\\', s[i]) default: buf = append(buf, s[i]) } } buf = append(buf, '"') return string(buf) } func HardQuotePowerShell(s string) string { if s == "" { return "\"\"" } if !checkQuoteSize(s) { return "" } buf := make([]byte, 0, len(s)+5) buf = append(buf, '"') for i := 0; i < len(s); i++ { c := s[i] // In PowerShell, backtick (`) is the escape character switch c { case '"', '`', '$': buf = append(buf, '`') case '\n': buf = append(buf, '`', 'n') // PowerShell uses `n for newline } buf = append(buf, c) } buf = append(buf, '"') return string(buf) } func SoftQuote(s string) string { if s == "" { return "\"\"" } // Handle special case of ~ paths if len(s) > 0 && s[0] == '~' { // If it's just ~ or ~/something with no special chars, leave it as is if len(s) == 1 || (len(s) > 1 && s[1] == '/' && safePattern.MatchString(s[2:])) { return s } // Otherwise quote everything after the ~ (including the /) if len(s) > 1 && s[1] == '/' { return "~" + SoftQuote(s[1:]) } } if safePattern.MatchString(s) { return s } if !checkQuoteSize(s) { return "" } buf := make([]byte, 0, len(s)+5) buf = append(buf, '"') for i := 0; i < len(s); i++ { c := s[i] // In soft quote, we don't escape $ to allow expansion if c == '"' || c == '\\' || c == '`' { buf = append(buf, '\\') } buf = append(buf, c) } buf = append(buf, '"') return string(buf) } func checkQuoteSize(s string) bool { if len(s) > MaxQuoteSize { log.Printf("string too long to quote: %s", s) return false } return true } ================================================ FILE: pkg/util/shellutil/shellquote_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package shellutil import "testing" func TestQuote(t *testing.T) { tests := []struct { name string input string wantHard string wantSoft string }{ { name: "simple strings", input: "simple", wantHard: "simple", wantSoft: "simple", }, { name: "safe path", input: "path/to/file.txt", wantHard: "path/to/file.txt", wantSoft: "path/to/file.txt", }, { name: "empty string", input: "", wantHard: `""`, wantSoft: `""`, }, { name: "tilde alone", input: "~", wantHard: `"~"`, wantSoft: "~", }, { name: "tilde with safe path", input: "~/foo", wantHard: `"~/foo"`, wantSoft: "~/foo", }, { name: "tilde with spaces", input: "~/foo bar", wantHard: `"~/foo bar"`, wantSoft: `~"/foo bar"`, }, { name: "tilde with variable", input: "~/foo$bar", wantHard: `"~/foo\$bar"`, wantSoft: `~"/foo$bar"`, }, { name: "invalid tilde path", input: "~foo", wantHard: `"~foo"`, wantSoft: `"~foo"`, }, { name: "variable at start", input: "$HOME/.config", wantHard: `"\$HOME/.config"`, wantSoft: `"$HOME/.config"`, }, { name: "variable in middle", input: "prefix$HOME", wantHard: `"prefix\$HOME"`, wantSoft: `"prefix$HOME"`, }, { name: "double quotes", input: `has "quotes"`, wantHard: `"has \"quotes\""`, wantSoft: `"has \"quotes\""`, }, { name: "backslash", input: `back\slash`, wantHard: `"back\\slash"`, wantSoft: `"back\\slash"`, }, { name: "backtick", input: "`cmd`", wantHard: "\"\\`cmd\\`\"", wantSoft: "\"\\`cmd\\`\"", }, { name: "spaces", input: "spaces here", wantHard: `"spaces here"`, wantSoft: `"spaces here"`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := HardQuote(tt.input); got != tt.wantHard { t.Errorf("HardQuote(%q) = %q, want %q", tt.input, got, tt.wantHard) } if got := SoftQuote(tt.input); got != tt.wantSoft { t.Errorf("SoftQuote(%q) = %q, want %q", tt.input, got, tt.wantSoft) } }) } } ================================================ FILE: pkg/util/shellutil/shellutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package shellutil import ( "context" _ "embed" "fmt" "log" "os" "os/exec" "os/user" "path/filepath" "regexp" "runtime" "strings" "sync" "time" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" ) var ( //go:embed shellintegration/zsh_zprofile.sh ZshStartup_Zprofile string //go:embed shellintegration/zsh_zshrc.sh ZshStartup_Zshrc string //go:embed shellintegration/zsh_zlogin.sh ZshStartup_Zlogin string //go:embed shellintegration/zsh_zshenv.sh ZshStartup_Zshenv string //go:embed shellintegration/bash_bashrc.sh BashStartup_Bashrc string //go:embed shellintegration/bash_preexec.sh BashStartup_Preexec string //go:embed shellintegration/fish_wavefish.sh FishStartup_Wavefish string //go:embed shellintegration/pwsh_wavepwsh.sh PwshStartup_wavepwsh string ZshExtendedHistoryPattern = regexp.MustCompile(`^: [0-9]+:`) ) const DefaultTermType = "xterm-256color" const DefaultTermRows = 24 const DefaultTermCols = 80 var cachedMacUserShell string var macUserShellOnce = &sync.Once{} var userShellRegexp = regexp.MustCompile(`^UserShell: (.*)$`) var gitBashCache = utilds.MakeSyncCache(findInstalledGitBash) const DefaultShellPath = "/bin/bash" const ( ShellType_bash = "bash" ShellType_zsh = "zsh" ShellType_fish = "fish" ShellType_pwsh = "pwsh" ShellType_unknown = "unknown" ) const ( // there must be no spaces in these integration dir paths ZshIntegrationDir = "shell/zsh" BashIntegrationDir = "shell/bash" PwshIntegrationDir = "shell/pwsh" FishIntegrationDir = "shell/fish" WaveHomeBinDir = "bin" ZshHistoryFileName = ".zsh_history" ) func DetectLocalShellPath() string { if runtime.GOOS == "windows" { if pwshPath, lpErr := exec.LookPath("pwsh"); lpErr == nil { return pwshPath } if powershellPath, lpErr := exec.LookPath("powershell"); lpErr == nil { return powershellPath } return "powershell.exe" } shellPath := GetMacUserShell() if shellPath == "" { shellPath = os.Getenv("SHELL") } if shellPath == "" { return DefaultShellPath } return shellPath } func GetMacUserShell() string { if runtime.GOOS != "darwin" { return "" } macUserShellOnce.Do(func() { cachedMacUserShell = internalMacUserShell() }) return cachedMacUserShell } // dscl . -read /Users/[username] UserShell // defaults to /bin/bash func internalMacUserShell() string { osUser, err := user.Current() if err != nil { return DefaultShellPath } ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() userStr := "/Users/" + osUser.Username out, err := exec.CommandContext(ctx, "dscl", ".", "-read", userStr, "UserShell").CombinedOutput() if err != nil { return DefaultShellPath } outStr := strings.TrimSpace(string(out)) m := userShellRegexp.FindStringSubmatch(outStr) if m == nil { return DefaultShellPath } return m[1] } func hasDirPart(dir string, part string) bool { dir = filepath.Clean(dir) part = strings.ToLower(part) for { base := strings.ToLower(filepath.Base(dir)) if base == part { return true } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } return false } func FindGitBash(config *wconfig.FullConfigType, rescan bool) string { if runtime.GOOS != "windows" { return "" } if config != nil && config.Settings.TermGitBashPath != "" { return config.Settings.TermGitBashPath } path, _ := gitBashCache.Get(rescan) return path } func findInstalledGitBash() (string, error) { // Try PATH first (skip system32, and only accept if in a Git directory) pathEnv := os.Getenv("PATH") pathDirs := filepath.SplitList(pathEnv) for _, dir := range pathDirs { dir = strings.Trim(dir, `"`) if hasDirPart(dir, "system32") { continue } if !hasDirPart(dir, "git") { continue } bashPath := filepath.Join(dir, "bash.exe") if _, err := os.Stat(bashPath); err == nil { return bashPath, nil } } // Try scoop location userProfile := os.Getenv("USERPROFILE") if userProfile != "" { scoopPath := filepath.Join(userProfile, "scoop", "apps", "git", "current", "bin", "bash.exe") if _, err := os.Stat(scoopPath); err == nil { return scoopPath, nil } } // Try LocalAppData\programs\git\bin localAppData := os.Getenv("LOCALAPPDATA") if localAppData != "" { localPath := filepath.Join(localAppData, "programs", "git", "bin", "bash.exe") if _, err := os.Stat(localPath); err == nil { return localPath, nil } } // Try C:\Program Files\Git\bin programFilesPath := filepath.Join("C:\\", "Program Files", "Git", "bin", "bash.exe") if _, err := os.Stat(programFilesPath); err == nil { return programFilesPath, nil } return "", nil } func DefaultTermSize() waveobj.TermSize { return waveobj.TermSize{Rows: DefaultTermRows, Cols: DefaultTermCols} } func WaveshellLocalEnvVars(termType string) map[string]string { rtn := make(map[string]string) if termType != "" { rtn["TERM"] = termType } // these are not necessary since they should be set with the swap token, but no harm in setting them here rtn["TERM_PROGRAM"] = "waveterm" rtn["WAVETERM"], _ = os.Executable() rtn["WAVETERM_VERSION"] = wavebase.WaveVersion rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveDataDir(), WaveHomeBinDir) return rtn } func UpdateCmdEnv(cmd *exec.Cmd, envVars map[string]string) { if len(envVars) == 0 { return } found := make(map[string]bool) var newEnv []string for _, envStr := range cmd.Env { envKey := GetEnvStrKey(envStr) newEnvVal, ok := envVars[envKey] if ok { found[envKey] = true if newEnvVal != "" { newEnv = append(newEnv, envKey+"="+newEnvVal) } } else { newEnv = append(newEnv, envStr) } } for envKey, envVal := range envVars { if found[envKey] { continue } newEnv = append(newEnv, envKey+"="+envVal) } cmd.Env = newEnv } func GetEnvStrKey(envStr string) string { eqIdx := strings.Index(envStr, "=") if eqIdx == -1 { return envStr } return envStr[0:eqIdx] } var initStartupFilesOnce = &sync.Once{} // in a Once block so it can be called multiple times // we run it at startup, but also before launching local shells so we know everything is initialized before starting the shell func InitCustomShellStartupFiles() error { var err error initStartupFilesOnce.Do(func() { err = initCustomShellStartupFilesInternal() }) return err } func GetLocalBashRcFileOverride() string { return filepath.Join(wavebase.GetWaveDataDir(), BashIntegrationDir, ".bashrc") } func GetLocalWaveFishFilePath() string { return filepath.Join(wavebase.GetWaveDataDir(), FishIntegrationDir, "wave.fish") } func GetLocalWavePowershellEnv() string { return filepath.Join(wavebase.GetWaveDataDir(), PwshIntegrationDir, "wavepwsh.ps1") } func GetLocalZshZDotDir() string { return filepath.Join(wavebase.GetWaveDataDir(), ZshIntegrationDir) } func HasWaveZshHistory() (bool, int64) { zshDir := GetLocalZshZDotDir() historyFile := filepath.Join(zshDir, ZshHistoryFileName) fileInfo, err := os.Stat(historyFile) if err != nil { return false, 0 } return true, fileInfo.Size() } func IsExtendedZshHistoryFile(fileName string) (bool, error) { file, err := os.Open(fileName) if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } defer file.Close() buf := make([]byte, 1024) n, err := file.Read(buf) if err != nil { return false, err } content := string(buf[:n]) lines := strings.Split(content, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } return ZshExtendedHistoryPattern.MatchString(line), nil } return false, nil } func GetLocalWshBinaryPath(version string, goos string, goarch string) (string, error) { ext := "" if goarch == "amd64" { goarch = "x64" } if goarch == "aarch64" { goarch = "arm64" } if goos == "windows" { ext = ".exe" } if !wavebase.SupportedWshBinaries[fmt.Sprintf("%s-%s", goos, goarch)] { return "", fmt.Errorf("unsupported wsh platform: %s-%s", goos, goarch) } baseName := fmt.Sprintf("wsh-%s-%s.%s%s", version, goos, goarch, ext) return filepath.Join(wavebase.GetWaveAppBinPath(), baseName), nil } // absWshBinDir must be an absolute, expanded path (no ~ or $HOME, etc.) // it will be hard-quoted appropriately for the shell func InitRcFiles(waveHome string, absWshBinDir string) error { // ensure directories exist zshDir := filepath.Join(waveHome, ZshIntegrationDir) err := wavebase.CacheEnsureDir(zshDir, ZshIntegrationDir, 0755, ZshIntegrationDir) if err != nil { return err } bashDir := filepath.Join(waveHome, BashIntegrationDir) err = wavebase.CacheEnsureDir(bashDir, BashIntegrationDir, 0755, BashIntegrationDir) if err != nil { return err } fishDir := filepath.Join(waveHome, FishIntegrationDir) err = wavebase.CacheEnsureDir(fishDir, FishIntegrationDir, 0755, FishIntegrationDir) if err != nil { return err } pwshDir := filepath.Join(waveHome, PwshIntegrationDir) err = wavebase.CacheEnsureDir(pwshDir, PwshIntegrationDir, 0755, PwshIntegrationDir) if err != nil { return err } var pathSep string if runtime.GOOS == "windows" { pathSep = ";" } else { pathSep = ":" } params := map[string]string{ "WSHBINDIR": HardQuote(absWshBinDir), "WSHBINDIR_PWSH": HardQuotePowerShell(absWshBinDir), "PATHSEP": pathSep, } // write files to directory err = utilfn.WriteTemplateToFile(filepath.Join(zshDir, ".zprofile"), ZshStartup_Zprofile, params) if err != nil { return fmt.Errorf("error writing zsh-integration .zprofile: %v", err) } err = utilfn.WriteTemplateToFile(filepath.Join(zshDir, ".zshrc"), ZshStartup_Zshrc, params) if err != nil { return fmt.Errorf("error writing zsh-integration .zshrc: %v", err) } err = utilfn.WriteTemplateToFile(filepath.Join(zshDir, ".zlogin"), ZshStartup_Zlogin, params) if err != nil { return fmt.Errorf("error writing zsh-integration .zlogin: %v", err) } err = utilfn.WriteTemplateToFile(filepath.Join(zshDir, ".zshenv"), ZshStartup_Zshenv, params) if err != nil { return fmt.Errorf("error writing zsh-integration .zshenv: %v", err) } err = utilfn.WriteTemplateToFile(filepath.Join(bashDir, ".bashrc"), BashStartup_Bashrc, params) if err != nil { return fmt.Errorf("error writing bash-integration .bashrc: %v", err) } err = os.WriteFile(filepath.Join(bashDir, "bash_preexec.sh"), []byte(BashStartup_Preexec), 0644) if err != nil { return fmt.Errorf("error writing bash-integration bash_preexec.sh: %v", err) } err = utilfn.WriteTemplateToFile(filepath.Join(fishDir, "wave.fish"), FishStartup_Wavefish, params) if err != nil { return fmt.Errorf("error writing fish-integration wave.fish: %v", err) } err = utilfn.WriteTemplateToFile(filepath.Join(pwshDir, "wavepwsh.ps1"), PwshStartup_wavepwsh, params) if err != nil { return fmt.Errorf("error writing pwsh-integration wavepwsh.ps1: %v", err) } return nil } func initCustomShellStartupFilesInternal() error { log.Printf("initializing wsh and shell startup files\n") waveDataHome := wavebase.GetWaveDataDir() binDir := filepath.Join(waveDataHome, WaveHomeBinDir) err := InitRcFiles(waveDataHome, binDir) if err != nil { return err } err = wavebase.CacheEnsureDir(binDir, WaveHomeBinDir, 0755, WaveHomeBinDir) if err != nil { return err } // copy the correct binary to bin wshFullPath, err := GetLocalWshBinaryPath(wavebase.WaveVersion, runtime.GOOS, runtime.GOARCH) if err != nil { log.Printf("error (non-fatal), could not resolve wsh binary path: %v\n", err) } if _, err := os.Stat(wshFullPath); err != nil { log.Printf("error (non-fatal), could not resolve wsh binary %q: %v\n", wshFullPath, err) return nil } wshDstPath := filepath.Join(binDir, "wsh") if runtime.GOOS == "windows" { wshDstPath = wshDstPath + ".exe" } err = utilfn.AtomicRenameCopy(wshDstPath, wshFullPath, 0755) if err != nil { return fmt.Errorf("error copying wsh binary to bin: %v", err) } wshBaseName := filepath.Base(wshFullPath) log.Printf("wsh binary successfully copied from %q to %q\n", wshBaseName, wshDstPath) return nil } func GetShellTypeFromShellPath(shellPath string) string { shellBase := filepath.Base(shellPath) if strings.Contains(shellBase, "bash") { return ShellType_bash } if strings.Contains(shellBase, "zsh") { return ShellType_zsh } if strings.Contains(shellBase, "fish") { return ShellType_fish } if strings.Contains(shellBase, "pwsh") || strings.Contains(shellBase, "powershell") { return ShellType_pwsh } return ShellType_unknown } var ( bashVersionRegexp = regexp.MustCompile(`\bversion\s+(\d+\.\d+)`) zshVersionRegexp = regexp.MustCompile(`\bzsh\s+(\d+\.\d+)`) fishVersionRegexp = regexp.MustCompile(`\bversion\s+(\d+\.\d+)`) pwshVersionRegexp = regexp.MustCompile(`(?:PowerShell\s+)?(\d+\.\d+)`) ) func DetectShellTypeAndVersion() (string, string, error) { shellPath := DetectLocalShellPath() return DetectShellTypeAndVersionFromPath(shellPath) } func DetectShellTypeAndVersionFromPath(shellPath string) (string, string, error) { shellType := GetShellTypeFromShellPath(shellPath) if shellType == ShellType_unknown { return shellType, "", fmt.Errorf("unknown shell type: %s", shellPath) } shellBase := filepath.Base(shellPath) if shellType == ShellType_pwsh && strings.Contains(shellBase, "powershell") && !strings.Contains(shellBase, "pwsh") { return "powershell", "", nil } version, err := getShellVersion(shellPath, shellType) if err != nil { return shellType, "", err } return shellType, version, nil } func getShellVersion(shellPath string, shellType string) (string, error) { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() var cmd *exec.Cmd var versionRegex *regexp.Regexp switch shellType { case ShellType_bash: cmd = exec.CommandContext(ctx, shellPath, "--version") versionRegex = bashVersionRegexp case ShellType_zsh: cmd = exec.CommandContext(ctx, shellPath, "--version") versionRegex = zshVersionRegexp case ShellType_fish: cmd = exec.CommandContext(ctx, shellPath, "--version") versionRegex = fishVersionRegexp case ShellType_pwsh: cmd = exec.CommandContext(ctx, shellPath, "--version") versionRegex = pwshVersionRegexp default: return "", fmt.Errorf("unsupported shell type: %s", shellType) } output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("failed to get version for %s: %w", shellType, err) } outputStr := strings.TrimSpace(string(output)) matches := versionRegex.FindStringSubmatch(outputStr) if len(matches) < 2 { return "", fmt.Errorf("failed to parse version from output: %q", outputStr) } return matches[1], nil } func FixupWaveZshHistory() error { if runtime.GOOS != "darwin" { return nil } hasHistory, size := HasWaveZshHistory() if !hasHistory { return nil } zshDir := GetLocalZshZDotDir() waveHistFile := filepath.Join(zshDir, ZshHistoryFileName) if size == 0 { err := os.Remove(waveHistFile) if err != nil { log.Printf("error removing wave zsh history file %s: %v\n", waveHistFile, err) } return nil } log.Printf("merging wave zsh history %s into ~/.zsh_history\n", waveHistFile) homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("error getting home directory: %w", err) } realHistFile := filepath.Join(homeDir, ".zsh_history") isExtended, err := IsExtendedZshHistoryFile(realHistFile) if err != nil { return fmt.Errorf("error checking if history is extended: %w", err) } hasExtendedStr := "false" if isExtended { hasExtendedStr = "true" } quotedWaveHistFile := utilfn.ShellQuote(waveHistFile, true, -1) script := fmt.Sprintf(` HISTFILE=~/.zsh_history HISTSIZE=999999 SAVEHIST=999999 has_extended_history=%s [[ $has_extended_history == true ]] && setopt EXTENDED_HISTORY fc -RI fc -RI %s fc -W `, hasExtendedStr, quotedWaveHistFile) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() cmd := exec.CommandContext(ctx, "zsh", "-f", "-i", "-c", script) cmd.Stdin = nil envStr := envutil.SliceToEnv(os.Environ()) envStr = envutil.RmEnv(envStr, "ZDOTDIR") cmd.Env = envutil.EnvToSlice(envStr) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("error executing zsh history fixup script: %w, output: %s", err, string(output)) } err = os.Remove(waveHistFile) if err != nil { log.Printf("error removing wave zsh history file %s: %v\n", waveHistFile, err) } log.Printf("successfully merged wave zsh history %s into ~/.zsh_history\n", waveHistFile) return nil } func GetTerminalResetSeq() string { resetSeq := "\x1b[0m" // reset attributes resetSeq += "\x1b[?25h" // show cursor resetSeq += "\x1b[?1l" // normal cursor keys resetSeq += "\x1b[?7h" // wraparound on resetSeq += "\x1b[?45l" // reverse wraparound off resetSeq += "\x1b[?66l" // application keypad off (DECNKM) resetSeq += "\x1b[4l" // insert mode off (IRM) resetSeq += "\x1b[?9l" // X10 mouse tracking off resetSeq += "\x1b[?1000l" // disable Send Mouse X & Y on button press resetSeq += "\x1b[?1002l" // disable Use Cell Motion Mouse Tracking resetSeq += "\x1b[?1003l" // disable Use All Motion Mouse Tracking resetSeq += "\x1b[?1004l" // disable Send FocusIn/FocusOut events resetSeq += "\x1b[?1006l" // disable Enable SGR Mouse Mode resetSeq += "\x1b[?1007l" // disable Enable Alternate Scroll Mode resetSeq += "\x1b[?2004l" // disable bracketed paste mode resetSeq += "\x1b[?2026l" // synchronized output off resetSeq += FormatOSC(16162, "R") // disable alternate screen mode return resetSeq } func FormatOSC(oscNum int, parts ...string) string { if len(parts) == 0 { return fmt.Sprintf("\x1b]%d\x07", oscNum) } return fmt.Sprintf("\x1b]%d;%s\x07", oscNum, strings.Join(parts, ";")) } ================================================ FILE: pkg/util/shellutil/tokenswap.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package shellutil import ( "encoding/base64" "encoding/json" "fmt" "sync" "time" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) var tokenSwapMap map[string]*TokenSwapEntry = make(map[string]*TokenSwapEntry) var tokenMapLock = &sync.Mutex{} type TokenSwapEntry struct { Token string `json:"token"` RpcContext *wshrpc.RpcContext `json:"rpccontext,omitempty"` Env map[string]string `json:"env,omitempty"` ScriptText string `json:"scripttext,omitempty"` Exp time.Time `json:"-"` } type UnpackedTokenType struct { Token string `json:"token"` // uuid RpcContext *wshrpc.RpcContext `json:"rpccontext,omitempty"` } func (t *UnpackedTokenType) Pack() (string, error) { // convert to json, and then base64 encode barr, err := json.Marshal(t) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(barr), nil } func UnpackSwapToken(token string) (*UnpackedTokenType, error) { // base64 decode, then convert from json barr, err := base64.StdEncoding.DecodeString(token) if err != nil { return nil, err } var unpacked UnpackedTokenType err = json.Unmarshal(barr, &unpacked) if err != nil { return nil, err } return &unpacked, nil } func (t *TokenSwapEntry) PackForClient() (string, error) { unpackedToken := &UnpackedTokenType{ Token: t.Token, RpcContext: t.RpcContext, } return unpackedToken.Pack() } func removeExpiredTokens() { now := time.Now() tokenMapLock.Lock() defer tokenMapLock.Unlock() for k, v := range tokenSwapMap { if v.Exp.Before(now) { delete(tokenSwapMap, k) } } } func AddTokenSwapEntry(entry *TokenSwapEntry) error { removeExpiredTokens() if entry.Token == "" { return fmt.Errorf("token cannot be empty") } tokenMapLock.Lock() defer tokenMapLock.Unlock() if _, ok := tokenSwapMap[entry.Token]; ok { return fmt.Errorf("token already exists: %s", entry.Token) } tokenSwapMap[entry.Token] = entry return nil } func GetAndRemoveTokenSwapEntry(token string) *TokenSwapEntry { removeExpiredTokens() tokenMapLock.Lock() defer tokenMapLock.Unlock() if entry, ok := tokenSwapMap[token]; ok { delete(tokenSwapMap, token) return entry } return nil } func encodeEnvVarsForBash(env map[string]string) (string, error) { var encoded string for k, v := range env { // validate key if !IsValidEnvVarName(k) { return "", fmt.Errorf("invalid env var name: %q", k) } encoded += fmt.Sprintf("export %s=%s\n", k, HardQuote(v)) } return encoded, nil } func encodeEnvVarsForFish(env map[string]string) (string, error) { var encoded string for k, v := range env { // validate key if !IsValidEnvVarName(k) { return "", fmt.Errorf("invalid env var name: %q", k) } encoded += fmt.Sprintf("set -x %s %s\n", k, HardQuoteFish(v)) } return encoded, nil } func encodeEnvVarsForPowerShell(env map[string]string) (string, error) { var encoded string for k, v := range env { // validate key if !IsValidEnvVarName(k) { return "", fmt.Errorf("invalid env var name: %q", k) } encoded += fmt.Sprintf("$env:%s = %s\n", k, HardQuotePowerShell(v)) } return encoded, nil } func EncodeEnvVarsForShell(shellType string, env map[string]string) (string, error) { switch shellType { case ShellType_bash, ShellType_zsh: return encodeEnvVarsForBash(env) case ShellType_fish: return encodeEnvVarsForFish(env) case ShellType_pwsh: return encodeEnvVarsForPowerShell(env) default: return "", fmt.Errorf("unknown or unsupported shell type for env var encoding: %s", shellType) } } ================================================ FILE: pkg/util/sigutil/sigusr1_notwindows.go ================================================ //go:build !windows // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package sigutil import ( "log" "os" "os/signal" "syscall" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) const DumpFilePath = "/tmp/waveterm-usr1-dump.log" func InstallSIGUSR1Handler() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGUSR1) go func() { defer func() { panichandler.PanicHandler("InstallSIGUSR1Handler", recover()) }() for range sigCh { file, err := os.Create(DumpFilePath) if err != nil { log.Printf("error creating dump file %q: %v", DumpFilePath, err) continue } utilfn.DumpGoRoutineStacks(file) file.Close() } }() } ================================================ FILE: pkg/util/sigutil/sigusr1_windows.go ================================================ //go:build windows // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package sigutil func InstallSIGUSR1Handler() { // do nothing } ================================================ FILE: pkg/util/sigutil/sigutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package sigutil import ( "fmt" "os" "os/signal" "syscall" "github.com/wavetermdev/waveterm/pkg/panichandler" ) func InstallShutdownSignalHandlers(doShutdown func(string)) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT) go func() { defer func() { panichandler.PanicHandler("InstallShutdownSignalHandlers", recover()) }() for sig := range sigCh { doShutdown(fmt.Sprintf("got signal %v", sig)) break } }() } ================================================ FILE: pkg/util/syncbuf/syncbuf.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package syncbuf import ( "bytes" "io" "sync" ) type SyncBuffer struct { lock sync.Mutex buf *bytes.Buffer } func MakeSyncBuffer() *SyncBuffer { return &SyncBuffer{ lock: sync.Mutex{}, buf: new(bytes.Buffer), } } // spawns a goroutine to copy the reader to the buffer func MakeSyncBufferFromReader(r io.Reader) *SyncBuffer { rtn := MakeSyncBuffer() go io.Copy(rtn, r) return rtn } func (s *SyncBuffer) Write(p []byte) (n int, err error) { s.lock.Lock() defer s.lock.Unlock() return s.buf.Write(p) } func (s *SyncBuffer) String() string { s.lock.Lock() defer s.lock.Unlock() return s.buf.String() } ================================================ FILE: pkg/util/unixutil/unixutil_unix.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 //go:build unix package unixutil import ( "fmt" "os" "strconv" "strings" "syscall" "golang.org/x/sys/unix" ) func GetProcessGroupId(pid int) (int, error) { pgid, err := syscall.Getpgid(pid) if err != nil { return 0, err } return pgid, nil } func ParseSignal(sigName string) os.Signal { sigName = strings.TrimSpace(sigName) sigName = strings.ToUpper(sigName) if n, err := strconv.Atoi(sigName); err == nil { if n <= 0 { return nil } return syscall.Signal(n) } if !strings.HasPrefix(sigName, "SIG") { sigName = "SIG" + sigName } sig := unix.SignalNum(sigName) if sig == 0 { return nil } return sig } func GetSignalName(sig os.Signal) string { if sig == nil { return "" } scSig, ok := sig.(syscall.Signal) if !ok { return sig.String() } name := unix.SignalName(scSig) if name == "" { return fmt.Sprintf("%d", int(scSig)) } return name } func SetCloseOnExec(fd int) { unix.CloseOnExec(fd) } func SignalTerm(pid int) error { return syscall.Kill(pid, syscall.SIGTERM) } func SignalHup(pid int) error { return syscall.Kill(pid, syscall.SIGHUP) } func IsPidRunning(pid int) bool { if pid <= 0 { return false } err := syscall.Kill(pid, 0) // EPERM means no permission, but it exists (ESRCH is not found) if err == nil || err == syscall.EPERM { return true } return false } ================================================ FILE: pkg/util/unixutil/unixutil_windows.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 //go:build windows package unixutil import ( "fmt" "os" ) func GetProcessGroupId(pid int) (int, error) { return 0, fmt.Errorf("process group id not supported on windows") } func ParseSignal(sigName string) os.Signal { return nil } func GetSignalName(sig os.Signal) string { if sig == nil { return "" } return sig.String() } func SetCloseOnExec(fd int) { } func SignalTerm(pid int) error { proc, err := os.FindProcess(pid) if err != nil { return err } return proc.Kill() } // this is a no-op on windows func SignalHup(pid int) error { return nil } func IsPidRunning(pid int) bool { return false } ================================================ FILE: pkg/util/utilfn/compare.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilfn import ( "bytes" "encoding/json" "reflect" ) func CompareAsMarshaledJson(a, b any) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } barrA, err := json.Marshal(a) if err != nil { return false } barrB, err := json.Marshal(b) if err != nil { return false } return bytes.Equal(barrA, barrB) } // this is a shallow equal, but with special handling for numeric types // it will up convert to float64 and compare func JsonValEqual(a, b any) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } typeA := reflect.TypeOf(a) typeB := reflect.TypeOf(b) if typeA == typeB && typeA.Comparable() { return a == b } if IsNumericType(a) && IsNumericType(b) { return CompareAsFloat64(a, b) } if typeA != typeB { return false } // for slices and maps, compare their pointers valA := reflect.ValueOf(a) valB := reflect.ValueOf(b) switch valA.Kind() { case reflect.Slice, reflect.Map: return valA.Pointer() == valB.Pointer() } return false } // Helper to check if a value is a numeric type func IsNumericType(val any) bool { switch val.(type) { case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: return true default: return false } } // Helper to handle numeric comparisons as float64 func CompareAsFloat64(a, b any) bool { valA, okA := ToFloat64(a) valB, okB := ToFloat64(b) return okA && okB && valA == valB } // Convert various numeric types to float64 for comparison func ToFloat64(val any) (float64, bool) { if val == nil { return 0, false } switch v := val.(type) { case int: return float64(v), true case int8: return float64(v), true case int16: return float64(v), true case int32: return float64(v), true case int64: return float64(v), true case uint: return float64(v), true case uint8: return float64(v), true case uint16: return float64(v), true case uint32: return float64(v), true case uint64: return float64(v), true case float32: return float64(v), true case float64: return v, true default: return 0, false } } func ToInt64(val any) (int64, bool) { if val == nil { return 0, false } switch v := val.(type) { case int: return int64(v), true case int8: return int64(v), true case int16: return int64(v), true case int32: return int64(v), true case int64: return v, true case uint: return int64(v), true case uint8: return int64(v), true case uint16: return int64(v), true case uint32: return int64(v), true case uint64: return int64(v), true case float32: return int64(v), true case float64: return int64(v), true default: return 0, false } } func ToInt(val any) (int, bool) { i, ok := ToInt64(val) if !ok { return 0, false } return int(i), true } func ToStr(val any) (string, bool) { if val == nil { return "", false } switch v := val.(type) { case string: return v, true default: return "", false } } ================================================ FILE: pkg/util/utilfn/marshal.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilfn import ( "bytes" "encoding/base64" "encoding/json" "fmt" "net/url" "reflect" "strings" "github.com/mitchellh/mapstructure" ) // MarshalIndentNoHTMLString marshals the value to JSON with indentation and SetEscapeHTML(false), returning a string func MarshalIndentNoHTMLString(v any, prefix, indent string) (string, error) { var buf bytes.Buffer encoder := json.NewEncoder(&buf) encoder.SetEscapeHTML(false) encoder.SetIndent(prefix, indent) err := encoder.Encode(v) if err != nil { return "", err } return strings.TrimRight(buf.String(), "\n"), nil } func MustPrettyPrintJSON(v any) string { str, _ := MarshalIndentNoHTMLString(v, "", " ") return str } func ReUnmarshal(out any, in any) error { barr, err := json.Marshal(in) if err != nil { return err } return json.Unmarshal(barr, out) } // does a mapstructure using "json" tags func DoMapStructure(out any, input any) error { dconfig := &mapstructure.DecoderConfig{ Result: out, TagName: "json", } decoder, err := mapstructure.NewDecoder(dconfig) if err != nil { return err } return decoder.Decode(input) } func MapToStruct(in map[string]any, out any) error { // Check that out is a pointer outValue := reflect.ValueOf(out) if outValue.Kind() != reflect.Ptr { return fmt.Errorf("out parameter must be a pointer, got %v", outValue.Kind()) } // Get the struct it points to elem := outValue.Elem() if elem.Kind() != reflect.Struct { return fmt.Errorf("out parameter must be a pointer to struct, got pointer to %v", elem.Kind()) } // Get type information typ := elem.Type() // For each field in the struct for i := 0; i < typ.NumField(); i++ { field := typ.Field(i) // Skip unexported fields if !field.IsExported() { continue } name := getJSONName(field) if value, ok := in[name]; ok { if err := setValue(elem.Field(i), value); err != nil { return fmt.Errorf("error setting field %s: %w", name, err) } } } return nil } func StructToMap(in any) (map[string]any, error) { // Get value and handle pointer val := reflect.ValueOf(in) if val.Kind() == reflect.Ptr { val = val.Elem() } // Check that we have a struct if val.Kind() != reflect.Struct { return nil, fmt.Errorf("input must be a struct or pointer to struct, got %v", val.Kind()) } // Get type information typ := val.Type() out := make(map[string]any) // For each field in the struct for i := 0; i < typ.NumField(); i++ { field := typ.Field(i) // Skip unexported fields if !field.IsExported() { continue } name := getJSONName(field) out[name] = val.Field(i).Interface() } return out, nil } // getJSONName returns the field name to use for JSON mapping func getJSONName(field reflect.StructField) string { tag := field.Tag.Get("json") if tag == "" || tag == "-" { return field.Name } return strings.Split(tag, ",")[0] } // setValue attempts to set a reflect.Value with a given interface{} value func setValue(field reflect.Value, value any) error { if value == nil { return nil } valueRef := reflect.ValueOf(value) // Direct assignment if types are exactly equal if valueRef.Type() == field.Type() { field.Set(valueRef) return nil } // Check if types are assignable if valueRef.Type().AssignableTo(field.Type()) { field.Set(valueRef) return nil } // If field is pointer and value isn't already a pointer, try address if field.Kind() == reflect.Ptr && valueRef.Kind() != reflect.Ptr { return setValue(field, valueRef.Addr().Interface()) } // Try conversion if types are convertible if valueRef.Type().ConvertibleTo(field.Type()) { field.Set(valueRef.Convert(field.Type())) return nil } return fmt.Errorf("cannot set value of type %v to field of type %v", valueRef.Type(), field.Type()) } // DecodeDataURL decodes a data URL and returns the mimetype and raw data bytes func DecodeDataURL(dataURL string) (mimeType string, data []byte, err error) { if !strings.HasPrefix(dataURL, "data:") { return "", nil, fmt.Errorf("invalid data URL: must start with 'data:'") } parts := strings.SplitN(dataURL, ",", 2) if len(parts) != 2 { return "", nil, fmt.Errorf("invalid data URL format: missing comma separator") } header := parts[0] dataStr := parts[1] // Parse mimetype from header: "data:text/plain;base64" -> "text/plain" headerWithoutPrefix := strings.TrimPrefix(header, "data:") mimeType = strings.Split(headerWithoutPrefix, ";")[0] if mimeType == "" { mimeType = "text/plain" // default mimetype } if strings.Contains(header, ";base64") { decoded, decodeErr := base64.StdEncoding.DecodeString(dataStr) if decodeErr != nil { return "", nil, fmt.Errorf("failed to decode base64 data: %w", decodeErr) } return mimeType, decoded, nil } // Non-base64 data URLs are percent-encoded decoded, decodeErr := url.QueryUnescape(dataStr) if decodeErr != nil { return "", nil, fmt.Errorf("failed to decode percent-encoded data: %w", decodeErr) } return mimeType, []byte(decoded), nil } // MarshalJSONString marshals a string to JSON format, returning the properly escaped JSON string. // Returns empty string if there's an error (rare). func MarshalJSONString(s string) string { jsonBytes, err := json.Marshal(s) if err != nil { return "" } return string(jsonBytes) } // ContainsBinaryData checks if the provided data contains binary (non-text) content func ContainsBinaryData(data []byte) bool { for _, b := range data { if b == 0 { return true } if b < 32 && b != 9 && b != 10 && b != 13 { return true } } return false } ================================================ FILE: pkg/util/utilfn/partial.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilfn import ( "encoding/json" ) type stackItem int const ( stackInvalid stackItem = iota stackLBrace stackLBrack stackBeforeKey stackKey stackKeyColon stackQuote ) type jsonStack []stackItem func (s *jsonStack) push(item stackItem) { *s = append(*s, item) } func (s *jsonStack) pop() stackItem { if len(*s) == 0 { return stackInvalid } item := (*s)[len(*s)-1] *s = (*s)[:len(*s)-1] return item } func (s jsonStack) peek() stackItem { if len(s) == 0 { return stackInvalid } return s[len(s)-1] } func (s jsonStack) isTop(items ...stackItem) bool { top := s.peek() for _, item := range items { if top == item { return true } } return false } func (s *jsonStack) replaceTop(item stackItem) { if len(*s) > 0 { (*s)[len(*s)-1] = item } } func repairJson(data []byte) []byte { if len(data) == 0 { return data } var stack jsonStack inString := false escaped := false lastComma := false for i := 0; i < len(data); i++ { b := data[i] if escaped { escaped = false continue } if inString { if b == '\\' { escaped = true continue } if b == '"' { inString = false } continue } if b == ' ' || b == '\t' || b == '\n' || b == '\r' { continue } valueStart := b == '{' || b == '[' || b == 'n' || b == 't' || b == 'f' || b == '"' || (b >= '0' && b <= '9') || b == '-' if valueStart && lastComma { lastComma = false } if valueStart && stack.isTop(stackKeyColon) { stack.pop() } if valueStart && stack.isTop(stackBeforeKey) { stack.replaceTop(stackKey) } switch b { case '{': stack.push(stackLBrace) stack.push(stackBeforeKey) case '[': stack.push(stackLBrack) case '}': if stack.isTop(stackBeforeKey) { stack.pop() } if stack.isTop(stackLBrace) { stack.pop() } case ']': if stack.isTop(stackLBrack) { stack.pop() } case '"': inString = true case ':': if stack.isTop(stackKey) { stack.replaceTop(stackKeyColon) } case ',': lastComma = true if stack.isTop(stackLBrace) { stack.push(stackBeforeKey) } default: } } if len(stack) == 0 && !inString { return data } result := append([]byte{}, data...) if escaped && len(result) > 0 { result = result[:len(result)-1] } if inString { result = append(result, '"') } if lastComma { for i := len(result) - 1; i >= 0; i-- { if result[i] == ',' { result = result[:i] break } } } for i := len(stack) - 1; i >= 0; i-- { switch stack[i] { case stackKeyColon: result = append(result, []byte("null")...) case stackKey: result = append(result, []byte(": null")...) case stackLBrace: result = append(result, '}') case stackLBrack: result = append(result, ']') } } return result } func ParsePartialJson(data []byte) (any, error) { fixedData := repairJson(data) var output any err := json.Unmarshal(fixedData, &output) if err != nil { return nil, err } return output, nil } ================================================ FILE: pkg/util/utilfn/partial_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilfn import ( "encoding/json" "testing" ) func TestRepairJson(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "open bracket", input: "[", expected: "[]", }, { name: "empty array", input: "[]", expected: "[]", }, { name: "unclosed string in array", input: `["a`, expected: `["a"]`, }, { name: "unclosed array with string", input: `["a"`, expected: `["a"]`, }, { name: "unclosed array with number", input: `[5`, expected: `[5]`, }, { name: "array with trailing comma", input: `["a",`, expected: `["a"]`, }, { name: "array with unclosed second string", input: `["a","`, expected: `["a",""]`, }, { name: "unclosed array with string and number", input: `["a",5`, expected: `["a",5]`, }, { name: "open brace", input: "{", expected: "{}", }, { name: "empty object", input: "{}", expected: "{}", }, { name: "unclosed key", input: `{"a`, expected: `{"a": null}`, }, { name: "key without colon", input: `{"a"`, expected: `{"a": null}`, }, { name: "key with colon no value", input: `{"a": `, expected: `{"a": null}`, }, { name: "unclosed object with number value", input: `{"a": 5`, expected: `{"a": 5}`, }, { name: "unclosed object with true", input: `{"a": true`, expected: `{"a": true}`, }, // { // name: "unclosed object with partial value", // input: `{"a": fa`, // expected: `{"a": fa}`, // }, { name: "object with trailing comma", input: `{"a": true,`, expected: `{"a": true}`, }, { name: "object with unclosed second key", input: `{"a": true, "`, expected: `{"a": true, "": null}`, }, { name: "complete object", input: `{"a": true, "b": false}`, expected: `{"a": true, "b": false}`, }, { name: "nested incomplete", input: `[1, {"a": true, "b`, expected: `[1, {"a": true, "b": null}]`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := repairJson([]byte(tt.input)) resultStr := string(result) if resultStr != tt.expected { t.Errorf("repairJson() of %s = %s, expected %s", tt.input, resultStr, tt.expected) } var parsed any err := json.Unmarshal(result, &parsed) if err != nil { t.Errorf("repaired JSON is not valid: %v\nInput: %q\nOutput: %q", err, tt.input, resultStr) } }) } } ================================================ FILE: pkg/util/utilfn/streamtolines.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilfn import ( "bytes" "context" "io" "time" ) type LineOutput struct { Line string Error error } type lineBuf struct { buf []byte inLongLine bool } const maxLineLength = 128 * 1024 func ReadLineWithTimeout(ch chan LineOutput, timeout time.Duration) (string, error) { select { case output := <-ch: if output.Error != nil { return "", output.Error } return output.Line, nil case <-time.After(timeout): return "", context.DeadlineExceeded } } func streamToLines_processBuf(lineBuf *lineBuf, readBuf []byte, lineFn func([]byte)) { for len(readBuf) > 0 { nlIdx := bytes.IndexByte(readBuf, '\n') if nlIdx == -1 { if lineBuf.inLongLine || len(lineBuf.buf)+len(readBuf) > maxLineLength { lineBuf.buf = nil lineBuf.inLongLine = true return } lineBuf.buf = append(lineBuf.buf, readBuf...) return } if !lineBuf.inLongLine && len(lineBuf.buf)+nlIdx <= maxLineLength { line := append(lineBuf.buf, readBuf[:nlIdx]...) lineFn(line) } lineBuf.buf = nil lineBuf.inLongLine = false readBuf = readBuf[nlIdx+1:] } } func StreamToLines(input io.Reader, lineFn func([]byte), readCallback func()) error { var lineBuf lineBuf readBuf := make([]byte, 64*1024) for { n, err := input.Read(readBuf) streamToLines_processBuf(&lineBuf, readBuf[:n], lineFn) if err != nil { return err } if readCallback != nil { readCallback() } } } // starts a goroutine to drive the channel // line output does not include the trailing newline func StreamToLinesChan(input io.Reader) chan LineOutput { ch := make(chan LineOutput) go func() { defer close(ch) err := StreamToLines(input, func(line []byte) { ch <- LineOutput{Line: string(line)} }, nil) if err != nil && err != io.EOF { ch <- LineOutput{Error: err} } }() return ch } // LineWriter is an io.Writer that processes data line-by-line via a callback. // Lines do not include the trailing newline. Lines longer than maxLineLength are dropped. type LineWriter struct { lineBuf lineBuf lineFn func([]byte) } // NewLineWriter creates a new LineWriter with the given callback function. func NewLineWriter(lineFn func([]byte)) *LineWriter { return &LineWriter{ lineFn: lineFn, } } // Write implements io.Writer, processing the data and calling the callback for each complete line. func (lw *LineWriter) Write(p []byte) (n int, err error) { streamToLines_processBuf(&lw.lineBuf, p, lw.lineFn) return len(p), nil } // Flush outputs any remaining buffered data as a final line. // Should be called when the input stream is complete (e.g., at EOF). func (lw *LineWriter) Flush() { if len(lw.lineBuf.buf) > 0 && !lw.lineBuf.inLongLine { lw.lineFn(lw.lineBuf.buf) lw.lineBuf.buf = nil } } ================================================ FILE: pkg/util/utilfn/utilfn.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilfn import ( "bytes" "context" "crypto/rand" "crypto/sha1" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "hash/fnv" "io" "log" "math" mathrand "math/rand" "os" "os/exec" "reflect" "regexp" "runtime" "sort" "strconv" "strings" "syscall" "text/template" "time" "unicode/utf8" ) var HexDigits = []byte{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'} var PTLoc *time.Location func init() { loc, err := time.LoadLocation("America/Los_Angeles") if err != nil { loc = time.FixedZone("PT", -8*60*60) } PTLoc = loc } func GetStrArr(v interface{}, field string) []string { if v == nil { return nil } m, ok := v.(map[string]interface{}) if !ok { return nil } fieldVal := m[field] if fieldVal == nil { return nil } iarr, ok := fieldVal.([]interface{}) if !ok { return nil } var sarr []string for _, iv := range iarr { if sv, ok := iv.(string); ok { sarr = append(sarr, sv) } } return sarr } func GetBool(v interface{}, field string) bool { if v == nil { return false } m, ok := v.(map[string]interface{}) if !ok { return false } fieldVal := m[field] if fieldVal == nil { return false } bval, ok := fieldVal.(bool) if !ok { return false } return bval } // converts an int, int64, or float64 to an int64 // nil or bad type returns 0 func ConvertInt(val any) int64 { if val == 0 { return 0 } switch typedVal := val.(type) { case int: return int64(typedVal) case int64: return typedVal case float64: return int64(typedVal) default: return 0 } } func ConvertMap(val any) map[string]any { if val == nil { return nil } m, ok := val.(map[string]any) if !ok { return nil } return m } var needsQuoteRe = regexp.MustCompile(`[^\w@%:,./=+-]`) // minimum maxlen=6, pass -1 for no max length func ShellQuote(val string, forceQuote bool, maxLen int) string { if maxLen != -1 && maxLen < 6 { maxLen = 6 } rtn := val if needsQuoteRe.MatchString(val) { rtn = "'" + strings.ReplaceAll(val, "'", `'"'"'`) + "'" } else if forceQuote { rtn = "\"" + rtn + "\"" } if maxLen == -1 || len(rtn) <= maxLen { return rtn } if strings.HasPrefix(rtn, "\"") || strings.HasPrefix(rtn, "'") { return rtn[0:maxLen-4] + "..." + rtn[len(rtn)-1:] } return rtn[0:maxLen-3] + "..." } func EllipsisStr(s string, maxLen int) string { if maxLen < 4 { maxLen = 4 } if len(s) > maxLen { return s[0:maxLen-3] + "..." } return s } func TruncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } if maxLen < 4 { maxLen = 4 } return s[:maxLen-3] + "..." } func LongestPrefix(root string, strs []string) string { if len(strs) == 0 { return root } if len(strs) == 1 { comp := strs[0] if len(comp) >= len(root) && strings.HasPrefix(comp, root) { if strings.HasSuffix(comp, "/") { return strs[0] } return strs[0] } } lcp := strs[0] for i := 1; i < len(strs); i++ { s := strs[i] for j := 0; j < len(lcp); j++ { if j >= len(s) || lcp[j] != s[j] { lcp = lcp[0:j] break } } } if len(lcp) < len(root) || !strings.HasPrefix(lcp, root) { return root } return lcp } func ContainsStr(strs []string, test string) bool { for _, s := range strs { if s == test { return true } } return false } func IsPrefix(strs []string, test string) bool { for _, s := range strs { if len(s) > len(test) && strings.HasPrefix(s, test) { return true } } return false } // sentinel value for StrWithPos.Pos to indicate no position const NoStrPos = -1 type StrWithPos struct { Str string `json:"str"` Pos int `json:"pos"` // this is a 'rune' position (not a byte position) } func (sp StrWithPos) String() string { return strWithCursor(sp.Str, sp.Pos) } func ParseToSP(s string) StrWithPos { idx := strings.Index(s, "[*]") if idx == -1 { return StrWithPos{Str: s, Pos: NoStrPos} } return StrWithPos{Str: s[0:idx] + s[idx+3:], Pos: utf8.RuneCountInString(s[0:idx])} } func strWithCursor(str string, pos int) string { if pos == NoStrPos { return str } if pos < 0 { // invalid position return "[*]_" + str } if pos > len(str) { // invalid position return str + "_[*]" } if pos == len(str) { return str + "[*]" } var rtn []rune for _, ch := range str { if len(rtn) == pos { rtn = append(rtn, '[', '*', ']') } rtn = append(rtn, ch) } return string(rtn) } func (sp StrWithPos) Prepend(str string) StrWithPos { return StrWithPos{Str: str + sp.Str, Pos: utf8.RuneCountInString(str) + sp.Pos} } func (sp StrWithPos) Append(str string) StrWithPos { return StrWithPos{Str: sp.Str + str, Pos: sp.Pos} } // returns base64 hash of data func Sha1Hash(data []byte) string { hvalRaw := sha1.Sum(data) hval := base64.StdEncoding.EncodeToString(hvalRaw[:]) return hval } func ChunkSlice[T any](s []T, chunkSize int) [][]T { var rtn [][]T for len(rtn) > 0 { if len(s) <= chunkSize { rtn = append(rtn, s) break } rtn = append(rtn, s[:chunkSize]) s = s[chunkSize:] } return rtn } var ErrOverflow = errors.New("integer overflow") // Add two int values, returning an error if the result overflows. func AddInt(left, right int) (int, error) { if right > 0 { if left > math.MaxInt-right { return 0, ErrOverflow } } else { if left < math.MinInt-right { return 0, ErrOverflow } } return left + right, nil } // Add a slice of ints, returning an error if the result overflows. func AddIntSlice(vals ...int) (int, error) { var rtn int for _, v := range vals { var err error rtn, err = AddInt(rtn, v) if err != nil { return 0, err } } return rtn, nil } func StrsEqual(s1arr []string, s2arr []string) bool { if len(s1arr) != len(s2arr) { return false } for i, s1 := range s1arr { s2 := s2arr[i] if s1 != s2 { return false } } return true } func StrMapsEqual(m1 map[string]string, m2 map[string]string) bool { if len(m1) != len(m2) { return false } for key, val1 := range m1 { val2, found := m2[key] if !found || val1 != val2 { return false } } for key := range m2 { _, found := m1[key] if !found { return false } } return true } func ByteMapsEqual(m1 map[string][]byte, m2 map[string][]byte) bool { if len(m1) != len(m2) { return false } for key, val1 := range m1 { val2, found := m2[key] if !found || !bytes.Equal(val1, val2) { return false } } for key := range m2 { _, found := m1[key] if !found { return false } } return true } func GetOrderedStringerMapKeys[K interface { comparable fmt.Stringer }, V any](m map[K]V) []K { keyStrMap := make(map[K]string) keys := make([]K, 0, len(m)) for key := range m { keys = append(keys, key) keyStrMap[key] = key.String() } sort.Slice(keys, func(i, j int) bool { return keyStrMap[keys[i]] < keyStrMap[keys[j]] }) return keys } func GetOrderedMapKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for key := range m { keys = append(keys, key) } sort.Strings(keys) return keys } const ( nullEncodeEscByte = '\\' nullEncodeSepByte = '|' nullEncodeEqByte = '=' nullEncodeZeroByteEsc = '0' nullEncodeEscByteEsc = '\\' nullEncodeSepByteEsc = 's' nullEncodeEqByteEsc = 'e' ) func EncodeStringMap(m map[string]string) []byte { var buf bytes.Buffer for idx, key := range GetOrderedMapKeys(m) { val := m[key] buf.Write(NullEncodeStr(key)) buf.WriteByte(nullEncodeEqByte) buf.Write(NullEncodeStr(val)) if idx < len(m)-1 { buf.WriteByte(nullEncodeSepByte) } } return buf.Bytes() } func DecodeStringMap(barr []byte) (map[string]string, error) { if len(barr) == 0 { return nil, nil } var rtn = make(map[string]string) for _, b := range bytes.Split(barr, []byte{nullEncodeSepByte}) { keyVal := bytes.SplitN(b, []byte{nullEncodeEqByte}, 2) if len(keyVal) != 2 { return nil, fmt.Errorf("invalid null encoding: %s", string(b)) } key, err := NullDecodeStr(keyVal[0]) if err != nil { return nil, err } val, err := NullDecodeStr(keyVal[1]) if err != nil { return nil, err } rtn[key] = val } return rtn, nil } func EncodeStringArray(arr []string) []byte { var buf bytes.Buffer for idx, s := range arr { buf.Write(NullEncodeStr(s)) if idx < len(arr)-1 { buf.WriteByte(nullEncodeSepByte) } } return buf.Bytes() } func DecodeStringArray(barr []byte) ([]string, error) { if len(barr) == 0 { return nil, nil } var rtn []string for _, b := range bytes.Split(barr, []byte{nullEncodeSepByte}) { s, err := NullDecodeStr(b) if err != nil { return nil, err } rtn = append(rtn, s) } return rtn, nil } func EncodedStringArrayHasFirstVal(encoded []byte, firstKey string) bool { firstKeyBytes := NullEncodeStr(firstKey) if !bytes.HasPrefix(encoded, firstKeyBytes) { return false } if len(encoded) == len(firstKeyBytes) || encoded[len(firstKeyBytes)] == nullEncodeSepByte { return true } return false } // on encoding error returns "" // this is used to perform logic on first value without decoding the entire array func EncodedStringArrayGetFirstVal(encoded []byte) string { sepIdx := bytes.IndexByte(encoded, nullEncodeSepByte) if sepIdx == -1 { str, _ := NullDecodeStr(encoded) return str } str, _ := NullDecodeStr(encoded[0:sepIdx]) return str } // encodes a string, removing null/zero bytes (and separators '|') // a zero byte is encoded as "\0", a '\' is encoded as "\\", sep is encoded as "\s" // allows for easy double splitting (first on \x00, and next on "|") func NullEncodeStr(s string) []byte { strBytes := []byte(s) if bytes.IndexByte(strBytes, 0) == -1 && bytes.IndexByte(strBytes, nullEncodeEscByte) == -1 && bytes.IndexByte(strBytes, nullEncodeSepByte) == -1 && bytes.IndexByte(strBytes, nullEncodeEqByte) == -1 { return strBytes } var rtn []byte for _, b := range strBytes { if b == 0 { rtn = append(rtn, nullEncodeEscByte, nullEncodeZeroByteEsc) } else if b == nullEncodeEscByte { rtn = append(rtn, nullEncodeEscByte, nullEncodeEscByteEsc) } else if b == nullEncodeSepByte { rtn = append(rtn, nullEncodeEscByte, nullEncodeSepByteEsc) } else if b == nullEncodeEqByte { rtn = append(rtn, nullEncodeEscByte, nullEncodeEqByteEsc) } else { rtn = append(rtn, b) } } return rtn } func NullDecodeStr(barr []byte) (string, error) { if bytes.IndexByte(barr, nullEncodeEscByte) == -1 { return string(barr), nil } var rtn []byte for i := 0; i < len(barr); i++ { curByte := barr[i] if curByte == nullEncodeEscByte { i++ nextByte := barr[i] if nextByte == nullEncodeZeroByteEsc { rtn = append(rtn, 0) } else if nextByte == nullEncodeEscByteEsc { rtn = append(rtn, nullEncodeEscByte) } else if nextByte == nullEncodeSepByteEsc { rtn = append(rtn, nullEncodeSepByte) } else if nextByte == nullEncodeEqByteEsc { rtn = append(rtn, nullEncodeEqByte) } else { // invalid encoding return "", fmt.Errorf("invalid null encoding: %d", nextByte) } } else { rtn = append(rtn, curByte) } } return string(rtn), nil } func SortStringRunes(s string) string { runes := []rune(s) sort.Slice(runes, func(i, j int) bool { return runes[i] < runes[j] }) return string(runes) } // will overwrite m1 with m2's values func CombineMaps[V any](m1 map[string]V, m2 map[string]V) { for key, val := range m2 { m1[key] = val } } // returns hex escaped string (\xNN for each byte) func ShellHexEscape(s string) string { var rtn []byte for _, ch := range []byte(s) { rtn = append(rtn, []byte(fmt.Sprintf("\\x%02x", ch))...) } return string(rtn) } func GetMapKeys[K comparable, V any](m map[K]V) []K { var rtn []K for key := range m { rtn = append(rtn, key) } return rtn } // combines string arrays and removes duplicates (returns a new array) func CombineStrArrays(sarr1 []string, sarr2 []string) []string { var rtn []string m := make(map[string]struct{}) for _, s := range sarr1 { if _, found := m[s]; found { continue } m[s] = struct{}{} rtn = append(rtn, s) } for _, s := range sarr2 { if _, found := m[s]; found { continue } m[s] = struct{}{} rtn = append(rtn, s) } return rtn } func StrSetIntersection(s1 []string, s2 []string) []string { set := make(map[string]bool) for _, s := range s1 { set[s] = true } var rtn []string for _, s := range s2 { if set[s] { rtn = append(rtn, s) } } return rtn } func QuickJson(v interface{}) string { barr, _ := json.Marshal(v) return string(barr) } func QuickParseJson[T any](s string) T { var v T _ = json.Unmarshal([]byte(s), &v) return v } func StrArrayToMap(sarr []string) map[string]bool { m := make(map[string]bool) for _, s := range sarr { m[s] = true } return m } func AppendNonZeroRandomBytes(b []byte, randLen int) []byte { if randLen <= 0 { return b } numAdded := 0 for numAdded < randLen { rn := mathrand.Intn(256) if rn > 0 && rn < 256 { // exclude 0, also helps to suppress security warning to have a guard here b = append(b, byte(rn)) numAdded++ } } return b } // returns (isEOF, error) func CopyWithEndBytes(outputBuf *bytes.Buffer, reader io.Reader, endBytes []byte) (bool, error) { buf := make([]byte, 4096) for { n, err := reader.Read(buf) if n > 0 { outputBuf.Write(buf[:n]) obytes := outputBuf.Bytes() if bytes.HasSuffix(obytes, endBytes) { outputBuf.Truncate(len(obytes) - len(endBytes)) return (err == io.EOF), nil } } if err == io.EOF { return true, nil } if err != nil { return false, err } } } // does *not* close outputCh on EOF or error func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error { buf := make([]byte, 4096) for { n, err := reader.Read(buf) if n > 0 { // copy so client can use []byte without it being overwritten bufCopy := make([]byte, n) copy(bufCopy, buf[:n]) outputCh <- bufCopy } if err == io.EOF { return nil } if err != nil { return err } } } func GetCmdExitCode(cmd *exec.Cmd, err error) int { if cmd == nil || cmd.ProcessState == nil { return GetExitCode(err) } status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus) if !ok { return cmd.ProcessState.ExitCode() } signaled := status.Signaled() if signaled { signal := status.Signal() return 128 + int(signal) } exitStatus := status.ExitStatus() return exitStatus } func GetExitCode(err error) int { if err == nil { return 0 } if exitErr, ok := err.(*exec.ExitError); ok { return exitErr.ExitCode() } else { return -1 } } func GetFirstLine(s string) string { idx := strings.Index(s, "\n") if idx == -1 { return s } return s[0:idx] } func JsonMapToStruct(m map[string]any, v interface{}) error { barr, err := json.Marshal(m) if err != nil { return err } return json.Unmarshal(barr, v) } func StructToJsonMap(v interface{}) (map[string]any, error) { barr, err := json.Marshal(v) if err != nil { return nil, err } var m map[string]any err = json.Unmarshal(barr, &m) if err != nil { return nil, err } return m, nil } func IndentString(indent string, str string) string { splitArr := strings.Split(str, "\n") var rtn strings.Builder for _, line := range splitArr { if line == "" { rtn.WriteByte('\n') continue } rtn.WriteString(indent) rtn.WriteString(line) rtn.WriteByte('\n') } return rtn.String() } func SliceIdx[T comparable](arr []T, elem T) int { for idx, e := range arr { if e == elem { return idx } } return -1 } // removes an element from a slice and modifies the original slice (the backing elements) // if it removes the last element from the slice, it will return nil so we free the original slice's backing memory func RemoveElemFromSlice[T comparable](arr []T, elem T) []T { idx := SliceIdx(arr, elem) if idx == -1 { return arr } if len(arr) == 1 { return nil } return append(arr[:idx], arr[idx+1:]...) } func AddElemToSliceUniq[T comparable](arr []T, elem T) []T { if SliceIdx(arr, elem) != -1 { return arr } return append(arr, elem) } func MoveSliceIdxToFront[T any](arr []T, idx int) []T { // create and return a new slice with idx moved to the front if idx == 0 || idx >= len(arr) { // make a copy still return append([]T(nil), arr...) } rtn := make([]T, 0, len(arr)) rtn = append(rtn, arr[idx]) rtn = append(rtn, arr[0:idx]...) rtn = append(rtn, arr[idx+1:]...) return rtn } // matches a delimited string with a pattern string // the pattern string can contain "*" to match a single part, or "**" to match the rest of the string // note that "**" may only appear at the end of the string func StarMatchString(pattern string, s string, delimiter string) bool { patternParts := strings.Split(pattern, delimiter) stringParts := strings.Split(s, delimiter) pLen, sLen := len(patternParts), len(stringParts) for i := 0; i < pLen; i++ { if patternParts[i] == "**" { // '**' must be at the end to be valid return i == pLen-1 } if i >= sLen { // If string is exhausted but pattern is not return false } if patternParts[i] != "*" && patternParts[i] != stringParts[i] { // If current parts don't match and pattern part is not '*' return false } } // Check if both pattern and string are fully matched return pLen == sLen } func MergeStrMaps[T any](m1 map[string]T, m2 map[string]T) map[string]T { rtn := make(map[string]T) for key, val := range m1 { rtn[key] = val } for key, val := range m2 { rtn[key] = val } return rtn } func AtomicRenameCopy(dstPath string, srcPath string, perms os.FileMode) error { // first copy the file to dstPath.new, then rename into place srcFd, err := os.Open(srcPath) if err != nil { return err } defer srcFd.Close() tempName := dstPath + ".new" dstFd, err := os.Create(tempName) if err != nil { return err } _, err = io.Copy(dstFd, srcFd) if err != nil { dstFd.Close() return err } err = dstFd.Close() if err != nil { return err } err = os.Chmod(tempName, perms) if err != nil { return err } err = os.Rename(tempName, dstPath) if err != nil { return err } return nil } func AtoiNoErr(str string) int { val, err := strconv.Atoi(str) if err != nil { return 0 } return val } func WriteTemplateToFile(fileName string, templateText string, vars map[string]string) error { outBuffer := &bytes.Buffer{} template.Must(template.New("").Parse(templateText)).Execute(outBuffer, vars) return os.WriteFile(fileName, outBuffer.Bytes(), 0644) } // every byte is 4-bits of randomness func RandomHexString(numHexDigits int) (string, error) { numBytes := (numHexDigits + 1) / 2 // Calculate the number of bytes needed bytes := make([]byte, numBytes) if _, err := rand.Read(bytes); err != nil { return "", err } hexStr := hex.EncodeToString(bytes) return hexStr[:numHexDigits], nil // Return the exact number of hex digits } func GetJsonTag(field reflect.StructField) string { jsonTag := field.Tag.Get("json") if jsonTag == "" { return "" } commaIdx := strings.Index(jsonTag, ",") if commaIdx != -1 { jsonTag = jsonTag[:commaIdx] } return jsonTag } func WriteFileIfDifferent(fileName string, contents []byte) (bool, error) { oldContents, err := os.ReadFile(fileName) if err == nil && bytes.Equal(oldContents, contents) { return false, nil } err = os.WriteFile(fileName, contents, 0644) if err != nil { return false, err } return true, nil } func GetLineColFromOffset(barr []byte, offset int) (int, int) { line := 1 col := 1 for i := 0; i < offset && i < len(barr); i++ { if barr[i] == '\n' { line++ col = 1 } else { col++ } } return line, col } func FindStringInSlice(slice []string, val string) int { for idx, v := range slice { if v == val { return idx } } return -1 } func FormatLsTime(t time.Time) string { now := time.Now() sixMonthsAgo := now.AddDate(0, -6, 0) if t.After(sixMonthsAgo) { // Recent files: "Nov 18 18:40" return t.Format("Jan _2 15:04") } else { // Older files: "Apr 12 2025" return t.Format("Jan _2 2006") } } /** * Helper function that will deref a pointer if not null * but returns a default value if it is null. */ func SafeDeref[T any](x *T) T { if x == nil { var safeOut T return safeOut } return *x } /** * Utility function for referencing a type with a pointer. * This is the same as dereferencing with &, but unlike & * you can directly use it on the ouput of a function * without needing to create an intermediate variable */ func Ptr[T any](x T) *T { return &x } /** * Utility function to convert know architecture patterns * to the patterns we use. It returns an error if the * provided name is unknown */ func FilterValidArch(arch string) (string, error) { formatted := strings.TrimSpace(strings.ToLower(arch)) switch formatted { case "amd64": return "x64", nil case "x86_64": return "x64", nil case "x64": return "x64", nil case "arm64": return "arm64", nil } return "", fmt.Errorf("unknown architecture: %s", formatted) } func ConvertUUIDv4Tov7(uuidv4 string) (string, error) { // Parse the UUIDv4 parts := strings.Split(uuidv4, "-") if len(parts) != 5 { return "", fmt.Errorf("invalid UUIDv4 format") } // Section 1 and 2: Fixed timestamp for Jan 1, 2024 section1 := "01823a80" // High 32 bits of the timestamp section2 := "0000" // Middle 16 bits of the timestamp // Section 3: Version (7) and the last 3 bytes of randomness from UUIDv4 section3 := "7" + parts[2][1:] // Replace the first nibble with '7' for version // Section 4 and 5: Copy from the original UUIDv4 section4 := parts[3] section5 := parts[4] // Combine sections to form UUIDv7 uuidv7 := fmt.Sprintf("%s-%s-%s-%s-%s", section1, section2, section3, section4, section5) return uuidv7, nil } func TimeoutFromContext(ctx context.Context, defaultTimeout time.Duration) time.Duration { deadline, ok := ctx.Deadline() if !ok { return defaultTimeout } return time.Until(deadline) } func HasBinaryData(data []byte) bool { for _, b := range data { if b < 32 && b != '\n' && b != '\r' && b != '\t' && b != '\f' && b != '\b' { return true } } return false } func DumpGoRoutineStacks(w io.Writer) { buf := make([]byte, 1<<20) n := runtime.Stack(buf, true) w.Write(buf[:n]) } func ConvertToWallClockPT(t time.Time) time.Time { year, month, day := t.Date() hour, min, sec := t.Clock() pstTime := time.Date(year, month, day, hour, min, sec, 0, PTLoc) return pstTime } func QuickHashString(s string) string { h := fnv.New64a() h.Write([]byte(s)) return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) } func SendWithCtxCheck[T any](ctx context.Context, ch chan<- T, val T) bool { select { case <-ctx.Done(): return false case ch <- val: return true } } const ( maxRetries = 5 retryDelay = 10 * time.Millisecond ) func GracefulClose(closer io.Closer, debugName, closerName string) bool { closed := false for retries := 0; retries < maxRetries; retries++ { if err := closer.Close(); err != nil { log.Printf("%s: error closing %s: %v, trying again in %dms\n", debugName, closerName, err, retryDelay.Milliseconds()) time.Sleep(retryDelay) continue } closed = true break } if !closed { log.Printf("%s: unable to close %s after %d retries\n", debugName, closerName, maxRetries) } return closed } // DrainChannelSafe will drain a channel until it is empty or until a timeout is reached. func DrainChannelSafe[T any](ch <-chan T, debugName string) { drainTimeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) go func() { defer cancel() outer: for { select { case <-drainTimeoutCtx.Done(): log.Printf("[error] timeout draining channel: %s\n", debugName) break outer case _, ok := <-ch: if !ok { return } } } }() } func IsBinaryContent(data []byte) bool { if len(data) == 0 { return false } sampleSize := min(8192, len(data)) sample := data[:sampleSize] nullCount := 0 for _, b := range sample { if b == 0 { nullCount++ } } if float64(nullCount)/float64(len(sample)) > 0.01 { return true } if !utf8.Valid(sample) { return true } return false } func FormatRelativeTime(modTime time.Time) string { now := time.Now() diff := now.Sub(modTime) if diff < time.Minute { return "just now" } if diff < time.Hour { minutes := int(diff.Minutes()) if minutes == 1 { return "1 minute ago" } return fmt.Sprintf("%d minutes ago", minutes) } if diff < 24*time.Hour { hours := int(diff.Hours()) if hours == 1 { return "1 hour ago" } return fmt.Sprintf("%d hours ago", hours) } if diff < 30*24*time.Hour { days := int(diff.Hours() / 24) if days == 1 { return "1 day ago" } return fmt.Sprintf("%d days ago", days) } if diff < 365*24*time.Hour { months := int(diff.Hours() / 24 / 30) if months == 1 { return "1 month ago" } return fmt.Sprintf("%d months ago", months) } years := int(diff.Hours() / 24 / 365) if years == 1 { return "1 year ago" } return fmt.Sprintf("%d years ago", years) } ================================================ FILE: pkg/utilds/codederror.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilds import ( "errors" "fmt" ) // CodedError wraps an error with a string code for categorization. // The code can be extracted from anywhere in an error chain using GetErrorCode. // SubCode provides additional granularity for error classification. type CodedError struct { Code string SubCode string Err error } func (e CodedError) Error() string { return e.Err.Error() } func (e CodedError) Unwrap() error { return e.Err } // MakeCodedError creates a new CodedError with the given code and error. func MakeCodedError(code string, err error) CodedError { return CodedError{Code: code, SubCode: "", Err: err} } // MakeSubCodedError creates a new CodedError with the given code, subcode, and error. func MakeSubCodedError(code string, subCode string, err error) CodedError { return CodedError{Code: code, SubCode: subCode, Err: err} } // GetErrorCode extracts the error code from anywhere in the error chain. // Returns empty string if no CodedError is found. func GetErrorCode(err error) string { if err == nil { return "" } var coded CodedError if errors.As(err, &coded) { return coded.Code } return "" } // GetErrorSubCode extracts the error subcode from anywhere in the error chain. // Returns empty string if no CodedError is found or if SubCode is not set. func GetErrorSubCode(err error) string { if err == nil { return "" } var coded CodedError if errors.As(err, &coded) { return coded.SubCode } return "" } // Errorf creates a formatted error wrapped in a CodedError. // This is a convenience function that combines fmt.Errorf with MakeCodedError. func Errorf(code string, format string, args ...interface{}) error { return MakeCodedError(code, fmt.Errorf(format, args...)) } ================================================ FILE: pkg/utilds/idlist.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilds import ( "sync" "github.com/google/uuid" ) type idListEntry[T any] struct { id string val T } type IdList[T any] struct { lock sync.Mutex entries []idListEntry[T] } func (il *IdList[T]) Register(val T) string { il.lock.Lock() defer il.lock.Unlock() id := uuid.New().String() il.entries = append(il.entries, idListEntry[T]{id: id, val: val}) return id } func (il *IdList[T]) RegisterWithId(id string, val T) { il.lock.Lock() defer il.lock.Unlock() il.unregister_nolock(id) il.entries = append(il.entries, idListEntry[T]{id: id, val: val}) } func (il *IdList[T]) Unregister(id string) { il.lock.Lock() defer il.lock.Unlock() il.unregister_nolock(id) } func (il *IdList[T]) unregister_nolock(id string) { for i, entry := range il.entries { if entry.id == id { il.entries = append(il.entries[:i], il.entries[i+1:]...) return } } } func (il *IdList[T]) GetList() []T { il.lock.Lock() defer il.lock.Unlock() result := make([]T, len(il.entries)) for i, entry := range il.entries { result[i] = entry.val } return result } ================================================ FILE: pkg/utilds/multireaderlinebuffer.go ================================================ package utilds import ( "bufio" "io" "sync" ) type MultiReaderLineBuffer struct { lock sync.Mutex lines []string maxLines int totalLineCount int lineCallback func(string) } func MakeMultiReaderLineBuffer(maxLines int) *MultiReaderLineBuffer { if maxLines <= 0 { maxLines = 1000 } return &MultiReaderLineBuffer{ lines: make([]string, 0, maxLines), maxLines: maxLines, totalLineCount: 0, } } // callback is synchronous. will block the consuming of lines and // guaranteed to run in order. it is also guaranteed only one callback // will be running at a time (protected by the internal line lock) func (mrlb *MultiReaderLineBuffer) SetLineCallback(callback func(string)) { mrlb.lock.Lock() defer mrlb.lock.Unlock() mrlb.lineCallback = callback } func (mrlb *MultiReaderLineBuffer) ReadAll(r io.Reader) { scanner := bufio.NewScanner(r) for scanner.Scan() { line := scanner.Text() mrlb.addLine(line) mrlb.callLineCallback(line) } } func (mrlb *MultiReaderLineBuffer) callLineCallback(line string) { mrlb.lock.Lock() defer mrlb.lock.Unlock() if mrlb.lineCallback != nil { mrlb.lineCallback(line) } } func (mrlb *MultiReaderLineBuffer) AddLine(line string) { mrlb.addLine(line) mrlb.callLineCallback(line) } func (mrlb *MultiReaderLineBuffer) addLine(line string) { mrlb.lock.Lock() defer mrlb.lock.Unlock() mrlb.totalLineCount++ if len(mrlb.lines) >= mrlb.maxLines { mrlb.lines = append(mrlb.lines[1:], line) } else { mrlb.lines = append(mrlb.lines, line) } } func (mrlb *MultiReaderLineBuffer) GetLines() []string { mrlb.lock.Lock() defer mrlb.lock.Unlock() result := make([]string, len(mrlb.lines)) copy(result, mrlb.lines) return result } func (mrlb *MultiReaderLineBuffer) GetLineCount() int { mrlb.lock.Lock() defer mrlb.lock.Unlock() return len(mrlb.lines) } func (mrlb *MultiReaderLineBuffer) GetTotalLineCount() int { mrlb.lock.Lock() defer mrlb.lock.Unlock() return mrlb.totalLineCount } ================================================ FILE: pkg/utilds/quickreorderqueue.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilds import ( "fmt" "sort" "sync" "time" ) // the quick reorder queue implements reordering of items with a certain timerame (the timeout passed) // if an item is queued in order, it gets processed immediately // if it comes in out of order it gets buffered for up to the timeout while we wait for the correct next seq to come in // if we still haven't received the "correct" next seq within the timeout the out of order event is flushed. // "old" events (less than the current nextseq) are flushed immediately // // we also implement a "session" system. each session is assigned a virtual order based on the timestamp // it was first seen. so all events of a session are either "before" or "after" all the events of a different session. // the assumption is that sessions will always be separated by an amount of time greater than the timeout of the reorder queue (e.g. a system reboot, or main server restart) // // enqueuing without a sessionid or if seqNum is 0, will bypass the reorder queue and just flush the event type queuedItem[T any] struct { sessionId string seqNum int data T timestamp time.Time } type QuickReorderQueue[T any] struct { lock sync.Mutex sessionOrder map[string]int64 // sessionId -> timestamp millis when first seen currentSessionId string nextSeqNum int buffer []queuedItem[T] outCh chan T timeout time.Duration timer *time.Timer closed bool } func MakeQuickReorderQueue[T any](bufSize int, timeout time.Duration) *QuickReorderQueue[T] { return &QuickReorderQueue[T]{ sessionOrder: make(map[string]int64), nextSeqNum: 1, outCh: make(chan T, bufSize), timeout: timeout, } } func (q *QuickReorderQueue[T]) C() <-chan T { return q.outCh } func (q *QuickReorderQueue[T]) SetNextSeqNum(seqNum int) { q.lock.Lock() defer q.lock.Unlock() q.nextSeqNum = seqNum } func (q *QuickReorderQueue[T]) ensureSessionTs_withlock(sessionId string) { if sessionId == "" { return } if _, ok := q.sessionOrder[sessionId]; ok { return } ts := time.Now().UnixMilli() q.sessionOrder[sessionId] = ts q.flushBuffer_withlock() q.currentSessionId = sessionId q.nextSeqNum = 1 } func (q *QuickReorderQueue[T]) cmpSessionSeq_withlock(session1 string, seq1 int, session2 string, seq2 int) int { ts1 := q.sessionOrder[session1] ts2 := q.sessionOrder[session2] if ts1 < ts2 { return -1 } if ts1 > ts2 { return 1 } if seq1 < seq2 { return -1 } if seq1 > seq2 { return 1 } return 0 } func (q *QuickReorderQueue[T]) sortBuffer_withlock() { sort.Slice(q.buffer, func(i, j int) bool { return q.cmpSessionSeq_withlock(q.buffer[i].sessionId, q.buffer[i].seqNum, q.buffer[j].sessionId, q.buffer[j].seqNum) < 0 }) } func (q *QuickReorderQueue[T]) flushBuffer_withlock() { if len(q.buffer) == 0 { return } q.sortBuffer_withlock() for _, item := range q.buffer { q.outCh <- item.data } q.buffer = nil if q.timer != nil { q.timer.Stop() q.timer = nil } } func (q *QuickReorderQueue[T]) QueueItem(sessionId string, seqNum int, data T) error { q.lock.Lock() defer q.lock.Unlock() if q.closed { return fmt.Errorf("ReorderQueue is closed, cannot queue new item") } if len(q.buffer)+len(q.outCh) >= cap(q.outCh) { return fmt.Errorf("queue is full, cannot accept new items, cap: %d", cap(q.outCh)) } q.ensureSessionTs_withlock(sessionId) cmp := q.cmpSessionSeq_withlock(sessionId, seqNum, q.currentSessionId, q.nextSeqNum) if cmp < 0 || seqNum == 0 || sessionId == "" { q.outCh <- data return nil } if cmp == 0 { q.outCh <- data q.nextSeqNum++ q.processBuffer_withlock() return nil } q.buffer = append(q.buffer, queuedItem[T]{ sessionId: sessionId, seqNum: seqNum, data: data, timestamp: time.Now(), }) q.ensureTimer_withlock() return nil } func (q *QuickReorderQueue[T]) processBuffer_withlock() { if len(q.buffer) == 0 { return } q.sortBuffer_withlock() enqueued := 0 for i, item := range q.buffer { if item.sessionId == q.currentSessionId && item.seqNum == q.nextSeqNum { q.outCh <- item.data q.nextSeqNum++ enqueued = i + 1 } else { break } } if enqueued > 0 { q.buffer = q.buffer[enqueued:] } } func (q *QuickReorderQueue[T]) ensureTimer_withlock() { if q.timer != nil { return } q.timer = time.AfterFunc(q.timeout, func() { q.onTimeout() }) } func (q *QuickReorderQueue[T]) onTimeout() { q.lock.Lock() defer q.lock.Unlock() if q.closed { return } q.timer = nil if len(q.buffer) == 0 { return } now := time.Now() q.sortBuffer_withlock() highestTimedOutIdx := -1 for i, item := range q.buffer { if now.Sub(item.timestamp) >= q.timeout { highestTimedOutIdx = i } } if highestTimedOutIdx >= 0 { for i := 0; i <= highestTimedOutIdx; i++ { item := q.buffer[i] q.outCh <- item.data if item.sessionId == q.currentSessionId && item.seqNum >= q.nextSeqNum { q.nextSeqNum = item.seqNum + 1 } } q.buffer = q.buffer[highestTimedOutIdx+1:] } if len(q.buffer) > 0 { oldestTime := q.buffer[0].timestamp for _, item := range q.buffer[1:] { if item.timestamp.Before(oldestTime) { oldestTime = item.timestamp } } nextTimeout := q.timeout - now.Sub(oldestTime) if nextTimeout < 0 { nextTimeout = 0 } q.timer = time.AfterFunc(nextTimeout, func() { q.onTimeout() }) } } func (q *QuickReorderQueue[T]) Close() { q.lock.Lock() defer q.lock.Unlock() if q.closed { return } q.closed = true if q.timer != nil { q.timer.Stop() q.timer = nil } close(q.outCh) } ================================================ FILE: pkg/utilds/quickreorderqueue_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilds import ( "testing" "time" ) func collectItems[T any](ch <-chan T, count int, timeout time.Duration) []T { result := make([]T, 0, count) timer := time.NewTimer(timeout) defer timer.Stop() for i := 0; i < count; i++ { select { case item := <-ch: result = append(result, item) case <-timer.C: return result } } return result } func TestQuickReorderQueue_InOrder(t *testing.T) { q := MakeQuickReorderQueue[string](10, 100*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "item1") q.QueueItem("session1", 2, "item2") q.QueueItem("session1", 3, "item3") items := collectItems(q.C(), 3, 500*time.Millisecond) if len(items) != 3 { t.Fatalf("expected 3 items, got %d", len(items)) } if items[0] != "item1" || items[1] != "item2" || items[2] != "item3" { t.Errorf("expected [item1, item2, item3], got %v", items) } } func TestQuickReorderQueue_OutOfOrder(t *testing.T) { q := MakeQuickReorderQueue[string](10, 200*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "item1") q.QueueItem("session1", 3, "item3") q.QueueItem("session1", 2, "item2") items := collectItems(q.C(), 3, 500*time.Millisecond) if len(items) != 3 { t.Fatalf("expected 3 items, got %d", len(items)) } if items[0] != "item1" || items[1] != "item2" || items[2] != "item3" { t.Errorf("expected [item1, item2, item3], got %v", items) } } func TestQuickReorderQueue_MultipleOutOfOrder(t *testing.T) { q := MakeQuickReorderQueue[int](10, 200*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, 1) q.QueueItem("session1", 5, 5) q.QueueItem("session1", 3, 3) q.QueueItem("session1", 2, 2) q.QueueItem("session1", 4, 4) items := collectItems(q.C(), 5, 500*time.Millisecond) if len(items) != 5 { t.Fatalf("expected 5 items, got %d", len(items)) } for i := 0; i < 5; i++ { if items[i] != i+1 { t.Errorf("expected item %d at position %d, got %d", i+1, i, items[i]) } } } func TestQuickReorderQueue_TwoSessions_StrongSeparation(t *testing.T) { q := MakeQuickReorderQueue[string](10, 200*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "s1-1") q.QueueItem("session1", 2, "s1-2") q.QueueItem("session1", 3, "s1-3") time.Sleep(500 * time.Millisecond) q.QueueItem("session2", 1, "s2-1") q.QueueItem("session2", 2, "s2-2") items := collectItems(q.C(), 5, 500*time.Millisecond) if len(items) != 5 { t.Fatalf("expected 5 items, got %d", len(items)) } expected := []string{"s1-1", "s1-2", "s1-3", "s2-1", "s2-2"} for i, exp := range expected { if items[i] != exp { t.Errorf("expected %s at position %d, got %s", exp, i, items[i]) } } } func TestQuickReorderQueue_TwoSessions_OutOfOrder(t *testing.T) { q := MakeQuickReorderQueue[string](10, 200*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "s1-1") q.QueueItem("session1", 3, "s1-3") time.Sleep(500 * time.Millisecond) q.QueueItem("session2", 1, "s2-1") q.QueueItem("session1", 2, "s1-2") q.QueueItem("session2", 3, "s2-3") q.QueueItem("session2", 2, "s2-2") items := collectItems(q.C(), 6, 500*time.Millisecond) if len(items) != 6 { t.Fatalf("expected 6 items, got %d", len(items)) } expected := []string{"s1-1", "s1-3", "s2-1", "s1-2", "s2-2", "s2-3"} for i, exp := range expected { if items[i] != exp { t.Errorf("expected %s at position %d, got %s", exp, i, items[i]) } } } func TestQuickReorderQueue_ThreeSessions_Sequential(t *testing.T) { q := MakeQuickReorderQueue[string](20, 200*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "s1-1") q.QueueItem("session1", 2, "s1-2") time.Sleep(500 * time.Millisecond) q.QueueItem("session2", 1, "s2-1") q.QueueItem("session2", 2, "s2-2") time.Sleep(500 * time.Millisecond) q.QueueItem("session3", 1, "s3-1") q.QueueItem("session3", 2, "s3-2") items := collectItems(q.C(), 6, 1*time.Second) if len(items) != 6 { t.Fatalf("expected 6 items, got %d", len(items)) } expected := []string{"s1-1", "s1-2", "s2-1", "s2-2", "s3-1", "s3-2"} for i, exp := range expected { if items[i] != exp { t.Errorf("expected %s at position %d, got %s", exp, i, items[i]) } } } func TestQuickReorderQueue_SimpleTimeout(t *testing.T) { q := MakeQuickReorderQueue[string](10, 50*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "item1") q.QueueItem("session1", 3, "item3") time.Sleep(100 * time.Millisecond) items := collectItems(q.C(), 2, 100*time.Millisecond) if len(items) != 2 { t.Fatalf("expected 2 items after timeout, got %d", len(items)) } if items[0] != "item1" { t.Errorf("expected item1 first, got %s", items[0]) } if items[1] != "item3" { t.Errorf("expected item3 second (due to timeout), got %s", items[1]) } q.QueueItem("session1", 5, "item5") q.QueueItem("session1", 4, "item4") time.Sleep(100 * time.Millisecond) items2 := collectItems(q.C(), 2, 100*time.Millisecond) if len(items2) != 2 { t.Fatalf("expected 2 more items after second timeout, got %d", len(items2)) } if items2[0] != "item4" || items2[1] != "item5" { t.Errorf("expected [item4, item5] after reordering, got %v", items2) } } func TestQuickReorderQueue_RollingTimeout(t *testing.T) { q := MakeQuickReorderQueue[string](20, 50*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "item1") time.Sleep(10 * time.Millisecond) q.QueueItem("session1", 5, "item5") time.Sleep(10 * time.Millisecond) q.QueueItem("session1", 3, "item3") time.Sleep(10 * time.Millisecond) q.QueueItem("session1", 2, "item2") time.Sleep(10 * time.Millisecond) q.QueueItem("session1", 4, "item4") time.Sleep(10 * time.Millisecond) q.QueueItem("session1", 7, "item7") time.Sleep(10 * time.Millisecond) q.QueueItem("session1", 6, "item6") time.Sleep(100 * time.Millisecond) items := collectItems(q.C(), 7, 200*time.Millisecond) if len(items) != 7 { t.Fatalf("expected 7 items, got %d: %v", len(items), items) } expected := []string{"item1", "item2", "item3", "item4", "item5", "item6", "item7"} for i, exp := range expected { if items[i] != exp { t.Errorf("expected %s at position %d, got %s. Full output: %v", exp, i, items[i], items) } } } func TestQuickReorderQueue_Timeout(t *testing.T) { q := MakeQuickReorderQueue[string](10, 150*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "item1") q.QueueItem("session1", 3, "item3") time.Sleep(200 * time.Millisecond) items := collectItems(q.C(), 2, 100*time.Millisecond) if len(items) != 2 { t.Fatalf("expected 2 items after timeout, got %d", len(items)) } if items[0] != "item1" { t.Errorf("expected item1 first, got %s", items[0]) } if items[1] != "item3" { t.Errorf("expected item3 second (due to timeout), got %s", items[1]) } } func TestQuickReorderQueue_TimeoutWithLateArrival(t *testing.T) { q := MakeQuickReorderQueue[string](10, 100*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "item1") q.QueueItem("session1", 3, "item3") time.Sleep(150 * time.Millisecond) items := collectItems(q.C(), 2, 100*time.Millisecond) if len(items) != 2 { t.Fatalf("expected 2 items after timeout, got %d", len(items)) } q.QueueItem("session1", 2, "item2") lateItem := collectItems(q.C(), 1, 100*time.Millisecond) if len(lateItem) != 1 { t.Fatalf("expected 1 late item, got %d", len(lateItem)) } if lateItem[0] != "item2" { t.Errorf("expected item2, got %s", lateItem[0]) } } func TestQuickReorderQueue_SessionOverlap_SmallWindow(t *testing.T) { q := MakeQuickReorderQueue[string](10, 200*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "s1-1") q.QueueItem("session1", 2, "s1-2") q.QueueItem("session1", 3, "s1-3") time.Sleep(500 * time.Millisecond) q.QueueItem("session2", 1, "s2-1") time.Sleep(50 * time.Millisecond) q.QueueItem("session1", 4, "s1-4") q.QueueItem("session2", 2, "s2-2") items := collectItems(q.C(), 6, 500*time.Millisecond) if len(items) != 6 { t.Fatalf("expected 6 items, got %d", len(items)) } expected := []string{"s1-1", "s1-2", "s1-3", "s2-1", "s1-4", "s2-2"} for i, exp := range expected { if items[i] != exp { t.Errorf("expected %s at position %d, got %s", exp, i, items[i]) } } } func TestQuickReorderQueue_DuplicateSequence(t *testing.T) { q := MakeQuickReorderQueue[string](10, 200*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "item1-first") q.QueueItem("session1", 2, "item2") q.QueueItem("session1", 1, "item1-duplicate") items := collectItems(q.C(), 3, 500*time.Millisecond) if len(items) != 3 { t.Fatalf("expected 3 items, got %d", len(items)) } if items[0] != "item1-first" || items[1] != "item2" || items[2] != "item1-duplicate" { t.Errorf("got %v", items) } } func TestQuickReorderQueue_SetNextSeqNum(t *testing.T) { q := MakeQuickReorderQueue[string](10, 200*time.Millisecond) defer q.Close() q.SetNextSeqNum(5) q.QueueItem("session1", 5, "item5") q.QueueItem("session1", 6, "item6") q.QueueItem("session1", 7, "item7") items := collectItems(q.C(), 3, 500*time.Millisecond) if len(items) != 3 { t.Fatalf("expected 3 items, got %d", len(items)) } if items[0] != "item5" || items[1] != "item6" || items[2] != "item7" { t.Errorf("expected [item5, item6, item7], got %v", items) } } func TestQuickReorderQueue_EmptyBuffer(t *testing.T) { q := MakeQuickReorderQueue[string](10, 200*time.Millisecond) defer q.Close() select { case <-q.C(): t.Error("should not have any items") case <-time.After(50 * time.Millisecond): } } func TestQuickReorderQueue_Close(t *testing.T) { q := MakeQuickReorderQueue[string](10, 200*time.Millisecond) q.QueueItem("session1", 1, "item1") q.Close() _, ok := <-q.C() if !ok { t.Error("expected to read item1 before close") } _, ok = <-q.C() if ok { t.Error("channel should be closed") } } func TestQuickReorderQueue_CloseWithBufferedItems(t *testing.T) { q := MakeQuickReorderQueue[string](10, 200*time.Millisecond) q.QueueItem("session1", 1, "item1") q.QueueItem("session1", 3, "item3") q.Close() item, ok := <-q.C() if !ok || item != "item1" { t.Errorf("expected item1, got %s (ok=%v)", item, ok) } _, ok = <-q.C() if ok { t.Error("channel should be closed, item3 should be dropped as buffered") } } func TestQuickReorderQueue_MultiSessionComplexReordering(t *testing.T) { q := MakeQuickReorderQueue[string](20, 300*time.Millisecond) defer q.Close() q.QueueItem("session1", 1, "s1-1") q.QueueItem("session1", 4, "s1-4") q.QueueItem("session1", 2, "s1-2") time.Sleep(500 * time.Millisecond) q.QueueItem("session2", 2, "s2-2") q.QueueItem("session2", 1, "s2-1") q.QueueItem("session1", 3, "s1-3") time.Sleep(500 * time.Millisecond) q.QueueItem("session3", 1, "s3-1") q.QueueItem("session2", 3, "s2-3") items := collectItems(q.C(), 8, 1*time.Second) if len(items) != 8 { t.Fatalf("expected 8 items, got %d", len(items)) } expected := []string{"s1-1", "s1-2", "s1-4", "s2-1", "s2-2", "s1-3", "s3-1", "s2-3"} for i, exp := range expected { if items[i] != exp { t.Errorf("expected %s at position %d, got %s", exp, i, items[i]) } } } ================================================ FILE: pkg/utilds/readerlinebuffer.go ================================================ package utilds import ( "bufio" "io" "sync" ) type ReaderLineBuffer struct { lock sync.Mutex lines []string maxLines int totalLineCount int reader io.Reader scanner *bufio.Scanner done bool lineCallback func(string) } func MakeReaderLineBuffer(reader io.Reader, maxLines int) *ReaderLineBuffer { if maxLines <= 0 { maxLines = 1000 // default max lines } rlb := &ReaderLineBuffer{ lines: make([]string, 0, maxLines), maxLines: maxLines, totalLineCount: 0, reader: reader, scanner: bufio.NewScanner(reader), done: false, } return rlb } func (rlb *ReaderLineBuffer) SetLineCallback(callback func(string)) { rlb.lock.Lock() defer rlb.lock.Unlock() rlb.lineCallback = callback } func (rlb *ReaderLineBuffer) IsDone() bool { rlb.lock.Lock() defer rlb.lock.Unlock() return rlb.done } func (rlb *ReaderLineBuffer) setDone() { rlb.lock.Lock() defer rlb.lock.Unlock() rlb.done = true } func (rlb *ReaderLineBuffer) ReadLine() (string, error) { if rlb.IsDone() { return "", io.EOF } if rlb.scanner.Scan() { line := rlb.scanner.Text() rlb.addLine(line) return line, nil } // Check for scanner error if err := rlb.scanner.Err(); err != nil { rlb.setDone() return "", err } rlb.setDone() return "", io.EOF } func (rlb *ReaderLineBuffer) addLine(line string) { rlb.lock.Lock() defer rlb.lock.Unlock() rlb.totalLineCount++ if len(rlb.lines) >= rlb.maxLines { rlb.lines = append(rlb.lines[1:], line) } else { rlb.lines = append(rlb.lines, line) } } func (rlb *ReaderLineBuffer) GetLines() []string { rlb.lock.Lock() defer rlb.lock.Unlock() result := make([]string, len(rlb.lines)) copy(result, rlb.lines) return result } func (rlb *ReaderLineBuffer) GetLineCount() int { rlb.lock.Lock() defer rlb.lock.Unlock() return len(rlb.lines) } func (rlb *ReaderLineBuffer) GetTotalLineCount() int { rlb.lock.Lock() defer rlb.lock.Unlock() return rlb.totalLineCount } func (rlb *ReaderLineBuffer) ReadAll() { for { line, err := rlb.ReadLine() if err != nil { break } if rlb.lineCallback != nil { rlb.lineCallback(line) } } } ================================================ FILE: pkg/utilds/synccache.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package utilds import "sync" type SyncCache[T any] struct { lock sync.Mutex computeFn func() (T, error) value T err error cached bool } func MakeSyncCache[T any](computeFn func() (T, error)) *SyncCache[T] { return &SyncCache[T]{ computeFn: computeFn, } } func (sc *SyncCache[T]) Get(force bool) (T, error) { sc.lock.Lock() defer sc.lock.Unlock() if sc.cached && !force { return sc.value, sc.err } sc.value, sc.err = sc.computeFn() sc.cached = true return sc.value, sc.err } ================================================ FILE: pkg/utilds/versionts.go ================================================ package utilds import ( "sync" "time" ) type VersionTs struct { lock sync.Mutex lastVersion int64 } func (v *VersionTs) GetVersionTs() int64 { v.lock.Lock() defer v.lock.Unlock() nowMs := time.Now().UnixMilli() if nowMs <= v.lastVersion { v.lastVersion++ return v.lastVersion } v.lastVersion = nowMs return v.lastVersion } ================================================ FILE: pkg/utilds/workqueue.go ================================================ package utilds import "sync" type WorkQueue[T any] struct { lock sync.Mutex cond *sync.Cond queue []T closed bool started bool wg sync.WaitGroup workFn func(T) } func NewWorkQueue[T any](workFn func(T)) *WorkQueue[T] { wq := &WorkQueue[T]{ workFn: workFn, } wq.cond = sync.NewCond(&wq.lock) return wq } func (wq *WorkQueue[T]) Enqueue(item T) bool { wq.lock.Lock() defer wq.lock.Unlock() if wq.closed { return false } if !wq.started { wq.started = true wq.wg.Add(1) go wq.worker() } wq.queue = append(wq.queue, item) wq.cond.Signal() return true } func (wq *WorkQueue[T]) worker() { defer wq.wg.Done() for { wq.lock.Lock() for len(wq.queue) == 0 && !wq.closed { wq.cond.Wait() } if wq.closed && len(wq.queue) == 0 { wq.lock.Unlock() return } item := wq.queue[0] wq.queue = wq.queue[1:] wq.lock.Unlock() wq.workFn(item) } } func (wq *WorkQueue[T]) Close(immediate bool) { wq.lock.Lock() wq.closed = true if immediate { wq.queue = nil } wq.cond.Broadcast() wq.lock.Unlock() } func (wq *WorkQueue[T]) Wait() { wq.wg.Wait() } ================================================ FILE: pkg/vdom/cssparser/cssparser.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cssparser import ( "fmt" "strings" "unicode" ) type Parser struct { Input string Pos int Length int InQuote bool QuoteChar rune OpenParens int Debug bool } func MakeParser(input string) *Parser { return &Parser{ Input: input, Length: len(input), } } func (p *Parser) Parse() (map[string]string, error) { result := make(map[string]string) lastProp := "" for { p.skipWhitespace() if p.eof() { break } propName, err := p.parseIdentifierColon(lastProp) if err != nil { return nil, err } lastProp = propName p.skipWhitespace() value, err := p.parseValue(propName) if err != nil { return nil, err } result[propName] = value p.skipWhitespace() if p.eof() { break } if !p.expectChar(';') { break } } p.skipWhitespace() if !p.eof() { return nil, fmt.Errorf("bad style attribute, unexpected character %q at pos %d", string(p.Input[p.Pos]), p.Pos+1) } return result, nil } func (p *Parser) parseIdentifierColon(lastProp string) (string, error) { start := p.Pos for !p.eof() { c := p.peekChar() if isIdentChar(c) || c == '-' { p.advance() } else { break } } attrName := p.Input[start:p.Pos] p.skipWhitespace() if p.eof() { return "", fmt.Errorf("bad style attribute, expected colon after property %q, got EOF, at pos %d", attrName, p.Pos+1) } if attrName == "" { return "", fmt.Errorf("bad style attribute, invalid property name after property %q, at pos %d", lastProp, p.Pos+1) } if !p.expectChar(':') { return "", fmt.Errorf("bad style attribute, bad property name starting with %q, expected colon, got %q, at pos %d", attrName, string(p.Input[p.Pos]), p.Pos+1) } return attrName, nil } func (p *Parser) parseValue(propName string) (string, error) { start := p.Pos quotePos := 0 parenPosStack := make([]int, 0) for !p.eof() { c := p.peekChar() if p.InQuote { if c == p.QuoteChar { p.InQuote = false } else if c == '\\' { p.advance() } } else { if c == '"' || c == '\'' { p.InQuote = true p.QuoteChar = c quotePos = p.Pos } else if c == '(' { p.OpenParens++ parenPosStack = append(parenPosStack, p.Pos) } else if c == ')' { if p.OpenParens == 0 { return "", fmt.Errorf("unmatched ')' at pos %d", p.Pos+1) } p.OpenParens-- parenPosStack = parenPosStack[:len(parenPosStack)-1] } else if c == ';' && p.OpenParens == 0 { break } } p.advance() } if p.eof() && p.InQuote { return "", fmt.Errorf("bad style attribute, while parsing attribute %q, unmatched quote at pos %d", propName, quotePos+1) } if p.eof() && p.OpenParens > 0 { return "", fmt.Errorf("bad style attribute, while parsing property %q, unmatched '(' at pos %d", propName, parenPosStack[len(parenPosStack)-1]+1) } return strings.TrimSpace(p.Input[start:p.Pos]), nil } func isIdentChar(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } func (p *Parser) skipWhitespace() { for !p.eof() && unicode.IsSpace(p.peekChar()) { p.advance() } } func (p *Parser) expectChar(expected rune) bool { if !p.eof() && p.peekChar() == expected { p.advance() return true } return false } func (p *Parser) peekChar() rune { if p.Pos >= p.Length { return 0 } return rune(p.Input[p.Pos]) } func (p *Parser) advance() { p.Pos++ } func (p *Parser) eof() bool { return p.Pos >= p.Length } ================================================ FILE: pkg/vdom/cssparser/cssparser_test.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cssparser import ( "fmt" "log" "testing" ) func compareMaps(a, b map[string]string) error { if len(a) != len(b) { return fmt.Errorf("map length mismatch: %d != %d", len(a), len(b)) } for k, v := range a { if b[k] != v { return fmt.Errorf("value mismatch for key %s: %q != %q", k, v, b[k]) } } return nil } func TestParse1(t *testing.T) { style := `background: url("example;with;semicolons.jpg"); color: red; margin-right: 5px; content: "hello;world";` p := MakeParser(style) parsed, err := p.Parse() if err != nil { t.Fatalf("Parse failed: %v", err) return } expected := map[string]string{ "background": `url("example;with;semicolons.jpg")`, "color": "red", "margin-right": "5px", "content": `"hello;world"`, } if err := compareMaps(parsed, expected); err != nil { t.Fatalf("Parsed map does not match expected: %v", err) } style = `margin-right: calc(10px + 5px); color: red; font-family: "Arial";` p = MakeParser(style) parsed, err = p.Parse() if err != nil { t.Fatalf("Parse failed: %v", err) return } expected = map[string]string{ "margin-right": `calc(10px + 5px)`, "color": "red", "font-family": `"Arial"`, } if err := compareMaps(parsed, expected); err != nil { t.Fatalf("Parsed map does not match expected: %v", err) } } func TestParserErrors(t *testing.T) { style := `hello more: bad;` p := MakeParser(style) _, err := p.Parse() if err == nil { t.Fatalf("expected error, got nil") } log.Printf("got expected error: %v\n", err) style = `background: url("example.jpg` p = MakeParser(style) _, err = p.Parse() if err == nil { t.Fatalf("expected error, got nil") } log.Printf("got expected error: %v\n", err) style = `foo: url(...` p = MakeParser(style) _, err = p.Parse() if err == nil { t.Fatalf("expected error, got nil") } log.Printf("got expected error: %v\n", err) } ================================================ FILE: pkg/vdom/vdom.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package vdom import ( "context" "encoding/json" "fmt" "log" "reflect" "strconv" "strings" "unicode" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) // ReactNode types = nil | string | Elem // generic hook structure type Hook struct { Init bool // is initialized Idx int // index in the hook array Fn func() func() // for useEffect UnmountFn func() // for useEffect Val any // for useState, useMemo, useRef Deps []any } type Component[P any] func(props P) *VDomElem type styleAttrWrapper struct { StyleAttr string Val any } type classAttrWrapper struct { ClassName string Cond bool } type styleAttrMapWrapper struct { StyleAttrMap map[string]any } func (e *VDomElem) Key() string { keyVal, ok := e.Props[KeyPropKey] if !ok { return "" } keyStr, ok := keyVal.(string) if ok { return keyStr } return "" } func (e *VDomElem) WithKey(key string) *VDomElem { if e == nil { return nil } if e.Props == nil { e.Props = make(map[string]any) } e.Props[KeyPropKey] = key return e } func TextElem(text string) VDomElem { return VDomElem{Tag: TextTag, Text: text} } func mergeProps(props *map[string]any, newProps map[string]any) { if *props == nil { *props = make(map[string]any) } for k, v := range newProps { if v == nil { delete(*props, k) continue } (*props)[k] = v } } func mergeStyleAttr(props *map[string]any, styleAttr styleAttrWrapper) { if *props == nil { *props = make(map[string]any) } if (*props)["style"] == nil { (*props)["style"] = make(map[string]any) } styleMap, ok := (*props)["style"].(map[string]any) if !ok { return } styleMap[styleAttr.StyleAttr] = styleAttr.Val } func mergeClassAttr(props *map[string]any, classAttr classAttrWrapper) { if *props == nil { *props = make(map[string]any) } if classAttr.Cond { if (*props)["className"] == nil { (*props)["className"] = classAttr.ClassName return } classVal, ok := (*props)["className"].(string) if !ok { return } // check if class already exists (must split, contains won't work) splitArr := strings.Split(classVal, " ") for _, class := range splitArr { if class == classAttr.ClassName { return } } (*props)["className"] = classVal + " " + classAttr.ClassName } else { classVal, ok := (*props)["className"].(string) if !ok { return } splitArr := strings.Split(classVal, " ") for i, class := range splitArr { if class == classAttr.ClassName { splitArr = append(splitArr[:i], splitArr[i+1:]...) break } } if len(splitArr) == 0 { delete(*props, "className") } else { (*props)["className"] = strings.Join(splitArr, " ") } } } func Classes(classes ...any) string { var parts []string for _, class := range classes { switch c := class.(type) { case nil: continue case string: if c != "" { parts = append(parts, c) } } // Ignore any other types } return strings.Join(parts, " ") } func H(tag string, props map[string]any, children ...any) *VDomElem { rtn := &VDomElem{Tag: tag, Props: props} if len(children) > 0 { for _, part := range children { elems := partToElems(part) rtn.Children = append(rtn.Children, elems...) } } return rtn } func E(tag string, parts ...any) *VDomElem { rtn := &VDomElem{Tag: tag} for _, part := range parts { if part == nil { continue } props, ok := part.(map[string]any) if ok { mergeProps(&rtn.Props, props) continue } if styleAttr, ok := part.(styleAttrWrapper); ok { mergeStyleAttr(&rtn.Props, styleAttr) continue } if styleAttrMap, ok := part.(styleAttrMapWrapper); ok { for k, v := range styleAttrMap.StyleAttrMap { mergeStyleAttr(&rtn.Props, styleAttrWrapper{StyleAttr: k, Val: v}) } continue } if classAttr, ok := part.(classAttrWrapper); ok { mergeClassAttr(&rtn.Props, classAttr) continue } elems := partToElems(part) rtn.Children = append(rtn.Children, elems...) } return rtn } func Class(name string) classAttrWrapper { return classAttrWrapper{ClassName: name, Cond: true} } func ClassIf(cond bool, name string) classAttrWrapper { return classAttrWrapper{ClassName: name, Cond: cond} } func ClassIfElse(cond bool, name string, elseName string) classAttrWrapper { if cond { return classAttrWrapper{ClassName: name, Cond: true} } return classAttrWrapper{ClassName: elseName, Cond: true} } func If(cond bool, part any) any { if cond { return part } return nil } func IfElse(cond bool, part any, elsePart any) any { if cond { return part } return elsePart } func ForEach[T any](items []T, fn func(T) any) []any { var elems []any for _, item := range items { fnResult := fn(item) elems = append(elems, fnResult) } return elems } func ForEachIdx[T any](items []T, fn func(T, int) any) []any { var elems []any for idx, item := range items { fnResult := fn(item, idx) elems = append(elems, fnResult) } return elems } func Filter[T any](items []T, fn func(T) bool) []T { var elems []T for _, item := range items { if fn(item) { elems = append(elems, item) } } return elems } func FilterIdx[T any](items []T, fn func(T, int) bool) []T { var elems []T for idx, item := range items { if fn(item, idx) { elems = append(elems, item) } } return elems } func Props(props any) map[string]any { m, err := utilfn.StructToMap(props) if err != nil { return nil } return m } func PStyle(styleAttr string, propVal any) any { return styleAttrWrapper{StyleAttr: styleAttr, Val: propVal} } func Fragment(parts ...any) any { return parts } func P(propName string, propVal any) any { if propVal == nil { return map[string]any{propName: nil} } if propName == "style" { strVal, ok := propVal.(string) if ok { styleMap, err := styleAttrStrToStyleMap(strVal, nil) if err == nil { return styleAttrMapWrapper{StyleAttrMap: styleMap} } log.Printf("Error parsing style attribute: %v\n", err) return nil } } return map[string]any{propName: propVal} } func getHookFromCtx(ctx context.Context) (*VDomContextVal, *Hook) { vc := getRenderContext(ctx) if vc == nil { panic("UseState must be called within a component (no context)") } if vc.Comp == nil { panic("UseState must be called within a component (vc.Comp is nil)") } for len(vc.Comp.Hooks) <= vc.HookIdx { vc.Comp.Hooks = append(vc.Comp.Hooks, &Hook{Idx: len(vc.Comp.Hooks)}) } hookVal := vc.Comp.Hooks[vc.HookIdx] vc.HookIdx++ return vc, hookVal } func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) { vc, hookVal := getHookFromCtx(ctx) if !hookVal.Init { hookVal.Init = true hookVal.Val = initialVal } var rtnVal T rtnVal, ok := hookVal.Val.(T) if !ok { panic("UseState hook value is not a state (possible out of order or conditional hooks)") } setVal := func(newVal T) { hookVal.Val = newVal vc.Root.AddRenderWork(vc.Comp.WaveId) } return rtnVal, setVal } func UseStateWithFn[T any](ctx context.Context, initialVal T) (T, func(T), func(func(T) T)) { vc, hookVal := getHookFromCtx(ctx) if !hookVal.Init { hookVal.Init = true hookVal.Val = initialVal } var rtnVal T rtnVal, ok := hookVal.Val.(T) if !ok { panic("UseState hook value is not a state (possible out of order or conditional hooks)") } setVal := func(newVal T) { hookVal.Val = newVal vc.Root.AddRenderWork(vc.Comp.WaveId) } setFuncVal := func(updateFunc func(T) T) { hookVal.Val = updateFunc(hookVal.Val.(T)) vc.Root.AddRenderWork(vc.Comp.WaveId) } return rtnVal, setVal, setFuncVal } func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) { vc, hookVal := getHookFromCtx(ctx) if !hookVal.Init { hookVal.Init = true closedWaveId := vc.Comp.WaveId hookVal.UnmountFn = func() { atom := vc.Root.GetAtom(atomName) delete(atom.UsedBy, closedWaveId) } } atom := vc.Root.GetAtom(atomName) atom.UsedBy[vc.Comp.WaveId] = true atomVal, ok := atom.Val.(T) if !ok { panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, atom.Val)) } setVal := func(newVal T) { atom.Val = newVal for waveId := range atom.UsedBy { vc.Root.AddRenderWork(waveId) } } return atomVal, setVal } func UseVDomRef(ctx context.Context) *VDomRef { vc, hookVal := getHookFromCtx(ctx) if !hookVal.Init { hookVal.Init = true refId := vc.Comp.WaveId + ":" + strconv.Itoa(hookVal.Idx) hookVal.Val = &VDomRef{Type: ObjectType_Ref, RefId: refId} } refVal, ok := hookVal.Val.(*VDomRef) if !ok { panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") } return refVal } func UseRef[T any](ctx context.Context, val T) *VDomSimpleRef[T] { _, hookVal := getHookFromCtx(ctx) if !hookVal.Init { hookVal.Init = true hookVal.Val = &VDomSimpleRef[T]{Current: val} } refVal, ok := hookVal.Val.(*VDomSimpleRef[T]) if !ok { panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") } return refVal } func UseId(ctx context.Context) string { vc := getRenderContext(ctx) if vc == nil { panic("UseId must be called within a component (no context)") } return vc.Comp.WaveId } func UseRenderTs(ctx context.Context) int64 { vc := getRenderContext(ctx) if vc == nil { panic("UseRenderTs must be called within a component (no context)") } return vc.Root.RenderTs } func QueueRefOp(ctx context.Context, ref *VDomRef, op VDomRefOperation) { if ref == nil || !ref.HasCurrent { return } vc := getRenderContext(ctx) if vc == nil { panic("QueueRefOp must be called within a component (no context)") } if op.RefId == "" { op.RefId = ref.RefId } vc.Root.QueueRefOp(op) } func depsEqual(deps1 []any, deps2 []any) bool { if len(deps1) != len(deps2) { return false } for i := range deps1 { if deps1[i] != deps2[i] { return false } } return true } func UseEffect(ctx context.Context, fn func() func(), deps []any) { // note UseEffect never actually runs anything, it just queues the effect to run later vc, hookVal := getHookFromCtx(ctx) if !hookVal.Init { hookVal.Init = true hookVal.Fn = fn hookVal.Deps = deps vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) return } if depsEqual(hookVal.Deps, deps) { return } hookVal.Fn = fn hookVal.Deps = deps vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) } func numToString[T any](value T) (string, bool) { switch v := any(value).(type) { case int: return strconv.FormatInt(int64(v), 10), true case int8: return strconv.FormatInt(int64(v), 10), true case int16: return strconv.FormatInt(int64(v), 10), true case int32: return strconv.FormatInt(int64(v), 10), true case int64: return strconv.FormatInt(v, 10), true case uint: return strconv.FormatUint(uint64(v), 10), true case uint8: return strconv.FormatUint(uint64(v), 10), true case uint16: return strconv.FormatUint(uint64(v), 10), true case uint32: return strconv.FormatUint(uint64(v), 10), true case uint64: return strconv.FormatUint(v, 10), true case float32: return strconv.FormatFloat(float64(v), 'f', -1, 32), true case float64: return strconv.FormatFloat(v, 'f', -1, 64), true default: return "", false } } func partToElems(part any) []VDomElem { if part == nil { return nil } switch part := part.(type) { case string: return []VDomElem{TextElem(part)} case *VDomElem: if part == nil { return nil } return []VDomElem{*part} case VDomElem: return []VDomElem{part} case []VDomElem: return part case []*VDomElem: var rtn []VDomElem for _, e := range part { if e == nil { continue } rtn = append(rtn, *e) } return rtn } sval, ok := numToString(part) if ok { return []VDomElem{TextElem(sval)} } partVal := reflect.ValueOf(part) if partVal.Kind() == reflect.Slice { var rtn []VDomElem for i := 0; i < partVal.Len(); i++ { subPart := partVal.Index(i).Interface() rtn = append(rtn, partToElems(subPart)...) } return rtn } stringer, ok := part.(fmt.Stringer) if ok { return []VDomElem{TextElem(stringer.String())} } jsonStr, jsonErr := json.Marshal(part) if jsonErr == nil { return []VDomElem{TextElem(string(jsonStr))} } typeText := "invalid:" + reflect.TypeOf(part).String() return []VDomElem{TextElem(typeText)} } func isWaveTag(tag string) bool { return strings.HasPrefix(tag, "wave:") || strings.HasPrefix(tag, "w:") } func isBaseTag(tag string) bool { if len(tag) == 0 { return false } return tag[0] == '#' || unicode.IsLower(rune(tag[0])) || isWaveTag(tag) } ================================================ FILE: pkg/vdom/vdom_comp.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package vdom // so components either render to another component (or fragment) // or to a base element (text or vdom). base elements can then render children type ChildKey struct { Tag string Idx int Key string } type ComponentImpl struct { WaveId string Tag string Key string Elem *VDomElem Mounted bool // hooks Hooks []*Hook // #text component Text string // base component -- vdom, wave elem, or #fragment Children []*ComponentImpl // component -> component Comp *ComponentImpl } func (c *ComponentImpl) compMatch(tag string, key string) bool { if c == nil { return false } return c.Tag == tag && c.Key == key } ================================================ FILE: pkg/vdom/vdom_html.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package vdom import ( "encoding/json" "errors" "fmt" "io" "strings" "github.com/wavetermdev/htmltoken" "github.com/wavetermdev/waveterm/pkg/vdom/cssparser" ) // can tokenize and bind HTML to Elems const Html_BindPrefix = "#bind:" const Html_ParamPrefix = "#param:" const Html_GlobalEventPrefix = "#globalevent" const Html_BindParamTagName = "bindparam" const Html_BindTagName = "bind" func appendChildToStack(stack []*VDomElem, child *VDomElem) { if child == nil { return } if len(stack) == 0 { return } parent := stack[len(stack)-1] parent.Children = append(parent.Children, *child) } func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem { if elem == nil { return stack } return append(stack, elem) } func popElemStack(stack []*VDomElem) []*VDomElem { if len(stack) <= 1 { return stack } curElem := stack[len(stack)-1] appendChildToStack(stack[:len(stack)-1], curElem) return stack[:len(stack)-1] } func curElemTag(stack []*VDomElem) string { if len(stack) == 0 { return "" } return stack[len(stack)-1].Tag } func finalizeStack(stack []*VDomElem) *VDomElem { if len(stack) == 0 { return nil } for len(stack) > 1 { stack = popElemStack(stack) } rtnElem := stack[0] if len(rtnElem.Children) == 0 { return nil } if len(rtnElem.Children) == 1 { return &rtnElem.Children[0] } return rtnElem } // returns value, isjson func getAttrString(token htmltoken.Token, key string) string { for _, attr := range token.Attr { if attr.Key == key { return attr.Val } } return "" } func attrToProp(attrVal string, isJson bool, params map[string]any) any { if isJson { var val any err := json.Unmarshal([]byte(attrVal), &val) if err != nil { return nil } unmStrVal, ok := val.(string) if !ok { return val } attrVal = unmStrVal // fallthrough using the json str val } if strings.HasPrefix(attrVal, Html_ParamPrefix) { bindKey := attrVal[len(Html_ParamPrefix):] bindVal, ok := params[bindKey] if !ok { return nil } return bindVal } if strings.HasPrefix(attrVal, Html_BindPrefix) { bindKey := attrVal[len(Html_BindPrefix):] if bindKey == "" { return nil } return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey} } if strings.HasPrefix(attrVal, Html_GlobalEventPrefix) { splitArr := strings.Split(attrVal, ":") if len(splitArr) < 2 { return nil } eventName := splitArr[1] if eventName == "" { return nil } return &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName} } return attrVal } func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem { elem := &VDomElem{Tag: token.Data} if len(token.Attr) > 0 { elem.Props = make(map[string]any) } for _, attr := range token.Attr { if attr.Key == "" || attr.Val == "" { continue } propVal := attrToProp(attr.Val, attr.IsJson, params) elem.Props[attr.Key] = propVal } return elem } func isWsChar(char rune) bool { return char == ' ' || char == '\t' || char == '\n' || char == '\r' } func isWsByte(char byte) bool { return char == ' ' || char == '\t' || char == '\n' || char == '\r' } func isFirstCharLt(s string) bool { for _, char := range s { if isWsChar(char) { continue } return char == '<' } return false } func isLastCharGt(s string) bool { for i := len(s) - 1; i >= 0; i-- { char := s[i] if isWsByte(char) { continue } return char == '>' } return false } func isAllWhitespace(s string) bool { for _, char := range s { if !isWsChar(char) { return false } } return true } func trimWhitespaceConditionally(s string) string { // Trim leading whitespace if the first non-whitespace character is '<' if isAllWhitespace(s) { return "" } if isFirstCharLt(s) { s = strings.TrimLeftFunc(s, func(r rune) bool { return isWsChar(r) }) } // Trim trailing whitespace if the last non-whitespace character is '>' if isLastCharGt(s) { s = strings.TrimRightFunc(s, func(r rune) bool { return isWsChar(r) }) } return s } func processWhitespace(htmlStr string) string { lines := strings.Split(htmlStr, "\n") var newLines []string for _, line := range lines { trimmedLine := trimWhitespaceConditionally(line + "\n") if trimmedLine == "" { continue } newLines = append(newLines, trimmedLine) } return strings.Join(newLines, "") } func processTextStr(s string) string { if s == "" { return "" } if isAllWhitespace(s) { return " " } return strings.TrimSpace(s) } func makePathStr(elemPath []string) string { return strings.Join(elemPath, " ") } func capitalizeAscii(s string) string { if s == "" || s[0] < 'a' || s[0] > 'z' { return s } return strings.ToUpper(s[:1]) + s[1:] } func toReactName(input string) string { // Check for CSS custom properties (variables) which start with '--' if strings.HasPrefix(input, "--") { return input } parts := strings.Split(input, "-") result := "" index := 0 if parts[0] == "" && len(parts) > 1 { // handle vendor prefixes prefix := parts[1] if prefix == "ms" { result += "ms" } else { result += capitalizeAscii(prefix) } index = 2 // Skip the empty string and prefix } else { result += parts[0] index = 1 } // Convert remaining parts to CamelCase for ; index < len(parts); index++ { if parts[index] != "" { result += capitalizeAscii(parts[index]) } } return result } func convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any { if len(styleMap) == 0 { return nil } rtn := make(map[string]any) for key, val := range styleMap { rtn[toReactName(key)] = attrToProp(val, false, params) } return rtn } func styleAttrStrToStyleMap(styleText string, params map[string]any) (map[string]any, error) { parser := cssparser.MakeParser(styleText) m, err := parser.Parse() if err != nil { return nil, err } return convertStyleToReactStyles(m, params), nil } func fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error { styleText, ok := elem.Props["style"].(string) if !ok { return nil } styleMap, err := styleAttrStrToStyleMap(styleText, params) if err != nil { return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath)) } elem.Props["style"] = styleMap return nil } func fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) { if elem == nil { return } // call fixStyleAttribute, and walk children elemCountMap := make(map[string]int) if len(elemPath) == 0 { elemPath = append(elemPath, elem.Tag) } fixStyleAttribute(elem, params, elemPath) for i := range elem.Children { child := &elem.Children[i] elemCountMap[child.Tag]++ subPath := child.Tag if elemCountMap[child.Tag] > 1 { subPath = fmt.Sprintf("%s[%d]", child.Tag, elemCountMap[child.Tag]) } elemPath = append(elemPath, subPath) fixupStyleAttributes(&elem.Children[i], params, elemPath) elemPath = elemPath[:len(elemPath)-1] } } func Bind(htmlStr string, params map[string]any) *VDomElem { htmlStr = processWhitespace(htmlStr) r := strings.NewReader(htmlStr) iter := htmltoken.NewTokenizer(r) var elemStack []*VDomElem elemStack = append(elemStack, &VDomElem{Tag: FragmentTag}) var tokenErr error outer: for { tokenType := iter.Next() token := iter.Token() switch tokenType { case htmltoken.StartTagToken: if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { tokenErr = errors.New("bind tags must be self closing") break outer } elem := tokenToElem(token, params) elemStack = pushElemStack(elemStack, elem) case htmltoken.EndTagToken: if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { tokenErr = errors.New("bind tags must be self closing") break outer } if len(elemStack) <= 1 { tokenErr = fmt.Errorf("end tag %q without start tag", token.Data) break outer } if curElemTag(elemStack) != token.Data { tokenErr = fmt.Errorf("end tag %q does not match start tag %q", token.Data, curElemTag(elemStack)) break outer } elemStack = popElemStack(elemStack) case htmltoken.SelfClosingTagToken: if token.Data == Html_BindParamTagName { keyAttr := getAttrString(token, "key") dataVal := params[keyAttr] elemList := partToElems(dataVal) for _, elem := range elemList { appendChildToStack(elemStack, &elem) } continue } if token.Data == Html_BindTagName { keyAttr := getAttrString(token, "key") binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr} appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}}) continue } elem := tokenToElem(token, params) appendChildToStack(elemStack, elem) case htmltoken.TextToken: if token.Data == "" { continue } textStr := processTextStr(token.Data) if textStr == "" { continue } elem := TextElem(textStr) appendChildToStack(elemStack, &elem) case htmltoken.CommentToken: continue case htmltoken.DoctypeToken: tokenErr = errors.New("doctype not supported") break outer case htmltoken.ErrorToken: if iter.Err() == io.EOF { break outer } tokenErr = iter.Err() break outer } } if tokenErr != nil { errTextElem := TextElem(tokenErr.Error()) appendChildToStack(elemStack, &errTextElem) } rtn := finalizeStack(elemStack) fixupStyleAttributes(rtn, params, nil) return rtn } ================================================ FILE: pkg/vdom/vdom_root.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package vdom import ( "context" "fmt" "log" "reflect" "strconv" "strings" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) const ( BackendUpdate_InitialChunkSize = 50 // Size for initial chunks that contain both TransferElems and StateSync BackendUpdate_ChunkSize = 100 // Size for subsequent chunks ) type vdomContextKeyType struct{} var vdomContextKey = vdomContextKeyType{} type VDomContextVal struct { Root *RootElem Comp *ComponentImpl HookIdx int } type Atom struct { Val any Dirty bool UsedBy map[string]bool // component waveid -> true } type RootElem struct { OuterCtx context.Context Root *ComponentImpl RenderTs int64 CFuncs map[string]any CompMap map[string]*ComponentImpl // component waveid -> component EffectWorkQueue []*EffectWorkElem NeedsRenderMap map[string]bool Atoms map[string]*Atom RefOperations []VDomRefOperation } const ( WorkType_Render = "render" WorkType_Effect = "effect" ) type EffectWorkElem struct { Id string EffectIndex int } func (r *RootElem) AddRenderWork(id string) { if r.NeedsRenderMap == nil { r.NeedsRenderMap = make(map[string]bool) } r.NeedsRenderMap[id] = true } func (r *RootElem) AddEffectWork(id string, effectIndex int) { r.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{Id: id, EffectIndex: effectIndex}) } func MakeRoot() *RootElem { return &RootElem{ Root: nil, CFuncs: make(map[string]any), CompMap: make(map[string]*ComponentImpl), Atoms: make(map[string]*Atom), } } func (r *RootElem) GetAtom(name string) *Atom { atom, ok := r.Atoms[name] if !ok { atom = &Atom{UsedBy: make(map[string]bool)} r.Atoms[name] = atom } return atom } func (r *RootElem) GetAtomVal(name string) any { atom := r.GetAtom(name) return atom.Val } func (r *RootElem) GetStateSync(full bool) []VDomStateSync { stateSync := make([]VDomStateSync, 0) for atomName, atom := range r.Atoms { if atom.Dirty || full { stateSync = append(stateSync, VDomStateSync{Atom: atomName, Value: atom.Val}) atom.Dirty = false } } return stateSync } func (r *RootElem) SetAtomVal(name string, val any, markDirty bool) { atom := r.GetAtom(name) if !markDirty { atom.Val = val return } // try to avoid setting the value and marking as dirty if it's the "same" if utilfn.JsonValEqual(val, atom.Val) { return } atom.Val = val atom.Dirty = true } func (r *RootElem) SetOuterCtx(ctx context.Context) { r.OuterCtx = ctx } func validateCFunc(cfunc any) error { if cfunc == nil { return fmt.Errorf("Component function cannot b nil") } rval := reflect.ValueOf(cfunc) if rval.Kind() != reflect.Func { return fmt.Errorf("Component function must be a function") } rtype := rval.Type() if rtype.NumIn() != 2 { return fmt.Errorf("Component function must take exactly 2 arguments") } if rtype.NumOut() != 1 { return fmt.Errorf("Component function must return exactly 1 value") } // first arg must be context.Context if rtype.In(0) != reflect.TypeOf((*context.Context)(nil)).Elem() { return fmt.Errorf("Component function first argument must be context.Context") } // second can a map[string]any, or a struct, or ptr to struct (we'll reflect the value into it) arg2Type := rtype.In(1) if arg2Type.Kind() == reflect.Ptr { arg2Type = arg2Type.Elem() } if arg2Type.Kind() == reflect.Map { if arg2Type.Key().Kind() != reflect.String || !(arg2Type.Elem().Kind() == reflect.Interface && arg2Type.Elem().NumMethod() == 0) { return fmt.Errorf("Map argument must be map[string]any") } } else if arg2Type.Kind() != reflect.Struct && !(arg2Type.Kind() == reflect.Interface && arg2Type.NumMethod() == 0) { return fmt.Errorf("Component function second argument must be map[string]any, struct, or any") } return nil } func (r *RootElem) RegisterComponent(name string, cfunc any) error { if err := validateCFunc(cfunc); err != nil { return err } r.CFuncs[name] = cfunc return nil } func (r *RootElem) Render(elem *VDomElem) { r.render(elem, &r.Root) } func (vdf *VDomFunc) CallFn(event VDomEvent) { if vdf.Fn == nil { return } rval := reflect.ValueOf(vdf.Fn) if rval.Kind() != reflect.Func { return } rtype := rval.Type() if rtype.NumIn() == 0 { rval.Call(nil) } if rtype.NumIn() == 1 { if rtype.In(0) == reflect.TypeOf((*VDomEvent)(nil)).Elem() { rval.Call([]reflect.Value{reflect.ValueOf(event)}) } } } func callVDomFn(fnVal any, data VDomEvent) { if fnVal == nil { return } fn := fnVal if vdf, ok := fnVal.(*VDomFunc); ok { fn = vdf.Fn } if fn == nil { return } rval := reflect.ValueOf(fn) if rval.Kind() != reflect.Func { return } rtype := rval.Type() if rtype.NumIn() == 0 { rval.Call(nil) return } if rtype.NumIn() == 1 { rval.Call([]reflect.Value{reflect.ValueOf(data)}) return } } func (r *RootElem) Event(id string, propName string, event VDomEvent) { comp := r.CompMap[id] if comp == nil || comp.Elem == nil { return } fnVal := comp.Elem.Props[propName] callVDomFn(fnVal, event) } // this will be called by the frontend to say the DOM has been mounted // it will eventually send any updated "refs" to the backend as well func (r *RootElem) RunWork() { workQueue := r.EffectWorkQueue r.EffectWorkQueue = nil // first, run effect cleanups for _, work := range workQueue { comp := r.CompMap[work.Id] if comp == nil { continue } hook := comp.Hooks[work.EffectIndex] if hook.UnmountFn != nil { hook.UnmountFn() } } // now run, new effects for _, work := range workQueue { comp := r.CompMap[work.Id] if comp == nil { continue } hook := comp.Hooks[work.EffectIndex] if hook.Fn != nil { hook.UnmountFn = hook.Fn() } } // now check if we need a render if len(r.NeedsRenderMap) > 0 { r.NeedsRenderMap = nil r.render(r.Root.Elem, &r.Root) } } func (r *RootElem) render(elem *VDomElem, comp **ComponentImpl) { if elem == nil || elem.Tag == "" { r.unmount(comp) return } elemKey := elem.Key() if *comp == nil || !(*comp).compMatch(elem.Tag, elemKey) { r.unmount(comp) r.createComp(elem.Tag, elemKey, comp) } (*comp).Elem = elem if elem.Tag == TextTag { r.renderText(elem.Text, comp) return } if isBaseTag(elem.Tag) { // simple vdom, fragment, wave element r.renderSimple(elem, comp) return } cfunc := r.CFuncs[elem.Tag] if cfunc == nil { text := fmt.Sprintf("<%s>", elem.Tag) r.renderText(text, comp) return } r.renderComponent(cfunc, elem, comp) } func (r *RootElem) unmount(comp **ComponentImpl) { if *comp == nil { return } // parent clean up happens first for _, hook := range (*comp).Hooks { if hook.UnmountFn != nil { hook.UnmountFn() } } // clean up any children if (*comp).Comp != nil { r.unmount(&(*comp).Comp) } if (*comp).Children != nil { for _, child := range (*comp).Children { r.unmount(&child) } } delete(r.CompMap, (*comp).WaveId) *comp = nil } func (r *RootElem) createComp(tag string, key string, comp **ComponentImpl) { *comp = &ComponentImpl{WaveId: uuid.New().String(), Tag: tag, Key: key} r.CompMap[(*comp).WaveId] = *comp } func (r *RootElem) renderText(text string, comp **ComponentImpl) { if (*comp).Text != text { (*comp).Text = text } } func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*ComponentImpl) []*ComponentImpl { newChildren := make([]*ComponentImpl, len(elems)) curCM := make(map[ChildKey]*ComponentImpl) usedMap := make(map[*ComponentImpl]bool) for idx, child := range curChildren { if child.Key != "" { curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child } else { curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: ""}] = child } } for idx, elem := range elems { elemKey := elem.Key() var curChild *ComponentImpl if elemKey != "" { curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}] } else { curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}] } usedMap[curChild] = true newChildren[idx] = curChild r.render(&elem, &newChildren[idx]) } for _, child := range curChildren { if !usedMap[child] { r.unmount(&child) } } return newChildren } func (r *RootElem) renderSimple(elem *VDomElem, comp **ComponentImpl) { if (*comp).Comp != nil { r.unmount(&(*comp).Comp) } (*comp).Children = r.renderChildren(elem.Children, (*comp).Children) } func (r *RootElem) makeRenderContext(comp *ComponentImpl) context.Context { var ctx context.Context if r.OuterCtx != nil { ctx = r.OuterCtx } else { ctx = context.Background() } ctx = context.WithValue(ctx, vdomContextKey, &VDomContextVal{Root: r, Comp: comp, HookIdx: 0}) return ctx } func getRenderContext(ctx context.Context) *VDomContextVal { v := ctx.Value(vdomContextKey) if v == nil { return nil } return v.(*VDomContextVal) } func callCFunc(cfunc any, ctx context.Context, props map[string]any) any { rval := reflect.ValueOf(cfunc) arg2Type := rval.Type().In(1) var arg2Val reflect.Value if arg2Type.Kind() == reflect.Interface && arg2Type.NumMethod() == 0 { // For any/interface{}, pass nil properly arg2Val = reflect.New(arg2Type) } else { arg2Val = reflect.New(arg2Type) // if arg2 is a map, just pass props if arg2Type.Kind() == reflect.Map { arg2Val.Elem().Set(reflect.ValueOf(props)) } else { err := utilfn.MapToStruct(props, arg2Val.Interface()) if err != nil { fmt.Printf("error unmarshalling props: %v\n", err) } } } rtnVal := rval.Call([]reflect.Value{reflect.ValueOf(ctx), arg2Val.Elem()}) if len(rtnVal) == 0 { return nil } return rtnVal[0].Interface() } func (r *RootElem) renderComponent(cfunc any, elem *VDomElem, comp **ComponentImpl) { if (*comp).Children != nil { for _, child := range (*comp).Children { r.unmount(&child) } (*comp).Children = nil } props := make(map[string]any) for k, v := range elem.Props { props[k] = v } props[ChildrenPropKey] = elem.Children ctx := r.makeRenderContext(*comp) renderedElem := callCFunc(cfunc, ctx, props) rtnElemArr := partToElems(renderedElem) if len(rtnElemArr) == 0 { r.unmount(&(*comp).Comp) return } var rtnElem *VDomElem if len(rtnElemArr) == 1 { rtnElem = &rtnElemArr[0] } else { rtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr} } r.render(rtnElem, &(*comp).Comp) } func (r *RootElem) UpdateRef(updateRef VDomRefUpdate) { refId := updateRef.RefId split := strings.SplitN(refId, ":", 2) if len(split) != 2 { log.Printf("invalid ref id: %s\n", refId) return } waveId := split[0] hookIdx, err := strconv.Atoi(split[1]) if err != nil { log.Printf("invalid ref id (bad hook idx): %s\n", refId) return } comp := r.CompMap[waveId] if comp == nil { return } if hookIdx < 0 || hookIdx >= len(comp.Hooks) { return } hook := comp.Hooks[hookIdx] if hook == nil { return } ref, ok := hook.Val.(*VDomRef) if !ok { return } ref.HasCurrent = updateRef.HasCurrent ref.Position = updateRef.Position r.AddRenderWork(waveId) } func (r *RootElem) QueueRefOp(op VDomRefOperation) { r.RefOperations = append(r.RefOperations, op) } func (r *RootElem) GetRefOperations() []VDomRefOperation { ops := r.RefOperations r.RefOperations = nil return ops } func convertPropsToVDom(props map[string]any) map[string]any { if len(props) == 0 { return nil } vdomProps := make(map[string]any) for k, v := range props { if v == nil { continue } val := reflect.ValueOf(v) if val.Kind() == reflect.Func { vdomProps[k] = VDomFunc{Type: ObjectType_Func} continue } vdomProps[k] = v } return vdomProps } func convertBaseToVDom(c *ComponentImpl) *VDomElem { elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag} if c.Elem != nil { elem.Props = convertPropsToVDom(c.Elem.Props) } for _, child := range c.Children { childVDom := convertToVDom(child) if childVDom != nil { elem.Children = append(elem.Children, *childVDom) } } return elem } func convertToVDom(c *ComponentImpl) *VDomElem { if c == nil { return nil } if c.Tag == TextTag { return &VDomElem{Tag: TextTag, Text: c.Text} } if isBaseTag(c.Tag) { return convertBaseToVDom(c) } else { return convertToVDom(c.Comp) } } func (r *RootElem) makeVDom(comp *ComponentImpl) *VDomElem { vdomElem := convertToVDom(comp) return vdomElem } func (r *RootElem) MakeVDom() *VDomElem { return r.makeVDom(r.Root) } func ConvertElemsToTransferElems(elems []VDomElem) []VDomTransferElem { var transferElems []VDomTransferElem textCounter := 0 // Counter for generating unique IDs for #text nodes // Helper function to recursively process each VDomElem in preorder var processElem func(elem VDomElem) string processElem = func(elem VDomElem) string { // Handle #text nodes by generating a unique placeholder ID if elem.Tag == "#text" { textId := fmt.Sprintf("text-%d", textCounter) textCounter++ transferElems = append(transferElems, VDomTransferElem{ WaveId: textId, Tag: elem.Tag, Text: elem.Text, Props: nil, Children: nil, }) return textId } // Convert children to WaveId references, handling potential #text nodes childrenIds := make([]string, len(elem.Children)) for i, child := range elem.Children { childrenIds[i] = processElem(child) // Children are not roots } // Create the VDomTransferElem for the current element transferElem := VDomTransferElem{ WaveId: elem.WaveId, Tag: elem.Tag, Props: elem.Props, Children: childrenIds, Text: elem.Text, } transferElems = append(transferElems, transferElem) return elem.WaveId } // Start processing each top-level element, marking them as roots for _, elem := range elems { processElem(elem) } return transferElems } func DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem { seen := make(map[string]int) // maps WaveId to its index in the result slice var result []VDomTransferElem for _, elem := range elems { if idx, exists := seen[elem.WaveId]; exists { // Overwrite the previous element with the latest one result[idx] = elem } else { // Add new element and store its index seen[elem.WaveId] = len(result) result = append(result, elem) } } return result } func (beUpdate *VDomBackendUpdate) CreateTransferElems() { var vdomElems []VDomElem for idx, reUpdate := range beUpdate.RenderUpdates { if reUpdate.VDom == nil { continue } vdomElems = append(vdomElems, *reUpdate.VDom) beUpdate.RenderUpdates[idx].VDomWaveId = reUpdate.VDom.WaveId beUpdate.RenderUpdates[idx].VDom = nil } transferElems := ConvertElemsToTransferElems(vdomElems) transferElems = DedupTransferElems(transferElems) beUpdate.TransferElems = transferElems } // SplitBackendUpdate splits a large VDomBackendUpdate into multiple smaller updates // The first update contains all the core fields, while subsequent updates only contain // array elements that need to be appended func SplitBackendUpdate(update *VDomBackendUpdate) []*VDomBackendUpdate { // If the update is small enough, return it as is if len(update.TransferElems) <= BackendUpdate_InitialChunkSize && len(update.StateSync) <= BackendUpdate_InitialChunkSize { return []*VDomBackendUpdate{update} } var updates []*VDomBackendUpdate // First update contains core fields and initial chunks firstUpdate := &VDomBackendUpdate{ Type: update.Type, Ts: update.Ts, BlockId: update.BlockId, Opts: update.Opts, HasWork: update.HasWork, RenderUpdates: update.RenderUpdates, RefOperations: update.RefOperations, Messages: update.Messages, } // Add initial chunks of arrays if len(update.TransferElems) > 0 { firstUpdate.TransferElems = update.TransferElems[:min(BackendUpdate_InitialChunkSize, len(update.TransferElems))] } if len(update.StateSync) > 0 { firstUpdate.StateSync = update.StateSync[:min(BackendUpdate_InitialChunkSize, len(update.StateSync))] } updates = append(updates, firstUpdate) // Create subsequent updates for remaining TransferElems for i := BackendUpdate_InitialChunkSize; i < len(update.TransferElems); i += BackendUpdate_ChunkSize { end := min(i+BackendUpdate_ChunkSize, len(update.TransferElems)) updates = append(updates, &VDomBackendUpdate{ Type: update.Type, Ts: update.Ts, BlockId: update.BlockId, TransferElems: update.TransferElems[i:end], }) } // Create subsequent updates for remaining StateSync for i := BackendUpdate_InitialChunkSize; i < len(update.StateSync); i += BackendUpdate_ChunkSize { end := min(i+BackendUpdate_ChunkSize, len(update.StateSync)) updates = append(updates, &VDomBackendUpdate{ Type: update.Type, Ts: update.Ts, BlockId: update.BlockId, StateSync: update.StateSync[i:end], }) } return updates } ================================================ FILE: pkg/vdom/vdom_test.go ================================================ package vdom import ( "context" "encoding/json" "fmt" "log" "reflect" "testing" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) type renderContextKeyType struct{} var renderContextKey = renderContextKeyType{} type TestContext struct { ButtonId string } func Page(ctx context.Context, props map[string]any) any { clicked, setClicked := UseState(ctx, false) var clickedDiv *VDomElem if clicked { clickedDiv = Bind(`<div>clicked</div>`, nil) } clickFn := func() { log.Printf("run clickFn\n") setClicked(true) } return Bind( ` <div> <h1>hello world</h1> <Button onClick="#param:clickFn">hello</Button> <bindparam key="clickedDiv"/> </div> `, map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv}, ) } func Button(ctx context.Context, props map[string]any) any { ref := UseVDomRef(ctx) clName, setClName := UseState(ctx, "button") UseEffect(ctx, func() func() { fmt.Printf("Button useEffect\n") setClName("button mounted") return nil }, nil) compId := UseId(ctx) testContext := getTestContext(ctx) if testContext != nil { testContext.ButtonId = compId } return Bind(` <div className="#param:clName" ref="#param:ref" onClick="#param:onClick"> <bindparam key="children"/> </div> `, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]}) } func printVDom(root *RootElem) { vd := root.MakeVDom() jsonBytes, _ := json.MarshalIndent(vd, "", " ") fmt.Printf("%s\n", string(jsonBytes)) } func getTestContext(ctx context.Context) *TestContext { val := ctx.Value(renderContextKey) if val == nil { return nil } return val.(*TestContext) } func Test1(t *testing.T) { log.Printf("hello!\n") testContext := &TestContext{ButtonId: ""} ctx := context.WithValue(context.Background(), renderContextKey, testContext) root := MakeRoot() root.SetOuterCtx(ctx) root.RegisterComponent("Page", Page) root.RegisterComponent("Button", Button) root.Render(E("Page")) if root.Root == nil { t.Fatalf("root.Root is nil") } printVDom(root) root.RunWork() printVDom(root) root.Event(testContext.ButtonId, "onClick", VDomEvent{EventType: "onClick"}) root.RunWork() printVDom(root) } func TestBind(t *testing.T) { elem := Bind(`<div>clicked</div>`, nil) jsonBytes, _ := json.MarshalIndent(elem, "", " ") log.Printf("%s\n", string(jsonBytes)) elem = Bind(` <div> clicked </div>`, nil) jsonBytes, _ = json.MarshalIndent(elem, "", " ") log.Printf("%s\n", string(jsonBytes)) elem = Bind(`<Button>foo</Button>`, nil) jsonBytes, _ = json.MarshalIndent(elem, "", " ") log.Printf("%s\n", string(jsonBytes)) elem = Bind(` <div> <h1>hello world</h1> <Button onClick="#param:clickFn">hello</Button> <bindparam key="clickedDiv"/> </div> `, nil) jsonBytes, _ = json.MarshalIndent(elem, "", " ") log.Printf("%s\n", string(jsonBytes)) } func TestJsonBind(t *testing.T) { elem := Bind(`<div data1={5} data2={[1,2,3]} data3={{"a": 1}}/>`, nil) if elem == nil { t.Fatalf("elem is nil") } if elem.Tag != "div" { t.Fatalf("elem.Tag: %s (expected 'div')\n", elem.Tag) } if elem.Props == nil || len(elem.Props) != 3 { t.Fatalf("elem.Props: %v\n", elem.Props) } data1Val, ok := elem.Props["data1"] if !ok { t.Fatalf("data1 not found\n") } _, ok = data1Val.(float64) if !ok { t.Fatalf("data1: %T\n", data1Val) } data1Int, ok := utilfn.ToInt(data1Val) if !ok || data1Int != 5 { t.Fatalf("data1: %v\n", data1Val) } data2Val, ok := elem.Props["data2"] if !ok { t.Fatalf("data2 not found\n") } d2type := reflect.TypeOf(data2Val) if d2type.Kind() != reflect.Slice { t.Fatalf("data2: %T\n", data2Val) } data2Arr := data2Val.([]any) if len(data2Arr) != 3 { t.Fatalf("data2: %v\n", data2Val) } d2v2, ok := data2Arr[1].(float64) if !ok || d2v2 != 2 { t.Fatalf("data2: %v\n", data2Val) } data3Val, ok := elem.Props["data3"] if !ok || data3Val == nil { t.Fatalf("data3 not found\n") } d3type := reflect.TypeOf(data3Val) if d3type.Kind() != reflect.Map { t.Fatalf("data3: %T\n", data3Val) } data3Map := data3Val.(map[string]any) if len(data3Map) != 1 { t.Fatalf("data3: %v\n", data3Val) } d3v1, ok := data3Map["a"] if !ok { t.Fatalf("data3: %v\n", data3Val) } mval, ok := utilfn.ToInt(d3v1) if !ok || mval != 1 { t.Fatalf("data3: %v\n", data3Val) } log.Printf("elem: %v\n", elem) } ================================================ FILE: pkg/vdom/vdom_types.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package vdom import ( "time" "github.com/wavetermdev/waveterm/pkg/waveobj" ) const TextTag = "#text" const WaveTextTag = "wave:text" const WaveNullTag = "wave:null" const FragmentTag = "#fragment" const BindTag = "#bind" const ChildrenPropKey = "children" const KeyPropKey = "key" const ObjectType_Ref = "ref" const ObjectType_Binding = "binding" const ObjectType_Func = "func" // vdom element type VDomElem struct { WaveId string `json:"waveid,omitempty"` // required, except for #text nodes Tag string `json:"tag"` Props map[string]any `json:"props,omitempty"` Children []VDomElem `json:"children,omitempty"` Text string `json:"text,omitempty"` } // the over the wire format for a vdom element type VDomTransferElem struct { WaveId string `json:"waveid,omitempty"` // required, except for #text nodes Tag string `json:"tag"` Props map[string]any `json:"props,omitempty"` Children []string `json:"children,omitempty"` Text string `json:"text,omitempty"` } //// protocol messages type VDomCreateContext struct { Type string `json:"type" tstype:"\"createcontext\""` Ts int64 `json:"ts"` Meta waveobj.MetaMapType `json:"meta,omitempty"` Target *VDomTarget `json:"target,omitempty"` Persist bool `json:"persist,omitempty"` } type VDomAsyncInitiationRequest struct { Type string `json:"type" tstype:"\"asyncinitiationrequest\""` Ts int64 `json:"ts"` BlockId string `json:"blockid,omitempty"` } func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest { return VDomAsyncInitiationRequest{ Type: "asyncinitiationrequest", Ts: time.Now().UnixMilli(), BlockId: blockId, } } type VDomFrontendUpdate struct { Type string `json:"type" tstype:"\"frontendupdate\""` Ts int64 `json:"ts"` BlockId string `json:"blockid"` CorrelationId string `json:"correlationid,omitempty"` Dispose bool `json:"dispose,omitempty"` // the vdom context was closed Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads RenderContext VDomRenderContext `json:"rendercontext,omitempty"` Events []VDomEvent `json:"events,omitempty"` StateSync []VDomStateSync `json:"statesync,omitempty"` RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"` Messages []VDomMessage `json:"messages,omitempty"` } type VDomBackendUpdate struct { Type string `json:"type" tstype:"\"backendupdate\""` Ts int64 `json:"ts"` BlockId string `json:"blockid"` Opts *VDomBackendOpts `json:"opts,omitempty"` HasWork bool `json:"haswork,omitempty"` RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"` TransferElems []VDomTransferElem `json:"transferelems,omitempty"` StateSync []VDomStateSync `json:"statesync,omitempty"` RefOperations []VDomRefOperation `json:"refoperations,omitempty"` Messages []VDomMessage `json:"messages,omitempty"` } ///// prop types // used in props type VDomBinding struct { Type string `json:"type" tstype:"\"binding\""` Bind string `json:"bind"` } // used in props type VDomFunc struct { Fn any `json:"-"` // server side function (called with reflection) Type string `json:"type" tstype:"\"func\""` StopPropagation bool `json:"stoppropagation,omitempty"` PreventDefault bool `json:"preventdefault,omitempty"` GlobalEvent string `json:"globalevent,omitempty"` Keys []string `json:"#keys,omitempty"` // special for keyDown events a list of keys to "capture" } // used in props type VDomRef struct { Type string `json:"type" tstype:"\"ref\""` RefId string `json:"refid"` TrackPosition bool `json:"trackposition,omitempty"` Position *VDomRefPosition `json:"position,omitempty"` HasCurrent bool `json:"hascurrent,omitempty"` } type VDomSimpleRef[T any] struct { Current T `json:"current"` } type DomRect struct { Top float64 `json:"top"` Left float64 `json:"left"` Right float64 `json:"right"` Bottom float64 `json:"bottom"` Width float64 `json:"width"` Height float64 `json:"height"` } type VDomRefPosition struct { OffsetHeight int `json:"offsetheight"` OffsetWidth int `json:"offsetwidth"` ScrollHeight int `json:"scrollheight"` ScrollWidth int `json:"scrollwidth"` ScrollTop int `json:"scrolltop"` BoundingClientRect DomRect `json:"boundingclientrect"` } ///// subbordinate protocol types type VDomEvent struct { WaveId string `json:"waveid"` EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) GlobalEventType string `json:"globaleventtype,omitempty"` TargetValue string `json:"targetvalue,omitempty"` TargetChecked bool `json:"targetchecked,omitempty"` TargetName string `json:"targetname,omitempty"` TargetId string `json:"targetid,omitempty"` KeyData *WaveKeyboardEvent `json:"keydata,omitempty"` MouseData *WavePointerData `json:"mousedata,omitempty"` } type VDomRenderContext struct { BlockId string `json:"blockid"` Focused bool `json:"focused"` Width int `json:"width"` Height int `json:"height"` RootRefId string `json:"rootrefid"` Background bool `json:"background,omitempty"` } type VDomStateSync struct { Atom string `json:"atom"` Value any `json:"value"` } type VDomRefUpdate struct { RefId string `json:"refid"` HasCurrent bool `json:"hascurrent"` Position *VDomRefPosition `json:"position,omitempty"` } type VDomBackendOpts struct { CloseOnCtrlC bool `json:"closeonctrlc,omitempty"` GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"` GlobalStyles bool `json:"globalstyles,omitempty"` } type VDomRenderUpdate struct { UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""` WaveId string `json:"waveid,omitempty"` VDomWaveId string `json:"vdomwaveid,omitempty"` VDom *VDomElem `json:"vdom,omitempty"` // these get removed for transfer (encoded to transferelems) Index *int `json:"index,omitempty"` } type VDomRefOperation struct { RefId string `json:"refid"` Op string `json:"op"` Params []any `json:"params,omitempty"` OutputRef string `json:"outputref,omitempty"` } type VDomMessage struct { MessageType string `json:"messagetype"` Message string `json:"message"` StackTrace string `json:"stacktrace,omitempty"` Params []any `json:"params,omitempty"` } // target -- to support new targets in the future, like toolbars, partial blocks, splits, etc. // default is vdom context inside of a terminal block type VDomTarget struct { NewBlock bool `json:"newblock,omitempty"` Magnified bool `json:"magnified,omitempty"` Toolbar *VDomTargetToolbar `json:"toolbar,omitempty"` } type VDomTargetToolbar struct { Toolbar bool `json:"toolbar"` Height string `json:"height,omitempty"` } // matches WaveKeyboardEvent type VDomKeyboardEvent struct { Type string `json:"type"` Key string `json:"key"` Code string `json:"code"` Shift bool `json:"shift,omitempty"` Control bool `json:"ctrl,omitempty"` Alt bool `json:"alt,omitempty"` Meta bool `json:"meta,omitempty"` Cmd bool `json:"cmd,omitempty"` Option bool `json:"option,omitempty"` Repeat bool `json:"repeat,omitempty"` Location int `json:"location,omitempty"` } type WaveKeyboardEvent struct { Type string `json:"type" tstype:"\"keydown\"|\"keyup\"|\"keypress\"|\"unknown\""` Key string `json:"key"` // KeyboardEvent.key Code string `json:"code"` // KeyboardEvent.code Repeat bool `json:"repeat,omitempty"` Location int `json:"location,omitempty"` // KeyboardEvent.location // modifiers Shift bool `json:"shift,omitempty"` Control bool `json:"control,omitempty"` Alt bool `json:"alt,omitempty"` Meta bool `json:"meta,omitempty"` Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) } type WavePointerData struct { Button int `json:"button"` Buttons int `json:"buttons"` ClientX int `json:"clientx,omitempty"` ClientY int `json:"clienty,omitempty"` PageX int `json:"pagex,omitempty"` PageY int `json:"pagey,omitempty"` ScreenX int `json:"screenx,omitempty"` ScreenY int `json:"screeny,omitempty"` MovementX int `json:"movementx,omitempty"` MovementY int `json:"movementy,omitempty"` // Modifiers Shift bool `json:"shift,omitempty"` Control bool `json:"control,omitempty"` Alt bool `json:"alt,omitempty"` Meta bool `json:"meta,omitempty"` Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) } ================================================ FILE: pkg/waveai/anthropicbackend.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveai import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type AnthropicBackend struct{} var _ AIBackend = AnthropicBackend{} // Claude API request types type anthropicMessage struct { Role string `json:"role"` Content string `json:"content"` } type anthropicRequest struct { Model string `json:"model"` Messages []anthropicMessage `json:"messages"` System string `json:"system,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` Stream bool `json:"stream"` Temperature float32 `json:"temperature,omitempty"` } // Claude API response types for SSE events type anthropicContentBlock struct { Type string `json:"type"` // "text" or other content types Text string `json:"text,omitempty"` } type anthropicUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } type anthropicResponseMessage struct { ID string `json:"id"` Type string `json:"type"` Role string `json:"role"` Content []anthropicContentBlock `json:"content"` Model string `json:"model"` StopReason string `json:"stop_reason,omitempty"` StopSequence string `json:"stop_sequence,omitempty"` Usage *anthropicUsage `json:"usage,omitempty"` } type anthropicStreamEventError struct { Type string `json:"type"` Message string `json:"message"` } type anthropicStreamEventDelta struct { Text string `json:"text"` } type anthropicStreamEvent struct { Type string `json:"type"` Message *anthropicResponseMessage `json:"message,omitempty"` ContentBlock *anthropicContentBlock `json:"content_block,omitempty"` Delta *anthropicStreamEventDelta `json:"delta,omitempty"` Error *anthropicStreamEventError `json:"error,omitempty"` Usage *anthropicUsage `json:"usage,omitempty"` } // SSE event represents a parsed Server-Sent Event type sseEvent struct { Event string // The event type field Data string // The data field } // parseSSE reads and parses SSE format from a bufio.Reader func parseSSE(reader *bufio.Reader) (*sseEvent, error) { var event sseEvent for { line, err := reader.ReadString('\n') if err != nil { return nil, err } line = strings.TrimSpace(line) if line == "" { // Empty line signals end of event if event.Event != "" || event.Data != "" { return &event, nil } continue } if strings.HasPrefix(line, "event:") { event.Event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { event.Data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } } func (AnthropicBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) go func() { defer func() { panicErr := panichandler.PanicHandler("AnthropicBackend.StreamCompletion", recover()) if panicErr != nil { rtn <- makeAIError(panicErr) } close(rtn) }() if request.Opts == nil { rtn <- makeAIError(errors.New("no anthropic opts found")) return } model := request.Opts.Model if model == "" { model = "claude-3-sonnet-20250229" // default model } // Convert messages format var messages []anthropicMessage var systemPrompt string for _, msg := range request.Prompt { if msg.Role == "system" { if systemPrompt != "" { systemPrompt += "\n" } systemPrompt += msg.Content continue } role := "user" if msg.Role == "assistant" { role = "assistant" } messages = append(messages, anthropicMessage{ Role: role, Content: msg.Content, }) } anthropicReq := anthropicRequest{ Model: model, Messages: messages, System: systemPrompt, Stream: true, MaxTokens: request.Opts.MaxTokens, } reqBody, err := json.Marshal(anthropicReq) if err != nil { rtn <- makeAIError(fmt.Errorf("failed to marshal anthropic request: %v", err)) return } // Build endpoint allowing custom base URL from presets/settings endpoint := "https://api.anthropic.com/v1/messages" if request.Opts.BaseURL != "" { endpoint = strings.TrimSpace(request.Opts.BaseURL) } req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(string(reqBody))) if err != nil { rtn <- makeAIError(fmt.Errorf("failed to create anthropic request: %v", err)) return } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("x-api-key", request.Opts.APIToken) version := "2023-06-01" if request.Opts.APIVersion != "" { version = request.Opts.APIVersion } req.Header.Set("anthropic-version", version) // Configure HTTP client with proxy if specified client := &http.Client{} if request.Opts.ProxyURL != "" { proxyURL, err := url.Parse(request.Opts.ProxyURL) if err != nil { rtn <- makeAIError(fmt.Errorf("invalid proxy URL: %v", err)) return } transport := &http.Transport{ Proxy: http.ProxyURL(proxyURL), } client.Transport = transport } resp, err := client.Do(req) if err != nil { rtn <- makeAIError(fmt.Errorf("failed to send anthropic request: %v", err)) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) rtn <- makeAIError(fmt.Errorf("Anthropic API error: %s - %s", resp.Status, string(bodyBytes))) return } reader := bufio.NewReader(resp.Body) for { // Check for context cancellation select { case <-ctx.Done(): rtn <- makeAIError(fmt.Errorf("request cancelled: %v", ctx.Err())) return default: } sse, err := parseSSE(reader) if err == io.EOF { break } if err != nil { rtn <- makeAIError(fmt.Errorf("error reading SSE stream: %v", err)) break } if sse.Event == "ping" { continue // Ignore ping events } var event anthropicStreamEvent if err := json.Unmarshal([]byte(sse.Data), &event); err != nil { rtn <- makeAIError(fmt.Errorf("error unmarshaling event data: %v", err)) break } if event.Error != nil { rtn <- makeAIError(fmt.Errorf("Anthropic API error: %s - %s", event.Error.Type, event.Error.Message)) break } switch sse.Event { case "message_start": if event.Message != nil { pk := MakeWaveAIPacket() pk.Model = event.Message.Model rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} } case "content_block_start": if event.ContentBlock != nil && event.ContentBlock.Text != "" { pk := MakeWaveAIPacket() pk.Text = event.ContentBlock.Text rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} } case "content_block_delta": if event.Delta != nil && event.Delta.Text != "" { pk := MakeWaveAIPacket() pk.Text = event.Delta.Text rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} } case "content_block_stop": // Note: According to the docs, this just signals the end of a content block // We might want to use this for tracking block boundaries, but for now // we don't need to send anything special to match OpenAI's format case "message_delta": // Update message metadata, usage stats if event.Usage != nil { pk := MakeWaveAIPacket() pk.Usage = &wshrpc.WaveAIUsageType{ PromptTokens: event.Usage.InputTokens, CompletionTokens: event.Usage.OutputTokens, TotalTokens: event.Usage.InputTokens + event.Usage.OutputTokens, } rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} } case "message_stop": if event.Message != nil { pk := MakeWaveAIPacket() pk.FinishReason = event.Message.StopReason if event.Message.Usage != nil { pk.Usage = &wshrpc.WaveAIUsageType{ PromptTokens: event.Message.Usage.InputTokens, CompletionTokens: event.Message.Usage.OutputTokens, TotalTokens: event.Message.Usage.InputTokens + event.Message.Usage.OutputTokens, } } rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} } default: rtn <- makeAIError(fmt.Errorf("unknown Anthropic event type: %s", sse.Event)) return } } }() return rtn } ================================================ FILE: pkg/waveai/cloudbackend.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveai import ( "context" "encoding/json" "fmt" "io" "log" "time" "github.com/gorilla/websocket" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/wcloud" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type WaveAICloudBackend struct{} var _ AIBackend = WaveAICloudBackend{} const CloudWebsocketConnectTimeout = 1 * time.Minute const OpenAICloudReqStr = "openai-cloudreq" const PacketEOFStr = "EOF" type WaveAICloudReqPacketType struct { Type string `json:"type"` ClientId string `json:"clientid"` Prompt []wshrpc.WaveAIPromptMessageType `json:"prompt"` MaxTokens int `json:"maxtokens,omitempty"` MaxChoices int `json:"maxchoices,omitempty"` } func MakeWaveAICloudReqPacket() *WaveAICloudReqPacketType { return &WaveAICloudReqPacketType{ Type: OpenAICloudReqStr, } } func (WaveAICloudBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) wsEndpoint := wcloud.GetWSEndpoint() go func() { defer func() { panicErr := panichandler.PanicHandler("WaveAICloudBackend.StreamCompletion", recover()) if panicErr != nil { rtn <- makeAIError(panicErr) } close(rtn) }() if wsEndpoint == "" { rtn <- makeAIError(fmt.Errorf("no cloud ws endpoint found")) return } if request.Opts == nil { rtn <- makeAIError(fmt.Errorf("no openai opts found")) return } websocketContext, dialCancelFn := context.WithTimeout(context.Background(), CloudWebsocketConnectTimeout) defer dialCancelFn() conn, _, err := websocket.DefaultDialer.DialContext(websocketContext, wsEndpoint, nil) if err == context.DeadlineExceeded { rtn <- makeAIError(fmt.Errorf("OpenAI request, timed out connecting to cloud server: %v", err)) return } else if err != nil { rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket connect error: %v", err)) return } defer func() { err = conn.Close() if err != nil { rtn <- makeAIError(fmt.Errorf("unable to close openai channel: %v", err)) } }() var sendablePromptMsgs []wshrpc.WaveAIPromptMessageType for _, promptMsg := range request.Prompt { if promptMsg.Role == "error" { continue } sendablePromptMsgs = append(sendablePromptMsgs, promptMsg) } reqPk := MakeWaveAICloudReqPacket() reqPk.ClientId = request.ClientId reqPk.Prompt = sendablePromptMsgs reqPk.MaxTokens = request.Opts.MaxTokens reqPk.MaxChoices = request.Opts.MaxChoices configMessageBuf, err := json.Marshal(reqPk) if err != nil { rtn <- makeAIError(fmt.Errorf("OpenAI request, packet marshal error: %v", err)) return } err = conn.WriteMessage(websocket.TextMessage, configMessageBuf) if err != nil { rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket write config error: %v", err)) return } for { _, socketMessage, err := conn.ReadMessage() if err == io.EOF { break } if err != nil { log.Printf("err received: %v", err) rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket error reading message: %v", err)) break } var streamResp *wshrpc.WaveAIPacketType err = json.Unmarshal(socketMessage, &streamResp) if err != nil { rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket response json decode error: %v", err)) break } if streamResp.Error == PacketEOFStr { // got eof packet from socket break } else if streamResp.Error != "" { // use error from server directly rtn <- makeAIError(fmt.Errorf("%v", streamResp.Error)) break } rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *streamResp} } }() return rtn } ================================================ FILE: pkg/waveai/googlebackend.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveai import ( "context" "fmt" "log" "net/http" "net/url" "github.com/google/generative-ai-go/genai" "github.com/wavetermdev/waveterm/pkg/wshrpc" "google.golang.org/api/iterator" "google.golang.org/api/option" ) type GoogleBackend struct{} var _ AIBackend = GoogleBackend{} func (GoogleBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { var clientOptions []option.ClientOption clientOptions = append(clientOptions, option.WithAPIKey(request.Opts.APIToken)) // Configure proxy if specified if request.Opts.ProxyURL != "" { proxyURL, err := url.Parse(request.Opts.ProxyURL) if err != nil { rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) go func() { defer close(rtn) rtn <- makeAIError(fmt.Errorf("invalid proxy URL: %v", err)) }() return rtn } transport := &http.Transport{ Proxy: http.ProxyURL(proxyURL), } httpClient := &http.Client{ Transport: transport, } clientOptions = append(clientOptions, option.WithHTTPClient(httpClient)) } client, err := genai.NewClient(ctx, clientOptions...) if err != nil { log.Printf("failed to create client: %v", err) return nil } model := client.GenerativeModel(request.Opts.Model) if model == nil { log.Println("model not found") client.Close() return nil } cs := model.StartChat() cs.History = extractHistory(request.Prompt) iter := cs.SendMessageStream(ctx, extractPrompt(request.Prompt)) rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) go func() { defer client.Close() defer close(rtn) for { // Check for context cancellation if err := ctx.Err(); err != nil { rtn <- makeAIError(fmt.Errorf("request cancelled: %v", err)) break } resp, err := iter.Next() if err == iterator.Done { break } if err != nil { rtn <- makeAIError(fmt.Errorf("Google API error: %v", err)) break } rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: wshrpc.WaveAIPacketType{Text: convertCandidatesToText(resp.Candidates)}} } }() return rtn } func extractHistory(history []wshrpc.WaveAIPromptMessageType) []*genai.Content { var rtn []*genai.Content for _, h := range history[:len(history)-1] { if h.Role == "user" || h.Role == "model" { rtn = append(rtn, &genai.Content{ Role: h.Role, Parts: []genai.Part{genai.Text(h.Content)}, }) } } return rtn } func extractPrompt(prompt []wshrpc.WaveAIPromptMessageType) genai.Part { p := prompt[len(prompt)-1] return genai.Text(p.Content) } func convertCandidatesToText(candidates []*genai.Candidate) string { var rtn string for _, c := range candidates { for _, p := range c.Content.Parts { rtn += fmt.Sprintf("%v", p) } } return rtn } ================================================ FILE: pkg/waveai/openaibackend.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveai import ( "context" "errors" "fmt" "io" "net/http" "net/url" "regexp" "strings" openaiapi "github.com/sashabaranov/go-openai" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type OpenAIBackend struct{} var _ AIBackend = OpenAIBackend{} const DefaultAzureAPIVersion = "2023-05-15" // copied from go-openai/config.go func defaultAzureMapperFn(model string) string { return regexp.MustCompile(`[.:]`).ReplaceAllString(model, "") } func isReasoningModel(model string) bool { m := strings.ToLower(model) return strings.HasPrefix(m, "o1") || strings.HasPrefix(m, "o3") || strings.HasPrefix(m, "o4") || strings.HasPrefix(m, "gpt-5") || strings.HasPrefix(m, "gpt-5.1") } func setApiType(opts *wshrpc.WaveAIOptsType, clientConfig *openaiapi.ClientConfig) error { ourApiType := strings.ToLower(opts.APIType) if ourApiType == "" || ourApiType == APIType_OpenAI || ourApiType == strings.ToLower(string(openaiapi.APITypeOpenAI)) { clientConfig.APIType = openaiapi.APITypeOpenAI return nil } else if ourApiType == strings.ToLower(string(openaiapi.APITypeAzure)) { clientConfig.APIType = openaiapi.APITypeAzure clientConfig.APIVersion = DefaultAzureAPIVersion clientConfig.AzureModelMapperFunc = defaultAzureMapperFn return nil } else if ourApiType == strings.ToLower(string(openaiapi.APITypeAzureAD)) { clientConfig.APIType = openaiapi.APITypeAzureAD clientConfig.APIVersion = DefaultAzureAPIVersion clientConfig.AzureModelMapperFunc = defaultAzureMapperFn return nil } else if ourApiType == strings.ToLower(string(openaiapi.APITypeCloudflareAzure)) { clientConfig.APIType = openaiapi.APITypeCloudflareAzure clientConfig.APIVersion = DefaultAzureAPIVersion clientConfig.AzureModelMapperFunc = defaultAzureMapperFn return nil } else { return fmt.Errorf("invalid api type %q", opts.APIType) } } func convertPrompt(prompt []wshrpc.WaveAIPromptMessageType) []openaiapi.ChatCompletionMessage { var rtn []openaiapi.ChatCompletionMessage for _, p := range prompt { msg := openaiapi.ChatCompletionMessage{Role: p.Role, Content: p.Content, Name: p.Name} rtn = append(rtn, msg) } return rtn } func (OpenAIBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) go func() { defer func() { panicErr := panichandler.PanicHandler("OpenAIBackend.StreamCompletion", recover()) if panicErr != nil { rtn <- makeAIError(panicErr) } close(rtn) }() if request.Opts == nil { rtn <- makeAIError(errors.New("no openai opts found")) return } if request.Opts.Model == "" { rtn <- makeAIError(errors.New("no openai model specified")) return } if request.Opts.BaseURL == "" && request.Opts.APIToken == "" { rtn <- makeAIError(errors.New("no api token")) return } clientConfig := openaiapi.DefaultConfig(request.Opts.APIToken) if request.Opts.BaseURL != "" { clientConfig.BaseURL = request.Opts.BaseURL } err := setApiType(request.Opts, &clientConfig) if err != nil { rtn <- makeAIError(err) return } if request.Opts.OrgID != "" { clientConfig.OrgID = request.Opts.OrgID } if request.Opts.APIVersion != "" { clientConfig.APIVersion = request.Opts.APIVersion } // Configure proxy if specified if request.Opts.ProxyURL != "" { proxyURL, err := url.Parse(request.Opts.ProxyURL) if err != nil { rtn <- makeAIError(fmt.Errorf("invalid proxy URL: %v", err)) return } transport := &http.Transport{ Proxy: http.ProxyURL(proxyURL), } clientConfig.HTTPClient = &http.Client{ Transport: transport, } } client := openaiapi.NewClientWithConfig(clientConfig) req := openaiapi.ChatCompletionRequest{ Model: request.Opts.Model, Messages: convertPrompt(request.Prompt), } // Set MaxCompletionTokens for reasoning models, MaxTokens for others if isReasoningModel(request.Opts.Model) { req.MaxCompletionTokens = request.Opts.MaxTokens } else { req.MaxTokens = request.Opts.MaxTokens } req.Stream = true if request.Opts.MaxChoices > 1 { req.N = request.Opts.MaxChoices } apiResp, err := client.CreateChatCompletionStream(ctx, req) if err != nil { rtn <- makeAIError(fmt.Errorf("error calling openai API: %v", err)) return } sentHeader := false for { streamResp, err := apiResp.Recv() if err == io.EOF { break } if err != nil { rtn <- makeAIError(fmt.Errorf("OpenAI request, error reading message: %v", err)) break } if streamResp.Model != "" && !sentHeader { pk := MakeWaveAIPacket() pk.Model = streamResp.Model pk.Created = streamResp.Created rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} sentHeader = true } for _, choice := range streamResp.Choices { pk := MakeWaveAIPacket() pk.Index = choice.Index pk.Text = choice.Delta.Content pk.FinishReason = string(choice.FinishReason) rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} } } }() return rtn } ================================================ FILE: pkg/waveai/perplexitybackend.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveai import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type PerplexityBackend struct{} var _ AIBackend = PerplexityBackend{} // Perplexity API request types type perplexityMessage struct { Role string `json:"role"` Content string `json:"content"` } type perplexityRequest struct { Model string `json:"model"` Messages []perplexityMessage `json:"messages"` Stream bool `json:"stream"` } // Perplexity API response types type perplexityResponseDelta struct { Content string `json:"content"` } type perplexityResponseChoice struct { Delta perplexityResponseDelta `json:"delta"` FinishReason string `json:"finish_reason"` } type perplexityResponse struct { ID string `json:"id"` Choices []perplexityResponseChoice `json:"choices"` Model string `json:"model"` } func (PerplexityBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) go func() { defer func() { panicErr := panichandler.PanicHandler("PerplexityBackend.StreamCompletion", recover()) if panicErr != nil { rtn <- makeAIError(panicErr) } close(rtn) }() if request.Opts == nil { rtn <- makeAIError(errors.New("no perplexity opts found")) return } model := request.Opts.Model if model == "" { model = "llama-3.1-sonar-small-128k-online" } // Convert messages format var messages []perplexityMessage for _, msg := range request.Prompt { role := "user" if msg.Role == "assistant" { role = "assistant" } else if msg.Role == "system" { role = "system" } messages = append(messages, perplexityMessage{ Role: role, Content: msg.Content, }) } perplexityReq := perplexityRequest{ Model: model, Messages: messages, Stream: true, } reqBody, err := json.Marshal(perplexityReq) if err != nil { rtn <- makeAIError(fmt.Errorf("failed to marshal perplexity request: %v", err)) return } req, err := http.NewRequestWithContext(ctx, "POST", "https://api.perplexity.ai/chat/completions", strings.NewReader(string(reqBody))) if err != nil { rtn <- makeAIError(fmt.Errorf("failed to create perplexity request: %v", err)) return } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+request.Opts.APIToken) // Configure HTTP client with proxy if specified client := &http.Client{} if request.Opts.ProxyURL != "" { proxyURL, err := url.Parse(request.Opts.ProxyURL) if err != nil { rtn <- makeAIError(fmt.Errorf("invalid proxy URL: %v", err)) return } transport := &http.Transport{ Proxy: http.ProxyURL(proxyURL), } client.Transport = transport } resp, err := client.Do(req) if err != nil { rtn <- makeAIError(fmt.Errorf("failed to send perplexity request: %v", err)) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) rtn <- makeAIError(fmt.Errorf("Perplexity API error: %s - %s", resp.Status, string(bodyBytes))) return } reader := bufio.NewReader(resp.Body) sentHeader := false for { // Check for context cancellation select { case <-ctx.Done(): rtn <- makeAIError(fmt.Errorf("request cancelled: %v", ctx.Err())) return default: } line, err := reader.ReadString('\n') if err == io.EOF { break } if err != nil { rtn <- makeAIError(fmt.Errorf("error reading stream: %v", err)) break } line = strings.TrimSpace(line) if !strings.HasPrefix(line, "data: ") { continue } data := strings.TrimPrefix(line, "data: ") if data == "[DONE]" { break } var response perplexityResponse if err := json.Unmarshal([]byte(data), &response); err != nil { rtn <- makeAIError(fmt.Errorf("error unmarshaling response: %v", err)) break } if !sentHeader { pk := MakeWaveAIPacket() pk.Model = response.Model rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} sentHeader = true } for _, choice := range response.Choices { pk := MakeWaveAIPacket() pk.Text = choice.Delta.Content pk.FinishReason = choice.FinishReason rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} } } }() return rtn } ================================================ FILE: pkg/waveai/waveai.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveai import ( "context" "log" "net/url" "strings" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const WaveAIPacketstr = "waveai" const APIType_Anthropic = "anthropic" const APIType_Perplexity = "perplexity" const APIType_Google = "google" const APIType_OpenAI = "openai" type WaveAICmdInfoPacketOutputType struct { Model string `json:"model,omitempty"` Created int64 `json:"created,omitempty"` FinishReason string `json:"finish_reason,omitempty"` Message string `json:"message,omitempty"` Error string `json:"error,omitempty"` } func MakeWaveAIPacket() *wshrpc.WaveAIPacketType { return &wshrpc.WaveAIPacketType{Type: WaveAIPacketstr} } type WaveAICmdInfoChatMessage struct { MessageID int `json:"messageid"` IsAssistantResponse bool `json:"isassistantresponse,omitempty"` AssistantResponse *WaveAICmdInfoPacketOutputType `json:"assistantresponse,omitempty"` UserQuery string `json:"userquery,omitempty"` UserEngineeredQuery string `json:"userengineeredquery,omitempty"` } type AIBackend interface { StreamCompletion( ctx context.Context, request wshrpc.WaveAIStreamRequest, ) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] } func IsCloudAIRequest(opts *wshrpc.WaveAIOptsType) bool { if opts == nil { return true } return opts.BaseURL == "" && opts.APIToken == "" } func isLocalURL(baseURL string) bool { if baseURL == "" { return false } u, err := url.Parse(baseURL) if err != nil { return false } host := strings.ToLower(u.Hostname()) return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || strings.HasPrefix(host, "192.168.") || strings.HasPrefix(host, "10.") || (strings.HasPrefix(host, "172.") && len(host) > 4) } func makeAIError(err error) wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { return wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Error: err} } func RunAICommand(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NumAIReqs: 1}, "RunAICommand") endpoint := request.Opts.BaseURL if endpoint == "" { endpoint = "default" } var backend AIBackend var backendType string if request.Opts.APIType == APIType_Anthropic { backend = AnthropicBackend{} backendType = APIType_Anthropic } else if request.Opts.APIType == APIType_Perplexity { backend = PerplexityBackend{} backendType = APIType_Perplexity } else if request.Opts.APIType == APIType_Google { backend = GoogleBackend{} backendType = APIType_Google } else if IsCloudAIRequest(request.Opts) { endpoint = "waveterm cloud" request.Opts.APIType = APIType_OpenAI request.Opts.Model = "default" backend = WaveAICloudBackend{} backendType = "wave" } else { backend = OpenAIBackend{} backendType = APIType_OpenAI } if backend == nil { log.Printf("no backend found for %s\n", request.Opts.APIType) return nil } aiLocal := backendType != "wave" && isLocalURL(request.Opts.BaseURL) telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "action:runaicmd", Props: telemetrydata.TEventProps{ AiBackendType: backendType, AiLocal: aiLocal, }, }) log.Printf("sending ai chat message to %s endpoint %q using model %s\n", request.Opts.APIType, endpoint, request.Opts.Model) return backend.StreamCompletion(ctx, request) } ================================================ FILE: pkg/waveapp/streamingresp.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveapp import ( "bytes" "net/http" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const maxChunkSize = 64 * 1024 // 64KB maximum chunk size // StreamingResponseWriter implements http.ResponseWriter interface to stream response // data through a channel rather than buffering it in memory. This is particularly // useful for handling large responses like video streams or file downloads. type StreamingResponseWriter struct { header http.Header statusCode int respChan chan<- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] headerSent bool buffer *bytes.Buffer } func NewStreamingResponseWriter(respChan chan<- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]) *StreamingResponseWriter { return &StreamingResponseWriter{ header: make(http.Header), statusCode: http.StatusOK, respChan: respChan, headerSent: false, buffer: bytes.NewBuffer(make([]byte, 0, maxChunkSize)), } } func (w *StreamingResponseWriter) Header() http.Header { return w.header } func (w *StreamingResponseWriter) WriteHeader(statusCode int) { if w.headerSent { return } w.statusCode = statusCode w.headerSent = true headers := make(map[string]string) for key, values := range w.header { if len(values) > 0 { headers[key] = values[0] } } w.respChan <- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]{ Response: wshrpc.VDomUrlRequestResponse{ StatusCode: w.statusCode, Headers: headers, }, } } // sendChunk sends a single chunk of exactly maxChunkSize (or less) func (w *StreamingResponseWriter) sendChunk(data []byte) { if len(data) == 0 { return } chunk := make([]byte, len(data)) copy(chunk, data) w.respChan <- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]{ Response: wshrpc.VDomUrlRequestResponse{ Body: chunk, }, } } func (w *StreamingResponseWriter) Write(data []byte) (int, error) { if !w.headerSent { w.WriteHeader(http.StatusOK) } originalLen := len(data) // If we already have data in the buffer if w.buffer.Len() > 0 { // Fill the buffer up to maxChunkSize spaceInBuffer := maxChunkSize - w.buffer.Len() if spaceInBuffer > 0 { // How much of the new data can fit in the buffer toBuffer := spaceInBuffer if toBuffer > len(data) { toBuffer = len(data) } w.buffer.Write(data[:toBuffer]) data = data[toBuffer:] // Advance data slice } // If buffer is full, send it if w.buffer.Len() == maxChunkSize { w.sendChunk(w.buffer.Bytes()) w.buffer.Reset() } } // Send any full chunks from data for len(data) >= maxChunkSize { w.sendChunk(data[:maxChunkSize]) data = data[maxChunkSize:] } // Buffer any remaining data if len(data) > 0 { w.buffer.Write(data) } return originalLen, nil } func (w *StreamingResponseWriter) Close() error { if !w.headerSent { w.WriteHeader(http.StatusOK) } if w.buffer.Len() > 0 { w.sendChunk(w.buffer.Bytes()) w.buffer.Reset() } return nil } ================================================ FILE: pkg/waveapp/waveapp.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveapp import ( "context" "flag" "fmt" "io" "io/fs" "log" "net/http" "os" "strings" "sync" "time" "unicode" "github.com/google/uuid" "github.com/gorilla/mux" "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) type AppOpts struct { CloseOnCtrlC bool GlobalKeyboardEvents bool GlobalStyles []byte RootComponentName string // defaults to "App" NewBlockFlag string // defaults to "n" (set to "-" to disable) TargetNewBlock bool TargetToolbar *vdom.VDomTargetToolbar } type Client struct { Lock *sync.Mutex AppOpts AppOpts Root *vdom.RootElem RootElem *vdom.VDomElem RpcClient *wshutil.WshRpc RpcContext *wshrpc.RpcContext ServerImpl *WaveAppServerImpl IsDone bool RouteId string VDomContextBlockId string DoneReason string DoneCh chan struct{} Opts vdom.VDomBackendOpts GlobalEventHandler func(client *Client, event vdom.VDomEvent) GlobalStylesOption *FileHandlerOption UrlHandlerMux *mux.Router OverrideUrlHandler http.Handler NewBlockFlag bool SetupFn func() } func (c *Client) GetIsDone() bool { c.Lock.Lock() defer c.Lock.Unlock() return c.IsDone } func (c *Client) doShutdown(reason string) { c.Lock.Lock() defer c.Lock.Unlock() if c.IsDone { return } c.DoneReason = reason c.IsDone = true close(c.DoneCh) } func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) { c.GlobalEventHandler = handler } func (c *Client) SetOverrideUrlHandler(handler http.Handler) { c.OverrideUrlHandler = handler } func MakeClient(appOpts AppOpts) *Client { if appOpts.RootComponentName == "" { appOpts.RootComponentName = "App" } if appOpts.NewBlockFlag == "" { appOpts.NewBlockFlag = "n" } client := &Client{ Lock: &sync.Mutex{}, AppOpts: appOpts, Root: vdom.MakeRoot(), DoneCh: make(chan struct{}), UrlHandlerMux: mux.NewRouter(), Opts: vdom.VDomBackendOpts{ CloseOnCtrlC: appOpts.CloseOnCtrlC, GlobalKeyboardEvents: appOpts.GlobalKeyboardEvents, }, } if len(appOpts.GlobalStyles) > 0 { client.Opts.GlobalStyles = true client.GlobalStylesOption = &FileHandlerOption{Data: appOpts.GlobalStyles, MimeType: "text/css"} } client.SetRootElem(vdom.E(appOpts.RootComponentName)) return client } func (client *Client) runMainE() error { if client.SetupFn != nil { client.SetupFn() } err := client.Connect() if err != nil { return err } target := &vdom.VDomTarget{} if client.AppOpts.TargetNewBlock || client.NewBlockFlag { target.NewBlock = client.NewBlockFlag } if client.AppOpts.TargetToolbar != nil { target.Toolbar = client.AppOpts.TargetToolbar } if target.NewBlock && target.Toolbar != nil { return fmt.Errorf("cannot specify both new block and toolbar target") } err = client.CreateVDomContext(target) if err != nil { return err } <-client.DoneCh return nil } func (client *Client) AddSetupFn(fn func()) { client.SetupFn = fn } func (client *Client) RegisterDefaultFlags() { if client.AppOpts.NewBlockFlag != "-" { flag.BoolVar(&client.NewBlockFlag, client.AppOpts.NewBlockFlag, false, "new block") } } func (client *Client) RunMain() { if !flag.Parsed() { client.RegisterDefaultFlags() flag.Parse() } err := client.runMainE() if err != nil { fmt.Println(err) os.Exit(1) } } func (client *Client) Connect() error { jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) if jwtToken == "" { return fmt.Errorf("no %s env var set", wshutil.WaveJwtTokenVarName) } rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken) if err != nil { return fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err) } client.RpcContext = rpcCtx if client.RpcContext == nil || client.RpcContext.BlockId == "" { return fmt.Errorf("no block id in rpc context") } client.ServerImpl = &WaveAppServerImpl{BlockId: client.RpcContext.BlockId, Client: client} sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) if err != nil { return fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err) } rpcClient, err := wshutil.SetupDomainSocketRpcClient(sockName, client.ServerImpl, "vdomclient") if err != nil { return fmt.Errorf("error setting up domain socket rpc client: %v", err) } client.RpcClient = rpcClient authRtnData, err := wshclient.AuthenticateCommand(client.RpcClient, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) if err != nil { return fmt.Errorf("error authenticating rpc connection: %v", err) } if authRtnData.RouteId == "" { return fmt.Errorf("authentication returned empty routeid") } client.RouteId = authRtnData.RouteId return nil } func (c *Client) SetRootElem(elem *vdom.VDomElem) { c.RootElem = elem } func (c *Client) CreateVDomContext(target *vdom.VDomTarget) error { blockORef, err := wshclient.VDomCreateContextCommand( c.RpcClient, vdom.VDomCreateContext{Target: target}, &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)}, ) if err != nil { return err } c.VDomContextBlockId = blockORef.OID log.Printf("created vdom context: %v\n", blockORef) gotRoute, err := wshclient.WaitForRouteCommand(c.RpcClient, wshrpc.CommandWaitForRouteData{ RouteId: wshutil.MakeFeBlockRouteId(blockORef.OID), WaitMs: 4000, }, &wshrpc.RpcOpts{Timeout: 5000}) if err != nil { return fmt.Errorf("error waiting for vdom context route: %v", err) } if !gotRoute { return fmt.Errorf("vdom context route could not be established") } wshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{ blockORef.String(), }}, nil) c.RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { c.doShutdown("got blockclose event") }) return nil } func (c *Client) SendAsyncInitiation() error { if c.VDomContextBlockId == "" { return fmt.Errorf("no vdom context block id") } if c.GetIsDone() { return fmt.Errorf("client is done") } return wshclient.VDomAsyncInitiationCommand( c.RpcClient, vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.VDomContextBlockId)}, ) } func (c *Client) SetAtomVals(m map[string]any) { for k, v := range m { c.Root.SetAtomVal(k, v, true) } } func (c *Client) SetAtomVal(name string, val any) { c.Root.SetAtomVal(name, val, true) } func (c *Client) GetAtomVal(name string) any { return c.Root.GetAtomVal(name) } func makeNullVDom() *vdom.VDomElem { return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } func DefineComponent[P any](client *Client, name string, renderFn func(ctx context.Context, props P) any) vdom.Component[P] { if name == "" { panic("Component name cannot be empty") } if !unicode.IsUpper(rune(name[0])) { panic("Component name must start with an uppercase letter") } err := client.RegisterComponent(name, renderFn) if err != nil { panic(err) } return func(props P) *vdom.VDomElem { return vdom.E(name, vdom.Props(props)) } } func (c *Client) RegisterComponent(name string, cfunc any) error { return c.Root.RegisterComponent(name, cfunc) } func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { c.Root.RunWork() c.Root.Render(c.RootElem) renderedVDom := c.Root.MakeVDom() if renderedVDom == nil { renderedVDom = makeNullVDom() } return &vdom.VDomBackendUpdate{ Type: "backendupdate", Ts: time.Now().UnixMilli(), BlockId: c.RpcContext.BlockId, HasWork: len(c.Root.EffectWorkQueue) > 0, Opts: &c.Opts, RenderUpdates: []vdom.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, RefOperations: c.Root.GetRefOperations(), StateSync: c.Root.GetStateSync(true), }, nil } func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) { c.Root.RunWork() renderedVDom := c.Root.MakeVDom() if renderedVDom == nil { renderedVDom = makeNullVDom() } return &vdom.VDomBackendUpdate{ Type: "backendupdate", Ts: time.Now().UnixMilli(), BlockId: c.RpcContext.BlockId, RenderUpdates: []vdom.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, RefOperations: c.Root.GetRefOperations(), StateSync: c.Root.GetStateSync(false), }, nil } func (c *Client) RegisterUrlPathHandler(path string, handler http.Handler) { c.UrlHandlerMux.Handle(path, handler) } type FileHandlerOption struct { FilePath string // optional file path on disk Data []byte // optional byte slice content Reader io.Reader // optional reader for content File fs.File // optional embedded or opened file MimeType string // optional mime type ETag string // optional ETag (if set, resource may be cached) } func determineMimeType(option FileHandlerOption) (string, []byte) { // If MimeType is set, use it directly if option.MimeType != "" { return option.MimeType, nil } // Detect from Data if available, no need to buffer if option.Data != nil { return http.DetectContentType(option.Data), nil } // Detect from FilePath, no buffering necessary if option.FilePath != "" { filePath := wavebase.ExpandHomeDirSafe(option.FilePath) file, err := os.Open(filePath) if err != nil { return "application/octet-stream", nil // Fallback on error } defer file.Close() // Read first 512 bytes for MIME detection buf := make([]byte, 512) _, err = file.Read(buf) if err != nil && err != io.EOF { return "application/octet-stream", nil } return http.DetectContentType(buf), nil } // Buffer for File (fs.File), since it lacks Seek if option.File != nil { buf := make([]byte, 512) n, err := option.File.Read(buf) if err != nil && err != io.EOF { return "application/octet-stream", nil } return http.DetectContentType(buf[:n]), buf[:n] } // Buffer for Reader (io.Reader), same as File if option.Reader != nil { buf := make([]byte, 512) n, err := option.Reader.Read(buf) if err != nil && err != io.EOF { return "application/octet-stream", nil } return http.DetectContentType(buf[:n]), buf[:n] } // Default MIME type if none specified return "application/octet-stream", nil } // ServeFileOption handles serving content based on the provided FileHandlerOption func ServeFileOption(w http.ResponseWriter, r *http.Request, option FileHandlerOption) error { // Determine MIME type and get buffered data if needed contentType, bufferedData := determineMimeType(option) w.Header().Set("Content-Type", contentType) // Handle ETag if option.ETag != "" { w.Header().Set("ETag", option.ETag) // Check If-None-Match header if inm := r.Header.Get("If-None-Match"); inm != "" { // Strip W/ prefix and quotes if present inm = strings.Trim(inm, `"`) inm = strings.TrimPrefix(inm, "W/") etag := strings.Trim(option.ETag, `"`) etag = strings.TrimPrefix(etag, "W/") if inm == etag { // Resource not modified w.WriteHeader(http.StatusNotModified) return nil } } } // Handle the content based on the option type switch { case option.FilePath != "": filePath := wavebase.ExpandHomeDirSafe(option.FilePath) http.ServeFile(w, r, filePath) case option.Data != nil: w.Header().Set("Content-Length", fmt.Sprintf("%d", len(option.Data))) w.WriteHeader(http.StatusOK) if _, err := w.Write(option.Data); err != nil { return fmt.Errorf("failed to write data: %v", err) } case option.File != nil: if bufferedData != nil { if _, err := w.Write(bufferedData); err != nil { return fmt.Errorf("failed to write buffered data: %v", err) } } if _, err := io.Copy(w, option.File); err != nil { return fmt.Errorf("failed to copy from file: %v", err) } case option.Reader != nil: if bufferedData != nil { if _, err := w.Write(bufferedData); err != nil { return fmt.Errorf("failed to write buffered data: %v", err) } } if _, err := io.Copy(w, option.Reader); err != nil { return fmt.Errorf("failed to copy from reader: %v", err) } default: return fmt.Errorf("no content available") } return nil } func (c *Client) RegisterFilePrefixHandler(prefix string, optionProvider func(path string) (*FileHandlerOption, error)) { c.UrlHandlerMux.PathPrefix(prefix).HandlerFunc(func(w http.ResponseWriter, r *http.Request) { option, err := optionProvider(r.URL.Path) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if option == nil { http.Error(w, "no content available", http.StatusNotFound) return } if err := ServeFileOption(w, r, *option); err != nil { http.Error(w, fmt.Sprintf("Failed to serve content: %v", err), http.StatusInternalServerError) } }) } func (c *Client) RegisterFileHandler(path string, option FileHandlerOption) { c.UrlHandlerMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { if err := ServeFileOption(w, r, option); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } }) } ================================================ FILE: pkg/waveapp/waveappserverimpl.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveapp import ( "bytes" "context" "fmt" "log" "net/http" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type WaveAppServerImpl struct { Client *Client BlockId string } func (*WaveAppServerImpl) WshServerImpl() {} func (impl *WaveAppServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) chan wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate] { respChan := make(chan wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate], 5) defer func() { panicErr := panichandler.PanicHandler("VDomRenderCommand", recover()) if panicErr != nil { respChan <- wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{ Error: panicErr, } close(respChan) } }() if feUpdate.Dispose { defer close(respChan) log.Printf("got dispose from frontend\n") impl.Client.doShutdown("got dispose from frontend") return respChan } if impl.Client.GetIsDone() { close(respChan) return respChan } impl.Client.Root.RenderTs = feUpdate.Ts // set atoms for _, ss := range feUpdate.StateSync { impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false) } // run events for _, event := range feUpdate.Events { if event.GlobalEventType != "" { if impl.Client.GlobalEventHandler != nil { impl.Client.GlobalEventHandler(impl.Client, event) } } else { impl.Client.Root.Event(event.WaveId, event.EventType, event) } } // update refs for _, ref := range feUpdate.RefUpdates { impl.Client.Root.UpdateRef(ref) } var update *vdom.VDomBackendUpdate var err error if feUpdate.Resync || true { update, err = impl.Client.fullRender() } else { update, err = impl.Client.incrementalRender() } update.CreateTransferElems() if err != nil { respChan <- wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{ Error: err, } close(respChan) return respChan } // Split the update into chunks and send them sequentially updates := vdom.SplitBackendUpdate(update) go func() { defer func() { panichandler.PanicHandler("VDomRenderCommand:splitUpdates", recover()) }() defer close(respChan) for _, splitUpdate := range updates { respChan <- wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{ Response: splitUpdate, } } }() return respChan } func (impl *WaveAppServerImpl) VDomUrlRequestCommand(ctx context.Context, data wshrpc.VDomUrlRequestData) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] { respChan := make(chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]) writer := NewStreamingResponseWriter(respChan) go func() { defer close(respChan) // Declared first, so it executes last defer writer.Close() // Ensures writer is closed before the channel is closed defer func() { panicErr := panichandler.PanicHandler("VDomUrlRequestCommand", recover()) if panicErr != nil { writer.WriteHeader(http.StatusInternalServerError) writer.Write([]byte(fmt.Sprintf("internal server error: %v", panicErr))) } }() // Create an HTTP request from the RPC request data var bodyReader *bytes.Reader if data.Body != nil { bodyReader = bytes.NewReader(data.Body) } else { bodyReader = bytes.NewReader([]byte{}) } httpReq, err := http.NewRequest(data.Method, data.URL, bodyReader) if err != nil { writer.WriteHeader(http.StatusInternalServerError) writer.Write([]byte(err.Error())) return } for key, value := range data.Headers { httpReq.Header.Set(key, value) } if httpReq.URL.Path == "/wave/global.css" && impl.Client.GlobalStylesOption != nil { ServeFileOption(writer, httpReq, *impl.Client.GlobalStylesOption) return } if impl.Client.OverrideUrlHandler != nil { impl.Client.OverrideUrlHandler.ServeHTTP(writer, httpReq) return } impl.Client.UrlHandlerMux.ServeHTTP(writer, httpReq) }() return respChan } ================================================ FILE: pkg/waveappstore/waveappstore.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveappstore import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "regexp" "strings" "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const ( AppNSLocal = "local" AppNSDraft = "draft" MaxNamespaceLen = 30 MaxAppNameLen = 50 ManifestFileName = "manifest.json" SecretBindingsFileName = "secret-bindings.json" ) var ( namespaceRegex = regexp.MustCompile(`^@?[a-z0-9-]+$`) appNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) ) type FileData struct { Contents []byte ModTs int64 } func MakeAppId(appNS string, appName string) string { return appNS + "/" + appName } func ParseAppId(appId string) (appNS string, appName string, err error) { parts := strings.Split(appId, "/") if len(parts) != 2 { return "", "", fmt.Errorf("invalid appId format: must be namespace/name") } appNS = parts[0] appName = parts[1] if appNS == "" || appName == "" { return "", "", fmt.Errorf("invalid appId: namespace and name cannot be empty") } return appNS, appName, nil } func ValidateAppId(appId string) error { appNS, appName, err := ParseAppId(appId) if err != nil { return err } if len(appNS) > MaxNamespaceLen { return fmt.Errorf("namespace too long: max %d characters", MaxNamespaceLen) } if len(appName) > MaxAppNameLen { return fmt.Errorf("app name too long: max %d characters", MaxAppNameLen) } if !namespaceRegex.MatchString(appNS) { return fmt.Errorf("invalid namespace: must match pattern @?[a-z0-9-]+") } if !appNameRegex.MatchString(appName) { return fmt.Errorf("invalid app name: must match pattern [a-zA-Z0-9_-]+") } return nil } func GetAppDir(appId string) (string, error) { if err := ValidateAppId(appId); err != nil { return "", err } appNS, appName, _ := ParseAppId(appId) homeDir := wavebase.GetHomeDir() return filepath.Join(homeDir, "waveapps", appNS, appName), nil } func copyDir(src, dst string) error { if err := os.RemoveAll(dst); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove existing directory: %w", err) } if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return fmt.Errorf("failed to create parent directory: %w", err) } return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } relPath, err := filepath.Rel(src, path) if err != nil { return err } dstPath := filepath.Join(dst, relPath) if info.IsDir() { return os.MkdirAll(dstPath, info.Mode()) } data, err := os.ReadFile(path) if err != nil { return err } return os.WriteFile(dstPath, data, info.Mode()) }) } func PublishDraft(draftAppId string) (string, error) { if err := ValidateAppId(draftAppId); err != nil { return "", fmt.Errorf("invalid appId: %w", err) } appNS, appName, _ := ParseAppId(draftAppId) if appNS != AppNSDraft { return "", fmt.Errorf("appId must be in draft namespace, got: %s", appNS) } draftDir, err := GetAppDir(draftAppId) if err != nil { return "", err } if _, err := os.Stat(draftDir); os.IsNotExist(err) { return "", fmt.Errorf("draft app does not exist: %s", draftDir) } localAppId := MakeAppId(AppNSLocal, appName) localDir, err := GetAppDir(localAppId) if err != nil { return "", err } if err := copyDir(draftDir, localDir); err != nil { return "", err } return localAppId, nil } func RevertDraft(draftAppId string) error { if err := ValidateAppId(draftAppId); err != nil { return fmt.Errorf("invalid appId: %w", err) } appNS, appName, _ := ParseAppId(draftAppId) if appNS != AppNSDraft { return fmt.Errorf("appId must be in draft namespace, got: %s", appNS) } draftDir, err := GetAppDir(draftAppId) if err != nil { return err } localAppId := MakeAppId(AppNSLocal, appName) localDir, err := GetAppDir(localAppId) if err != nil { return err } if _, err := os.Stat(localDir); os.IsNotExist(err) { return fmt.Errorf("local app does not exist: %s", localDir) } return copyDir(localDir, draftDir) } func MakeDraftFromLocal(localAppId string) (string, error) { if err := ValidateAppId(localAppId); err != nil { return "", fmt.Errorf("invalid appId: %w", err) } appNS, appName, _ := ParseAppId(localAppId) if appNS != AppNSLocal { return "", fmt.Errorf("appId must be in local namespace, got: %s", appNS) } localDir, err := GetAppDir(localAppId) if err != nil { return "", err } if _, err := os.Stat(localDir); os.IsNotExist(err) { return "", fmt.Errorf("local app does not exist: %s", localDir) } draftAppId := MakeAppId(AppNSDraft, appName) draftDir, err := GetAppDir(draftAppId) if err != nil { return "", err } if _, err := os.Stat(draftDir); err == nil { // draft already exists, don't overwrite (that's what RevertDraft is for) return draftAppId, nil } else if !os.IsNotExist(err) { return "", err } if err := copyDir(localDir, draftDir); err != nil { return "", err } return draftAppId, nil } func DeleteApp(appId string) error { if err := ValidateAppId(appId); err != nil { return fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return err } if err := os.RemoveAll(appDir); err != nil { return fmt.Errorf("failed to delete app directory: %w", err) } return nil } func validateAndResolveFilePath(appDir string, fileName string) (string, error) { if filepath.IsAbs(fileName) { return "", fmt.Errorf("fileName must be relative, got absolute path: %s", fileName) } cleanPath := filepath.Clean(fileName) if strings.HasPrefix(cleanPath, "..") || strings.Contains(cleanPath, string(filepath.Separator)+"..") { return "", fmt.Errorf("path traversal not allowed: %s", fileName) } fullPath := filepath.Join(appDir, cleanPath) resolvedPath, err := filepath.Abs(fullPath) if err != nil { return "", fmt.Errorf("failed to resolve path: %w", err) } resolvedAppDir, err := filepath.Abs(appDir) if err != nil { return "", fmt.Errorf("failed to resolve app directory: %w", err) } if !strings.HasPrefix(resolvedPath, resolvedAppDir+string(filepath.Separator)) && resolvedPath != resolvedAppDir { return "", fmt.Errorf("path escapes app directory: %s", fileName) } return resolvedPath, nil } func WriteAppFile(appId string, fileName string, contents []byte) error { if err := ValidateAppId(appId); err != nil { return fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return err } filePath, err := validateAndResolveFilePath(appDir, fileName) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } if err := os.WriteFile(filePath, contents, 0644); err != nil { return fmt.Errorf("failed to write file: %w", err) } return nil } func ReadAppFile(appId string, fileName string) (*FileData, error) { if err := ValidateAppId(appId); err != nil { return nil, fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return nil, err } filePath, err := validateAndResolveFilePath(appDir, fileName) if err != nil { return nil, err } fileInfo, err := os.Stat(filePath) if err != nil { return nil, fmt.Errorf("failed to stat file: %w", err) } contents, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } return &FileData{ Contents: contents, ModTs: fileInfo.ModTime().UnixMilli(), }, nil } func DeleteAppFile(appId string, fileName string) error { if err := ValidateAppId(appId); err != nil { return fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return err } filePath, err := validateAndResolveFilePath(appDir, fileName) if err != nil { return err } if err := os.Remove(filePath); err != nil { return fmt.Errorf("failed to delete file: %w", err) } return nil } func ReplaceInAppFile(appId string, fileName string, edits []fileutil.EditSpec) error { if err := ValidateAppId(appId); err != nil { return fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return err } filePath, err := validateAndResolveFilePath(appDir, fileName) if err != nil { return err } return fileutil.ReplaceInFile(filePath, edits) } func ReplaceInAppFilePartial(appId string, fileName string, edits []fileutil.EditSpec) ([]fileutil.EditResult, error) { if err := ValidateAppId(appId); err != nil { return nil, fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return nil, err } filePath, err := validateAndResolveFilePath(appDir, fileName) if err != nil { return nil, err } return fileutil.ReplaceInFilePartial(filePath, edits) } func RenameAppFile(appId string, fromFileName string, toFileName string) error { if err := ValidateAppId(appId); err != nil { return fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return err } fromPath, err := validateAndResolveFilePath(appDir, fromFileName) if err != nil { return fmt.Errorf("invalid source path: %w", err) } toPath, err := validateAndResolveFilePath(appDir, toFileName) if err != nil { return fmt.Errorf("invalid destination path: %w", err) } if err := os.MkdirAll(filepath.Dir(toPath), 0755); err != nil { return fmt.Errorf("failed to create destination directory: %w", err) } if err := os.Rename(fromPath, toPath); err != nil { return fmt.Errorf("failed to rename file: %w", err) } return nil } func FormatGoFile(appId string, fileName string) error { if err := ValidateAppId(appId); err != nil { return fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return err } filePath, err := validateAndResolveFilePath(appDir, fileName) if err != nil { return err } if filepath.Ext(filePath) != ".go" { return fmt.Errorf("file is not a Go file: %s", fileName) } gofmtPath, err := waveapputil.ResolveGoFmtPath() if err != nil { return fmt.Errorf("failed to resolve gofmt path: %w", err) } cmd := exec.Command(gofmtPath, "-w", filePath) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("gofmt failed: %w\nOutput: %s", err, string(output)) } return nil } func ListAllAppFiles(appId string) (*fileutil.ReadDirResult, error) { if err := ValidateAppId(appId); err != nil { return nil, fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return nil, err } if _, err := os.Stat(appDir); os.IsNotExist(err) { return nil, fmt.Errorf("app directory does not exist: %s", appDir) } return fileutil.ReadDirRecursive(appDir, 10000) } func ListAllApps() ([]wshrpc.AppInfo, error) { homeDir := wavebase.GetHomeDir() waveappsDir := filepath.Join(homeDir, "waveapps") if _, err := os.Stat(waveappsDir); os.IsNotExist(err) { return []wshrpc.AppInfo{}, nil } namespaces, err := os.ReadDir(waveappsDir) if err != nil { return nil, fmt.Errorf("failed to read waveapps directory: %w", err) } var appInfos []wshrpc.AppInfo for _, ns := range namespaces { if !ns.IsDir() { continue } namespace := ns.Name() nsPath := filepath.Join(waveappsDir, namespace) apps, err := os.ReadDir(nsPath) if err != nil { continue } for _, app := range apps { if !app.IsDir() { continue } appName := app.Name() appId := MakeAppId(namespace, appName) if err := ValidateAppId(appId); err == nil { modTime, _ := GetAppModTime(appId) appInfo := wshrpc.AppInfo{ AppId: appId, ModTime: modTime, } if manifest, err := ReadAppManifest(appId); err == nil { appInfo.Manifest = manifest } appInfos = append(appInfos, appInfo) } } } return appInfos, nil } func GetAppModTime(appId string) (int64, error) { if err := ValidateAppId(appId); err != nil { return 0, err } homeDir := wavebase.GetHomeDir() appNS, appName, err := ParseAppId(appId) if err != nil { return 0, err } appPath := filepath.Join(homeDir, "waveapps", appNS, appName) appGoPath := filepath.Join(appPath, "app.go") fileInfo, err := os.Stat(appGoPath) if err == nil { return fileInfo.ModTime().UnixMilli(), nil } dirInfo, err := os.Stat(appPath) if err != nil { return 0, nil } return dirInfo.ModTime().UnixMilli(), nil } func ListAllEditableApps() ([]wshrpc.AppInfo, error) { homeDir := wavebase.GetHomeDir() waveappsDir := filepath.Join(homeDir, "waveapps") if _, err := os.Stat(waveappsDir); os.IsNotExist(err) { return []wshrpc.AppInfo{}, nil } localApps := make(map[string]bool) draftApps := make(map[string]bool) localPath := filepath.Join(waveappsDir, AppNSLocal) if localEntries, err := os.ReadDir(localPath); err == nil { for _, app := range localEntries { if app.IsDir() { appName := app.Name() appId := MakeAppId(AppNSLocal, appName) if err := ValidateAppId(appId); err == nil { localApps[appName] = true } } } } draftPath := filepath.Join(waveappsDir, AppNSDraft) if draftEntries, err := os.ReadDir(draftPath); err == nil { for _, app := range draftEntries { if app.IsDir() { appName := app.Name() appId := MakeAppId(AppNSDraft, appName) if err := ValidateAppId(appId); err == nil { draftApps[appName] = true } } } } allAppNames := make(map[string]bool) for appName := range localApps { allAppNames[appName] = true } for appName := range draftApps { allAppNames[appName] = true } var appInfos []wshrpc.AppInfo for appName := range allAppNames { var appId string var manifestAppId string if localApps[appName] { appId = MakeAppId(AppNSLocal, appName) } else { appId = MakeAppId(AppNSDraft, appName) } if draftApps[appName] { manifestAppId = MakeAppId(AppNSDraft, appName) } else { manifestAppId = appId } modTime, _ := GetAppModTime(manifestAppId) appInfo := wshrpc.AppInfo{ AppId: appId, ModTime: modTime, } if manifest, err := ReadAppManifest(manifestAppId); err == nil { appInfo.Manifest = manifest } appInfos = append(appInfos, appInfo) } return appInfos, nil } func DraftHasLocalVersion(draftAppId string) (bool, error) { if err := ValidateAppId(draftAppId); err != nil { return false, fmt.Errorf("invalid appId: %w", err) } appNS, appName, _ := ParseAppId(draftAppId) if appNS != AppNSDraft { return false, fmt.Errorf("appId must be in draft namespace, got: %s", appNS) } localAppId := MakeAppId(AppNSLocal, appName) localDir, err := GetAppDir(localAppId) if err != nil { return false, err } if _, err := os.Stat(localDir); os.IsNotExist(err) { return false, nil } return true, nil } // RenameLocalApp renames a local app by renaming its directories in both the local and draft namespaces. // It takes the current app name and the new app name (without namespace prefixes). // Both local/[appName] and draft/[appName] will be renamed if they exist. // Returns an error if the app doesn't exist in either namespace, if the new name is invalid, // or if the new name conflicts with an existing app. func RenameLocalApp(appName string, newAppName string) error { // Validate the old app name by constructing a valid appId oldLocalAppId := MakeAppId(AppNSLocal, appName) if err := ValidateAppId(oldLocalAppId); err != nil { return fmt.Errorf("invalid app name: %w", err) } // Validate the new app name by constructing a valid appId newLocalAppId := MakeAppId(AppNSLocal, newAppName) if err := ValidateAppId(newLocalAppId); err != nil { return fmt.Errorf("invalid new app name: %w", err) } homeDir := wavebase.GetHomeDir() waveappsDir := filepath.Join(homeDir, "waveapps") oldLocalDir := filepath.Join(waveappsDir, AppNSLocal, appName) newLocalDir := filepath.Join(waveappsDir, AppNSLocal, newAppName) oldDraftDir := filepath.Join(waveappsDir, AppNSDraft, appName) newDraftDir := filepath.Join(waveappsDir, AppNSDraft, newAppName) // Check if at least one of the apps exists localExists := false draftExists := false if _, err := os.Stat(oldLocalDir); err == nil { localExists = true } else if !os.IsNotExist(err) { return fmt.Errorf("failed to check local app: %w", err) } if _, err := os.Stat(oldDraftDir); err == nil { draftExists = true } else if !os.IsNotExist(err) { return fmt.Errorf("failed to check draft app: %w", err) } if !localExists && !draftExists { return fmt.Errorf("app '%s' does not exist in local or draft namespace", appName) } // Check if new app name already exists in either namespace if _, err := os.Stat(newLocalDir); err == nil { return fmt.Errorf("local app '%s' already exists", newAppName) } else if !os.IsNotExist(err) { return fmt.Errorf("failed to check if new local app exists: %w", err) } if _, err := os.Stat(newDraftDir); err == nil { return fmt.Errorf("draft app '%s' already exists", newAppName) } else if !os.IsNotExist(err) { return fmt.Errorf("failed to check if new draft app exists: %w", err) } // Rename local app if it exists if localExists { if err := os.Rename(oldLocalDir, newLocalDir); err != nil { return fmt.Errorf("failed to rename local app: %w", err) } } // Rename draft app if it exists if draftExists { if err := os.Rename(oldDraftDir, newDraftDir); err != nil { // If local was renamed but draft fails, try to rollback local rename if localExists { if rollbackErr := os.Rename(newLocalDir, oldLocalDir); rollbackErr != nil { return fmt.Errorf("failed to rename draft app (and failed to rollback local rename: %v): %w", rollbackErr, err) } } return fmt.Errorf("failed to rename draft app: %w", err) } } return nil } func ReadAppManifest(appId string) (*wshrpc.AppManifest, error) { if err := ValidateAppId(appId); err != nil { return nil, fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return nil, err } manifestPath := filepath.Join(appDir, ManifestFileName) data, err := os.ReadFile(manifestPath) if err != nil { return nil, fmt.Errorf("failed to read %s: %w", ManifestFileName, err) } var manifest wshrpc.AppManifest if err := json.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("failed to parse %s: %w", ManifestFileName, err) } return &manifest, nil } func ReadAppSecretBindings(appId string) (map[string]string, error) { if err := ValidateAppId(appId); err != nil { return nil, fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return nil, err } bindingsPath := filepath.Join(appDir, SecretBindingsFileName) data, err := os.ReadFile(bindingsPath) if err != nil { if os.IsNotExist(err) { return make(map[string]string), nil } return nil, fmt.Errorf("failed to read %s: %w", SecretBindingsFileName, err) } var bindings map[string]string if err := json.Unmarshal(data, &bindings); err != nil { return nil, fmt.Errorf("failed to parse %s: %w", SecretBindingsFileName, err) } if bindings == nil { bindings = make(map[string]string) } return bindings, nil } func WriteAppSecretBindings(appId string, bindings map[string]string) error { if err := ValidateAppId(appId); err != nil { return fmt.Errorf("invalid appId: %w", err) } appDir, err := GetAppDir(appId) if err != nil { return err } if bindings == nil { bindings = make(map[string]string) } data, err := json.MarshalIndent(bindings, "", " ") if err != nil { return fmt.Errorf("failed to marshal bindings: %w", err) } bindingsPath := filepath.Join(appDir, SecretBindingsFileName) if err := os.WriteFile(bindingsPath, data, 0644); err != nil { return fmt.Errorf("failed to write %s: %w", SecretBindingsFileName, err) } return nil } func BuildAppSecretEnv(appId string, manifest *wshrpc.AppManifest, bindings map[string]string) (map[string]string, error) { if manifest == nil { return make(map[string]string), nil } if bindings == nil { bindings = make(map[string]string) } secretEnv := make(map[string]string) for secretName, secretMeta := range manifest.Secrets { boundSecretName, hasBinding := bindings[secretName] if !secretMeta.Optional && !hasBinding { return nil, fmt.Errorf("required secret %q is not bound", secretName) } if !hasBinding { continue } secretValue, exists, err := secretstore.GetSecret(boundSecretName) if err != nil { return nil, fmt.Errorf("failed to get secret %q: %w", boundSecretName, err) } if !exists { if !secretMeta.Optional { return nil, fmt.Errorf("required secret %q is bound to %q which does not exist in secret store", secretName, boundSecretName) } continue } secretEnv[secretName] = secretValue } return secretEnv, nil } ================================================ FILE: pkg/waveapputil/waveapputil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveapputil import ( "bytes" "fmt" "os" "os/exec" "path/filepath" "runtime" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/tsunami/build" ) const DefaultTsunamiSdkVersion = "v0.12.4" func GetTsunamiScaffoldPath() string { settings := wconfig.GetWatcher().GetFullConfig().Settings scaffoldPath := settings.TsunamiScaffoldPath if scaffoldPath == "" { scaffoldPath = filepath.Join(wavebase.GetWaveAppResourcesPath(), "tsunamiscaffold") } return scaffoldPath } func ResolveGoFmtPath() (string, error) { settings := wconfig.GetWatcher().GetFullConfig().Settings goPath := settings.TsunamiGoPath if goPath == "" { var err error goPath, err = build.FindGoExecutable() if err != nil { return "", err } } goDir := filepath.Dir(goPath) gofmtName := "gofmt" if runtime.GOOS == "windows" { gofmtName = "gofmt.exe" } gofmtPath := filepath.Join(goDir, gofmtName) info, err := os.Stat(gofmtPath) if err != nil { return "", fmt.Errorf("gofmt not found at %s: %w", gofmtPath, err) } if info.IsDir() { return "", fmt.Errorf("gofmt path is a directory: %s", gofmtPath) } if info.Mode()&0111 == 0 { return "", fmt.Errorf("gofmt is not executable: %s", gofmtPath) } return gofmtPath, nil } func FormatGoCode(contents []byte) []byte { gofmtPath, err := ResolveGoFmtPath() if err != nil { return contents } cmd := exec.Command(gofmtPath) cmd.Stdin = bytes.NewReader(contents) formattedOutput, err := cmd.Output() if err != nil { return contents } return formattedOutput } ================================================ FILE: pkg/wavebase/wavebase-posix.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 //go:build !windows package wavebase import ( "log" "os" "path/filepath" "golang.org/x/sys/unix" ) func AcquireWaveLock() (FDLock, error) { dataHomeDir := GetWaveDataDir() lockFileName := filepath.Join(dataHomeDir, WaveLockFile) log.Printf("[base] acquiring lock on %s\n", lockFileName) fd, err := os.OpenFile(lockFileName, os.O_RDWR|os.O_CREATE, 0600) if err != nil { return nil, err } err = unix.Flock(int(fd.Fd()), unix.LOCK_EX|unix.LOCK_NB) if err != nil { fd.Close() return nil, err } return fd, nil } ================================================ FILE: pkg/wavebase/wavebase-win.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 //go:build windows package wavebase import ( "fmt" "log" "path/filepath" "github.com/alexflint/go-filemutex" ) func AcquireWaveLock() (FDLock, error) { dataHomeDir := GetWaveDataDir() lockFileName := filepath.Join(dataHomeDir, WaveLockFile) log.Printf("[base] acquiring lock on %s\n", lockFileName) m, err := filemutex.New(lockFileName) if err != nil { return nil, fmt.Errorf("filemutex new error: %w", err) } err = m.TryLock() if err != nil { return nil, fmt.Errorf("filemutex trylock error: %w", err) } return m, nil } ================================================ FILE: pkg/wavebase/wavebase.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wavebase import ( "context" "errors" "fmt" "io/fs" "log" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "sync" "time" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) // set by main-server.go var WaveVersion = "0.0.0" var BuildTime = "0" const ( WaveConfigHomeEnvVar = "WAVETERM_CONFIG_HOME" WaveDataHomeEnvVar = "WAVETERM_DATA_HOME" WaveAppPathVarName = "WAVETERM_APP_PATH" WaveAppResourcesPathVarName = "WAVETERM_RESOURCES_PATH" WaveAppElectronExecPathVarName = "WAVETERM_ELECTRONEXECPATH" WaveDevVarName = "WAVETERM_DEV" WaveDevViteVarName = "WAVETERM_DEV_VITE" WaveWshForceUpdateVarName = "WAVETERM_WSHFORCEUPDATE" WaveNoConfirmQuitVarName = "WAVETERM_NOCONFIRMQUIT" WaveJwtTokenVarName = "WAVETERM_JWT" WaveSwapTokenVarName = "WAVETERM_SWAPTOKEN" ) const ( BlockFile_Term = "term" // used for main pty output BlockFile_Cache = "cache:term:full" // for cached block BlockFile_VDom = "vdom" // used for alt html layout BlockFile_Env = "env" ) const NeedJwtConst = "NEED-JWT" var ConfigHome_VarCache string // caches WAVETERM_CONFIG_HOME var DataHome_VarCache string // caches WAVETERM_DATA_HOME var AppPath_VarCache string // caches WAVETERM_APP_PATH var AppResourcesPath_VarCache string // caches WAVETERM_RESOURCES_PATH var AppElectronExecPath_VarCache string // caches WAVETERM_ELECTRONEXECPATH var Dev_VarCache string // caches WAVETERM_DEV const WaveLockFile = "wave.lock" const DomainSocketBaseName = "wave.sock" const RemoteDomainSocketBaseName = "wave-remote.sock" const WaveDBDir = "db" const ConfigDir = "config" const RemoteWaveHomeDirName = ".waveterm" const RemoteWshBinDirName = "bin" const RemoteFullWshBinPath = "~/.waveterm/bin/wsh" const RemoteFullDomainSocketPath = "~/.waveterm/wave-remote.sock" const AppPathBinDir = "bin" var baseLock = &sync.Mutex{} var ensureDirCache = map[string]bool{} var waveCachesDirOnce = &sync.Once{} var waveCachesDir string var SupportedWshBinaries = map[string]bool{ "darwin-x64": true, "darwin-arm64": true, "linux-x64": true, "linux-arm64": true, "windows-x64": true, "windows-arm64": true, } type FDLock interface { Close() error } func CacheAndRemoveEnvVars() error { ConfigHome_VarCache = os.Getenv(WaveConfigHomeEnvVar) if ConfigHome_VarCache == "" { return fmt.Errorf(WaveConfigHomeEnvVar + " not set") } os.Unsetenv(WaveConfigHomeEnvVar) DataHome_VarCache = os.Getenv(WaveDataHomeEnvVar) if DataHome_VarCache == "" { return fmt.Errorf("%s not set", WaveDataHomeEnvVar) } os.Unsetenv(WaveDataHomeEnvVar) AppPath_VarCache = os.Getenv(WaveAppPathVarName) os.Unsetenv(WaveAppPathVarName) AppResourcesPath_VarCache = os.Getenv(WaveAppResourcesPathVarName) os.Unsetenv(WaveAppResourcesPathVarName) AppElectronExecPath_VarCache = os.Getenv(WaveAppElectronExecPathVarName) os.Unsetenv(WaveAppElectronExecPathVarName) Dev_VarCache = os.Getenv(WaveDevVarName) os.Unsetenv(WaveDevVarName) os.Unsetenv(WaveDevViteVarName) os.Unsetenv(WaveNoConfirmQuitVarName) return nil } func IsDevMode() bool { return Dev_VarCache != "" } func GetWaveAppPath() string { return AppPath_VarCache } func GetWaveAppResourcesPath() string { return AppResourcesPath_VarCache } func GetWaveDataDir() string { return DataHome_VarCache } func GetWaveConfigDir() string { return ConfigHome_VarCache } func GetWaveAppBinPath() string { return filepath.Join(GetWaveAppPath(), AppPathBinDir) } func GetWaveAppElectronExecPath() string { return AppElectronExecPath_VarCache } func GetHomeDir() string { homeVar, err := os.UserHomeDir() if err != nil { return "/" } return homeVar } func ExpandHomeDir(pathStr string) (string, error) { if pathStr != "~" && !strings.HasPrefix(pathStr, "~/") && (!strings.HasPrefix(pathStr, `~\`) || runtime.GOOS != "windows") { return filepath.Clean(pathStr), nil } homeDir := GetHomeDir() if pathStr == "~" { return homeDir, nil } expandedPath := filepath.Clean(filepath.Join(homeDir, pathStr[2:])) absPath, err := filepath.Abs(filepath.Join(homeDir, expandedPath)) if err != nil || !strings.HasPrefix(absPath, homeDir) { return "", fmt.Errorf("potential path traversal detected for path %s", pathStr) } return expandedPath, nil } func ExpandHomeDirSafe(pathStr string) string { path, _ := ExpandHomeDir(pathStr) return path } func ReplaceHomeDir(pathStr string) string { homeDir := GetHomeDir() if pathStr == homeDir { return "~" } if strings.HasPrefix(pathStr, homeDir+"/") { return "~" + pathStr[len(homeDir):] } return pathStr } func GetDomainSocketName() string { return filepath.Join(GetWaveDataDir(), DomainSocketBaseName) } // returns a Unix-style path for the remote socket (using fmt.Sprintf instead of filepath.Join // because this path is for a remote Unix system, not the local OS which might be Windows) func GetPersistentRemoteSockName(clientId string) string { return fmt.Sprintf("~/.waveterm/client/%s/waveterm.sock", clientId) } func EnsureWaveDataDir() error { return CacheEnsureDir(GetWaveDataDir(), "wavehome", 0700, "wave home directory") } func EnsureWaveDBDir() error { return CacheEnsureDir(filepath.Join(GetWaveDataDir(), WaveDBDir), "wavedb", 0700, "wave db directory") } func EnsureWaveConfigDir() error { return CacheEnsureDir(GetWaveConfigDir(), "waveconfig", 0700, "wave config directory") } func EnsureWavePresetsDir() error { return CacheEnsureDir(filepath.Join(GetWaveConfigDir(), "presets"), "wavepresets", 0700, "wave presets directory") } func resolveWaveCachesDir() string { var cacheDir string appBundle := "waveterm" if IsDevMode() { appBundle = "waveterm-dev" } switch runtime.GOOS { case "darwin": homeDir := GetHomeDir() cacheDir = filepath.Join(homeDir, "Library", "Caches", appBundle) case "linux": xdgCache := os.Getenv("XDG_CACHE_HOME") if xdgCache != "" { cacheDir = filepath.Join(xdgCache, appBundle) } else { homeDir := GetHomeDir() cacheDir = filepath.Join(homeDir, ".cache", appBundle) } case "windows": localAppData := os.Getenv("LOCALAPPDATA") if localAppData != "" { cacheDir = filepath.Join(localAppData, appBundle, "Cache") } } if cacheDir == "" { tmpDir := os.TempDir() cacheDir = filepath.Join(tmpDir, appBundle) } return cacheDir } func GetWaveCachesDir() string { waveCachesDirOnce.Do(func() { waveCachesDir = resolveWaveCachesDir() }) return waveCachesDir } func EnsureWaveCachesDir() error { return CacheEnsureDir(GetWaveCachesDir(), "wavecaches", 0700, "wave caches directory") } func CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error { baseLock.Lock() ok := ensureDirCache[cacheKey] baseLock.Unlock() if ok { return nil } err := TryMkdirs(dirName, perm, dirDesc) if err != nil { return err } baseLock.Lock() ensureDirCache[cacheKey] = true baseLock.Unlock() return nil } func TryMkdirs(dirName string, perm os.FileMode, dirDesc string) error { info, err := os.Stat(dirName) if errors.Is(err, fs.ErrNotExist) { err = os.MkdirAll(dirName, perm) if err != nil { return fmt.Errorf("cannot make %s %q: %w", dirDesc, dirName, err) } info, err = os.Stat(dirName) } if err != nil { return fmt.Errorf("error trying to stat %s: %w", dirDesc, err) } if !info.IsDir() { return fmt.Errorf("%s %q must be a directory", dirDesc, dirName) } return nil } func listValidLangs(ctx context.Context) []string { out, err := exec.CommandContext(ctx, "locale", "-a").CombinedOutput() if err != nil { log.Printf("error running 'locale -a': %s\n", err) return []string{} } // don't bother with CRLF line endings // this command doesn't work on windows return strings.Split(string(out), "\n") } var osLangOnce = &sync.Once{} var osLang string func determineLang() string { defaultLang := "en_US.UTF-8" ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() if runtime.GOOS == "darwin" { out, err := exec.CommandContext(ctx, "defaults", "read", "-g", "AppleLocale").CombinedOutput() if err != nil { log.Printf("error executing 'defaults read -g AppleLocale', will use default 'en_US.UTF-8': %v\n", err) return defaultLang } strOut := string(out) truncOut := strings.Split(strOut, "@")[0] preferredLang := strings.TrimSpace(truncOut) + ".UTF-8" validLangs := listValidLangs(ctx) if !utilfn.ContainsStr(validLangs, preferredLang) { log.Printf("unable to use desired lang %s, will use default 'en_US.UTF-8'\n", preferredLang) return defaultLang } return preferredLang } else { // this is specifically to get the wavesrv LANG so waveshell // on a remote uses the same LANG return os.Getenv("LANG") } } func DetermineLang() string { osLangOnce.Do(func() { osLang = determineLang() }) return osLang } func DetermineLocale() string { truncated := strings.Split(DetermineLang(), ".")[0] if truncated == "" { return "C" } return strings.Replace(truncated, "_", "-", -1) } func ClientArch() string { return fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) } func ClientPackageType() string { if os.Getenv("SNAP") != "" { return "snap" } if os.Getenv("APPIMAGE") != "" { return "appimage" } return "" } var macOSVersionOnce = &sync.Once{} var cachedMacOSVersion string var macOSVersionRegex = regexp.MustCompile(`^(\d+\.\d+(?:\.\d+)?)`) func internalMacOSVersion() string { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() out, err := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output() if err != nil { return "" } versionStr := strings.TrimSpace(string(out)) m := macOSVersionRegex.FindStringSubmatch(versionStr) if len(m) < 2 { return "" } return m[1] } func ClientMacOSVersion() string { if runtime.GOOS != "darwin" { return "" } macOSVersionOnce.Do(func() { cachedMacOSVersion = internalMacOSVersion() }) return cachedMacOSVersion } var releaseRegex = regexp.MustCompile(`^(\d+\.\d+\.\d+)`) var osReleaseOnce = &sync.Once{} var osRelease string func unameKernelRelease() string { if runtime.GOOS == "windows" { return "-" } ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() out, err := exec.CommandContext(ctx, "uname", "-r").CombinedOutput() if err != nil { log.Printf("error executing uname -r: %v\n", err) return "-" } releaseStr := strings.TrimSpace(string(out)) m := releaseRegex.FindStringSubmatch(releaseStr) if len(m) < 2 { log.Printf("invalid uname -r output: [%s]\n", releaseStr) return "-" } return m[1] } func UnameKernelRelease() string { osReleaseOnce.Do(func() { osRelease = unameKernelRelease() }) return osRelease } var systemSummaryOnce = &sync.Once{} var systemSummary string func GetSystemSummary() string { systemSummaryOnce.Do(func() { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() systemSummary = getSystemSummary(ctx) }) return systemSummary } func ValidateWshSupportedArch(os string, arch string) error { if SupportedWshBinaries[fmt.Sprintf("%s-%s", os, arch)] { return nil } return fmt.Errorf("unsupported wsh platform: %s-%s", os, arch) } func getSystemSummary(ctx context.Context) string { osName := runtime.GOOS switch osName { case "darwin": out, _ := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output() return fmt.Sprintf("macOS %s (%s)", strings.TrimSpace(string(out)), runtime.GOARCH) case "linux": // Read /etc/os-release directly (standard location since 2012) data, err := os.ReadFile("/etc/os-release") var prettyName string if err == nil { for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "PRETTY_NAME=") { prettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"") break } } } if prettyName == "" { prettyName = "Linux" } else if !strings.Contains(strings.ToLower(prettyName), "linux") { prettyName = "Linux " + prettyName } return fmt.Sprintf("%s (%s)", prettyName, runtime.GOARCH) case "windows": var details string out, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", "(Get-CimInstance Win32_OperatingSystem).Caption").Output() if err == nil && len(out) > 0 { details = strings.TrimSpace(string(out)) } else { details = "Windows" } return fmt.Sprintf("%s (%s)", details, runtime.GOARCH) default: return fmt.Sprintf("%s (%s)", runtime.GOOS, runtime.GOARCH) } } // job socket path on remote machine func GetRemoteJobSocketPath(jobId string) string { socketDir := filepath.Join("/tmp", fmt.Sprintf("waveterm-%d", os.Getuid())) return filepath.Join(socketDir, fmt.Sprintf("%s.sock", jobId)) } // job file path on remote machine func GetRemoteJobFilePath(jobId string, extension string) string { jobDir := GetRemoteJobLogDir() return filepath.Join(jobDir, fmt.Sprintf("%s.%s", jobId, extension)) } // job file dir on remote machines func GetRemoteJobLogDir() string { homeDir := GetHomeDir() jobDir := filepath.Join(homeDir, ".waveterm", "jobs") return jobDir } ================================================ FILE: pkg/wavejwt/wavejwt.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wavejwt import ( "crypto/ed25519" "crypto/rand" "encoding/base64" "fmt" "sync" "time" "github.com/golang-jwt/jwt/v5" ) const ( IssuerWaveTerm = "waveterm" ) var ( globalLock sync.Mutex publicKey ed25519.PublicKey privateKey ed25519.PrivateKey ) type WaveJwtClaims struct { jwt.RegisteredClaims MainServer bool `json:"mainserver,omitempty"` Sock string `json:"sock,omitempty"` RouteId string `json:"routeid,omitempty"` ProcRoute bool `json:"procroute,omitempty"` BlockId string `json:"blockid,omitempty"` JobId string `json:"jobid,omitempty"` Conn string `json:"conn,omitempty"` Router bool `json:"router,omitempty"` } type KeyPair struct { PublicKey []byte PrivateKey []byte } func GenerateKeyPair() (*KeyPair, error) { pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, fmt.Errorf("failed to generate key pair: %w", err) } return &KeyPair{ PublicKey: pubKey, PrivateKey: privKey, }, nil } func SetPublicKey(keyData []byte) error { if len(keyData) != ed25519.PublicKeySize { return fmt.Errorf("invalid public key size: expected %d, got %d", ed25519.PublicKeySize, len(keyData)) } globalLock.Lock() defer globalLock.Unlock() publicKey = ed25519.PublicKey(keyData) return nil } func GetPublicKey() []byte { globalLock.Lock() defer globalLock.Unlock() return publicKey } func GetPublicKeyBase64() string { pubKey := GetPublicKey() if len(pubKey) == 0 { return "" } return base64.StdEncoding.EncodeToString(pubKey) } func SetPrivateKey(keyData []byte) error { if len(keyData) != ed25519.PrivateKeySize { return fmt.Errorf("invalid private key size: expected %d, got %d", ed25519.PrivateKeySize, len(keyData)) } globalLock.Lock() defer globalLock.Unlock() privateKey = ed25519.PrivateKey(keyData) return nil } func ValidateAndExtract(tokenStr string) (*WaveJwtClaims, error) { globalLock.Lock() pubKey := publicKey globalLock.Unlock() if pubKey == nil { return nil, fmt.Errorf("public key not set") } token, err := jwt.ParseWithClaims(tokenStr, &WaveJwtClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return pubKey, nil }) if err != nil { return nil, fmt.Errorf("failed to parse token: %w", err) } claims, ok := token.Claims.(*WaveJwtClaims) if !ok || !token.Valid { return nil, fmt.Errorf("invalid token") } return claims, nil } func Sign(claims *WaveJwtClaims) (string, error) { globalLock.Lock() privKey := privateKey globalLock.Unlock() if privKey == nil { return "", fmt.Errorf("private key not set") } if claims.IssuedAt == nil { claims.IssuedAt = jwt.NewNumericDate(time.Now()) } if claims.Issuer == "" { claims.Issuer = IssuerWaveTerm } if claims.ExpiresAt == nil { claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 365)) } token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) tokenStr, err := token.SignedString(privKey) if err != nil { return "", fmt.Errorf("error signing token: %w", err) } return tokenStr, nil } ================================================ FILE: pkg/waveobj/ctxupdate.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveobj import ( "bytes" "context" "fmt" "log" ) var waveObjUpdateKey = struct{}{} type contextUpdatesType struct { UpdatesStack []map[ORef]WaveObjUpdate } func dumpUpdateStack(updates *contextUpdatesType) { log.Printf("dumpUpdateStack len:%d\n", len(updates.UpdatesStack)) for idx, update := range updates.UpdatesStack { var buf bytes.Buffer buf.WriteString(fmt.Sprintf(" [%d]:", idx)) for k := range update { buf.WriteString(fmt.Sprintf(" %s:%s", k.OType, k.OID)) } buf.WriteString("\n") log.Print(buf.String()) } } func ContextWithUpdates(ctx context.Context) context.Context { updatesVal := ctx.Value(waveObjUpdateKey) if updatesVal != nil { return ctx } return context.WithValue(ctx, waveObjUpdateKey, &contextUpdatesType{ UpdatesStack: []map[ORef]WaveObjUpdate{make(map[ORef]WaveObjUpdate)}, }) } func ContextGetUpdates(ctx context.Context) map[ORef]WaveObjUpdate { updatesVal := ctx.Value(waveObjUpdateKey) if updatesVal == nil { return nil } updates := updatesVal.(*contextUpdatesType) if len(updates.UpdatesStack) == 1 { return updates.UpdatesStack[0] } rtn := make(map[ORef]WaveObjUpdate) for _, update := range updates.UpdatesStack { for k, v := range update { rtn[k] = v } } return rtn } func ContextGetUpdate(ctx context.Context, oref ORef) *WaveObjUpdate { updatesVal := ctx.Value(waveObjUpdateKey) if updatesVal == nil { return nil } updates := updatesVal.(*contextUpdatesType) for idx := len(updates.UpdatesStack) - 1; idx >= 0; idx-- { if obj, ok := updates.UpdatesStack[idx][oref]; ok { return &obj } } return nil } func ContextAddUpdate(ctx context.Context, update WaveObjUpdate) { updatesVal := ctx.Value(waveObjUpdateKey) if updatesVal == nil { return } updates := updatesVal.(*contextUpdatesType) oref := ORef{ OType: update.OType, OID: update.OID, } updates.UpdatesStack[len(updates.UpdatesStack)-1][oref] = update } func ContextUpdatesBeginTx(ctx context.Context) context.Context { updatesVal := ctx.Value(waveObjUpdateKey) if updatesVal == nil { return ctx } updates := updatesVal.(*contextUpdatesType) updates.UpdatesStack = append(updates.UpdatesStack, make(map[ORef]WaveObjUpdate)) return ctx } func ContextUpdatesCommitTx(ctx context.Context) { updatesVal := ctx.Value(waveObjUpdateKey) if updatesVal == nil { return } updates := updatesVal.(*contextUpdatesType) if len(updates.UpdatesStack) <= 1 { panic(fmt.Errorf("no updates transaction to commit")) } // merge the last two updates curUpdateMap := updates.UpdatesStack[len(updates.UpdatesStack)-1] prevUpdateMap := updates.UpdatesStack[len(updates.UpdatesStack)-2] for k, v := range curUpdateMap { prevUpdateMap[k] = v } updates.UpdatesStack = updates.UpdatesStack[:len(updates.UpdatesStack)-1] } func ContextUpdatesRollbackTx(ctx context.Context) { updatesVal := ctx.Value(waveObjUpdateKey) if updatesVal == nil { return } updates := updatesVal.(*contextUpdatesType) if len(updates.UpdatesStack) <= 1 { panic(fmt.Errorf("no updates transaction to rollback")) } updates.UpdatesStack = updates.UpdatesStack[:len(updates.UpdatesStack)-1] } func ContextGetUpdatesRtn(ctx context.Context) UpdatesRtnType { updatesMap := ContextGetUpdates(ctx) if updatesMap == nil { return nil } rtn := make(UpdatesRtnType, 0, len(updatesMap)) for _, v := range updatesMap { rtn = append(rtn, v) } return rtn } func ContextPrintUpdates(ctx context.Context) { updatesVal := ctx.Value(waveObjUpdateKey) if updatesVal == nil { log.Print("no updates\n") return } updates := updatesVal.(*contextUpdatesType) log.Printf("updates len:%d\n", len(updates.UpdatesStack)) for idx, update := range updates.UpdatesStack { log.Printf(" update[%d]:\n", idx) for k, v := range update { log.Printf(" %s:%s %s\n", k.OType, k.OID, v.UpdateType) } } } ================================================ FILE: pkg/waveobj/metaconsts.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // Generated Code. DO NOT EDIT. package waveobj const ( MetaKey_View = "view" MetaKey_Controller = "controller" MetaKey_File = "file" MetaKey_Url = "url" MetaKey_PinnedUrl = "pinnedurl" MetaKey_Connection = "connection" MetaKey_Edit = "edit" MetaKey_History = "history" MetaKey_HistoryForward = "history:forward" MetaKey_DisplayName = "display:name" MetaKey_DisplayOrder = "display:order" MetaKey_Icon = "icon" MetaKey_IconColor = "icon:color" MetaKey_FrameClear = "frame:*" MetaKey_Frame = "frame" MetaKey_FrameBorderColor = "frame:bordercolor" MetaKey_FrameActiveBorderColor = "frame:activebordercolor" MetaKey_FrameTitle = "frame:title" MetaKey_FrameIcon = "frame:icon" MetaKey_FrameText = "frame:text" MetaKey_CmdClear = "cmd:*" MetaKey_Cmd = "cmd" MetaKey_CmdInteractive = "cmd:interactive" MetaKey_CmdLogin = "cmd:login" MetaKey_CmdPersistent = "cmd:persistent" MetaKey_CmdRunOnStart = "cmd:runonstart" MetaKey_CmdClearOnStart = "cmd:clearonstart" MetaKey_CmdRunOnce = "cmd:runonce" MetaKey_CmdCloseOnExit = "cmd:closeonexit" MetaKey_CmdCloseOnExitForce = "cmd:closeonexitforce" MetaKey_CmdCloseOnExitDelay = "cmd:closeonexitdelay" MetaKey_CmdNoWsh = "cmd:nowsh" MetaKey_CmdArgs = "cmd:args" MetaKey_CmdShell = "cmd:shell" MetaKey_CmdAllowConnChange = "cmd:allowconnchange" MetaKey_CmdJwt = "cmd:jwt" MetaKey_CmdEnv = "cmd:env" MetaKey_CmdCwd = "cmd:cwd" MetaKey_CmdInitScript = "cmd:initscript" MetaKey_CmdInitScriptSh = "cmd:initscript.sh" MetaKey_CmdInitScriptBash = "cmd:initscript.bash" MetaKey_CmdInitScriptZsh = "cmd:initscript.zsh" MetaKey_CmdInitScriptPwsh = "cmd:initscript.pwsh" MetaKey_CmdInitScriptFish = "cmd:initscript.fish" MetaKey_AiClear = "ai:*" MetaKey_AiPresetKey = "ai:preset" MetaKey_AiApiType = "ai:apitype" MetaKey_AiBaseURL = "ai:baseurl" MetaKey_AiApiToken = "ai:apitoken" MetaKey_AiName = "ai:name" MetaKey_AiModel = "ai:model" MetaKey_AiOrgID = "ai:orgid" MetaKey_AIApiVersion = "ai:apiversion" MetaKey_AiMaxTokens = "ai:maxtokens" MetaKey_AiTimeoutMs = "ai:timeoutms" MetaKey_AiFileDiffChatId = "aifilediff:chatid" MetaKey_AiFileDiffToolCallId = "aifilediff:toolcallid" MetaKey_EditorClear = "editor:*" MetaKey_EditorMinimapEnabled = "editor:minimapenabled" MetaKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled" MetaKey_EditorWordWrap = "editor:wordwrap" MetaKey_EditorFontSize = "editor:fontsize" MetaKey_GraphClear = "graph:*" MetaKey_GraphNumPoints = "graph:numpoints" MetaKey_GraphMetrics = "graph:metrics" MetaKey_SysinfoType = "sysinfo:type" MetaKey_TabFlagColor = "tab:flagcolor" MetaKey_BgClear = "bg:*" MetaKey_Bg = "bg" MetaKey_BgOpacity = "bg:opacity" MetaKey_BgBlendMode = "bg:blendmode" MetaKey_BgBorderColor = "bg:bordercolor" MetaKey_BgActiveBorderColor = "bg:activebordercolor" MetaKey_LayoutVTabBarWidth = "layout:vtabbarwidth" MetaKey_WaveAiPanelOpen = "waveai:panelopen" MetaKey_WaveAiPanelWidth = "waveai:panelwidth" MetaKey_WaveAiModel = "waveai:model" MetaKey_WaveAiChatId = "waveai:chatid" MetaKey_WaveAiWidgetContext = "waveai:widgetcontext" MetaKey_TermClear = "term:*" MetaKey_TermFontSize = "term:fontsize" MetaKey_TermFontFamily = "term:fontfamily" MetaKey_TermMode = "term:mode" MetaKey_TermTheme = "term:theme" MetaKey_TermLocalShellPath = "term:localshellpath" MetaKey_TermLocalShellOpts = "term:localshellopts" MetaKey_TermScrollback = "term:scrollback" MetaKey_TermVDomSubBlockId = "term:vdomblockid" MetaKey_TermVDomToolbarBlockId = "term:vdomtoolbarblockid" MetaKey_TermTransparency = "term:transparency" MetaKey_TermAllowBracketedPaste = "term:allowbracketedpaste" MetaKey_TermShiftEnterNewline = "term:shiftenternewline" MetaKey_TermMacOptionIsMeta = "term:macoptionismeta" MetaKey_TermCursor = "term:cursor" MetaKey_TermCursorBlink = "term:cursorblink" MetaKey_TermConnDebug = "term:conndebug" MetaKey_TermBellSound = "term:bellsound" MetaKey_TermBellIndicator = "term:bellindicator" MetaKey_TermOsc52 = "term:osc52" MetaKey_TermDurable = "term:durable" MetaKey_WebZoom = "web:zoom" MetaKey_WebHideNav = "web:hidenav" MetaKey_WebPartition = "web:partition" MetaKey_WebUserAgentType = "web:useragenttype" MetaKey_MarkdownFontSize = "markdown:fontsize" MetaKey_MarkdownFixedFontSize = "markdown:fixedfontsize" MetaKey_TsunamiClear = "tsunami:*" MetaKey_TsunamiSdkReplacePath = "tsunami:sdkreplacepath" MetaKey_TsunamiAppPath = "tsunami:apppath" MetaKey_TsunamiAppId = "tsunami:appid" MetaKey_TsunamiScaffoldPath = "tsunami:scaffoldpath" MetaKey_TsunamiEnv = "tsunami:env" MetaKey_VDomClear = "vdom:*" MetaKey_VDomInitialized = "vdom:initialized" MetaKey_VDomCorrelationId = "vdom:correlationid" MetaKey_VDomRoute = "vdom:route" MetaKey_VDomPersist = "vdom:persist" MetaKey_OnboardingGithubStar = "onboarding:githubstar" MetaKey_OnboardingLastVersion = "onboarding:lastversion" MetaKey_Count = "count" ) ================================================ FILE: pkg/waveobj/metamap.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveobj import "github.com/google/uuid" type MetaMapType map[string]any var MetaMap_DeleteSentinel = uuid.NewString() func (m MetaMapType) GetString(key string, def string) string { if v, ok := m[key]; ok { if s, ok := v.(string); ok { return s } } return def } func (m MetaMapType) HasKey(key string) bool { _, ok := m[key] return ok } func (m MetaMapType) GetConnectionOverride(connName string) MetaMapType { v, ok := m["["+connName+"]"] if !ok { return nil } if mval, ok := v.(map[string]any); ok { return MetaMapType(mval) } return nil } func (m MetaMapType) GetStringList(key string) []string { v, ok := m[key] if !ok { return nil } varr, ok := v.([]any) if !ok { return nil } rtn := make([]string, 0) for _, varrVal := range varr { if s, ok := varrVal.(string); ok { rtn = append(rtn, s) } } return rtn } func (m MetaMapType) GetStringMap(key string, useDeleteSentinel bool) map[string]string { mval := m.GetMap(key) if len(mval) == 0 { return nil } rtn := make(map[string]string, len(mval)) for k, v := range mval { if v == nil { if useDeleteSentinel { rtn[k] = MetaMap_DeleteSentinel } continue } if s, ok := v.(string); ok { rtn[k] = s } } return rtn } func (m MetaMapType) GetBool(key string, def bool) bool { if v, ok := m[key]; ok { if b, ok := v.(bool); ok { return b } } return def } func (m MetaMapType) GetInt(key string, def int) int { if v, ok := m[key]; ok { if fval, ok := v.(float64); ok { return int(fval) } } return def } func (m MetaMapType) GetFloat(key string, def float64) float64 { if v, ok := m[key]; ok { if fval, ok := v.(float64); ok { return fval } } return def } func (m MetaMapType) GetMap(key string) MetaMapType { if v, ok := m[key]; ok { if mval, ok := v.(map[string]any); ok { return MetaMapType(mval) } } return nil } func (m MetaMapType) GetArray(key string) []any { if v, ok := m[key]; ok { if aval, ok := v.([]any); ok { return aval } } return nil } func (m MetaMapType) GetStringArray(key string) []string { arr := m.GetArray(key) if len(arr) == 0 { return nil } rtn := make([]string, 0, len(arr)) for _, v := range arr { if s, ok := v.(string); ok { rtn = append(rtn, s) } } return rtn } ================================================ FILE: pkg/waveobj/objrtinfo.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveobj type ObjRTInfo struct { TsunamiAppMeta any `json:"tsunami:appmeta,omitempty" tstype:"AppMeta"` TsunamiSchemas any `json:"tsunami:schemas,omitempty"` ShellHasCurCwd bool `json:"shell:hascurcwd,omitempty"` ShellState string `json:"shell:state,omitempty"` ShellType string `json:"shell:type,omitempty"` ShellVersion string `json:"shell:version,omitempty"` ShellUname string `json:"shell:uname,omitempty"` ShellIntegration bool `json:"shell:integration,omitempty"` ShellOmz bool `json:"shell:omz,omitempty"` ShellComp string `json:"shell:comp,omitempty"` ShellInputEmpty bool `json:"shell:inputempty,omitempty"` ShellLastCmd string `json:"shell:lastcmd,omitempty"` ShellLastCmdExitCode int `json:"shell:lastcmdexitcode,omitempty"` BuilderLayout map[string]float64 `json:"builder:layout,omitempty"` BuilderAppId string `json:"builder:appid,omitempty"` BuilderEnv map[string]string `json:"builder:env,omitempty"` WaveAIChatId string `json:"waveai:chatid,omitempty"` WaveAIMode string `json:"waveai:mode,omitempty"` WaveAIMaxOutputTokens int `json:"waveai:maxoutputtokens,omitempty"` } ================================================ FILE: pkg/waveobj/waveobj.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveobj import ( "encoding/json" "fmt" "reflect" "regexp" "strings" "sync" "github.com/google/uuid" "github.com/mitchellh/mapstructure" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) const ( OTypeKeyName = "otype" OIDKeyName = "oid" VersionKeyName = "version" MetaKeyName = "meta" OIDGoFieldName = "OID" VersionGoFieldName = "Version" MetaGoFieldName = "Meta" ) type ORef struct { // special JSON marshalling to string OType string `json:"otype" mapstructure:"otype"` OID string `json:"oid" mapstructure:"oid"` } func (oref ORef) String() string { if oref.OType == "" || oref.OID == "" { return "" } return fmt.Sprintf("%s:%s", oref.OType, oref.OID) } func (oref ORef) MarshalJSON() ([]byte, error) { return json.Marshal(oref.String()) } func (oref ORef) IsEmpty() bool { // either being empty is not valid return oref.OType == "" || oref.OID == "" } func (oref *ORef) UnmarshalJSON(data []byte) error { var orefStr string err := json.Unmarshal(data, &orefStr) if err != nil { return err } if len(orefStr) == 0 { oref.OType = "" oref.OID = "" return nil } parsed, err := ParseORef(orefStr) if err != nil { return err } *oref = parsed return nil } func MakeORef(otype string, oid string) ORef { return ORef{ OType: otype, OID: oid, } } var otypeRe = regexp.MustCompile(`^[a-z]+$`) func ParseORef(orefStr string) (ORef, error) { fields := strings.Split(orefStr, ":") if len(fields) != 2 { return ORef{}, fmt.Errorf("invalid object reference: %q", orefStr) } otype := fields[0] if !otypeRe.MatchString(otype) { return ORef{}, fmt.Errorf("invalid object type: %q", otype) } if !ValidOTypes[otype] { return ORef{}, fmt.Errorf("unknown object type: %q", otype) } oid := fields[1] _, err := uuid.Parse(oid) if err != nil { return ORef{}, fmt.Errorf("invalid object id: %q", oid) } return ORef{OType: otype, OID: oid}, nil } func ParseORefNoErr(orefStr string) *ORef { oref, err := ParseORef(orefStr) if err != nil { return nil } return &oref } type WaveObj interface { GetOType() string // should not depend on object state (should work with nil value) } type waveObjDesc struct { RType reflect.Type OIDField reflect.StructField VersionField reflect.StructField MetaField reflect.StructField } var waveObjMap = sync.Map{} var waveObjRType = reflect.TypeOf((*WaveObj)(nil)).Elem() var metaMapRType = reflect.TypeOf(MetaMapType{}) func RegisterType(rtype reflect.Type) { if rtype.Kind() != reflect.Ptr { panic(fmt.Sprintf("wave object must be a pointer for %v", rtype)) } if !rtype.Implements(waveObjRType) { panic(fmt.Sprintf("wave object must implement WaveObj for %v", rtype)) } waveObj := reflect.Zero(rtype).Interface().(WaveObj) otype := waveObj.GetOType() if otype == "" { panic(fmt.Sprintf("otype is empty for %v", rtype)) } oidField, found := rtype.Elem().FieldByName(OIDGoFieldName) if !found { panic(fmt.Sprintf("missing OID field for %v", rtype)) } if oidField.Type.Kind() != reflect.String { panic(fmt.Sprintf("OID field must be string for %v", rtype)) } oidJsonTag := utilfn.GetJsonTag(oidField) if oidJsonTag != OIDKeyName { panic(fmt.Sprintf("OID field json tag must be %q for %v", OIDKeyName, rtype)) } versionField, found := rtype.Elem().FieldByName(VersionGoFieldName) if !found { panic(fmt.Sprintf("missing Version field for %v", rtype)) } if versionField.Type.Kind() != reflect.Int { panic(fmt.Sprintf("Version field must be int for %v", rtype)) } versionJsonTag := utilfn.GetJsonTag(versionField) if versionJsonTag != VersionKeyName { panic(fmt.Sprintf("Version field json tag must be %q for %v", VersionKeyName, rtype)) } metaField, found := rtype.Elem().FieldByName(MetaGoFieldName) if !found { panic(fmt.Sprintf("missing Meta field for %v", rtype)) } if metaField.Type != metaMapRType { panic(fmt.Sprintf("Meta field must be MetaMapType for %v", rtype)) } _, found = waveObjMap.Load(otype) if found { panic(fmt.Sprintf("otype %q already registered", otype)) } waveObjMap.Store(otype, &waveObjDesc{ RType: rtype, OIDField: oidField, VersionField: versionField, MetaField: metaField, }) } func getWaveObjDesc(otype string) *waveObjDesc { desc, _ := waveObjMap.Load(otype) if desc == nil { return nil } return desc.(*waveObjDesc) } func GetOID(waveObj WaveObj) string { desc := getWaveObjDesc(waveObj.GetOType()) if desc == nil { return "" } return reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.OIDField.Index).String() } func SetOID(waveObj WaveObj, oid string) { desc := getWaveObjDesc(waveObj.GetOType()) if desc == nil { return } reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.OIDField.Index).SetString(oid) } func GetVersion(waveObj WaveObj) int { desc := getWaveObjDesc(waveObj.GetOType()) if desc == nil { return 0 } return int(reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.VersionField.Index).Int()) } func SetVersion(waveObj WaveObj, version int) { desc := getWaveObjDesc(waveObj.GetOType()) if desc == nil { return } reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.VersionField.Index).SetInt(int64(version)) } func GetMeta(waveObj WaveObj) MetaMapType { desc := getWaveObjDesc(waveObj.GetOType()) if desc == nil { return nil } mval := reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.MetaField.Index).Interface() if mval == nil { return nil } return mval.(MetaMapType) } func SetMeta(waveObj WaveObj, meta map[string]any) { desc := getWaveObjDesc(waveObj.GetOType()) if desc == nil { return } reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.MetaField.Index).Set(reflect.ValueOf(meta)) } func ToJsonMap(w WaveObj) (map[string]any, error) { if w == nil { return nil, nil } m := make(map[string]any) dconfig := &mapstructure.DecoderConfig{ Result: &m, TagName: "json", } decoder, err := mapstructure.NewDecoder(dconfig) if err != nil { return nil, err } err = decoder.Decode(w) if err != nil { return nil, err } m[OTypeKeyName] = w.GetOType() m[OIDKeyName] = GetOID(w) m[VersionKeyName] = GetVersion(w) return m, nil } func ToJson(w WaveObj) ([]byte, error) { m, err := ToJsonMap(w) if err != nil { return nil, err } return json.Marshal(m) } func FromJson(data []byte) (WaveObj, error) { var m map[string]any err := json.Unmarshal(data, &m) if err != nil { return nil, err } return FromJsonMap(m) } func FromJsonMap(m map[string]any) (WaveObj, error) { otype, ok := m[OTypeKeyName].(string) if !ok { return nil, fmt.Errorf("missing otype") } desc := getWaveObjDesc(otype) if desc == nil { return nil, fmt.Errorf("unknown otype: %s", otype) } wobj := reflect.Zero(desc.RType).Interface().(WaveObj) dconfig := &mapstructure.DecoderConfig{ Result: &wobj, TagName: "json", } decoder, err := mapstructure.NewDecoder(dconfig) if err != nil { return nil, err } err = decoder.Decode(m) if err != nil { return nil, err } return wobj, nil } func ORefFromMap(m map[string]any) (*ORef, error) { oref := ORef{} err := mapstructure.Decode(m, &oref) if err != nil { return nil, err } return &oref, nil } func ORefFromWaveObj(w WaveObj) *ORef { return &ORef{ OType: w.GetOType(), OID: GetOID(w), } } func FromJsonGen[T WaveObj](data []byte) (T, error) { obj, err := FromJson(data) if err != nil { var zero T return zero, err } rtn, ok := obj.(T) if !ok { var zero T return zero, fmt.Errorf("type mismatch got %T, expected %T", obj, zero) } return rtn, nil } ================================================ FILE: pkg/waveobj/wtype.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveobj import ( "encoding/json" "fmt" "reflect" ) type UpdatesRtnType = []WaveObjUpdate type UIContext struct { WindowId string `json:"windowid"` ActiveTabId string `json:"activetabid"` } const ( UpdateType_Update = "update" UpdateType_Delete = "delete" ) const ( OType_Client = "client" OType_Window = "window" OType_Workspace = "workspace" OType_Tab = "tab" OType_LayoutState = "layout" OType_Block = "block" OType_MainServer = "mainserver" OType_Job = "job" OType_Temp = "temp" OType_Builder = "builder" // not persisted to DB ) var ValidOTypes = map[string]bool{ OType_Client: true, OType_Window: true, OType_Workspace: true, OType_Tab: true, OType_LayoutState: true, OType_Block: true, OType_MainServer: true, OType_Job: true, OType_Temp: true, OType_Builder: true, } type WaveObjUpdate struct { UpdateType string `json:"updatetype"` OType string `json:"otype"` OID string `json:"oid"` Obj WaveObj `json:"obj,omitempty"` } func (update WaveObjUpdate) MarshalJSON() ([]byte, error) { rtn := make(map[string]any) rtn["updatetype"] = update.UpdateType rtn["otype"] = update.OType rtn["oid"] = update.OID if update.Obj != nil { var err error rtn["obj"], err = ToJsonMap(update.Obj) if err != nil { return nil, err } } return json.Marshal(rtn) } func MakeUpdate(obj WaveObj) WaveObjUpdate { return WaveObjUpdate{ UpdateType: UpdateType_Update, OType: obj.GetOType(), OID: GetOID(obj), Obj: obj, } } func MakeUpdates(objs []WaveObj) []WaveObjUpdate { rtn := make([]WaveObjUpdate, 0, len(objs)) for _, obj := range objs { rtn = append(rtn, MakeUpdate(obj)) } return rtn } func (update *WaveObjUpdate) UnmarshalJSON(data []byte) error { var objMap map[string]any err := json.Unmarshal(data, &objMap) if err != nil { return err } var ok1, ok2, ok3 bool if _, found := objMap["updatetype"]; !found { return fmt.Errorf("missing updatetype (in WaveObjUpdate)") } update.UpdateType, ok1 = objMap["updatetype"].(string) if !ok1 { return fmt.Errorf("in WaveObjUpdate bad updatetype type %T", objMap["updatetype"]) } if _, found := objMap["otype"]; !found { return fmt.Errorf("missing otype (in WaveObjUpdate)") } update.OType, ok2 = objMap["otype"].(string) if !ok2 { return fmt.Errorf("in WaveObjUpdate bad otype type %T", objMap["otype"]) } if _, found := objMap["oid"]; !found { return fmt.Errorf("missing oid (in WaveObjUpdate)") } update.OID, ok3 = objMap["oid"].(string) if !ok3 { return fmt.Errorf("in WaveObjUpdate bad oid type %T", objMap["oid"]) } if _, found := objMap["obj"]; found { objMap, ok := objMap["obj"].(map[string]any) if !ok { return fmt.Errorf("in WaveObjUpdate bad obj type %T", objMap["obj"]) } waveObj, err := FromJsonMap(objMap) if err != nil { return fmt.Errorf("in WaveObjUpdate error decoding obj: %w", err) } update.Obj = waveObj } return nil } type Client struct { OID string `json:"oid"` Version int `json:"version"` WindowIds []string `json:"windowids"` Meta MetaMapType `json:"meta"` TosAgreed int64 `json:"tosagreed,omitempty"` // unix milli HasOldHistory bool `json:"hasoldhistory,omitempty"` TempOID string `json:"tempoid,omitempty"` InstallId string `json:"installid,omitempty"` } func (*Client) GetOType() string { return OType_Client } // stores the ui-context of the window, points to a workspace containing the actual data being displayed in the window type Window struct { OID string `json:"oid"` Version int `json:"version"` WorkspaceId string `json:"workspaceid"` IsNew bool `json:"isnew,omitempty"` // set when a window is created on the backend so the FE can size it properly. cleared on first resize Pos Point `json:"pos"` WinSize WinSize `json:"winsize"` LastFocusTs int64 `json:"lastfocusts"` Meta MetaMapType `json:"meta"` } func (*Window) GetOType() string { return OType_Window } type WorkspaceListEntry struct { WorkspaceId string `json:"workspaceid"` WindowId string `json:"windowid"` } type WorkspaceList []*WorkspaceListEntry type ActiveTabUpdate struct { WorkspaceId string `json:"workspaceid"` NewActiveTabId string `json:"newactivetabid"` } type Workspace struct { OID string `json:"oid"` Version int `json:"version"` Name string `json:"name,omitempty"` Icon string `json:"icon,omitempty"` Color string `json:"color,omitempty"` TabIds []string `json:"tabids"` ActiveTabId string `json:"activetabid"` Meta MetaMapType `json:"meta"` } func (*Workspace) GetOType() string { return OType_Workspace } type Tab struct { OID string `json:"oid"` Version int `json:"version"` Name string `json:"name"` LayoutState string `json:"layoutstate"` BlockIds []string `json:"blockids"` Meta MetaMapType `json:"meta"` } func (*Tab) GetOType() string { return OType_Tab } func (t *Tab) GetBlockORefs() []ORef { rtn := make([]ORef, 0, len(t.BlockIds)) for _, blockId := range t.BlockIds { rtn = append(rtn, ORef{OType: OType_Block, OID: blockId}) } return rtn } type LayoutActionData struct { ActionType string `json:"actiontype"` ActionId string `json:"actionid"` BlockId string `json:"blockid"` NodeSize *uint `json:"nodesize,omitempty"` IndexArr *[]int `json:"indexarr,omitempty"` Focused bool `json:"focused"` Magnified bool `json:"magnified"` Ephemeral bool `json:"ephemeral"` TargetBlockId string `json:"targetblockid,omitempty"` Position string `json:"position,omitempty"` } type LeafOrderEntry struct { NodeId string `json:"nodeid"` BlockId string `json:"blockid"` } type LayoutState struct { OID string `json:"oid"` Version int `json:"version"` RootNode any `json:"rootnode,omitempty"` MagnifiedNodeId string `json:"magnifiednodeid,omitempty"` FocusedNodeId string `json:"focusednodeid,omitempty"` LeafOrder *[]LeafOrderEntry `json:"leaforder,omitempty"` PendingBackendActions *[]LayoutActionData `json:"pendingbackendactions,omitempty"` Meta MetaMapType `json:"meta,omitempty"` } func (*LayoutState) GetOType() string { return OType_LayoutState } type FileDef struct { Content string `json:"content,omitempty"` Meta map[string]any `json:"meta,omitempty"` } type BlockDef struct { Files map[string]*FileDef `json:"files,omitempty"` Meta MetaMapType `json:"meta,omitempty"` } type StickerClickOptsType struct { SendInput string `json:"sendinput,omitempty"` CreateBlock *BlockDef `json:"createblock,omitempty"` } type StickerDisplayOptsType struct { Icon string `json:"icon"` ImgSrc string `json:"imgsrc"` SvgBlob string `json:"svgblob,omitempty"` } type StickerType struct { StickerType string `json:"stickertype"` Style map[string]any `json:"style"` ClickOpts *StickerClickOptsType `json:"clickopts,omitempty"` Display *StickerDisplayOptsType `json:"display"` } type RuntimeOpts struct { TermSize TermSize `json:"termsize,omitempty"` WinSize WinSize `json:"winsize,omitempty"` } type Point struct { X int `json:"x"` Y int `json:"y"` } type WinSize struct { Width int `json:"width"` Height int `json:"height"` } type Block struct { OID string `json:"oid"` ParentORef string `json:"parentoref,omitempty"` Version int `json:"version"` RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"` Stickers []*StickerType `json:"stickers,omitempty"` Meta MetaMapType `json:"meta"` SubBlockIds []string `json:"subblockids,omitempty"` JobId string `json:"jobid,omitempty"` // if set, the block will render this jobid's pty output } func (*Block) GetOType() string { return OType_Block } type MainServer struct { OID string `json:"oid"` Version int `json:"version"` Meta MetaMapType `json:"meta"` JwtPrivateKey string `json:"jwtprivatekey"` // base64 JwtPublicKey string `json:"jwtpublickey"` // base64 } func (*MainServer) GetOType() string { return OType_MainServer } type Job struct { OID string `json:"oid"` Version int `json:"version"` // job metadata Connection string `json:"connection"` JobKind string `json:"jobkind"` // shell, task Cmd string `json:"cmd"` CmdArgs []string `json:"cmdargs,omitempty"` CmdEnv map[string]string `json:"cmdenv,omitempty"` JobAuthToken string `json:"jobauthtoken"` // job manger -> wave AttachedBlockId string `json:"attachedblockid,omitempty"` WaveVersion string `json:"waveversion,omitempty"` // reconnect option (e.g. orphaned, so we need to kill on connect) TerminateOnReconnect bool `json:"terminateonreconnect,omitempty"` // job manager state JobManagerStatus string `json:"jobmanagerstatus"` // init, running, done JobManagerDoneReason string `json:"jobmanagerdonereason,omitempty"` // startuperror, gone, terminated JobManagerStartupError string `json:"jobmanagerstartuperror,omitempty"` JobManagerPid int `json:"jobmanagerpid,omitempty"` JobManagerStartTs int64 `json:"jobmanagerstartts,omitempty"` // exact process start time (milliseconds) // cmd/process runtime info CmdPid int `json:"cmdpid,omitempty"` // command process id CmdStartTs int64 `json:"cmdstartts,omitempty"` // exact command process start time (milliseconds from epoch) CmdTermSize TermSize `json:"cmdtermsize"` CmdExitTs int64 `json:"cmdexitts,omitempty"` // timestamp (milliseconds) -- use CmdExitTs > 0 to check if command has exited CmdExitCode *int `json:"cmdexitcode,omitempty"` // nil when CmdExitSignal is set. success exit is when CmdExitCode is 0 CmdExitSignal string `json:"cmdexitsignal,omitempty"` // empty string if CmdExitCode is set CmdExitError string `json:"cmdexiterror,omitempty"` // output info StreamDone bool `json:"streamdone,omitempty"` StreamError string `json:"streamerror,omitempty"` Meta MetaMapType `json:"meta"` } func (*Job) GetOType() string { return OType_Job } func AllWaveObjTypes() []reflect.Type { return []reflect.Type{ reflect.TypeOf(&Client{}), reflect.TypeOf(&Window{}), reflect.TypeOf(&Workspace{}), reflect.TypeOf(&Tab{}), reflect.TypeOf(&Block{}), reflect.TypeOf(&LayoutState{}), reflect.TypeOf(&MainServer{}), reflect.TypeOf(&Job{}), } } type TermSize struct { Rows int `json:"rows"` Cols int `json:"cols"` } ================================================ FILE: pkg/waveobj/wtypemeta.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package waveobj import ( "strings" ) const Entity_Any = "any" // for typescript typing type MetaTSType struct { // shared View string `json:"view,omitempty"` Controller string `json:"controller,omitempty"` File string `json:"file,omitempty"` Url string `json:"url,omitempty"` PinnedUrl string `json:"pinnedurl,omitempty"` Connection string `json:"connection,omitempty"` Edit bool `json:"edit,omitempty"` History []string `json:"history,omitempty"` HistoryForward []string `json:"history:forward,omitempty"` DisplayName string `json:"display:name,omitempty"` DisplayOrder float64 `json:"display:order,omitempty"` Icon string `json:"icon,omitempty"` IconColor string `json:"icon:color,omitempty"` FrameClear bool `json:"frame:*,omitempty"` Frame bool `json:"frame,omitempty"` FrameBorderColor string `json:"frame:bordercolor,omitempty"` FrameActiveBorderColor string `json:"frame:activebordercolor,omitempty"` FrameTitle string `json:"frame:title,omitempty"` FrameIcon string `json:"frame:icon,omitempty"` FrameText string `json:"frame:text,omitempty"` CmdClear bool `json:"cmd:*,omitempty"` Cmd string `json:"cmd,omitempty"` CmdInteractive bool `json:"cmd:interactive,omitempty"` CmdLogin bool `json:"cmd:login,omitempty"` CmdPersistent bool `json:"cmd:persistent,omitempty"` CmdRunOnStart bool `json:"cmd:runonstart,omitempty"` CmdClearOnStart bool `json:"cmd:clearonstart,omitempty"` CmdRunOnce bool `json:"cmd:runonce,omitempty"` CmdCloseOnExit bool `json:"cmd:closeonexit,omitempty"` CmdCloseOnExitForce bool `json:"cmd:closeonexitforce,omitempty"` CmdCloseOnExitDelay float64 `json:"cmd:closeonexitdelay,omitempty"` CmdNoWsh bool `json:"cmd:nowsh,omitempty"` CmdArgs []string `json:"cmd:args,omitempty"` // args for cmd (only if cmd:shell is false) CmdShell bool `json:"cmd:shell,omitempty"` // shell expansion for cmd+args (defaults to true) CmdAllowConnChange bool `json:"cmd:allowconnchange,omitempty"` CmdJwt bool `json:"cmd:jwt,omitempty"` // force adding JWT to environment // these can be nested under "[conn]" CmdEnv map[string]string `json:"cmd:env,omitempty"` CmdCwd string `json:"cmd:cwd,omitempty"` CmdInitScript string `json:"cmd:initscript,omitempty"` CmdInitScriptSh string `json:"cmd:initscript.sh,omitempty"` CmdInitScriptBash string `json:"cmd:initscript.bash,omitempty"` CmdInitScriptZsh string `json:"cmd:initscript.zsh,omitempty"` CmdInitScriptPwsh string `json:"cmd:initscript.pwsh,omitempty"` CmdInitScriptFish string `json:"cmd:initscript.fish,omitempty"` // AI options match settings AiClear bool `json:"ai:*,omitempty"` AiPresetKey string `json:"ai:preset,omitempty"` AiApiType string `json:"ai:apitype,omitempty"` AiBaseURL string `json:"ai:baseurl,omitempty"` AiApiToken string `json:"ai:apitoken,omitempty"` AiName string `json:"ai:name,omitempty"` AiModel string `json:"ai:model,omitempty"` AiOrgID string `json:"ai:orgid,omitempty"` AIApiVersion string `json:"ai:apiversion,omitempty"` AiMaxTokens float64 `json:"ai:maxtokens,omitempty"` AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"` AiFileDiffChatId string `json:"aifilediff:chatid,omitempty"` AiFileDiffToolCallId string `json:"aifilediff:toolcallid,omitempty"` EditorClear bool `json:"editor:*,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` EditorWordWrap bool `json:"editor:wordwrap,omitempty"` EditorFontSize float64 `json:"editor:fontsize,omitempty"` GraphClear bool `json:"graph:*,omitempty"` GraphNumPoints int `json:"graph:numpoints,omitempty"` GraphMetrics []string `json:"graph:metrics,omitempty"` SysinfoType string `json:"sysinfo:type,omitempty"` // for tabs TabFlagColor string `json:"tab:flagcolor,omitempty"` BgClear bool `json:"bg:*,omitempty"` Bg string `json:"bg,omitempty"` BgOpacity float64 `json:"bg:opacity,omitempty"` BgBlendMode string `json:"bg:blendmode,omitempty"` BgBorderColor string `json:"bg:bordercolor,omitempty"` // frame:bordercolor BgActiveBorderColor string `json:"bg:activebordercolor,omitempty"` // frame:activebordercolor // for workspace LayoutVTabBarWidth int `json:"layout:vtabbarwidth,omitempty"` // for tabs+waveai WaveAiPanelOpen bool `json:"waveai:panelopen,omitempty"` WaveAiPanelWidth int `json:"waveai:panelwidth,omitempty"` WaveAiModel string `json:"waveai:model,omitempty"` WaveAiChatId string `json:"waveai:chatid,omitempty"` WaveAiWidgetContext *bool `json:"waveai:widgetcontext,omitempty"` // default is true TermClear bool `json:"term:*,omitempty"` TermFontSize int `json:"term:fontsize,omitempty"` TermFontFamily string `json:"term:fontfamily,omitempty"` TermMode string `json:"term:mode,omitempty"` TermTheme string `json:"term:theme,omitempty"` TermLocalShellPath string `json:"term:localshellpath,omitempty"` // matches settings TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` // matches settings TermScrollback *int `json:"term:scrollback,omitempty"` TermVDomSubBlockId string `json:"term:vdomblockid,omitempty"` TermVDomToolbarBlockId string `json:"term:vdomtoolbarblockid,omitempty"` TermTransparency *float64 `json:"term:transparency,omitempty"` // default 0.5 TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"` TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"` TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"` TermCursor string `json:"term:cursor,omitempty"` TermCursorBlink *bool `json:"term:cursorblink,omitempty"` TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` TermOsc52 string `json:"term:osc52,omitempty"` TermDurable *bool `json:"term:durable,omitempty"` WebZoom float64 `json:"web:zoom,omitempty"` WebHideNav *bool `json:"web:hidenav,omitempty"` WebPartition string `json:"web:partition,omitempty"` WebUserAgentType string `json:"web:useragenttype,omitempty"` MarkdownFontSize float64 `json:"markdown:fontsize,omitempty"` MarkdownFixedFontSize float64 `json:"markdown:fixedfontsize,omitempty"` TsunamiClear bool `json:"tsunami:*,omitempty"` TsunamiSdkReplacePath string `json:"tsunami:sdkreplacepath,omitempty"` TsunamiAppPath string `json:"tsunami:apppath,omitempty"` TsunamiAppId string `json:"tsunami:appid,omitempty"` TsunamiScaffoldPath string `json:"tsunami:scaffoldpath,omitempty"` TsunamiEnv map[string]string `json:"tsunami:env,omitempty"` VDomClear bool `json:"vdom:*,omitempty"` VDomInitialized bool `json:"vdom:initialized,omitempty"` VDomCorrelationId string `json:"vdom:correlationid,omitempty"` VDomRoute string `json:"vdom:route,omitempty"` VDomPersist bool `json:"vdom:persist,omitempty"` OnboardingGithubStar bool `json:"onboarding:githubstar,omitempty"` // for client OnboardingLastVersion string `json:"onboarding:lastversion,omitempty"` // for client (tracks semver of last 'onboarding' shown) Count int `json:"count,omitempty"` // temp for cpu plot. will remove later } type MetaDataDecl struct { Key string `json:"key"` Desc string `json:"desc,omitempty"` Type string `json:"type"` // string, int, float, bool, array, object Default any `json:"default,omitempty"` StrOptions []string `json:"stroptions,omitempty"` NumRange []*int `json:"numrange,omitempty"` // inclusive, null means no limit Entity []string `json:"entity"` // what entities this applies to, e.g. "block", "tab", "any", etc. Special []string `json:"special,omitempty"` // special handling. things that need to happen if this gets updated } type MetaPresetDecl struct { Preset string `json:"preset"` Desc string `json:"desc,omitempty"` Keys []string `json:"keys"` Entity []string `json:"entity"` // what entities this applies to, e.g. "block", "tab", etc. } // returns a clean copy of meta with mergeMeta merged in // if mergeSpecial is false, then special keys will not be merged (like display:*) func MergeMeta(meta MetaMapType, metaUpdate MetaMapType, mergeSpecial bool) MetaMapType { rtn := make(MetaMapType) for k, v := range meta { rtn[k] = v } // deal with "section:*" keys for k := range metaUpdate { if !strings.HasSuffix(k, ":*") { continue } if !metaUpdate.GetBool(k, false) { continue } prefix := strings.TrimSuffix(k, ":*") if prefix == "" { continue } // delete "[prefix]" and all keys that start with "[prefix]:" prefixColon := prefix + ":" for k2 := range rtn { if k2 == prefix || strings.HasPrefix(k2, prefixColon) { delete(rtn, k2) } } } // now deal with regular keys for k, v := range metaUpdate { if !mergeSpecial && strings.HasPrefix(k, "display:") { continue } if strings.HasSuffix(k, ":*") { continue } if v == nil { delete(rtn, k) continue } rtn[k] = v } return rtn } ================================================ FILE: pkg/wcloud/wcloud.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wcloud import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "strconv" "strings" "time" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/daystr" "github.com/wavetermdev/waveterm/pkg/wavebase" ) const WCloudEndpoint = "https://api.waveterm.dev/central" const WCloudEndpointVarName = "WCLOUD_ENDPOINT" const WCloudWSEndpoint = "wss://wsapi.waveterm.dev/" const WCloudWSEndpointVarName = "WCLOUD_WS_ENDPOINT" const WCloudPingEndpoint = "https://ping.waveterm.dev/central" const WCloudPingEndpointVarName = "WCLOUD_PING_ENDPOINT" var WCloudWSEndpoint_VarCache string var WCloudEndpoint_VarCache string var WCloudPingEndpoint_VarCache string const APIVersion = 1 const MaxPtyUpdateSize = (128 * 1024) const MaxUpdatesPerReq = 10 const MaxUpdatesToDeDup = 1000 const MaxUpdateWriterErrors = 3 const WCloudDefaultTimeout = 5 * time.Second const WCloudWebShareUpdateTimeout = 15 * time.Second // setting to 1M to be safe (max is 6M for API-GW + Lambda, but there is base64 encoding and upload time) // we allow one extra update past this estimated size const MaxUpdatePayloadSize = 1 * (1024 * 1024) const TelemetryUrl = "/telemetry" const TEventsUrl = "/tevents" const NoTelemetryUrl = "/no-telemetry" const WebShareUpdateUrl = "/auth/web-share-update" const PingUrl = "/ping" func CacheAndRemoveEnvVars() error { WCloudEndpoint_VarCache = os.Getenv(WCloudEndpointVarName) err := checkEndpointVar(WCloudEndpoint_VarCache, "wcloud endpoint", WCloudEndpointVarName) if err != nil { return err } os.Unsetenv(WCloudEndpointVarName) WCloudWSEndpoint_VarCache = os.Getenv(WCloudWSEndpointVarName) err = checkWSEndpointVar(WCloudWSEndpoint_VarCache, "wcloud ws endpoint", WCloudWSEndpointVarName) if err != nil { return err } os.Unsetenv(WCloudWSEndpointVarName) WCloudPingEndpoint_VarCache = os.Getenv(WCloudPingEndpointVarName) os.Unsetenv(WCloudPingEndpointVarName) return nil } func checkEndpointVar(endpoint string, debugName string, varName string) error { if !wavebase.IsDevMode() { return nil } if endpoint == "" || !strings.HasPrefix(endpoint, "https://") { return fmt.Errorf("invalid %s, %s not set or invalid", debugName, varName) } return nil } func checkWSEndpointVar(endpoint string, debugName string, varName string) error { if !wavebase.IsDevMode() { return nil } log.Printf("checking endpoint %q\n", endpoint) if endpoint == "" || !strings.HasPrefix(endpoint, "wss://") { return fmt.Errorf("invalid %s, %s not set or invalid", debugName, varName) } return nil } func GetEndpoint() string { if !wavebase.IsDevMode() { return WCloudEndpoint } endpoint := WCloudEndpoint_VarCache return endpoint } func GetWSEndpoint() string { if !wavebase.IsDevMode() { return WCloudWSEndpoint } endpoint := WCloudWSEndpoint_VarCache return endpoint } func GetPingEndpoint() string { if !wavebase.IsDevMode() { return WCloudPingEndpoint } endpoint := WCloudPingEndpoint_VarCache return endpoint } func makeAnonPostReq(ctx context.Context, apiUrl string, data interface{}) (*http.Request, error) { endpoint := GetEndpoint() if endpoint == "" { return nil, errors.New("wcloud endpoint not set") } var dataReader io.Reader if data != nil { byteArr, err := json.Marshal(data) if err != nil { return nil, fmt.Errorf("error marshaling json for %s request: %v", apiUrl, err) } dataReader = bytes.NewReader(byteArr) } fullUrl := GetEndpoint() + apiUrl req, err := http.NewRequestWithContext(ctx, "POST", fullUrl, dataReader) if err != nil { return nil, fmt.Errorf("error creating %s request: %v", apiUrl, err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-PromptAPIVersion", strconv.Itoa(APIVersion)) req.Header.Set("X-PromptAPIUrl", apiUrl) req.Close = true return req, nil } func doRequest(req *http.Request, outputObj interface{}, verbose bool) (*http.Response, error) { apiUrl := req.Header.Get("X-PromptAPIUrl") if verbose { log.Printf("[wcloud] sending request %s %v\n", req.Method, req.URL) } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("error contacting wcloud %q service: %v", apiUrl, err) } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return resp, fmt.Errorf("error reading %q response body: %v", apiUrl, err) } if resp.StatusCode != http.StatusOK { return resp, fmt.Errorf("error contacting wcloud %q service: %s", apiUrl, resp.Status) } if outputObj != nil && resp.Header.Get("Content-Type") == "application/json" { err = json.Unmarshal(bodyBytes, outputObj) if err != nil { return resp, fmt.Errorf("error decoding json: %v", err) } } return resp, nil } type TEventsInputType struct { ClientId string `json:"clientid"` Events []*telemetrydata.TEvent `json:"events"` } const TEventsBatchSize = 200 const TEventsMaxBatches = 10 // returns (done, num-sent, error) func sendTEventsBatch(clientId string) (bool, int, error) { ctx, cancelFn := context.WithTimeout(context.Background(), WCloudDefaultTimeout) defer cancelFn() events, err := telemetry.GetNonUploadedTEvents(ctx, TEventsBatchSize) if err != nil { return true, 0, fmt.Errorf("cannot get events: %v", err) } if len(events) == 0 { return true, 0, nil } input := TEventsInputType{ ClientId: clientId, Events: events, } req, err := makeAnonPostReq(ctx, TEventsUrl, input) if err != nil { return true, 0, err } startTime := time.Now() _, err = doRequest(req, nil, true) latency := time.Since(startTime) log.Printf("[wcloud] sent %d tevents (latency: %v)\n", len(events), latency) if err != nil { return true, 0, err } err = telemetry.MarkTEventsAsUploaded(ctx, events) if err != nil { return true, 0, fmt.Errorf("error marking activity as uploaded: %v", err) } return len(events) < TEventsBatchSize, len(events), nil } func sendTEvents(clientId string) (int, error) { numIters := 0 totalEvents := 0 for { numIters++ done, numEvents, err := sendTEventsBatch(clientId) if err != nil { log.Printf("error sending telemetry events: %v\n", err) break } totalEvents += numEvents if done { break } if numIters > TEventsMaxBatches { log.Printf("sendTEvents, hit %d iterations, stopping\n", numIters) break } } return totalEvents, nil } func SendAllTelemetry(clientId string) error { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() if err := telemetry.CleanOldTEvents(ctx); err != nil { log.Printf("error cleaning old telemetry events: %v\n", err) } if !telemetry.IsTelemetryEnabled() { log.Printf("telemetry disabled, not sending\n") return nil } _, err := sendTEvents(clientId) if err != nil { return err } return nil } func sendTelemetry(clientId string) error { ctx, cancelFn := context.WithTimeout(context.Background(), WCloudDefaultTimeout) defer cancelFn() activity, err := telemetry.GetNonUploadedActivity(ctx) if err != nil { return fmt.Errorf("cannot get activity: %v", err) } if len(activity) == 0 { return nil } log.Printf("[wcloud] sending telemetry data\n") dayStr := daystr.GetCurDayStr() input := TelemetryInputType{ ClientId: clientId, UserId: clientId, AppType: "w2", AutoUpdateEnabled: telemetry.IsAutoUpdateEnabled(), AutoUpdateChannel: telemetry.AutoUpdateChannel(), CurDay: dayStr, Activity: activity, } req, err := makeAnonPostReq(ctx, TelemetryUrl, input) if err != nil { return err } _, err = doRequest(req, nil, true) if err != nil { return err } err = telemetry.MarkActivityAsUploaded(ctx, activity) if err != nil { return fmt.Errorf("error marking activity as uploaded: %v", err) } return nil } func SendNoTelemetryUpdate(ctx context.Context, clientId string, noTelemetryVal bool) error { req, err := makeAnonPostReq(ctx, NoTelemetryUrl, NoTelemetryInputType{ClientId: clientId, Value: noTelemetryVal}) if err != nil { return err } _, err = doRequest(req, nil, true) if err != nil { return err } return nil } func makePingPostReq(ctx context.Context, apiUrl string, data interface{}) (*http.Request, error) { endpoint := GetPingEndpoint() if endpoint == "" { return nil, errors.New("wcloud ping endpoint not set") } var dataReader io.Reader if data != nil { byteArr, err := json.Marshal(data) if err != nil { return nil, fmt.Errorf("error marshaling json for %s request: %v", apiUrl, err) } dataReader = bytes.NewReader(byteArr) } fullUrl := endpoint + apiUrl req, err := http.NewRequestWithContext(ctx, "POST", fullUrl, dataReader) if err != nil { return nil, fmt.Errorf("error creating %s request: %v", apiUrl, err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-PromptAPIVersion", strconv.Itoa(APIVersion)) req.Close = true return req, nil } type PingInputType struct { ClientId string `json:"clientid"` Arch string `json:"arch"` Version string `json:"version"` LocalDate string `json:"localdate"` UsageTelemetry bool `json:"usagetelemetry"` } func SendDiagnosticPing(ctx context.Context, clientId string, usageTelemetry bool) error { endpoint := GetPingEndpoint() if endpoint == "" { return nil } localDate := time.Now().Format("2006-01-02") input := PingInputType{ ClientId: clientId, Arch: wavebase.ClientArch(), Version: "v" + wavebase.WaveVersion, LocalDate: localDate, UsageTelemetry: usageTelemetry, } req, err := makePingPostReq(ctx, PingUrl, input) if err != nil { return err } _, err = doRequest(req, nil, false) if err != nil { return err } return nil } ================================================ FILE: pkg/wcloud/wclouddata.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wcloud import ( "github.com/wavetermdev/waveterm/pkg/telemetry" ) type NoTelemetryInputType struct { ClientId string `json:"clientid"` Value bool `json:"value"` } type TelemetryInputType struct { UserId string `json:"userid"` ClientId string `json:"clientid"` AppType string `json:"apptype,omitempty"` AutoUpdateEnabled bool `json:"autoupdateenabled,omitempty"` AutoUpdateChannel string `json:"autoupdatechannel,omitempty"` CurDay string `json:"curday"` Activity []*telemetry.ActivityType `json:"activity"` } ================================================ FILE: pkg/wconfig/defaultconfig/defaultconfig.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package defaultconfig import "embed" //go:embed *.json all:*/*.json var ConfigFS embed.FS ================================================ FILE: pkg/wconfig/defaultconfig/mimetypes.json ================================================ { "audio": { "icon": "file-audio" }, "application/pdf": { "icon": "file-pdf" }, "application/javascript": { "icon": "js fa-brands" }, "application/typescript": { "icon": "js fa-brands" }, "application/json": { "icon": "file-lines" }, "directory": { "icon": "folder", "color": "var(--term-bright-blue)" }, "font": { "icon": "book-font" }, "image": { "icon": "file-image" }, "text": { "icon": "file-lines" }, "text/css": { "icon": "css3-alt fa-brands" }, "text/javascript": { "icon": "js fa-brands" }, "text/typescript": { "icon": "js fa-brands" }, "text/golang": { "icon": "golang fa-brands" }, "text/html": { "icon": "html5 fa-brands" }, "text/less": { "icon": "less fa-brands" }, "text/markdown": { "icon": "markdown fa-brands" }, "text/rust": { "icon": "rust fa-brands" }, "text/scss": { "icon": "sass fa-brands" }, "video": { "icon": "file-video" }, "text/csv": { "icon": "file-csv" }, "text/x-dart": { "icon": "dart-lang fa-brands" }, "text/x-go": { "icon": "golang fa-brands" }, "text/x-rust": { "icon": "rust fa-brands" } } ================================================ FILE: pkg/wconfig/defaultconfig/presets/ai.json ================================================ { "ai@global": { "display:name": "Global default", "display:order": -1, "ai:*": true }, "ai@wave": { "display:name": "Wave Proxy - gpt-5-mini", "display:order": 0, "ai:*": true, "ai:apitype": "", "ai:baseurl": "", "ai:apitoken": "", "ai:name": "", "ai:orgid": "", "ai:model": "gpt-5-mini", "ai:maxtokens": 4000, "ai:timeoutms": 60000 } } ================================================ FILE: pkg/wconfig/defaultconfig/presets.json ================================================ { "bg@default": { "display:name": "Default", "display:order": -1, "bg:*": true }, "bg@rainbow": { "display:name": "Rainbow", "display:order": 2.1, "bg:*": true, "bg": "linear-gradient( 226.4deg, rgba(255,26,1,1) 28.9%, rgba(254,155,1,1) 33%, rgba(255,241,0,1) 48.6%, rgba(34,218,1,1) 65.3%, rgba(0,141,254,1) 80.6%, rgba(113,63,254,1) 100.1% )", "bg:opacity": 0.3 }, "bg@green": { "display:name": "Green", "display:order": 1.2, "bg:*": true, "bg": "green", "bg:opacity": 0.3 }, "bg@blue": { "display:name": "Blue", "display:order": 1.1, "bg:*": true, "bg": "blue", "bg:opacity": 0.3, "bg:activebordercolor": "rgba(0, 0, 255, 1.0)" }, "bg@red": { "display:name": "Red", "display:order": 1.3, "bg:*": true, "bg": "red", "bg:opacity": 0.3, "bg:activebordercolor": "rgba(255, 0, 0, 1.0)" }, "bg@ocean-depths": { "display:name": "Ocean Depths", "display:order": 2.2, "bg:*": true, "bg": "linear-gradient(135deg, purple, blue, teal)", "bg:opacity": 0.7 }, "bg@aqua-horizon": { "display:name": "Aqua Horizon", "display:order": 2.3, "bg:*": true, "bg": "linear-gradient(135deg, rgba(15, 30, 50, 1) 0%, rgba(40, 90, 130, 0.85) 30%, rgba(20, 100, 150, 0.75) 60%, rgba(0, 120, 160, 0.65) 80%, rgba(0, 140, 180, 0.55) 100%), linear-gradient(135deg, rgba(100, 80, 255, 0.4), rgba(0, 180, 220, 0.4)), radial-gradient(circle at 70% 70%, rgba(255, 255, 255, 0.05), transparent 70%)", "bg:opacity": 0.85, "bg:blendmode": "overlay" }, "bg@sunset": { "display:name": "Sunset", "display:order": 2.4, "bg:*": true, "bg": "linear-gradient(135deg, rgba(128, 0, 0, 1), rgba(255, 69, 0, 0.8), rgba(75, 0, 130, 1))", "bg:opacity": 0.8, "bg:blendmode": "normal" }, "bg@enchantedforest": { "display:name": "Enchanted Forest", "display:order": 2.7, "bg:*": true, "bg": "linear-gradient(145deg, rgba(0,50,0,1), rgba(34,139,34,0.7) 20%, rgba(0,100,0,0.5) 40%, rgba(0,200,100,0.3) 60%, rgba(34,139,34,0.8) 80%, rgba(0,50,0,1)), radial-gradient(circle at 30% 30%, rgba(255,255,255,0.1), transparent 80%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.1), transparent 80%)", "bg:opacity": 0.8, "bg:blendmode": "soft-light" }, "bg@twilight-mist": { "display:name": "Twilight Mist", "display:order": 2.9, "bg:*": true, "bg": "linear-gradient(180deg, rgba(60,60,90,1) 0%, rgba(90,110,140,0.8) 40%, rgba(120,140,160,0.6) 70%, rgba(60,60,90,1) 100%), radial-gradient(circle at 30% 40%, rgba(255,255,255,0.15), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.1), transparent 70%)", "bg:opacity": 0.9, "bg:blendmode": "soft-light" }, "bg@duskhorizon": { "display:name": "Dusk Horizon", "display:order": 3.1, "bg:*": true, "bg": "linear-gradient(0deg, rgba(128,0,0,1) 0%, rgba(204,85,0,0.7) 20%, rgba(255,140,0,0.6) 45%, rgba(160,90,160,0.5) 65%, rgba(60,60,120,1) 100%), radial-gradient(circle at 30% 30%, rgba(255,255,255,0.1), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.05), transparent 70%)", "bg:opacity": 0.9, "bg:blendmode": "overlay" }, "bg@tropical-radiance": { "display:name": "Tropical Radiance", "display:order": 3.3, "bg:*": true, "bg": "linear-gradient(135deg, rgba(204, 51, 255, 0.9) 0%, rgba(255, 85, 153, 0.75) 30%, rgba(255, 51, 153, 0.65) 60%, rgba(204, 51, 255, 0.6) 80%, rgba(51, 102, 255, 0.5) 100%), radial-gradient(circle at 30% 40%, rgba(255,255,255,0.1), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,255,255,0.05), transparent 70%)", "bg:opacity": 0.9, "bg:blendmode": "overlay" }, "bg@twilight-ember": { "display:name": "Twilight Ember", "display:order": 3.5, "bg:*": true, "bg": "linear-gradient(120deg,hsla(350, 65%, 57%, 1),hsla(30,60%,60%, .75), hsla(208,69%,50%,.15), hsl(230,60%,40%)),radial-gradient(at top right,hsla(300,60%,70%,0.3),transparent),radial-gradient(at top left,hsla(330,100%,70%,.20),transparent),radial-gradient(at top right,hsla(190,100%,40%,.20),transparent),radial-gradient(at bottom left,hsla(323,54%,50%,.5),transparent),radial-gradient(at bottom left,hsla(144,54%,50%,.25),transparent)", "bg:blendmode": "overlay", "bg:text": "rgb(200, 200, 200)" }, "bg@cosmic-tide": { "display:name": "Cosmic Tide", "display:order": 3.6, "bg:activebordercolor": "#ff55aa", "bg:*": true, "bg": "linear-gradient(135deg, #00d9d9, #ff55aa, #1e1e2f, #2f3b57, #ff99ff)", "bg:opacity": 0.6 } } ================================================ FILE: pkg/wconfig/defaultconfig/settings.json ================================================ { "ai:preset": "ai@global", "ai:model": "gpt-5-mini", "ai:maxtokens": 4000, "ai:timeoutms": 60000, "app:defaultnewblock": "term", "app:tabbar": "top", "app:confirmquit": true, "app:hideaibutton": false, "app:disablectrlshiftarrows": false, "app:disablectrlshiftdisplay": false, "app:focusfollowscursor": "off", "autoupdate:enabled": true, "autoupdate:installonquit": true, "autoupdate:intervalms": 3600000, "conn:askbeforewshinstall": true, "conn:wshenabled": true, "editor:minimapenabled": true, "web:defaulturl": "https://github.com/wavetermdev/waveterm", "web:defaultsearch": "https://www.google.com/search?q={query}", "window:tilegapsize": 3, "window:maxtabcachesize": 10, "window:nativetitlebar": true, "window:magnifiedblockopacity": 0.6, "window:magnifiedblocksize": 0.95, "window:magnifiedblockblurprimarypx": 10, "window:fullscreenonlaunch": false, "window:magnifiedblockblursecondarypx": 2, "window:confirmclose": true, "window:savelastwindow": true, "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": true, "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, "term:durable": false, "waveai:showcloudmodes": true, "waveai:defaultmode": "waveai@balanced", "preview:defaultsort": "name" } ================================================ FILE: pkg/wconfig/defaultconfig/termthemes.json ================================================ { "default-dark": { "display:name": "Default Dark", "display:order": 1, "black": "#757575", "red": "#cc685c", "green": "#76c266", "yellow": "#cbca9b", "blue": "#85aacb", "magenta": "#cc72ca", "cyan": "#74a7cb", "white": "#c1c1c1", "brightBlack": "#727272", "brightRed": "#cc9d97", "brightGreen": "#a3dd97", "brightYellow": "#cbcaaa", "brightBlue": "#9ab6cb", "brightMagenta": "#cc8ecb", "brightCyan": "#b7b8cb", "brightWhite": "#f0f0f0", "gray": "#8b918a", "cmdtext": "#f0f0f0", "foreground": "#c1c1c1", "selectionBackground": "", "background": "#000000", "cursor": "" }, "onedarkpro": { "display:name": "One Dark Pro", "display:order": 2, "background": "#21252B", "foreground": "#ABB2BF", "cursor": "#D7DAE0", "black": "#3F4451", "red": "#E06C75", "green": "#98C379", "yellow": "#D18F52", "blue": "#61AFEF", "magenta": "#C678DD", "cyan": "#42B3C2", "white": "#D7DAE0", "brightBlack": "#4F5666", "brightRed": "#FF616E", "brightGreen": "#A5E075", "brightYellow": "#F0A45D", "brightBlue": "#4DC4FF", "brightMagenta": "#DE73FF", "brightCyan": "#4CD1E0", "brightWhite": "#E6E6E6", "gray": "#495162", "cmdtext": "#ABB2BF" }, "dracula": { "display:name": "Dracula", "display:order": 3, "black": "#21222C", "red": "#FF5555", "green": "#50FA7B", "yellow": "#F1FA8C", "blue": "#BD93F9", "magenta": "#FF79C6", "cyan": "#8BE9FD", "white": "#F8F8F2", "brightBlack": "#6272A4", "brightRed": "#FF6E6E", "brightGreen": "#69FF94", "brightYellow": "#FFFFA5", "brightBlue": "#D6ACFF", "brightMagenta": "#FF92DF", "brightCyan": "#A4FFFF", "brightWhite": "#FFFFFF", "gray": "#6272A4", "cmdtext": "#F8F8F2", "foreground": "#F8F8F2", "background": "#282a36", "cursor": "#f8f8f2" }, "monokai": { "display:name": "Monokai", "display:order": 4, "black": "#1B1D1E", "red": "#F92672", "green": "#A6E22E", "yellow": "#E6DB74", "blue": "#66D9EF", "magenta": "#AE81FF", "cyan": "#A1EFE4", "white": "#F8F8F2", "brightBlack": "#75715E", "brightRed": "#FD5FF1", "brightGreen": "#A6E22E", "brightYellow": "#E6DB74", "brightBlue": "#66D9EF", "brightMagenta": "#AE81FF", "brightCyan": "#A1EFE4", "brightWhite": "#F9F8F5", "gray": "#75715E", "cmdtext": "#F8F8F2", "foreground": "#F8F8F2", "background": "#272822", "cursor": "#F8F8F2" }, "campbell": { "display:name": "Campbell", "display:order": 5, "black": "#0C0C0C", "red": "#C50F1F", "green": "#13A10E", "yellow": "#C19C00", "blue": "#0037DA", "magenta": "#881798", "cyan": "#3A96DD", "white": "#CCCCCC", "brightBlack": "#767676", "brightRed": "#E74856", "brightGreen": "#16C60C", "brightYellow": "#F9F1A5", "brightBlue": "#3B78FF", "brightMagenta": "#B4009E", "brightCyan": "#61D6D6", "brightWhite": "#F2F2F2", "gray": "#767676", "cmdtext": "#CCCCCC", "foreground": "#CCCCCC", "selectionBackground": "#3A96DD77", "background": "#0C0C0C", "cursor": "#CCCCCC" }, "warmyellow": { "display:name": "Warm Yellow", "display:order": 6, "black": "#3C3228", "red": "#E67E22", "green": "#A5D6A7", "yellow": "#F9D784", "blue": "#7FB3D5", "magenta": "#C39BD3", "cyan": "#5DADE2", "white": "#ECF0F1", "brightBlack": "#7E705A", "brightRed": "#E74C3C", "brightGreen": "#82E0AA", "brightYellow": "#F4D03F", "brightBlue": "#3498DB", "brightMagenta": "#9B59B6", "brightCyan": "#1ABC9C", "brightWhite": "#FFFFFF", "background": "#2B2620", "foreground": "#F2E6D4", "selectionBackground": "#B7950B77", "cursor": "#F9D784" }, "rosepine": { "display:name": "Rose Pine", "display:order": 7, "black": "#26233a", "red": "#eb6f92", "green": "#3e8fb0", "yellow": "#f6c177", "blue": "#9ccfd8", "magenta": "#c4a7e7", "cyan": "#ebbcba", "white": "#e0def4", "brightBlack": "#908caa", "brightRed": "#ff8cab", "brightGreen": "#9ccfb0", "brightYellow": "#ffd196", "brightBlue": "#bee6e0", "brightMagenta": "#e2c4ff", "brightCyan": "#ffd1d0", "brightWhite": "#fffaf3", "gray": "#908caa", "cmdtext": "#e0def4", "foreground": "#e0def4", "background": "#191724", "cursor": "#524f67" } } ================================================ FILE: pkg/wconfig/defaultconfig/waveai.json ================================================ { "waveai@quick": { "display:name": "Quick", "display:order": -3, "display:icon": "bolt", "display:description": "Fastest responses (gpt-5-mini)", "ai:provider": "wave", "ai:apitype": "openai-responses", "ai:model": "gpt-5-mini", "ai:thinkinglevel": "low", "ai:verbosity": "low", "ai:capabilities": ["tools", "images", "pdfs"], "ai:switchcompat": ["wavecloud"] }, "waveai@balanced": { "display:name": "Balanced", "display:order": -2, "display:icon": "sparkles", "display:description": "Good mix of speed and accuracy\n(gpt-5.1 with minimal thinking)", "ai:provider": "wave", "ai:apitype": "openai-responses", "ai:model": "gpt-5.1", "ai:thinkinglevel": "low", "ai:verbosity": "low", "ai:capabilities": ["tools", "images", "pdfs"], "waveai:premium": true, "ai:switchcompat": ["wavecloud"] }, "waveai@deep": { "display:name": "Deep", "display:order": -1, "display:icon": "lightbulb", "display:description": "Slower but most capable\n(gpt-5.1 with full reasoning)", "ai:provider": "wave", "ai:apitype": "openai-responses", "ai:model": "gpt-5.1", "ai:thinkinglevel": "medium", "ai:verbosity": "low", "ai:capabilities": ["tools", "images", "pdfs"], "waveai:premium": true, "ai:switchcompat": ["wavecloud"] } } ================================================ FILE: pkg/wconfig/defaultconfig/widgets.json ================================================ { "defwidget@terminal": { "display:order": -5, "icon": "square-terminal", "label": "terminal", "blockdef": { "meta": { "view": "term", "controller": "shell" } } }, "defwidget@files": { "display:order": -4, "icon": "folder", "label": "files", "blockdef": { "meta": { "view": "preview", "file": "~" } } }, "defwidget@web": { "display:order": -3, "icon": "globe", "label": "web", "blockdef": { "meta": { "view": "web" } } }, "defwidget@ai": { "display:order": -2, "icon": "sparkles", "label": "ai", "blockdef": { "meta": { "view": "waveai" } } }, "defwidget@sysinfo": { "display:order": -1, "icon": "chart-line", "label": "sysinfo", "blockdef": { "meta": { "view": "sysinfo" } } } } ================================================ FILE: pkg/wconfig/filewatcher.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wconfig import ( "log" "os" "path/filepath" "regexp" "sync" "github.com/fsnotify/fsnotify" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wps" ) var instance *Watcher var once sync.Once type ConfigUpdateHandler func(FullConfigType) type Watcher struct { initialized bool watcher *fsnotify.Watcher mutex sync.Mutex fullConfig FullConfigType handlers []ConfigUpdateHandler } type WatcherUpdate struct { FullConfig FullConfigType `json:"fullconfig"` } // GetWatcher returns the singleton instance of the Watcher func GetWatcher() *Watcher { once.Do(func() { watcher, err := fsnotify.NewWatcher() if err != nil { log.Printf("failed to create file watcher: %v", err) return } configDirAbsPath := wavebase.GetWaveConfigDir() log.Printf("create config watcher, configdir=%q", configDirAbsPath) instance = &Watcher{watcher: watcher} err = instance.watcher.Add(configDirAbsPath) const failedStr = "failed to add path %s to watcher: %v" if err != nil { log.Printf(failedStr, configDirAbsPath, err) } subdirs := GetConfigSubdirs() for _, dir := range subdirs { err = instance.watcher.Add(dir) if err != nil && !os.IsNotExist(err) { log.Printf(failedStr, dir, err) } } }) return instance } func (w *Watcher) Start() { w.mutex.Lock() defer w.mutex.Unlock() log.Printf("starting file watcher\n") w.initialized = true w.sendInitialValues() go func() { defer func() { panichandler.PanicHandler("filewatcher:Start", recover()) }() for { select { case event, ok := <-w.watcher.Events: if !ok { return } w.handleEvent(event) case err, ok := <-w.watcher.Errors: if !ok { return } log.Println("watcher error:", err) } } }() } // for initial values, exit on first error func (w *Watcher) sendInitialValues() error { w.fullConfig = ReadFullConfig() message := WatcherUpdate{ FullConfig: w.fullConfig, } w.broadcast(message) return nil } func (w *Watcher) Close() { w.mutex.Lock() defer w.mutex.Unlock() if w.watcher != nil { w.watcher.Close() w.watcher = nil log.Println("file watcher closed") } } func (w *Watcher) broadcast(message WatcherUpdate) { wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_Config, Data: message, }) w.notifyHandlers(message.FullConfig) } func (w *Watcher) RegisterUpdateHandler(handler ConfigUpdateHandler) { w.mutex.Lock() defer w.mutex.Unlock() w.handlers = append(w.handlers, handler) } func (w *Watcher) notifyHandlers(config FullConfigType) { handlers := w.handlers for _, handler := range handlers { go func(h ConfigUpdateHandler) { defer func() { panichandler.PanicHandler("filewatcher:notifyHandlers", recover()) }() h(config) }(handler) } } func (w *Watcher) GetFullConfig() FullConfigType { w.mutex.Lock() defer w.mutex.Unlock() return w.fullConfig } func (w *Watcher) handleEvent(event fsnotify.Event) { w.mutex.Lock() defer w.mutex.Unlock() fileName := filepath.ToSlash(event.Name) if event.Op == fsnotify.Chmod { return } if !isValidSubSettingsFileName(fileName) { return } w.handleSettingsFileEvent(event, fileName) } var validFileRe = regexp.MustCompile(`^[a-zA-Z0-9_@.-]+\.json$`) func isValidSubSettingsFileName(fileName string) bool { if filepath.Ext(fileName) != ".json" { return false } baseName := filepath.Base(fileName) return validFileRe.MatchString(baseName) } func (w *Watcher) handleSettingsFileEvent(_ fsnotify.Event, _ string) { fullConfig := ReadFullConfig() w.fullConfig = fullConfig w.broadcast(WatcherUpdate{FullConfig: w.fullConfig}) } ================================================ FILE: pkg/wconfig/metaconsts.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // Generated Code. DO NOT EDIT. package wconfig const ( ConfigKey_AppClear = "app:*" ConfigKey_AppGlobalHotkey = "app:globalhotkey" ConfigKey_AppDismissArchitectureWarning = "app:dismissarchitecturewarning" ConfigKey_AppDefaultNewBlock = "app:defaultnewblock" ConfigKey_AppShowOverlayBlockNums = "app:showoverlayblocknums" ConfigKey_AppCtrlVPaste = "app:ctrlvpaste" ConfigKey_AppConfirmQuit = "app:confirmquit" ConfigKey_AppHideAiButton = "app:hideaibutton" ConfigKey_AppDisableCtrlShiftArrows = "app:disablectrlshiftarrows" ConfigKey_AppDisableCtrlShiftDisplay = "app:disablectrlshiftdisplay" ConfigKey_AppFocusFollowsCursor = "app:focusfollowscursor" ConfigKey_AppTabBar = "app:tabbar" ConfigKey_FeatureWaveAppBuilder = "feature:waveappbuilder" ConfigKey_AiClear = "ai:*" ConfigKey_AiPreset = "ai:preset" ConfigKey_AiApiType = "ai:apitype" ConfigKey_AiBaseURL = "ai:baseurl" ConfigKey_AiApiToken = "ai:apitoken" ConfigKey_AiName = "ai:name" ConfigKey_AiModel = "ai:model" ConfigKey_AiOrgID = "ai:orgid" ConfigKey_AIApiVersion = "ai:apiversion" ConfigKey_AiMaxTokens = "ai:maxtokens" ConfigKey_AiTimeoutMs = "ai:timeoutms" ConfigKey_AiProxyUrl = "ai:proxyurl" ConfigKey_AiFontSize = "ai:fontsize" ConfigKey_AiFixedFontSize = "ai:fixedfontsize" ConfigKey_WaveAiShowCloudModes = "waveai:showcloudmodes" ConfigKey_WaveAiDefaultMode = "waveai:defaultmode" ConfigKey_TermClear = "term:*" ConfigKey_TermFontSize = "term:fontsize" ConfigKey_TermFontFamily = "term:fontfamily" ConfigKey_TermTheme = "term:theme" ConfigKey_TermDisableWebGl = "term:disablewebgl" ConfigKey_TermLocalShellPath = "term:localshellpath" ConfigKey_TermLocalShellOpts = "term:localshellopts" ConfigKey_TermGitBashPath = "term:gitbashpath" ConfigKey_TermScrollback = "term:scrollback" ConfigKey_TermCopyOnSelect = "term:copyonselect" ConfigKey_TermTransparency = "term:transparency" ConfigKey_TermAllowBracketedPaste = "term:allowbracketedpaste" ConfigKey_TermShiftEnterNewline = "term:shiftenternewline" ConfigKey_TermMacOptionIsMeta = "term:macoptionismeta" ConfigKey_TermCursor = "term:cursor" ConfigKey_TermCursorBlink = "term:cursorblink" ConfigKey_TermBellSound = "term:bellsound" ConfigKey_TermBellIndicator = "term:bellindicator" ConfigKey_TermOsc52 = "term:osc52" ConfigKey_TermDurable = "term:durable" ConfigKey_EditorMinimapEnabled = "editor:minimapenabled" ConfigKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled" ConfigKey_EditorWordWrap = "editor:wordwrap" ConfigKey_EditorFontSize = "editor:fontsize" ConfigKey_EditorInlineDiff = "editor:inlinediff" ConfigKey_WebClear = "web:*" ConfigKey_WebOpenLinksInternally = "web:openlinksinternally" ConfigKey_WebDefaultUrl = "web:defaulturl" ConfigKey_WebDefaultSearch = "web:defaultsearch" ConfigKey_AutoUpdateClear = "autoupdate:*" ConfigKey_AutoUpdateEnabled = "autoupdate:enabled" ConfigKey_AutoUpdateIntervalMs = "autoupdate:intervalms" ConfigKey_AutoUpdateInstallOnQuit = "autoupdate:installonquit" ConfigKey_AutoUpdateChannel = "autoupdate:channel" ConfigKey_MarkdownFontSize = "markdown:fontsize" ConfigKey_MarkdownFixedFontSize = "markdown:fixedfontsize" ConfigKey_PreviewShowHiddenFiles = "preview:showhiddenfiles" ConfigKey_PreviewDefaultSort = "preview:defaultsort" ConfigKey_TabPreset = "tab:preset" ConfigKey_TabConfirmClose = "tab:confirmclose" ConfigKey_WidgetClear = "widget:*" ConfigKey_WidgetShowHelp = "widget:showhelp" ConfigKey_WindowClear = "window:*" ConfigKey_WindowFullscreenOnLaunch = "window:fullscreenonlaunch" ConfigKey_WindowTransparent = "window:transparent" ConfigKey_WindowBlur = "window:blur" ConfigKey_WindowOpacity = "window:opacity" ConfigKey_WindowBgColor = "window:bgcolor" ConfigKey_WindowReducedMotion = "window:reducedmotion" ConfigKey_WindowTileGapSize = "window:tilegapsize" ConfigKey_WindowShowMenuBar = "window:showmenubar" ConfigKey_WindowNativeTitleBar = "window:nativetitlebar" ConfigKey_WindowDisableHardwareAcceleration = "window:disablehardwareacceleration" ConfigKey_WindowMaxTabCacheSize = "window:maxtabcachesize" ConfigKey_WindowMagnifiedBlockOpacity = "window:magnifiedblockopacity" ConfigKey_WindowMagnifiedBlockSize = "window:magnifiedblocksize" ConfigKey_WindowMagnifiedBlockBlurPrimaryPx = "window:magnifiedblockblurprimarypx" ConfigKey_WindowMagnifiedBlockBlurSecondaryPx = "window:magnifiedblockblursecondarypx" ConfigKey_WindowConfirmClose = "window:confirmclose" ConfigKey_WindowSaveLastWindow = "window:savelastwindow" ConfigKey_WindowDimensions = "window:dimensions" ConfigKey_WindowZoom = "window:zoom" ConfigKey_TelemetryClear = "telemetry:*" ConfigKey_TelemetryEnabled = "telemetry:enabled" ConfigKey_ConnClear = "conn:*" ConfigKey_ConnAskBeforeWshInstall = "conn:askbeforewshinstall" ConfigKey_ConnWshEnabled = "conn:wshenabled" ConfigKey_ConnLocalHostnameDisplay = "conn:localhostdisplayname" ConfigKey_DebugClear = "debug:*" ConfigKey_DebugPprofPort = "debug:pprofport" ConfigKey_DebugPprofMemProfileRate = "debug:pprofmemprofilerate" ConfigKey_DebugWebGlStatus = "debug:webglstatus" ConfigKey_TsunamiClear = "tsunami:*" ConfigKey_TsunamiScaffoldPath = "tsunami:scaffoldpath" ConfigKey_TsunamiSdkReplacePath = "tsunami:sdkreplacepath" ConfigKey_TsunamiSdkVersion = "tsunami:sdkversion" ConfigKey_TsunamiGoPath = "tsunami:gopath" ) ================================================ FILE: pkg/wconfig/settingsconfig.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wconfig import ( "bytes" "encoding/json" "fmt" "io/fs" "log" "os" "path/filepath" "reflect" "sort" "strings" "sync" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig/defaultconfig" ) const SettingsFile = "settings.json" const ConnectionsFile = "connections.json" const ProfilesFile = "profiles.json" var configWriteLock sync.Mutex const AnySchema = ` { "type": "object", "additionalProperties": true } ` // old AI Widget presets (deprecated) type AiSettingsType struct { AiClear bool `json:"ai:*,omitempty"` AiPreset string `json:"ai:preset,omitempty"` AiApiType string `json:"ai:apitype,omitempty"` AiBaseURL string `json:"ai:baseurl,omitempty"` AiApiToken string `json:"ai:apitoken,omitempty"` AiName string `json:"ai:name,omitempty"` AiModel string `json:"ai:model,omitempty"` AiOrgID string `json:"ai:orgid,omitempty"` AIApiVersion string `json:"ai:apiversion,omitempty"` AiMaxTokens float64 `json:"ai:maxtokens,omitempty"` AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"` AiProxyUrl string `json:"ai:proxyurl,omitempty"` AiFontSize float64 `json:"ai:fontsize,omitempty"` AiFixedFontSize float64 `json:"ai:fixedfontsize,omitempty"` DisplayName string `json:"display:name,omitempty"` DisplayOrder float64 `json:"display:order,omitempty"` } type SettingsType struct { AppClear bool `json:"app:*,omitempty"` AppGlobalHotkey string `json:"app:globalhotkey,omitempty"` AppDismissArchitectureWarning bool `json:"app:dismissarchitecturewarning,omitempty"` AppDefaultNewBlock string `json:"app:defaultnewblock,omitempty"` AppShowOverlayBlockNums *bool `json:"app:showoverlayblocknums,omitempty"` AppCtrlVPaste *bool `json:"app:ctrlvpaste,omitempty"` AppConfirmQuit *bool `json:"app:confirmquit,omitempty"` AppHideAiButton bool `json:"app:hideaibutton,omitempty"` AppDisableCtrlShiftArrows bool `json:"app:disablectrlshiftarrows,omitempty"` AppDisableCtrlShiftDisplay bool `json:"app:disablectrlshiftdisplay,omitempty"` AppFocusFollowsCursor string `json:"app:focusfollowscursor,omitempty" jsonschema:"enum=off,enum=on,enum=term"` AppTabBar string `json:"app:tabbar,omitempty" jsonschema:"enum=top,enum=left"` FeatureWaveAppBuilder bool `json:"feature:waveappbuilder,omitempty"` AiClear bool `json:"ai:*,omitempty"` AiPreset string `json:"ai:preset,omitempty"` AiApiType string `json:"ai:apitype,omitempty"` AiBaseURL string `json:"ai:baseurl,omitempty"` AiApiToken string `json:"ai:apitoken,omitempty"` AiName string `json:"ai:name,omitempty"` AiModel string `json:"ai:model,omitempty"` AiOrgID string `json:"ai:orgid,omitempty"` AIApiVersion string `json:"ai:apiversion,omitempty"` AiMaxTokens float64 `json:"ai:maxtokens,omitempty"` AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"` AiProxyUrl string `json:"ai:proxyurl,omitempty"` AiFontSize float64 `json:"ai:fontsize,omitempty"` AiFixedFontSize float64 `json:"ai:fixedfontsize,omitempty"` WaveAiShowCloudModes bool `json:"waveai:showcloudmodes,omitempty"` WaveAiDefaultMode string `json:"waveai:defaultmode,omitempty"` TermClear bool `json:"term:*,omitempty"` TermFontSize float64 `json:"term:fontsize,omitempty"` TermFontFamily string `json:"term:fontfamily,omitempty"` TermTheme string `json:"term:theme,omitempty"` TermDisableWebGl bool `json:"term:disablewebgl,omitempty"` TermLocalShellPath string `json:"term:localshellpath,omitempty"` TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` TermGitBashPath string `json:"term:gitbashpath,omitempty"` TermScrollback *int64 `json:"term:scrollback,omitempty"` TermCopyOnSelect *bool `json:"term:copyonselect,omitempty"` TermTransparency *float64 `json:"term:transparency,omitempty"` TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"` TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"` TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"` TermCursor string `json:"term:cursor,omitempty"` TermCursorBlink *bool `json:"term:cursorblink,omitempty"` TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` TermOsc52 string `json:"term:osc52,omitempty" jsonschema:"enum=focus,enum=always"` TermDurable *bool `json:"term:durable,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` EditorWordWrap bool `json:"editor:wordwrap,omitempty"` EditorFontSize float64 `json:"editor:fontsize,omitempty"` EditorInlineDiff bool `json:"editor:inlinediff,omitempty"` WebClear bool `json:"web:*,omitempty"` WebOpenLinksInternally bool `json:"web:openlinksinternally,omitempty"` WebDefaultUrl string `json:"web:defaulturl,omitempty"` WebDefaultSearch string `json:"web:defaultsearch,omitempty"` AutoUpdateClear bool `json:"autoupdate:*,omitempty"` AutoUpdateEnabled bool `json:"autoupdate:enabled,omitempty"` AutoUpdateIntervalMs float64 `json:"autoupdate:intervalms,omitempty"` AutoUpdateInstallOnQuit bool `json:"autoupdate:installonquit,omitempty"` AutoUpdateChannel string `json:"autoupdate:channel,omitempty"` MarkdownFontSize float64 `json:"markdown:fontsize,omitempty"` MarkdownFixedFontSize float64 `json:"markdown:fixedfontsize,omitempty"` PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"` PreviewDefaultSort string `json:"preview:defaultsort,omitempty" jsonschema:"enum=name,enum=modtime"` TabPreset string `json:"tab:preset,omitempty"` TabConfirmClose bool `json:"tab:confirmclose,omitempty"` WidgetClear bool `json:"widget:*,omitempty"` WidgetShowHelp *bool `json:"widget:showhelp,omitempty"` WindowClear bool `json:"window:*,omitempty"` WindowFullscreenOnLaunch bool `json:"window:fullscreenonlaunch,omitempty"` WindowTransparent bool `json:"window:transparent,omitempty"` WindowBlur bool `json:"window:blur,omitempty"` WindowOpacity *float64 `json:"window:opacity,omitempty"` WindowBgColor string `json:"window:bgcolor,omitempty"` WindowReducedMotion bool `json:"window:reducedmotion,omitempty"` WindowTileGapSize *int64 `json:"window:tilegapsize,omitempty"` WindowShowMenuBar bool `json:"window:showmenubar,omitempty"` WindowNativeTitleBar bool `json:"window:nativetitlebar,omitempty"` WindowDisableHardwareAcceleration bool `json:"window:disablehardwareacceleration,omitempty"` WindowMaxTabCacheSize int `json:"window:maxtabcachesize,omitempty"` WindowMagnifiedBlockOpacity *float64 `json:"window:magnifiedblockopacity,omitempty"` WindowMagnifiedBlockSize *float64 `json:"window:magnifiedblocksize,omitempty"` WindowMagnifiedBlockBlurPrimaryPx *int64 `json:"window:magnifiedblockblurprimarypx,omitempty"` WindowMagnifiedBlockBlurSecondaryPx *int64 `json:"window:magnifiedblockblursecondarypx,omitempty"` WindowConfirmClose bool `json:"window:confirmclose,omitempty"` WindowSaveLastWindow bool `json:"window:savelastwindow,omitempty"` WindowDimensions string `json:"window:dimensions,omitempty"` WindowZoom *float64 `json:"window:zoom,omitempty"` TelemetryClear bool `json:"telemetry:*,omitempty"` TelemetryEnabled bool `json:"telemetry:enabled,omitempty"` ConnClear bool `json:"conn:*,omitempty"` ConnAskBeforeWshInstall *bool `json:"conn:askbeforewshinstall,omitempty"` ConnWshEnabled bool `json:"conn:wshenabled,omitempty"` ConnLocalHostnameDisplay *string `json:"conn:localhostdisplayname,omitempty"` DebugClear bool `json:"debug:*,omitempty"` DebugPprofPort *int `json:"debug:pprofport,omitempty"` DebugPprofMemProfileRate *int `json:"debug:pprofmemprofilerate,omitempty"` DebugWebGlStatus bool `json:"debug:webglstatus,omitempty"` TsunamiClear bool `json:"tsunami:*,omitempty"` TsunamiScaffoldPath string `json:"tsunami:scaffoldpath,omitempty"` TsunamiSdkReplacePath string `json:"tsunami:sdkreplacepath,omitempty"` TsunamiSdkVersion string `json:"tsunami:sdkversion,omitempty"` TsunamiGoPath string `json:"tsunami:gopath,omitempty"` } func (s *SettingsType) GetAiSettings() *AiSettingsType { return &AiSettingsType{ AiClear: s.AiClear, AiPreset: s.AiPreset, AiApiType: s.AiApiType, AiBaseURL: s.AiBaseURL, AiApiToken: s.AiApiToken, AiName: s.AiName, AiModel: s.AiModel, AiOrgID: s.AiOrgID, AIApiVersion: s.AIApiVersion, AiMaxTokens: s.AiMaxTokens, AiTimeoutMs: s.AiTimeoutMs, AiProxyUrl: s.AiProxyUrl, AiFontSize: s.AiFontSize, AiFixedFontSize: s.AiFixedFontSize, } } func MergeAiSettings(settings ...*AiSettingsType) *AiSettingsType { result := &AiSettingsType{} for _, s := range settings { if s == nil { continue } // If this setting has AiClear=true, replace result with this entire setting if s.AiClear { result = s result.AiClear = false continue } // Merge non-empty values if s.AiPreset != "" { result.AiPreset = s.AiPreset } if s.AiApiType != "" { result.AiApiType = s.AiApiType } if s.AiBaseURL != "" { result.AiBaseURL = s.AiBaseURL } if s.AiApiToken != "" { result.AiApiToken = s.AiApiToken } if s.AiName != "" { result.AiName = s.AiName } if s.AiModel != "" { result.AiModel = s.AiModel } if s.AiOrgID != "" { result.AiOrgID = s.AiOrgID } if s.AIApiVersion != "" { result.AIApiVersion = s.AIApiVersion } if s.AiProxyUrl != "" { result.AiProxyUrl = s.AiProxyUrl } if s.AiMaxTokens != 0 { result.AiMaxTokens = s.AiMaxTokens } if s.AiTimeoutMs != 0 { result.AiTimeoutMs = s.AiTimeoutMs } if s.AiFontSize != 0 { result.AiFontSize = s.AiFontSize } if s.AiFixedFontSize != 0 { result.AiFixedFontSize = s.AiFixedFontSize } if s.DisplayName != "" { result.DisplayName = s.DisplayName } if s.DisplayOrder != 0 { result.DisplayOrder = s.DisplayOrder } } return result } type ConfigError struct { File string `json:"file"` Err string `json:"err"` } type WebBookmark struct { Url string `json:"url"` Title string `json:"title,omitempty"` Icon string `json:"icon,omitempty"` IconColor string `json:"iconcolor,omitempty"` IconUrl string `json:"iconurl,omitempty"` DisplayOrder float64 `json:"display:order,omitempty"` } // Wave AI panel mode configuration (NEW) type AIModeConfigType struct { DisplayName string `json:"display:name"` DisplayOrder float64 `json:"display:order,omitempty"` DisplayIcon string `json:"display:icon,omitempty"` DisplayDescription string `json:"display:description,omitempty"` Provider string `json:"ai:provider,omitempty" jsonschema:"enum=wave,enum=google,enum=groq,enum=openrouter,enum=nanogpt,enum=openai,enum=azure,enum=azure-legacy,enum=custom"` APIType string `json:"ai:apitype,omitempty" jsonschema:"enum=google-gemini,enum=openai-responses,enum=openai-chat"` Model string `json:"ai:model,omitempty"` ThinkingLevel string `json:"ai:thinkinglevel,omitempty" jsonschema:"enum=low,enum=medium,enum=high"` Verbosity string `json:"ai:verbosity,omitempty" jsonschema:"enum=low,enum=medium,enum=high,description=Text verbosity level (OpenAI Responses API only)"` Endpoint string `json:"ai:endpoint,omitempty"` ProxyURL string `json:"ai:proxyurl,omitempty"` AzureAPIVersion string `json:"ai:azureapiversion,omitempty"` APIToken string `json:"ai:apitoken,omitempty"` APITokenSecretName string `json:"ai:apitokensecretname,omitempty"` AzureResourceName string `json:"ai:azureresourcename,omitempty"` AzureDeployment string `json:"ai:azuredeployment,omitempty"` Capabilities []string `json:"ai:capabilities,omitempty" jsonschema:"enum=pdfs,enum=images,enum=tools"` SwitchCompat []string `json:"ai:switchcompat,omitempty"` WaveAICloud bool `json:"waveai:cloud,omitempty"` WaveAIPremium bool `json:"waveai:premium,omitempty"` } type AIModeConfigUpdate struct { Configs map[string]AIModeConfigType `json:"configs"` } type FullConfigType struct { Settings SettingsType `json:"settings" merge:"meta"` MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` DefaultWidgets map[string]WidgetConfigType `json:"defaultwidgets"` Widgets map[string]WidgetConfigType `json:"widgets"` Presets map[string]waveobj.MetaMapType `json:"presets"` TermThemes map[string]TermThemeType `json:"termthemes"` Connections map[string]ConnKeywords `json:"connections"` Bookmarks map[string]WebBookmark `json:"bookmarks"` WaveAIModes map[string]AIModeConfigType `json:"waveai"` ConfigErrors []ConfigError `json:"configerrors" configfile:"-"` } type ConnKeywords struct { ConnWshEnabled *bool `json:"conn:wshenabled,omitempty"` ConnAskBeforeWshInstall *bool `json:"conn:askbeforewshinstall,omitempty"` ConnWshPath string `json:"conn:wshpath,omitempty"` ConnShellPath string `json:"conn:shellpath,omitempty"` ConnIgnoreSshConfig *bool `json:"conn:ignoresshconfig,omitempty"` DisplayHidden *bool `json:"display:hidden,omitempty"` DisplayOrder float32 `json:"display:order,omitempty"` TermClear bool `json:"term:*,omitempty"` TermFontSize float64 `json:"term:fontsize,omitempty"` TermFontFamily string `json:"term:fontfamily,omitempty"` TermTheme string `json:"term:theme,omitempty"` TermDurable *bool `json:"term:durable,omitempty"` CmdEnv map[string]string `json:"cmd:env,omitempty"` CmdInitScript string `json:"cmd:initscript,omitempty"` CmdInitScriptSh string `json:"cmd:initscript.sh,omitempty"` CmdInitScriptBash string `json:"cmd:initscript.bash,omitempty"` CmdInitScriptZsh string `json:"cmd:initscript.zsh,omitempty"` CmdInitScriptPwsh string `json:"cmd:initscript.pwsh,omitempty"` CmdInitScriptFish string `json:"cmd:initscript.fish,omitempty"` SshUser *string `json:"ssh:user,omitempty"` SshHostName *string `json:"ssh:hostname,omitempty"` SshPort *string `json:"ssh:port,omitempty"` SshIdentityFile []string `json:"ssh:identityfile,omitempty"` SshPasswordSecretName *string `json:"ssh:passwordsecretname,omitempty"` SshBatchMode *bool `json:"ssh:batchmode,omitempty"` SshPubkeyAuthentication *bool `json:"ssh:pubkeyauthentication,omitempty"` SshPasswordAuthentication *bool `json:"ssh:passwordauthentication,omitempty"` SshKbdInteractiveAuthentication *bool `json:"ssh:kbdinteractiveauthentication,omitempty"` SshPreferredAuthentications []string `json:"ssh:preferredauthentications,omitempty"` SshAddKeysToAgent *bool `json:"ssh:addkeystoagent,omitempty"` SshIdentityAgent *string `json:"ssh:identityagent,omitempty"` SshIdentitiesOnly *bool `json:"ssh:identitiesonly,omitempty"` SshProxyJump []string `json:"ssh:proxyjump,omitempty"` SshUserKnownHostsFile []string `json:"ssh:userknownhostsfile,omitempty"` SshGlobalKnownHostsFile []string `json:"ssh:globalknownhostsfile,omitempty"` } func DefaultBoolPtr(arg *bool, def bool) bool { if arg == nil { return def } return *arg } func goBackWS(barr []byte, offset int) int { if offset >= len(barr) { offset = offset - 1 } for i := offset - 1; i >= 0; i-- { if barr[i] == ' ' || barr[i] == '\t' || barr[i] == '\n' || barr[i] == '\r' { continue } return i } return 0 } func isTrailingCommaError(barr []byte, offset int) bool { if offset >= len(barr) { offset = offset - 1 } offset = goBackWS(barr, offset) if barr[offset] == '}' { offset = goBackWS(barr, offset) if barr[offset] == ',' { return true } } return false } func resolveEnvReplacements(m waveobj.MetaMapType) { if m == nil { return } for key, value := range m { switch v := value.(type) { case string: if resolved, ok := resolveEnvValue(v); ok { m[key] = resolved } case map[string]interface{}: resolveEnvReplacements(waveobj.MetaMapType(v)) case []interface{}: resolveEnvArray(v) } } } func resolveEnvArray(arr []interface{}) { for i, value := range arr { switch v := value.(type) { case string: if resolved, ok := resolveEnvValue(v); ok { arr[i] = resolved } case map[string]interface{}: resolveEnvReplacements(waveobj.MetaMapType(v)) case []interface{}: resolveEnvArray(v) } } } func resolveEnvValue(value string) (string, bool) { if !strings.HasPrefix(value, "$ENV:") { return "", false } envSpec := value[5:] // Remove "$ENV:" prefix parts := strings.SplitN(envSpec, ":", 2) envVar := parts[0] var fallback string if len(parts) > 1 { fallback = parts[1] } // Get the environment variable value if envValue, exists := os.LookupEnv(envVar); exists { return envValue, true } // Return fallback if provided, otherwise return empty string if fallback != "" { return fallback, true } return "", true } func readConfigHelper(fileName string, barr []byte, readErr error) (waveobj.MetaMapType, []ConfigError) { var cerrs []ConfigError if readErr != nil && !os.IsNotExist(readErr) { cerrs = append(cerrs, ConfigError{File: fileName, Err: readErr.Error()}) } if len(barr) == 0 { return nil, cerrs } var rtn waveobj.MetaMapType err := json.Unmarshal(barr, &rtn) if err != nil { if syntaxErr, ok := err.(*json.SyntaxError); ok { offset := syntaxErr.Offset if offset > 0 { offset = offset - 1 } lineNum, colNum := utilfn.GetLineColFromOffset(barr, int(offset)) isTrailingComma := isTrailingCommaError(barr, int(offset)) if isTrailingComma { err = fmt.Errorf("json syntax error at line %d, col %d: probably an extra trailing comma: %v", lineNum, colNum, syntaxErr) } else { err = fmt.Errorf("json syntax error at line %d, col %d: %v", lineNum, colNum, syntaxErr) } } cerrs = append(cerrs, ConfigError{File: fileName, Err: err.Error()}) } // Resolve environment variable replacements if rtn != nil { resolveEnvReplacements(rtn) } return rtn, cerrs } func readConfigFileFS(fsys fs.FS, logPrefix string, fileName string) (waveobj.MetaMapType, []ConfigError) { barr, readErr := fs.ReadFile(fsys, fileName) if readErr != nil { // If we get an error, we may be using the wrong path separator for the given FS interface. Try switching the separator. barr, readErr = fs.ReadFile(fsys, filepath.ToSlash(fileName)) } return readConfigHelper(logPrefix+fileName, barr, readErr) } func ReadDefaultsConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { return readConfigFileFS(defaultconfig.ConfigFS, "defaults:", fileName) } func ReadWaveHomeConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { configDirAbsPath := wavebase.GetWaveConfigDir() configDirFsys := os.DirFS(configDirAbsPath) return readConfigFileFS(configDirFsys, "", fileName) } func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { configWriteLock.Lock() defer configWriteLock.Unlock() configDirAbsPath := wavebase.GetWaveConfigDir() fullFileName := filepath.Join(configDirAbsPath, fileName) barr, err := jsonMarshalConfigInOrder(m) if err != nil { return err } return fileutil.AtomicWriteFile(fullFileName, barr, 0644) } // simple merge that overwrites func mergeMetaMapSimple(m waveobj.MetaMapType, toMerge waveobj.MetaMapType) waveobj.MetaMapType { if m == nil { return toMerge } if toMerge == nil { return m } for k, v := range toMerge { if v == nil { delete(m, k) continue } m[k] = v } if len(m) == 0 { return nil } return m } func mergeMetaMap(m waveobj.MetaMapType, toMerge waveobj.MetaMapType, simpleMerge bool) waveobj.MetaMapType { if simpleMerge { return mergeMetaMapSimple(m, toMerge) } else { return waveobj.MergeMeta(m, toMerge, true) } } func selectDirEntsBySuffix(dirEnts []fs.DirEntry, fileNameSuffix string) []fs.DirEntry { var rtn []fs.DirEntry for _, ent := range dirEnts { if ent.IsDir() { continue } if !strings.HasSuffix(ent.Name(), fileNameSuffix) { continue } rtn = append(rtn, ent) } return rtn } func SortFileNameDescend(files []fs.DirEntry) { sort.Slice(files, func(i, j int) bool { return files[i].Name() > files[j].Name() }) } // Read and merge all files in the specified directory matching the supplied suffix func readConfigFilesForDir(fsys fs.FS, logPrefix string, dirName string, fileName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { dirEnts, _ := fs.ReadDir(fsys, dirName) suffixEnts := selectDirEntsBySuffix(dirEnts, fileName+".json") SortFileNameDescend(suffixEnts) var rtn waveobj.MetaMapType var errs []ConfigError for _, ent := range suffixEnts { fileVal, cerrs := readConfigFileFS(fsys, logPrefix, filepath.Join(dirName, ent.Name())) rtn = mergeMetaMap(rtn, fileVal, simpleMerge) errs = append(errs, cerrs...) } return rtn, errs } // Read and merge all files in the specified config filesystem matching the patterns `<partName>.json` and `<partName>/*.json` func readConfigPartForFS(fsys fs.FS, logPrefix string, partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { config, errs := readConfigFilesForDir(fsys, logPrefix, partName, "", simpleMerge) allErrs := errs rtn := config config, errs = readConfigFileFS(fsys, logPrefix, partName+".json") allErrs = append(allErrs, errs...) return mergeMetaMap(rtn, config, simpleMerge), allErrs } // Combine files from the defaults and home directory for the specified config part name func readConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { configDirAbsPath := wavebase.GetWaveConfigDir() configDirFsys := os.DirFS(configDirAbsPath) defaultConfigs, cerrs := readConfigPartForFS(defaultconfig.ConfigFS, "defaults:", partName, simpleMerge) homeConfigs, cerrs1 := readConfigPartForFS(configDirFsys, "", partName, simpleMerge) rtn := defaultConfigs allErrs := append(cerrs, cerrs1...) return mergeMetaMap(rtn, homeConfigs, simpleMerge), allErrs } // this function should only be called by the wconfig code. // in golang code, the best way to get the current config is via the watcher -- wconfig.GetWatcher().GetFullConfig() func ReadFullConfig() FullConfigType { var fullConfig FullConfigType configRType := reflect.TypeOf(fullConfig) configRVal := reflect.ValueOf(&fullConfig).Elem() for fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ { field := configRType.Field(fieldIdx) if field.PkgPath != "" { continue } configFile := field.Tag.Get("configfile") if configFile == "-" { continue } jsonTag := utilfn.GetJsonTag(field) simpleMerge := field.Tag.Get("merge") == "" var configPart waveobj.MetaMapType var errs []ConfigError if jsonTag == "-" || jsonTag == "" { continue } else { configPart, errs = readConfigPart(jsonTag, simpleMerge) } fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, errs...) if configPart != nil { fieldPtr := configRVal.Field(fieldIdx).Addr().Interface() utilfn.ReUnmarshal(fieldPtr, configPart) } } return fullConfig } func GetConfigSubdirs() []string { var fullConfig FullConfigType configRType := reflect.TypeOf(fullConfig) var retVal []string configDirAbsPath := wavebase.GetWaveConfigDir() for fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ { field := configRType.Field(fieldIdx) if field.PkgPath != "" { continue } configFile := field.Tag.Get("configfile") if configFile == "-" { continue } jsonTag := utilfn.GetJsonTag(field) if jsonTag != "-" && jsonTag != "" && jsonTag != "settings" { retVal = append(retVal, filepath.Join(configDirAbsPath, jsonTag)) } } log.Printf("subdirs: %v\n", retVal) return retVal } func getConfigKeyType(configKey string) reflect.Type { ctype := reflect.TypeOf(SettingsType{}) for i := 0; i < ctype.NumField(); i++ { field := ctype.Field(i) jsonTag := utilfn.GetJsonTag(field) if jsonTag == configKey { return field.Type } } return nil } func getConfigKeyNamespace(key string) string { colonIdx := strings.Index(key, ":") if colonIdx == -1 { return "" } return key[:colonIdx] } func orderConfigKeys(m waveobj.MetaMapType) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { k1 := keys[i] k2 := keys[j] k1ns := getConfigKeyNamespace(k1) k2ns := getConfigKeyNamespace(k2) if k1ns != k2ns { return k1ns < k2ns } return k1 < k2 }) return keys } func reindentJson(barr []byte, indentStr string) []byte { if len(barr) < 2 { return barr } if barr[0] != '{' && barr[0] != '[' { return barr } if !bytes.Contains(barr, []byte("\n")) { return barr } outputLines := bytes.Split(barr, []byte("\n")) for i, line := range outputLines { if i == 0 { continue } outputLines[i] = append([]byte(indentStr), line...) } return bytes.Join(outputLines, []byte("\n")) } func jsonMarshalConfigInOrder(m waveobj.MetaMapType) ([]byte, error) { if len(m) == 0 { return []byte("{}"), nil } var buf bytes.Buffer orderedKeys := orderConfigKeys(m) buf.WriteString("{\n") for idx, key := range orderedKeys { val := m[key] keyBarr, err := json.Marshal(key) if err != nil { return nil, err } valBarr, err := json.MarshalIndent(val, "", " ") if err != nil { return nil, err } valBarr = reindentJson(valBarr, " ") buf.WriteString(" ") buf.Write(keyBarr) buf.WriteString(": ") buf.Write(valBarr) if idx < len(orderedKeys)-1 { buf.WriteString(",") } buf.WriteString("\n") } buf.WriteString("}") return buf.Bytes(), nil } var dummyNumber json.Number func convertJsonNumber(num json.Number, ctype reflect.Type) (interface{}, error) { // ctype might be int64, float64, string, *int64, *float64, *string // switch on ctype first if ctype.Kind() == reflect.Pointer { ctype = ctype.Elem() } if reflect.Int64 == ctype.Kind() { if ival, err := num.Int64(); err == nil { return ival, nil } return nil, fmt.Errorf("invalid number for int64: %s", num) } if reflect.Float64 == ctype.Kind() { if fval, err := num.Float64(); err == nil { return fval, nil } return nil, fmt.Errorf("invalid number for float64: %s", num) } if reflect.String == ctype.Kind() { return num.String(), nil } return nil, fmt.Errorf("cannot convert number to %s", ctype) } func SetBaseConfigValue(toMerge waveobj.MetaMapType) error { m, cerrs := ReadWaveHomeConfigFile(SettingsFile) if len(cerrs) > 0 { return fmt.Errorf("error reading config file: %v", cerrs[0]) } if m == nil { m = make(waveobj.MetaMapType) } for configKey, val := range toMerge { ctype := getConfigKeyType(configKey) if ctype == nil { return fmt.Errorf("invalid config key: %s", configKey) } if val == nil { delete(m, configKey) } else { rtype := reflect.TypeOf(val) if rtype == reflect.TypeOf(dummyNumber) { convertedVal, err := convertJsonNumber(val.(json.Number), ctype) if err != nil { return fmt.Errorf("cannot convert %s: %v", configKey, err) } val = convertedVal rtype = reflect.TypeOf(val) } if rtype != ctype { if ctype == reflect.PointerTo(rtype) { m[configKey] = &val } else { return fmt.Errorf("invalid value type for %s: %T", configKey, val) } } m[configKey] = val } } return WriteWaveHomeConfigFile(SettingsFile, m) } func SetConnectionsConfigValue(connName string, toMerge waveobj.MetaMapType) error { m, cerrs := ReadWaveHomeConfigFile(ConnectionsFile) if len(cerrs) > 0 { return fmt.Errorf("error reading config file: %v", cerrs[0]) } if m == nil { m = make(waveobj.MetaMapType) } connData := m.GetMap(connName) if connData == nil { connData = make(waveobj.MetaMapType) } for configKey, val := range toMerge { connData[configKey] = val } m[connName] = connData return WriteWaveHomeConfigFile(ConnectionsFile, m) } type WidgetConfigType struct { DisplayOrder float64 `json:"display:order,omitempty"` DisplayHidden bool `json:"display:hidden,omitempty"` Icon string `json:"icon,omitempty"` Color string `json:"color,omitempty"` Label string `json:"label,omitempty"` Description string `json:"description,omitempty"` Workspaces []string `json:"workspaces,omitempty"` Magnified bool `json:"magnified,omitempty"` BlockDef waveobj.BlockDef `json:"blockdef"` } type BgPresetsType struct { BgClear bool `json:"bg:*,omitempty"` Bg string `json:"bg,omitempty" jsonschema_description:"CSS background property value"` BgOpacity float64 `json:"bg:opacity,omitempty" jsonschema_description:"Background opacity (0.0-1.0)"` BgBlendMode string `json:"bg:blendmode,omitempty" jsonschema_description:"CSS background-blend-mode property value"` BgBorderColor string `json:"bg:bordercolor,omitempty" jsonschema_description:"Block frame border color"` BgActiveBorderColor string `json:"bg:activebordercolor,omitempty" jsonschema_description:"Block frame focused border color"` DisplayName string `json:"display:name,omitempty" jsonschema_description:"The name shown in the context menu"` DisplayOrder float64 `json:"display:order,omitempty" jsonschema_description:"Determines the order of the background in the context menu"` } type MimeTypeConfigType struct { Icon string `json:"icon"` Color string `json:"color"` } type TermThemeType struct { DisplayName string `json:"display:name"` DisplayOrder float64 `json:"display:order"` Black string `json:"black"` Red string `json:"red"` Green string `json:"green"` Yellow string `json:"yellow"` Blue string `json:"blue"` Magenta string `json:"magenta"` Cyan string `json:"cyan"` White string `json:"white"` BrightBlack string `json:"brightBlack"` BrightRed string `json:"brightRed"` BrightGreen string `json:"brightGreen"` BrightYellow string `json:"brightYellow"` BrightBlue string `json:"brightBlue"` BrightMagenta string `json:"brightMagenta"` BrightCyan string `json:"brightCyan"` BrightWhite string `json:"brightWhite"` Gray string `json:"gray"` CmdText string `json:"cmdtext"` Foreground string `json:"foreground"` SelectionBackground string `json:"selectionBackground"` Background string `json:"background"` Cursor string `json:"cursor"` } // CountCustomWidgets returns the number of custom widgets the user has defined. // Custom widgets are identified as widgets whose ID doesn't start with "defwidget@". func (fc *FullConfigType) CountCustomWidgets() int { count := 0 for widgetID := range fc.Widgets { if !strings.HasPrefix(widgetID, "defwidget@") { count++ } } return count } // CountCustomAIPresets returns the number of custom AI presets the user has defined. // Custom AI presets are identified as presets that start with "ai@" but aren't "ai@global" or "ai@wave". func (fc *FullConfigType) CountCustomAIPresets() int { count := 0 for presetID := range fc.Presets { if strings.HasPrefix(presetID, "ai@") && presetID != "ai@global" && presetID != "ai@wave" { count++ } } return count } // CountCustomAIModes returns the number of custom AI modes the user has defined. // Custom AI modes are identified as modes that don't start with "waveai@". func (fc *FullConfigType) CountCustomAIModes() int { count := 0 for modeID := range fc.WaveAIModes { if !strings.HasPrefix(modeID, "waveai@") { count++ } } return count } // CountCustomSettings returns the number of settings in the user's settings file. // This excludes telemetry:enabled and autoupdate:channel which don't count as customizations. func CountCustomSettings() int { // Load user settings userSettings, _ := ReadWaveHomeConfigFile("settings.json") if userSettings == nil { return 0 } // Count all keys except telemetry:enabled and autoupdate:channel count := 0 for key := range userSettings { if key == "telemetry:enabled" || key == "autoupdate:channel" { continue } count++ } return count } ================================================ FILE: pkg/wcore/badge.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wcore import ( "log" "sync" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) // BadgeStore is an in-memory store for transient badges. // Badges are not persisted and are cleared on restart. // Values are stored by value (not pointer) to prevent external mutation. type BadgeStore struct { lock *sync.Mutex transient map[string]baseds.Badge // keyed by oref string } var globalBadgeStore = &BadgeStore{ lock: &sync.Mutex{}, transient: make(map[string]baseds.Badge), } // InitBadgeStore subscribes to incoming badge events. func InitBadgeStore() error { log.Printf("initializing badge store\n") rpcClient := wshclient.GetBareRpcClient() rpcClient.EventListener.On(wps.Event_Badge, handleBadgeEvent) wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{ Event: wps.Event_Badge, AllScopes: true, }, nil) return nil } func handleBadgeEvent(event *wps.WaveEvent) { if event.Event != wps.Event_Badge { return } var data baseds.BadgeEvent err := utilfn.ReUnmarshal(&data, event.Data) if err != nil { log.Printf("badge store: error unmarshaling BadgeEvent: %v\n", err) return } if data.ClearAll { clearAllBadges() return } if data.ORef == "" { log.Printf("badge store: received badge event with empty oref\n") return } oref, err := waveobj.ParseORef(data.ORef) if err != nil { log.Printf("badge store: error parsing oref %q: %v\n", data.ORef, err) return } if oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab { log.Printf("badge store: can only handle block/tab orefs") return } setBadge(oref, data) } // cmpBadge compares two badges by priority then by badgeid (both descending). // Returns 1 if a > b, -1 if a < b, 0 if equal. func cmpBadge(a, b baseds.Badge) int { if a.Priority != b.Priority { if a.Priority > b.Priority { return 1 } return -1 } if a.BadgeId != b.BadgeId { if a.BadgeId > b.BadgeId { return 1 } return -1 } return 0 } // setBadge updates the in-memory transient map. func setBadge(oref waveobj.ORef, data baseds.BadgeEvent) { globalBadgeStore.lock.Lock() defer globalBadgeStore.lock.Unlock() orefStr := oref.String() if orefStr == "" { return } if data.ClearById != "" { existing, ok := globalBadgeStore.transient[orefStr] if !ok || existing.BadgeId != data.ClearById { return } delete(globalBadgeStore.transient, orefStr) log.Printf("badge store: badge cleared by id: oref=%s id=%s\n", orefStr, data.ClearById) return } if data.Clear { delete(globalBadgeStore.transient, orefStr) log.Printf("badge store: badge cleared: oref=%s\n", orefStr) return } if data.Badge == nil { return } incoming := *data.Badge existing, hasExisting := globalBadgeStore.transient[orefStr] if !hasExisting || cmpBadge(incoming, existing) > 0 { globalBadgeStore.transient[orefStr] = incoming log.Printf("badge store: badge set: oref=%s badge=%+v\n", orefStr, incoming) } } // clearAllBadges removes all badges from the transient store. func clearAllBadges() { globalBadgeStore.lock.Lock() defer globalBadgeStore.lock.Unlock() count := len(globalBadgeStore.transient) globalBadgeStore.transient = make(map[string]baseds.Badge) log.Printf("badge store: cleared all %d badges\n", count) } // GetAllBadges returns a snapshot of all currently active badges. func GetAllBadges() []baseds.BadgeEvent { globalBadgeStore.lock.Lock() defer globalBadgeStore.lock.Unlock() result := make([]baseds.BadgeEvent, 0, len(globalBadgeStore.transient)) for orefStr, badge := range globalBadgeStore.transient { b := badge // copy result = append(result, baseds.BadgeEvent{ ORef: orefStr, Badge: &b, }) } return result } ================================================ FILE: pkg/wcore/block.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wcore import ( "context" "fmt" "log" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" ) func CreateSubBlock(ctx context.Context, blockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) { if blockDef == nil { return nil, fmt.Errorf("blockDef is nil") } if blockDef.Meta == nil || blockDef.Meta.GetString(waveobj.MetaKey_View, "") == "" { return nil, fmt.Errorf("no view provided for new block") } blockData, err := createSubBlockObj(ctx, blockId, blockDef) if err != nil { return nil, fmt.Errorf("error creating sub block: %w", err) } return blockData, nil } func createSubBlockObj(ctx context.Context, parentBlockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) { return wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) (*waveobj.Block, error) { parentBlock, _ := wstore.DBGet[*waveobj.Block](tx.Context(), parentBlockId) if parentBlock == nil { return nil, fmt.Errorf("parent block not found: %q", parentBlockId) } blockId := uuid.NewString() blockData := &waveobj.Block{ OID: blockId, ParentORef: waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(), RuntimeOpts: nil, Meta: blockDef.Meta, } wstore.DBInsert(tx.Context(), blockData) parentBlock.SubBlockIds = append(parentBlock.SubBlockIds, blockId) wstore.DBUpdate(tx.Context(), parentBlock) return blockData, nil }) } func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (rtnBlock *waveobj.Block, rtnErr error) { return CreateBlockWithTelemetry(ctx, tabId, blockDef, rtOpts, true) } func CreateBlockWithTelemetry(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts, recordTelemetry bool) (rtnBlock *waveobj.Block, rtnErr error) { var blockCreated bool var newBlockOID string defer func() { if rtnErr == nil { return } // if there was an error, and we created the block, clean it up since the function failed if blockCreated && newBlockOID != "" { deleteBlockObj(ctx, newBlockOID) filestore.WFS.DeleteZone(ctx, newBlockOID) } }() if blockDef == nil { return nil, fmt.Errorf("blockDef is nil") } if blockDef.Meta == nil || blockDef.Meta.GetString(waveobj.MetaKey_View, "") == "" { return nil, fmt.Errorf("no view provided for new block") } blockData, err := createBlockObj(ctx, tabId, blockDef, rtOpts) if err != nil { return nil, fmt.Errorf("error creating block: %w", err) } blockCreated = true newBlockOID = blockData.OID // upload the files if present if len(blockDef.Files) > 0 { for fileName, fileDef := range blockDef.Files { err := filestore.WFS.MakeFile(ctx, newBlockOID, fileName, fileDef.Meta, wshrpc.FileOpts{}) if err != nil { return nil, fmt.Errorf("error making blockfile %q: %w", fileName, err) } err = filestore.WFS.WriteFile(ctx, newBlockOID, fileName, []byte(fileDef.Content)) if err != nil { return nil, fmt.Errorf("error writing blockfile %q: %w", fileName, err) } } } if recordTelemetry { blockView := blockDef.Meta.GetString(waveobj.MetaKey_View, "") blockController := blockDef.Meta.GetString(waveobj.MetaKey_Controller, "") go recordBlockCreationTelemetry(blockView, blockController) } return blockData, nil } func recordBlockCreationTelemetry(blockView string, blockController string) { defer func() { panichandler.PanicHandler("CreateBlock:telemetry", recover()) }() if blockView == "" { return } tctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() telemetry.UpdateActivity(tctx, wshrpc.ActivityUpdate{ Renderers: map[string]int{blockView: 1}, }) telemetry.RecordTEvent(tctx, &telemetrydata.TEvent{ Event: "action:createblock", Props: telemetrydata.TEventProps{ BlockView: blockView, BlockController: blockController, }, }) } func createBlockObj(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) { return wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) (*waveobj.Block, error) { tab, _ := wstore.DBGet[*waveobj.Tab](tx.Context(), tabId) if tab == nil { return nil, fmt.Errorf("tab not found: %q", tabId) } blockId := uuid.NewString() blockData := &waveobj.Block{ OID: blockId, ParentORef: waveobj.MakeORef(waveobj.OType_Tab, tabId).String(), RuntimeOpts: rtOpts, Meta: blockDef.Meta, } wstore.DBInsert(tx.Context(), blockData) tab.BlockIds = append(tab.BlockIds, blockId) wstore.DBUpdate(tx.Context(), tab) return blockData, nil }) } // Must delete all blocks individually first. // Also deletes LayoutState. // recursive: if true, will recursively close parent tab, window, workspace, if they are empty. // Returns new active tab id, error. func DeleteBlock(ctx context.Context, blockId string, recursive bool) error { block, err := wstore.DBGet[*waveobj.Block](ctx, blockId) if err != nil { return fmt.Errorf("error getting block: %w", err) } if block == nil { return nil } if len(block.SubBlockIds) > 0 { for _, subBlockId := range block.SubBlockIds { err := DeleteBlock(ctx, subBlockId, recursive) if err != nil { return fmt.Errorf("error deleting subblock %s: %w", subBlockId, err) } } } parentBlockCount, err := deleteBlockObj(ctx, blockId) if err != nil { return fmt.Errorf("error deleting block: %w", err) } log.Printf("DeleteBlock: parentBlockCount: %d", parentBlockCount) parentORef := waveobj.ParseORefNoErr(block.ParentORef) if recursive && parentORef.OType == waveobj.OType_Tab && parentBlockCount == 0 { // if parent tab has no blocks, delete the tab log.Printf("DeleteBlock: parent tab has no blocks, deleting tab %s", parentORef.OID) parentWorkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, parentORef.OID) if err != nil { return fmt.Errorf("error finding workspace for tab to delete %s: %w", parentORef.OID, err) } newActiveTabId, err := DeleteTab(ctx, parentWorkspaceId, parentORef.OID, true) if err != nil { return fmt.Errorf("error deleting tab %s: %w", parentORef.OID, err) } SendActiveTabUpdate(ctx, parentWorkspaceId, newActiveTabId) } sendBlockCloseEvent(blockId) return nil } // returns the updated block count for the parent object func deleteBlockObj(ctx context.Context, blockId string) (int, error) { return wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) (int, error) { block, err := wstore.DBGet[*waveobj.Block](tx.Context(), blockId) if err != nil { return -1, fmt.Errorf("error getting block: %w", err) } if block == nil { return -1, fmt.Errorf("block not found: %q", blockId) } if len(block.SubBlockIds) > 0 { return -1, fmt.Errorf("block has subblocks, must delete subblocks first") } parentORef := waveobj.ParseORefNoErr(block.ParentORef) parentBlockCount := -1 if parentORef != nil { if parentORef.OType == waveobj.OType_Tab { tab, _ := wstore.DBGet[*waveobj.Tab](tx.Context(), parentORef.OID) if tab != nil { tab.BlockIds = utilfn.RemoveElemFromSlice(tab.BlockIds, blockId) wstore.DBUpdate(tx.Context(), tab) parentBlockCount = len(tab.BlockIds) } } else if parentORef.OType == waveobj.OType_Block { parentBlock, _ := wstore.DBGet[*waveobj.Block](tx.Context(), parentORef.OID) if parentBlock != nil { parentBlock.SubBlockIds = utilfn.RemoveElemFromSlice(parentBlock.SubBlockIds, blockId) wstore.DBUpdate(tx.Context(), parentBlock) parentBlockCount = len(parentBlock.SubBlockIds) } } } wstore.DBDelete(tx.Context(), waveobj.OType_Block, blockId) // Clean up block runtime info blockORef := waveobj.MakeORef(waveobj.OType_Block, blockId) wstore.DeleteRTInfo(blockORef) return parentBlockCount, nil }) } func sendBlockCloseEvent(blockId string) { waveEvent := wps.WaveEvent{ Event: wps.Event_BlockClose, Scopes: []string{ waveobj.MakeORef(waveobj.OType_Block, blockId).String(), }, Data: blockId, } wps.Broker.Publish(waveEvent) } ================================================ FILE: pkg/wcore/layout.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wcore import ( "context" "fmt" "log" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wstore" ) const ( LayoutActionDataType_Insert = "insert" LayoutActionDataType_InsertAtIndex = "insertatindex" LayoutActionDataType_Remove = "delete" LayoutActionDataType_ClearTree = "clear" LayoutActionDataType_Replace = "replace" LayoutActionDataType_SplitHorizontal = "splithorizontal" LayoutActionDataType_SplitVertical = "splitvertical" LayoutActionDataType_CleanupOrphaned = "cleanuporphaned" ) type PortableLayout []struct { IndexArr []int `json:"indexarr"` Size *uint `json:"size,omitempty"` BlockDef *waveobj.BlockDef `json:"blockdef"` Focused bool `json:"focused"` } func GetStarterLayout() PortableLayout { return PortableLayout{ {IndexArr: []int{0}, BlockDef: &waveobj.BlockDef{ Meta: waveobj.MetaMapType{ waveobj.MetaKey_View: "term", waveobj.MetaKey_Controller: "shell", }, }, Focused: true}, {IndexArr: []int{1}, BlockDef: &waveobj.BlockDef{ Meta: waveobj.MetaMapType{ waveobj.MetaKey_View: "sysinfo", }, }}, {IndexArr: []int{1, 1}, BlockDef: &waveobj.BlockDef{ Meta: waveobj.MetaMapType{ waveobj.MetaKey_View: "web", waveobj.MetaKey_Url: "https://github.com/wavetermdev/waveterm", }, }}, {IndexArr: []int{1, 2}, BlockDef: &waveobj.BlockDef{ Meta: waveobj.MetaMapType{ waveobj.MetaKey_View: "preview", waveobj.MetaKey_File: "~", }, }}, } } func GetNewTabLayout() PortableLayout { return PortableLayout{ {IndexArr: []int{0}, BlockDef: &waveobj.BlockDef{ Meta: waveobj.MetaMapType{ waveobj.MetaKey_View: "term", waveobj.MetaKey_Controller: "shell", }, }, Focused: true}, } } func GetLayoutIdForTab(ctx context.Context, tabId string) (string, error) { tabObj, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) if err != nil { return "", fmt.Errorf("unable to get layout id for given tab id %s: %w", tabId, err) } return tabObj.LayoutState, nil } func QueueLayoutAction(ctx context.Context, layoutStateId string, actions ...waveobj.LayoutActionData) error { layoutStateObj, err := wstore.DBGet[*waveobj.LayoutState](ctx, layoutStateId) if err != nil { return fmt.Errorf("unable to get layout state for given id %s: %w", layoutStateId, err) } for i := range actions { if actions[i].ActionId == "" { actions[i].ActionId = uuid.New().String() } } if layoutStateObj.PendingBackendActions == nil { layoutStateObj.PendingBackendActions = &actions } else { *layoutStateObj.PendingBackendActions = append(*layoutStateObj.PendingBackendActions, actions...) } err = wstore.DBUpdate(ctx, layoutStateObj) if err != nil { return fmt.Errorf("unable to update layout state with new actions: %w", err) } return nil } func QueueLayoutActionForTab(ctx context.Context, tabId string, actions ...waveobj.LayoutActionData) error { layoutStateId, err := GetLayoutIdForTab(ctx, tabId) if err != nil { return err } return QueueLayoutAction(ctx, layoutStateId, actions...) } func ApplyPortableLayout(ctx context.Context, tabId string, layout PortableLayout, recordTelemetry bool) error { actions := make([]waveobj.LayoutActionData, len(layout)+1) actions[0] = waveobj.LayoutActionData{ActionType: LayoutActionDataType_ClearTree} for i := 0; i < len(layout); i++ { layoutAction := layout[i] blockData, err := CreateBlockWithTelemetry(ctx, tabId, layoutAction.BlockDef, &waveobj.RuntimeOpts{}, recordTelemetry) if err != nil { return fmt.Errorf("unable to create block to apply portable layout to tab %s: %w", tabId, err) } actions[i+1] = waveobj.LayoutActionData{ ActionType: LayoutActionDataType_InsertAtIndex, BlockId: blockData.OID, IndexArr: &layoutAction.IndexArr, NodeSize: layoutAction.Size, Focused: layoutAction.Focused, } } err := QueueLayoutActionForTab(ctx, tabId, actions...) if err != nil { return fmt.Errorf("unable to queue layout actions for portable layout: %w", err) } return nil } func BootstrapStarterLayout(ctx context.Context) error { ctx, cancelFn := context.WithTimeout(ctx, 2*time.Second) defer cancelFn() client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) if err != nil { log.Printf("unable to find client: %v\n", err) return fmt.Errorf("unable to find client: %w", err) } if len(client.WindowIds) < 1 { return fmt.Errorf("error bootstrapping layout, no windows exist") } windowId := client.WindowIds[0] window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) if err != nil { return fmt.Errorf("error getting window: %w", err) } workspace, err := wstore.DBMustGet[*waveobj.Workspace](ctx, window.WorkspaceId) if err != nil { return fmt.Errorf("error getting workspace: %w", err) } tabId := workspace.ActiveTabId starterLayout := GetStarterLayout() err = ApplyPortableLayout(ctx, tabId, starterLayout, false) if err != nil { return fmt.Errorf("error applying starter layout: %w", err) } return nil } ================================================ FILE: pkg/wcore/wcore.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // wave core application coordinator package wcore import ( "context" "crypto/ed25519" "crypto/x509" "encoding/base64" "encoding/pem" "fmt" "log" "strings" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcloud" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) // the wcore package coordinates actions across the storage layer // orchestrating the wave object store, the wave pubsub system, and the wave rpc system // Ensures that the initial data is present in the store, creates an initial window if needed func EnsureInitialData() (bool, error) { // does not need to run in a transaction since it is called on startup ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) firstLaunch := false if err == wstore.ErrNotFound { client, err = CreateClient(ctx) if err != nil { return false, fmt.Errorf("error creating client: %w", err) } firstLaunch = true } if client.TempOID == "" { log.Println("client.TempOID is empty") client.TempOID = uuid.NewString() err = wstore.DBUpdate(ctx, client) if err != nil { return firstLaunch, fmt.Errorf("error updating client: %w", err) } } if client.InstallId == "" { log.Println("client.InstallId is empty") client.InstallId = uuid.NewString() err = wstore.DBUpdate(ctx, client) if err != nil { return firstLaunch, fmt.Errorf("error updating client: %w", err) } } log.Printf("clientid: %s\n", client.OID) wstore.SetClientId(client.OID) if len(client.WindowIds) == 1 { log.Println("client has one window") CheckAndFixWindow(ctx, client.WindowIds[0]) return firstLaunch, nil } if len(client.WindowIds) > 0 { log.Println("client has windows") return firstLaunch, nil } wsId := "" if firstLaunch { log.Println("client has no windows and first launch, creating starter workspace") starterWs, err := CreateWorkspace(ctx, "Starter workspace", "custom@wave-logo-solid", "#58C142", false, true) if err != nil { return firstLaunch, fmt.Errorf("error creating starter workspace: %w", err) } wsId = starterWs.OID } _, err = CreateWindow(ctx, nil, wsId) if err != nil { return firstLaunch, fmt.Errorf("error creating window: %w", err) } return firstLaunch, nil } func CreateClient(ctx context.Context) (*waveobj.Client, error) { client := &waveobj.Client{ OID: uuid.NewString(), WindowIds: []string{}, } err := wstore.DBInsert(ctx, client) if err != nil { return nil, fmt.Errorf("error inserting client: %w", err) } return client, nil } func GetClientData(ctx context.Context) (*waveobj.Client, error) { clientData, err := wstore.DBGetSingleton[*waveobj.Client](ctx) if err != nil { return nil, fmt.Errorf("error getting client data: %w", err) } return clientData, nil } func SendWaveObjUpdate(oref waveobj.ORef) { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() // send a waveobj:update event waveObj, err := wstore.DBGetORef(ctx, oref) if err != nil { log.Printf("error getting object for update event: %v", err) return } wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveObjUpdate, Scopes: []string{oref.String()}, Data: waveobj.WaveObjUpdate{ UpdateType: waveobj.UpdateType_Update, OType: waveObj.GetOType(), OID: waveobj.GetOID(waveObj), Obj: waveObj, }, }) } func ResolveBlockIdFromPrefix(ctx context.Context, tabId string, blockIdPrefix string) (string, error) { if len(blockIdPrefix) != 8 { return "", fmt.Errorf("widget_id must be 8 characters") } tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId) if err != nil { return "", fmt.Errorf("error getting tab: %w", err) } for _, blockId := range tab.BlockIds { if strings.HasPrefix(blockId, blockIdPrefix) { return blockId, nil } } return "", fmt.Errorf("widget_id not found: %q", blockIdPrefix) } func GoSendNoTelemetryUpdate(telemetryEnabled bool) { go func() { defer func() { panichandler.PanicHandler("GoSendNoTelemetryUpdate", recover()) }() ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() clientId := wstore.GetClientId() err := wcloud.SendNoTelemetryUpdate(ctx, clientId, !telemetryEnabled) if err != nil { log.Printf("[error] sending no-telemetry update: %v\n", err) return } }() } func InitMainServer() error { ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() mainServer, err := wstore.DBGetSingleton[*waveobj.MainServer](ctx) if err == wstore.ErrNotFound { mainServer = &waveobj.MainServer{ OID: uuid.NewString(), } err = wstore.DBInsert(ctx, mainServer) if err != nil { return fmt.Errorf("error inserting mainserver: %w", err) } } else if err != nil { return fmt.Errorf("error getting mainserver: %w", err) } needsUpdate := false if mainServer.JwtPrivateKey == "" || mainServer.JwtPublicKey == "" { keyPair, err := wavejwt.GenerateKeyPair() if err != nil { return fmt.Errorf("error generating jwt keypair: %w", err) } mainServer.JwtPrivateKey = base64.StdEncoding.EncodeToString(keyPair.PrivateKey) mainServer.JwtPublicKey = base64.StdEncoding.EncodeToString(keyPair.PublicKey) needsUpdate = true } if needsUpdate { err = wstore.DBUpdate(ctx, mainServer) if err != nil { return fmt.Errorf("error updating mainserver: %w", err) } } privateKeyBytes, err := base64.StdEncoding.DecodeString(mainServer.JwtPrivateKey) if err != nil { return fmt.Errorf("error decoding jwt private key: %w", err) } publicKeyBytes, err := base64.StdEncoding.DecodeString(mainServer.JwtPublicKey) if err != nil { return fmt.Errorf("error decoding jwt public key: %w", err) } err = wavejwt.SetPrivateKey(privateKeyBytes) if err != nil { return fmt.Errorf("error setting jwt private key: %w", err) } err = wavejwt.SetPublicKey(publicKeyBytes) if err != nil { return fmt.Errorf("error setting jwt public key: %w", err) } pubKeyDer, err := x509.MarshalPKIXPublicKey(ed25519.PublicKey(publicKeyBytes)) if err != nil { log.Printf("warning: could not marshal public key for logging: %v", err) } else { pubKeyPem := pem.EncodeToMemory(&pem.Block{ Type: "PUBLIC KEY", Bytes: pubKeyDer, }) log.Printf("JWT Public Key:\n%s", string(pubKeyPem)) } return nil } ================================================ FILE: pkg/wcore/window.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wcore import ( "context" "fmt" "log" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/eventbus" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wstore" ) func SwitchWorkspace(ctx context.Context, windowId string, workspaceId string) (*waveobj.Workspace, error) { log.Printf("SwitchWorkspace %s %s\n", windowId, workspaceId) ws, err := GetWorkspace(ctx, workspaceId) if err != nil { return nil, fmt.Errorf("error getting new workspace: %w", err) } window, err := GetWindow(ctx, windowId) if err != nil { return nil, fmt.Errorf("error getting window: %w", err) } curWsId := window.WorkspaceId if curWsId == workspaceId { return nil, nil } allWindows, err := wstore.DBGetAllObjsByType[*waveobj.Window](ctx, waveobj.OType_Window) if err != nil { return nil, fmt.Errorf("error getting all windows: %w", err) } for _, w := range allWindows { if w.WorkspaceId == workspaceId { log.Printf("workspace %s already has a window %s, focusing that window\n", workspaceId, w.OID) client := wshclient.GetBareRpcClient() err = wshclient.FocusWindowCommand(client, w.OID, &wshrpc.RpcOpts{Route: wshutil.ElectronRoute}) return nil, err } } window.WorkspaceId = workspaceId err = wstore.DBUpdate(ctx, window) if err != nil { return nil, fmt.Errorf("error updating window: %w", err) } deleted, _, err := DeleteWorkspace(ctx, curWsId, false) if err != nil && deleted { print(err.Error()) // @jalileh isolated the error for now, curwId/workspace was deleted when this occurs. } else if err != nil { return nil, fmt.Errorf("error deleting workspace: %w", err) } if !deleted { log.Printf("current workspace %s was not deleted\n", curWsId) } else { log.Printf("deleted current workspace %s\n", curWsId) } log.Printf("switching window %s to workspace %s\n", windowId, workspaceId) return ws, nil } func GetWindow(ctx context.Context, windowId string) (*waveobj.Window, error) { window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) if err != nil { log.Printf("error getting window %q: %v\n", windowId, err) return nil, err } return window, nil } func CreateWindow(ctx context.Context, winSize *waveobj.WinSize, workspaceId string) (*waveobj.Window, error) { log.Printf("CreateWindow %v %v\n", winSize, workspaceId) var ws *waveobj.Workspace if workspaceId == "" { ws1, err := CreateWorkspace(ctx, "", "", "", false, false) if err != nil { return nil, fmt.Errorf("error creating workspace: %w", err) } ws = ws1 } else { ws1, err := GetWorkspace(ctx, workspaceId) if err != nil { return nil, fmt.Errorf("error getting workspace: %w", err) } ws = ws1 } windowId := uuid.NewString() if winSize == nil { winSize = &waveobj.WinSize{ Width: 0, Height: 0, } } window := &waveobj.Window{ OID: windowId, WorkspaceId: ws.OID, IsNew: true, Pos: waveobj.Point{ X: 0, Y: 0, }, WinSize: *winSize, } err := wstore.DBInsert(ctx, window) if err != nil { return nil, fmt.Errorf("error inserting window: %w", err) } client, err := GetClientData(ctx) if err != nil { return nil, fmt.Errorf("error getting client: %w", err) } client.WindowIds = append(client.WindowIds, windowId) err = wstore.DBUpdate(ctx, client) if err != nil { return nil, fmt.Errorf("error updating client: %w", err) } return GetWindow(ctx, windowId) } // CloseWindow closes a window and deletes its workspace if it is empty and not named. // If fromElectron is true, it does not send an event to Electron. func CloseWindow(ctx context.Context, windowId string, fromElectron bool) error { log.Printf("CloseWindow %s\n", windowId) window, err := GetWindow(ctx, windowId) if err == nil { log.Printf("got window %s\n", windowId) deleted, _, err := DeleteWorkspace(ctx, window.WorkspaceId, false) if err != nil { log.Printf("error deleting workspace: %v\n", err) } if deleted { log.Printf("deleted workspace %s\n", window.WorkspaceId) } err = wstore.DBDelete(ctx, waveobj.OType_Window, windowId) if err != nil { return fmt.Errorf("error deleting window: %w", err) } log.Printf("deleted window %s\n", windowId) } else { log.Printf("error getting window %s: %v\n", windowId, err) } client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) if err != nil { return fmt.Errorf("error getting client: %w", err) } client.WindowIds = utilfn.RemoveElemFromSlice(client.WindowIds, windowId) err = wstore.DBUpdate(ctx, client) if err != nil { return fmt.Errorf("error updating client: %w", err) } log.Printf("updated client\n") if !fromElectron { eventbus.SendEventToElectron(eventbus.WSEventType{ EventType: eventbus.WSEvent_ElectronCloseWindow, Data: windowId, }) } return nil } func CheckAndFixWindow(ctx context.Context, windowId string) *waveobj.Window { log.Printf("CheckAndFixWindow %s\n", windowId) window, err := GetWindow(ctx, windowId) if err != nil { log.Printf("error getting window %q (in checkAndFixWindow): %v\n", windowId, err) return nil } ws, err := GetWorkspace(ctx, window.WorkspaceId) if err != nil { log.Printf("error getting workspace %q (in checkAndFixWindow): %v\n", window.WorkspaceId, err) CloseWindow(ctx, windowId, false) return nil } if len(ws.TabIds) == 0 { log.Printf("fixing workspace with no tabs %q (in checkAndFixWindow)\n", ws.OID) _, err = CreateTab(ctx, ws.OID, "", true, false) if err != nil { log.Printf("error creating tab (in checkAndFixWindow): %v\n", err) } } return window } func FocusWindow(ctx context.Context, windowId string) error { log.Printf("FocusWindow %s\n", windowId) client, err := GetClientData(ctx) if err != nil { log.Printf("error getting client data: %v\n", err) return err } winIdx := utilfn.SliceIdx(client.WindowIds, windowId) if winIdx == -1 { log.Printf("window %s not found in client data\n", windowId) return nil } client.WindowIds = utilfn.MoveSliceIdxToFront(client.WindowIds, winIdx) log.Printf("client.WindowIds: %v\n", client.WindowIds) return wstore.DBUpdate(ctx, client) } ================================================ FILE: pkg/wcore/workspace.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wcore import ( "context" "fmt" "log" "regexp" "strconv" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/eventbus" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" ) var WorkspaceColors = [...]string{ "#58C142", // Green (accent) "#00FFDB", // Teal "#429DFF", // Blue "#BF55EC", // Purple "#FF453A", // Red "#FF9500", // Orange "#FFE900", // Yellow } var WorkspaceIcons = [...]string{ "custom@wave-logo-solid", "triangle", "star", "heart", "bolt", "solid@cloud", "moon", "layer-group", "rocket", "flask", "paperclip", "chart-line", "graduation-cap", "mug-hot", } func CreateWorkspace(ctx context.Context, name string, icon string, color string, applyDefaults bool, isInitialLaunch bool) (*waveobj.Workspace, error) { ws := &waveobj.Workspace{ OID: uuid.NewString(), TabIds: []string{}, Name: "", Icon: "", Color: "", } err := wstore.DBInsert(ctx, ws) if err != nil { return nil, fmt.Errorf("error inserting workspace: %w", err) } _, err = CreateTab(ctx, ws.OID, "", true, isInitialLaunch) if err != nil { return nil, fmt.Errorf("error creating tab: %w", err) } wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WorkspaceUpdate, }) ws, _, err = UpdateWorkspace(ctx, ws.OID, name, icon, color, applyDefaults) return ws, err } // Returns updated workspace, whether it was updated, error. func UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, applyDefaults bool) (*waveobj.Workspace, bool, error) { ws, err := GetWorkspace(ctx, workspaceId) updated := false if err != nil { return nil, updated, fmt.Errorf("workspace %s not found: %w", workspaceId, err) } if name != "" { ws.Name = name updated = true } else if applyDefaults && ws.Name == "" { ws.Name = fmt.Sprintf("New Workspace (%s)", ws.OID[0:5]) updated = true } if icon != "" { ws.Icon = icon updated = true } else if applyDefaults && ws.Icon == "" { ws.Icon = WorkspaceIcons[0] updated = true } if color != "" { ws.Color = color updated = true } else if applyDefaults && ws.Color == "" { wsList, err := ListWorkspaces(ctx) if err != nil { log.Printf("error listing workspaces: %v", err) wsList = waveobj.WorkspaceList{} } ws.Color = WorkspaceColors[len(wsList)%len(WorkspaceColors)] updated = true } if updated { wstore.DBUpdate(ctx, ws) } return ws, updated, nil } // If force is true, it will delete even if workspace is named. // If workspace is empty, it will be deleted, even if it is named. // Returns true if workspace was deleted, false if it was not deleted. func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool, string, error) { log.Printf("DeleteWorkspace %s\n", workspaceId) workspace, err := wstore.DBMustGet[*waveobj.Workspace](ctx, workspaceId) if err != nil && wstore.ErrNotFound == err { return true, "", fmt.Errorf("workspace already deleted %w", err) } // @jalileh list needs to be saved early on i assume workspaces, err := ListWorkspaces(ctx) if err != nil { return false, "", fmt.Errorf("error retrieving workspaceList: %w", err) } if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 { log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId) return false, "", nil } for _, tabId := range workspace.TabIds { log.Printf("deleting tab %s\n", tabId) _, err := DeleteTab(ctx, workspaceId, tabId, false) if err != nil { return false, "", fmt.Errorf("error closing tab: %w", err) } } windowId, _ := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId) err = wstore.DBDelete(ctx, waveobj.OType_Workspace, workspaceId) if err != nil { return false, "", fmt.Errorf("error deleting workspace: %w", err) } log.Printf("deleted workspace %s\n", workspaceId) wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WorkspaceUpdate, }) if windowId != "" { UnclaimedWorkspace, findAfter := "", false for _, ws := range workspaces { if ws.WorkspaceId == workspaceId { if UnclaimedWorkspace != "" { break } findAfter = true continue } if findAfter && ws.WindowId == "" { UnclaimedWorkspace = ws.WorkspaceId break } else if ws.WindowId == "" { UnclaimedWorkspace = ws.WorkspaceId } } if UnclaimedWorkspace != "" { return true, UnclaimedWorkspace, nil } else { err = CloseWindow(ctx, windowId, false) } if err != nil { return false, "", fmt.Errorf("error closing window: %w", err) } } return true, "", nil } func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error) { return wstore.DBMustGet[*waveobj.Workspace](ctx, wsID) } func getTabPresetMeta() (waveobj.MetaMapType, error) { settings := wconfig.GetWatcher().GetFullConfig() tabPreset := settings.Settings.TabPreset if tabPreset == "" { return nil, nil } presetMeta := settings.Presets[tabPreset] return presetMeta, nil } var tabNameRe = regexp.MustCompile(`^T(\d+)$`) // getNextTabName returns the next auto-generated tab name (e.g. "T3") given a // slice of existing tab names. It filters to names matching T[N] where N is a // positive integer, finds the maximum N, and returns T[max+1]. If no matching // names exist it returns "T1". func getNextTabName(tabNames []string) string { maxNum := 0 for _, name := range tabNames { m := tabNameRe.FindStringSubmatch(name) if m == nil { continue } n, err := strconv.Atoi(m[1]) if err != nil || n <= 0 { continue } if n > maxNum { maxNum = n } } return "T" + strconv.Itoa(maxNum+1) } // returns tabid func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, isInitialLaunch bool) (string, error) { if tabName == "" { ws, err := GetWorkspace(ctx, workspaceId) if err != nil { return "", fmt.Errorf("workspace %s not found: %w", workspaceId, err) } tabNames := make([]string, 0, len(ws.TabIds)) for _, tabId := range ws.TabIds { tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId) if err != nil || tab == nil { continue } tabNames = append(tabNames, tab.Name) } tabName = getNextTabName(tabNames) } tab, err := createTabObj(ctx, workspaceId, tabName, nil) if err != nil { return "", fmt.Errorf("error creating tab: %w", err) } if activateTab { err = SetActiveTab(ctx, workspaceId, tab.OID) if err != nil { return "", fmt.Errorf("error setting active tab: %w", err) } } // No need to apply an initial layout for the initial launch, since the starter layout will get applied after onboarding modal dismissal if !isInitialLaunch { err = ApplyPortableLayout(ctx, tab.OID, GetNewTabLayout(), true) if err != nil { return tab.OID, fmt.Errorf("error applying new tab layout: %w", err) } presetMeta, presetErr := getTabPresetMeta() if presetErr != nil { log.Printf("error getting tab preset meta: %v\n", presetErr) } else if len(presetMeta) > 0 { tabORef := waveobj.ORefFromWaveObj(tab) wstore.UpdateObjectMeta(ctx, *tabORef, presetMeta, true) } } telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab") telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "action:createtab", }) return tab.OID, nil } func createTabObj(ctx context.Context, workspaceId string, name string, meta waveobj.MetaMapType) (*waveobj.Tab, error) { ws, err := GetWorkspace(ctx, workspaceId) if err != nil { return nil, fmt.Errorf("workspace %s not found: %w", workspaceId, err) } layoutStateId := uuid.NewString() tab := &waveobj.Tab{ OID: uuid.NewString(), Name: name, BlockIds: []string{}, LayoutState: layoutStateId, Meta: meta, } layoutState := &waveobj.LayoutState{ OID: layoutStateId, } ws.TabIds = append(ws.TabIds, tab.OID) wstore.DBInsert(ctx, tab) wstore.DBInsert(ctx, layoutState) wstore.DBUpdate(ctx, ws) return tab, nil } // Must delete all blocks individually first. // Also deletes LayoutState. // recursive: if true, will recursively close parent window, workspace, if they are empty. // Returns new active tab id, error. func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive bool) (string, error) { ws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId) if ws == nil { return "", fmt.Errorf("workspace not found: %q", workspaceId) } // ensure tab is in workspace tabIdx := utilfn.FindStringInSlice(ws.TabIds, tabId) if tabIdx == -1 { return "", fmt.Errorf("tab %s not found in workspace %s", tabId, workspaceId) } ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...) // close blocks (sends events + stops block controllers) tab, _ := wstore.DBGet[*waveobj.Tab](ctx, tabId) if tab != nil { for _, blockId := range tab.BlockIds { err := DeleteBlock(ctx, blockId, false) if err != nil { return "", fmt.Errorf("error deleting block %s: %w", blockId, err) } } } // if the tab is active, determine new active tab newActiveTabId := ws.ActiveTabId if ws.ActiveTabId == tabId { if len(ws.TabIds) > 0 { newActiveTabId = ws.TabIds[max(0, min(tabIdx-1, len(ws.TabIds)-1))] } else { newActiveTabId = "" } } ws.ActiveTabId = newActiveTabId wstore.DBUpdate(ctx, ws) wstore.DBDelete(ctx, waveobj.OType_Tab, tabId) if tab != nil { wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState) } // if no tabs remaining, close window if recursive && newActiveTabId == "" { log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId) windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId) if err != nil { return newActiveTabId, fmt.Errorf("unable to find window for workspace id %v: %w", workspaceId, err) } err = CloseWindow(ctx, windowId, false) if err != nil { return newActiveTabId, err } } return newActiveTabId, nil } func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error { if tabId != "" && workspaceId != "" { workspace, err := GetWorkspace(ctx, workspaceId) if err != nil { return fmt.Errorf("workspace %s not found: %w", workspaceId, err) } tab, _ := wstore.DBGet[*waveobj.Tab](ctx, tabId) if tab == nil { return fmt.Errorf("tab not found: %q", tabId) } workspace.ActiveTabId = tabId wstore.DBUpdate(ctx, workspace) } return nil } func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId string) { eventbus.SendEventToElectron(eventbus.WSEventType{ EventType: eventbus.WSEvent_ElectronUpdateActiveTab, Data: &waveobj.ActiveTabUpdate{WorkspaceId: workspaceId, NewActiveTabId: newActiveTabId}, }) } func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string) error { ws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId) if ws == nil { return fmt.Errorf("workspace not found: %q", workspaceId) } ws.TabIds = tabIds wstore.DBUpdate(ctx, ws) return nil } func ListWorkspaces(ctx context.Context) (waveobj.WorkspaceList, error) { workspaces, err := wstore.DBGetAllObjsByType[*waveobj.Workspace](ctx, waveobj.OType_Workspace) if err != nil { return nil, err } windows, err := wstore.DBGetAllObjsByType[*waveobj.Window](ctx, waveobj.OType_Window) if err != nil { return nil, err } workspaceToWindow := make(map[string]string) for _, window := range windows { workspaceToWindow[window.WorkspaceId] = window.OID } var wl waveobj.WorkspaceList for _, workspace := range workspaces { if workspace.Name == "" || workspace.Icon == "" || workspace.Color == "" { continue } windowId, ok := workspaceToWindow[workspace.OID] if !ok { windowId = "" } wl = append(wl, &waveobj.WorkspaceListEntry{ WorkspaceId: workspace.OID, WindowId: windowId, }) } return wl, nil } func SetIcon(workspaceId string, icon string) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() ws, e := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId) if e != nil { return e } if ws == nil { return fmt.Errorf("workspace not found: %q", workspaceId) } ws.Icon = icon wstore.DBUpdate(ctx, ws) return nil } func SetColor(workspaceId string, color string) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() ws, e := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId) if e != nil { return e } if ws == nil { return fmt.Errorf("workspace not found: %q", workspaceId) } ws.Color = color wstore.DBUpdate(ctx, ws) return nil } func SetName(workspaceId string, name string) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() ws, e := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId) if e != nil { return e } if ws == nil { return fmt.Errorf("workspace not found: %q", workspaceId) } ws.Name = name wstore.DBUpdate(ctx, ws) return nil } ================================================ FILE: pkg/web/sse/ssehandler.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package sse import ( "context" "encoding/json" "fmt" "net/http" "strings" "sync" "time" "github.com/wavetermdev/waveterm/pkg/utilds" ) // see /aiprompts/usechat-streamingproto.md for protocol const ( SSEContentType = "text/event-stream" SSECacheControl = "no-cache" SSEConnection = "keep-alive" SSEKeepaliveMsg = ": keepalive\n\n" SSEStreamStartMsg = ": stream-start\n\n" SSEKeepaliveInterval = 1 * time.Second ) // SSEMessageType represents the type of message to write type SSEMessageType string const ( SSEMsgData SSEMessageType = "data" SSEMsgEvent SSEMessageType = "event" SSEMsgComment SSEMessageType = "comment" SSEMsgError SSEMessageType = "error" ) // AI message type constants const ( AiMsgStart = "start" AiMsgTextStart = "text-start" AiMsgTextDelta = "text-delta" AiMsgTextEnd = "text-end" AiMsgReasoningStart = "reasoning-start" AiMsgReasoningDelta = "reasoning-delta" AiMsgReasoningEnd = "reasoning-end" AiMsgToolInputStart = "tool-input-start" AiMsgToolInputDelta = "tool-input-delta" AiMsgToolInputAvailable = "tool-input-available" AiMsgToolOutputAvailable = "tool-output-available" // not used here, but reserved AiMsgStartStep = "start-step" AiMsgFinishStep = "finish-step" AiMsgFinish = "finish" AiMsgError = "error" ) // SSEMessage represents a message to be written to the SSE stream type SSEMessage struct { Type SSEMessageType Data string EventType string // Only used for SSEMsgEvent } // SSEHandlerCh provides channel-based Server-Sent Events functionality type SSEHandlerCh struct { w http.ResponseWriter rc *http.ResponseController ctx context.Context // the r.Context() writeCh chan SSEMessage lock sync.Mutex closed bool initialized bool err error wg sync.WaitGroup onCloseHandlers utilds.IdList[func()] handlersRun bool } // MakeSSEHandlerCh creates a new channel-based SSE handler func MakeSSEHandlerCh(w http.ResponseWriter, ctx context.Context) *SSEHandlerCh { return &SSEHandlerCh{ w: w, rc: http.NewResponseController(w), ctx: ctx, writeCh: make(chan SSEMessage, 10), // Buffered to prevent blocking } } func (h *SSEHandlerCh) Context() context.Context { return h.ctx } // SetupSSE configures the response headers and starts the writer goroutine func (h *SSEHandlerCh) SetupSSE() error { h.lock.Lock() defer h.lock.Unlock() if h.closed { return fmt.Errorf("SSE handler is closed") } h.initialized = true // Reset write deadline for streaming if err := h.rc.SetWriteDeadline(time.Time{}); err != nil { return fmt.Errorf("failed to reset write deadline: %v", err) } // Set SSE headers h.w.Header().Set("Content-Type", SSEContentType) h.w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate, no-transform") h.w.Header().Set("Connection", SSEConnection) h.w.Header().Set("x-vercel-ai-ui-message-stream", "v1") h.w.Header().Set("X-Accel-Buffering", "no") // Send headers and establish streaming h.w.WriteHeader(http.StatusOK) fmt.Fprint(h.w, SSEStreamStartMsg) if err := h.flush(); err != nil { return err } // Start the writer goroutine h.wg.Add(1) go h.writerLoop() return nil } // writerLoop handles all writes and keepalives in a single goroutine func (h *SSEHandlerCh) writerLoop() { defer h.wg.Done() defer h.runOnCloseHandlers() keepaliveTicker := time.NewTicker(SSEKeepaliveInterval) defer keepaliveTicker.Stop() for { select { case msg, ok := <-h.writeCh: if !ok { // Channel closed, send [DONE] and exit h.writeDirectly("[DONE]", SSEMsgData) return } if err := h.writeMessage(msg); err != nil { h.setError(err) return } case <-keepaliveTicker.C: if err := h.writeDirectly("keepalive", SSEMsgComment); err != nil { h.setError(err) return } case <-h.ctx.Done(): h.setError(h.ctx.Err()) return } } } // writeMessage writes a message to the SSE stream func (h *SSEHandlerCh) writeMessage(msg SSEMessage) error { if h.ctx.Err() != nil { return h.ctx.Err() } switch msg.Type { case SSEMsgData: return h.writeDirectly(msg.Data, SSEMsgData) case SSEMsgEvent: return h.writeEvent(msg.EventType, msg.Data) case SSEMsgComment: return h.writeDirectly(msg.Data, SSEMsgComment) case SSEMsgError: return h.writeDirectly(msg.Data, SSEMsgData) default: return fmt.Errorf("unknown message type: %s", msg.Type) } } // isInitialized returns whether SetupSSE has been called func (h *SSEHandlerCh) isInitialized() bool { h.lock.Lock() defer h.lock.Unlock() return h.initialized } // writeDirectly writes data directly to the response writer func (h *SSEHandlerCh) writeDirectly(data string, msgType SSEMessageType) error { if !h.isInitialized() { panic("SSEHandlerCh not initialized - call SetupSSE first") } switch msgType { case SSEMsgData: _, err := fmt.Fprintf(h.w, "data: %s\n\n", data) if err != nil { return err } case SSEMsgComment: _, err := fmt.Fprintf(h.w, ": %s\n\n", data) if err != nil { return err } default: panic(fmt.Sprintf("unsupported direct write type: %s", msgType)) } return h.flush() } // writeEvent writes an SSE event with optional event type func (h *SSEHandlerCh) writeEvent(eventType, data string) error { if !h.isInitialized() { panic("SSEHandlerCh not initialized - call SetupSSE first") } if eventType != "" { if _, err := fmt.Fprintf(h.w, "event: %s\n", eventType); err != nil { return err } } if _, err := fmt.Fprintf(h.w, "data: %s\n\n", data); err != nil { return err } return h.flush() } // flush attempts to flush the response writer func (h *SSEHandlerCh) flush() error { return h.rc.Flush() } // setError sets the error state thread-safely func (h *SSEHandlerCh) setError(err error) { h.lock.Lock() defer h.lock.Unlock() if h.err == nil { h.err = err } } // queueMessage queues an SSEMessage to be written func (h *SSEHandlerCh) queueMessage(msg SSEMessage) error { h.lock.Lock() closed := h.closed h.lock.Unlock() if closed { return fmt.Errorf("SSE handler is closed") } if err := h.Err(); err != nil { return err } select { case h.writeCh <- msg: return nil case <-h.ctx.Done(): return h.ctx.Err() default: return fmt.Errorf("write channel is full") } } // WriteData queues data to be written in SSE format func (h *SSEHandlerCh) WriteData(data string) error { return h.queueMessage(SSEMessage{Type: SSEMsgData, Data: data}) } // WriteJsonData marshals data to JSON and queues it for writing func (h *SSEHandlerCh) WriteJsonData(data interface{}) error { jsonData, err := json.Marshal(data) if err != nil { return fmt.Errorf("failed to marshal JSON: %v", err) } return h.WriteData(string(jsonData)) } // WriteError queues an error message and closes the handler func (h *SSEHandlerCh) WriteError(errorMsg string) error { errorResp := map[string]interface{}{ "type": AiMsgError, "errorText": errorMsg, } if err := h.WriteJsonData(errorResp); err != nil { return err } h.Close() return nil } // WriteEvent queues an SSE event with optional event type func (h *SSEHandlerCh) WriteEvent(eventType, data string) error { return h.queueMessage(SSEMessage{Type: SSEMsgEvent, Data: data, EventType: eventType}) } // WriteComment queues an SSE comment func (h *SSEHandlerCh) WriteComment(comment string) error { return h.queueMessage(SSEMessage{Type: SSEMsgComment, Data: comment}) } // Err returns any error that occurred during writing func (h *SSEHandlerCh) Err() error { h.lock.Lock() defer h.lock.Unlock() if h.err == nil && h.ctx.Err() != nil { h.err = h.ctx.Err() } return h.err } // RegisterOnClose registers a handler function to be called when the connection closes // Returns an ID that can be used to unregister the handler func (h *SSEHandlerCh) RegisterOnClose(fn func()) string { h.lock.Lock() defer h.lock.Unlock() return h.onCloseHandlers.Register(fn) } // UnregisterOnClose removes a previously registered onClose handler by ID func (h *SSEHandlerCh) UnregisterOnClose(id string) { h.lock.Lock() defer h.lock.Unlock() h.onCloseHandlers.Unregister(id) } // runOnCloseHandlers runs all registered onClose handlers exactly once func (h *SSEHandlerCh) runOnCloseHandlers() { h.lock.Lock() if h.handlersRun { h.lock.Unlock() return } h.handlersRun = true h.lock.Unlock() handlers := h.onCloseHandlers.GetList() for _, fn := range handlers { fn() } } // Close closes the write channel, sends [DONE], and cleans up resources func (h *SSEHandlerCh) Close() { h.lock.Lock() if h.closed || !h.initialized { h.lock.Unlock() return } h.closed = true // Close the write channel, which will trigger [DONE] in writerLoop close(h.writeCh) h.lock.Unlock() // Wait for writer goroutine to finish (without holding the lock) h.wg.Wait() } // AI message writing methods func (h *SSEHandlerCh) AiMsgStart(messageId string) error { resp := map[string]interface{}{ "type": AiMsgStart, "messageId": messageId, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgTextStart(textId string) error { resp := map[string]interface{}{ "type": AiMsgTextStart, "id": textId, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgTextDelta(textId string, text string) error { resp := map[string]interface{}{ "type": AiMsgTextDelta, "id": textId, "delta": text, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgTextEnd(textId string) error { resp := map[string]interface{}{ "type": AiMsgTextEnd, "id": textId, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgFinish(finishReason string, usage interface{}) error { resp := map[string]interface{}{ "type": AiMsgFinish, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgReasoningStart(reasoningId string) error { resp := map[string]interface{}{ "type": AiMsgReasoningStart, "id": reasoningId, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgReasoningDelta(reasoningId string, reasoning string) error { resp := map[string]interface{}{ "type": AiMsgReasoningDelta, "id": reasoningId, "delta": reasoning, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgReasoningEnd(reasoningId string) error { resp := map[string]interface{}{ "type": AiMsgReasoningEnd, "id": reasoningId, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgToolInputStart(toolCallId, toolName string) error { resp := map[string]interface{}{ "type": AiMsgToolInputStart, "toolCallId": toolCallId, "toolName": toolName, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgToolInputDelta(toolCallId, inputTextDelta string) error { resp := map[string]interface{}{ "type": AiMsgToolInputDelta, "toolCallId": toolCallId, "inputTextDelta": inputTextDelta, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgToolInputAvailable(toolCallId, toolName string, input json.RawMessage) error { resp := map[string]interface{}{ "type": AiMsgToolInputAvailable, "toolCallId": toolCallId, "toolName": toolName, "input": json.RawMessage(input), } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgStartStep() error { resp := map[string]interface{}{ "type": AiMsgStartStep, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgFinishStep() error { resp := map[string]interface{}{ "type": AiMsgFinishStep, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgError(errText string) error { resp := map[string]interface{}{ "type": AiMsgError, "errorText": errText, } return h.WriteJsonData(resp) } func (h *SSEHandlerCh) AiMsgData(dataType string, id string, data interface{}) error { if !strings.HasPrefix(dataType, "data-") { panic(fmt.Sprintf("AiMsgData type must start with 'data-', got: %s", dataType)) } resp := map[string]interface{}{ "type": dataType, "id": id, "data": data, } return h.WriteJsonData(resp) } ================================================ FILE: pkg/web/web.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package web import ( "encoding/base64" "encoding/json" "fmt" "io" "io/fs" "log" "net" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/google/uuid" "github.com/gorilla/mux" "github.com/wavetermdev/waveterm/pkg/aiusechat" "github.com/wavetermdev/waveterm/pkg/authkey" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" "github.com/wavetermdev/waveterm/pkg/schema" "github.com/wavetermdev/waveterm/pkg/service" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) type WebFnType = func(http.ResponseWriter, *http.Request) const TransparentGif64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" // Header constants const ( CacheControlHeaderKey = "Cache-Control" CacheControlHeaderNoCache = "no-cache" ContentTypeHeaderKey = "Content-Type" ContentTypeJson = "application/json" ContentTypeBinary = "application/octet-stream" ContentLengthHeaderKey = "Content-Length" LastModifiedHeaderKey = "Last-Modified" WaveZoneFileInfoHeaderKey = "X-ZoneFileInfo" ) const HttpReadTimeout = 5 * time.Second const HttpWriteTimeout = 21 * time.Second const HttpMaxHeaderBytes = 60000 const HttpTimeoutDuration = 21 * time.Second const WSStateReconnectTime = 30 * time.Second const WSStatePacketChSize = 20 type WebFnOpts struct { AllowCaching bool JsonErrors bool } func copyHeaders(dst, src http.Header) { for key, values := range src { for _, value := range values { dst.Add(key, value) } } } type notFoundBlockingResponseWriter struct { w http.ResponseWriter status int headers http.Header } func (rw *notFoundBlockingResponseWriter) Header() http.Header { return rw.headers } func (rw *notFoundBlockingResponseWriter) WriteHeader(status int) { if status == http.StatusNotFound { rw.status = status return } rw.status = status copyHeaders(rw.w.Header(), rw.headers) rw.w.WriteHeader(status) } func (rw *notFoundBlockingResponseWriter) Write(b []byte) (int, error) { if rw.status == http.StatusNotFound { // Block the write if it's a 404 return len(b), nil } if rw.status == 0 { rw.WriteHeader(http.StatusOK) } return rw.w.Write(b) } func handleService(w http.ResponseWriter, r *http.Request) { bodyData, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Unable to read request body", http.StatusBadRequest) return } defer r.Body.Close() if r.Method != http.MethodPost { http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) return } var webCall service.WebCallType err = json.Unmarshal(bodyData, &webCall) if err != nil { http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) } rtn := service.CallService(r.Context(), webCall) jsonRtn, err := json.Marshal(rtn) if err != nil { http.Error(w, fmt.Sprintf("error serializing response: %v", err), http.StatusInternalServerError) } w.Header().Set(ContentTypeHeaderKey, ContentTypeJson) w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", len(jsonRtn))) w.WriteHeader(http.StatusOK) w.Write(jsonRtn) } func marshalReturnValue(data any, err error) []byte { var mapRtn = make(map[string]any) if err != nil { mapRtn["error"] = err.Error() } else { mapRtn["success"] = true mapRtn["data"] = data } rtn, err := json.Marshal(mapRtn) if err != nil { return marshalReturnValue(nil, fmt.Errorf("error serializing response: %v", err)) } return rtn } func handleWaveFile(w http.ResponseWriter, r *http.Request) { zoneId := r.URL.Query().Get("zoneid") name := r.URL.Query().Get("name") offsetStr := r.URL.Query().Get("offset") var offset int64 = 0 if offsetStr != "" { var err error offset, err = strconv.ParseInt(offsetStr, 10, 64) if err != nil { http.Error(w, fmt.Sprintf("invalid offset: %v", err), http.StatusBadRequest) } } if _, err := uuid.Parse(zoneId); err != nil { http.Error(w, fmt.Sprintf("invalid zoneid: %v", err), http.StatusBadRequest) return } if name == "" { http.Error(w, "name is required", http.StatusBadRequest) return } file, err := filestore.WFS.Stat(r.Context(), zoneId, name) if err == fs.ErrNotExist { w.WriteHeader(http.StatusNoContent) return } if err != nil { http.Error(w, fmt.Sprintf("error getting file info: %v", err), http.StatusInternalServerError) return } jsonFileBArr, err := json.Marshal(file) if err != nil { http.Error(w, fmt.Sprintf("error serializing file info: %v", err), http.StatusInternalServerError) } // can make more efficient by checking modtime + If-Modified-Since headers to allow caching dataStartIdx := file.DataStartIdx() if offset >= dataStartIdx { dataStartIdx = offset } w.Header().Set(ContentTypeHeaderKey, ContentTypeBinary) w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", file.Size-dataStartIdx)) w.Header().Set(WaveZoneFileInfoHeaderKey, base64.StdEncoding.EncodeToString(jsonFileBArr)) w.Header().Set(LastModifiedHeaderKey, time.UnixMilli(file.ModTs).UTC().Format(http.TimeFormat)) if dataStartIdx >= file.Size { w.WriteHeader(http.StatusOK) return } for offset := dataStartIdx; offset < file.Size; offset += filestore.DefaultPartDataSize { _, data, err := filestore.WFS.ReadAt(r.Context(), zoneId, name, offset, filestore.DefaultPartDataSize) if err != nil { if offset == 0 { http.Error(w, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError) } else { // nothing to do, the headers have already been sent log.Printf("error reading file %s/%s @ %d: %v\n", zoneId, name, offset, err) } return } w.Write(data) } } func serveTransparentGIF(w http.ResponseWriter) { gifBytes, _ := base64.StdEncoding.DecodeString(TransparentGif64) w.Header().Set("Content-Type", "image/gif") w.WriteHeader(http.StatusOK) w.Write(gifBytes) } func handleLocalStreamFile(w http.ResponseWriter, r *http.Request, path string, no404 bool) { http.NewResponseController(w).SetWriteDeadline(time.Time{}) if no404 { log.Printf("streaming file w/no404: %q\n", path) // use the custom response writer rw := ¬FoundBlockingResponseWriter{w: w, headers: http.Header{}} // Serve the file using http.ServeFile path, err := wavebase.ExpandHomeDir(path) if err == nil { http.ServeFile(rw, r, filepath.Clean(path)) // if the file was not found, serve the transparent GIF log.Printf("got streamfile status: %d\n", rw.status) if rw.status == http.StatusNotFound { serveTransparentGIF(w) } } else { serveTransparentGIF(w) } } else { path, err := wavebase.ExpandHomeDir(path) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) } http.ServeFile(w, r, path) } } func handleStreamFileFromReader(w http.ResponseWriter, r *http.Request, path string, no404 bool) error { startTime := time.Now() rangeHeader := r.Header.Get("Range") log.Printf("stream-file path=%q range=%q\n", path, rangeHeader) writerRouteId, err := wshfs.GetConnectionRouteId(r.Context(), path) if err != nil { return err } byteRange := "" if rangeHeader != "" { stripped := strings.TrimPrefix(rangeHeader, "bytes=") br, parseErr := fileutil.ParseByteRange(stripped) if parseErr != nil || br.All { http.Error(w, "invalid range", http.StatusRequestedRangeNotSatisfiable) return nil } byteRange = stripped } bareRpc := wshclient.GetBareRpcClient() readerRouteId := wshclient.GetBareRpcClientRouteId() reader, streamMeta := bareRpc.StreamBroker.CreateStreamReader(readerRouteId, writerRouteId, 256*1024) defer reader.Close() go func() { <-r.Context().Done() reader.Close() }() data := wshrpc.CommandFileStreamData{ Info: &wshrpc.FileInfo{Path: path}, ByteRange: byteRange, StreamMeta: *streamMeta, } fileInfo, err := wshfs.FileStream(r.Context(), data) if err != nil { if no404 { serveTransparentGIF(w) return nil } return err } if fileInfo.NotFound { if no404 { serveTransparentGIF(w) return nil } http.Error(w, fmt.Sprintf("file not found: %q", path), http.StatusNotFound) return nil } if fileInfo.IsDir { http.Error(w, fmt.Sprintf("cannot stream directory: %q", path), http.StatusBadRequest) return nil } log.Printf("stream-file headers-ready path=%q time-to-headers=%v\n", path, time.Since(startTime)) w.Header().Set(ContentTypeHeaderKey, fileInfo.MimeType) w.Header().Set("Accept-Ranges", "bytes") if byteRange != "" { br, _ := fileutil.ParseByteRange(byteRange) var rangeEnd int64 if br.OpenEnd { rangeEnd = fileInfo.Size - 1 } else { rangeEnd = br.End } w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", rangeEnd-br.Start+1)) w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", br.Start, rangeEnd, fileInfo.Size)) w.WriteHeader(http.StatusPartialContent) } else { w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", fileInfo.Size)) } http.NewResponseController(w).SetWriteDeadline(time.Time{}) _, copyErr := io.Copy(w, reader) if copyErr != nil && r.Context().Err() == nil { log.Printf("error streaming file %q: %v\n", path, copyErr) } return nil } func handleStreamLocalFile(w http.ResponseWriter, r *http.Request) { path := r.URL.Query().Get("path") if path == "" { http.Error(w, "path is required", http.StatusBadRequest) return } no404 := r.URL.Query().Get("no404") handleLocalStreamFile(w, r, path, no404 != "") } func handleStreamFile(w http.ResponseWriter, r *http.Request) { path := r.URL.Query().Get("path") if path == "" { http.Error(w, "path is required", http.StatusBadRequest) return } no404 := r.URL.Query().Get("no404") // path should already be formatted as a wsh:// URI (e.g. wsh://local/path or wsh://connection/path) err := handleStreamFileFromReader(w, r, path, no404 != "") if err != nil { log.Printf("error streaming file %q: %v\n", path, err) http.Error(w, fmt.Sprintf("error streaming file: %v", err), http.StatusInternalServerError) } } func WriteJsonError(w http.ResponseWriter, errVal error) { w.Header().Set(ContentTypeHeaderKey, ContentTypeJson) w.WriteHeader(http.StatusOK) errMap := make(map[string]interface{}) errMap["error"] = errVal.Error() barr, _ := json.Marshal(errMap) w.Write(barr) } func WriteJsonSuccess(w http.ResponseWriter, data interface{}) { w.Header().Set(ContentTypeHeaderKey, ContentTypeJson) rtnMap := make(map[string]interface{}) rtnMap["success"] = true if data != nil { rtnMap["data"] = data } barr, err := json.Marshal(rtnMap) if err != nil { WriteJsonError(w, err) return } w.WriteHeader(http.StatusOK) w.Write(barr) } type ClientActiveState struct { Fg bool `json:"fg"` Active bool `json:"active"` Open bool `json:"open"` } func WebFnWrap(opts WebFnOpts, fn WebFnType) WebFnType { return func(w http.ResponseWriter, r *http.Request) { defer func() { recErr := panichandler.PanicHandler("WebFnWrap", recover()) if recErr == nil { return } if opts.JsonErrors { jsonRtn := marshalReturnValue(nil, recErr) w.Header().Set(ContentTypeHeaderKey, ContentTypeJson) w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", len(jsonRtn))) w.WriteHeader(http.StatusOK) w.Write(jsonRtn) } else { http.Error(w, recErr.Error(), http.StatusInternalServerError) } }() if !opts.AllowCaching { w.Header().Set(CacheControlHeaderKey, CacheControlHeaderNoCache) } w.Header().Set("Access-Control-Expose-Headers", "X-ZoneFileInfo") // Handle CORS preflight OPTIONS requests without auth validation if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } err := authkey.ValidateIncomingRequest(r) if err != nil { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(fmt.Sprintf("error validating authkey: %v", err))) return } fn(w, r) } } func MakeTCPListener(serviceName string) (net.Listener, error) { serverAddr := "127.0.0.1:" rtn, err := net.Listen("tcp", serverAddr) if err != nil { return nil, fmt.Errorf("error creating listener at %v: %v", serverAddr, err) } log.Printf("Server [%s] listening on %s\n", serviceName, rtn.Addr()) return rtn, nil } func MakeUnixListener() (net.Listener, error) { serverAddr := wavebase.GetDomainSocketName() os.Remove(serverAddr) // ignore error rtn, err := net.Listen("unix", serverAddr) if err != nil { return nil, fmt.Errorf("error creating listener at %v: %v", serverAddr, err) } os.Chmod(serverAddr, 0700) log.Printf("Server [unix-domain] listening on %s\n", serverAddr) return rtn, nil } const schemaPrefix = "/schema/" // blocking func RunWebServer(listener net.Listener) { gr := mux.NewRouter() // Streaming routes must be registered before the /wave/ prefix catch-all to bypass TimeoutHandler. // http.TimeoutHandler buffers the entire response before flushing, which stalls streaming. gr.HandleFunc("/wave/stream-local-file", WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamLocalFile)) gr.HandleFunc("/wave/stream-file", WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamFile)) gr.PathPrefix("/wave/stream-file/").HandlerFunc(WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamFile)) gr.HandleFunc("/api/post-chat-message", WebFnWrap(WebFnOpts{AllowCaching: false}, aiusechat.WaveAIPostMessageHandler)) // Non-streaming /wave/ routes get timeout protection waveRouter := mux.NewRouter() waveRouter.HandleFunc("/wave/file", WebFnWrap(WebFnOpts{AllowCaching: false}, handleWaveFile)) waveRouter.HandleFunc("/wave/service", WebFnWrap(WebFnOpts{JsonErrors: true}, handleService)) waveRouter.HandleFunc("/wave/aichat", WebFnWrap(WebFnOpts{JsonErrors: true, AllowCaching: false}, aiusechat.WaveAIGetChatHandler)) vdomRouter := mux.NewRouter() vdomRouter.HandleFunc("/vdom/{uuid}/{path:.*}", WebFnWrap(WebFnOpts{AllowCaching: true}, handleVDom)) gr.PathPrefix("/wave/").Handler(http.TimeoutHandler(waveRouter, HttpTimeoutDuration, "Timeout")) gr.PathPrefix("/vdom/").Handler(http.TimeoutHandler(vdomRouter, HttpTimeoutDuration, "Timeout")) // Other routes without timeout gr.PathPrefix(schemaPrefix).Handler(http.StripPrefix(schemaPrefix, schema.GetSchemaHandler())) handler := http.Handler(gr) if wavebase.IsDevMode() { originalHandler := handler handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin != "" { w.Header().Set("Access-Control-Allow-Origin", origin) } w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Session-Id, X-AuthKey, Authorization, X-Requested-With, Accept, x-vercel-ai-ui-message-stream") w.Header().Set("Access-Control-Expose-Headers", "X-ZoneFileInfo, Content-Length, Content-Type, x-vercel-ai-ui-message-stream") w.Header().Set("Access-Control-Allow-Credentials", "true") if r.Method == "OPTIONS" { w.WriteHeader(204) return } originalHandler.ServeHTTP(w, r) }) } server := &http.Server{ ReadTimeout: HttpReadTimeout, WriteTimeout: HttpWriteTimeout, MaxHeaderBytes: HttpMaxHeaderBytes, Handler: handler, } err := server.Serve(listener) if err != nil { log.Printf("ERROR: %v\n", err) } } ================================================ FILE: pkg/web/webcmd/webcmd.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package webcmd import ( "fmt" "reflect" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wshutil" ) const ( WSCommand_Rpc = "rpc" ) type WSCommandType interface { GetWSCommand() string } func WSCommandTypeUnionMeta() tsgenmeta.TypeUnionMeta { return tsgenmeta.TypeUnionMeta{ BaseType: reflect.TypeOf((*WSCommandType)(nil)).Elem(), TypeFieldName: "wscommand", Types: []reflect.Type{ reflect.TypeOf(WSRpcCommand{}), }, } } type WSRpcCommand struct { WSCommand string `json:"wscommand" tstype:"\"rpc\""` Message *wshutil.RpcMessage `json:"message"` } func (cmd *WSRpcCommand) GetWSCommand() string { return cmd.WSCommand } func ParseWSCommandMap(cmdMap map[string]any) (WSCommandType, error) { cmdType, ok := cmdMap["wscommand"].(string) if !ok { return nil, fmt.Errorf("no wscommand field in command map") } switch cmdType { case WSCommand_Rpc: var cmd WSRpcCommand err := utilfn.DoMapStructure(&cmd, cmdMap) if err != nil { return nil, fmt.Errorf("error decoding WSRpcCommand: %w", err) } return &cmd, nil default: return nil, fmt.Errorf("unknown wscommand type %q", cmdType) } } ================================================ FILE: pkg/web/webvdomproto.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package web import ( "fmt" "io" "log" "net/http" "strings" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" "github.com/wavetermdev/waveterm/pkg/wshutil" ) // Add the new handler function func handleVDom(w http.ResponseWriter, r *http.Request) { // Extract UUID and path from URL pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/vdom/"), "/") if len(pathParts) < 1 { http.Error(w, "Invalid VDOM URL format", http.StatusBadRequest) return } uuid := pathParts[0] // Simple UUID validation if len(uuid) != 36 { http.Error(w, "Invalid UUID format", http.StatusBadRequest) return } // Reconstruct the remaining path path := "/" + strings.Join(pathParts[1:], "/") if r.URL.RawQuery != "" { path += "?" + r.URL.RawQuery } // Read request body if present var body []byte var err error if r.Body != nil { body, err = io.ReadAll(r.Body) if err != nil { http.Error(w, fmt.Sprintf("Error reading request body: %v", err), http.StatusInternalServerError) return } defer r.Body.Close() } // Convert headers to map headers := make(map[string]string) for key, values := range r.Header { if len(values) > 0 { headers[key] = values[0] } } // Prepare RPC request data data := wshrpc.VDomUrlRequestData{ Method: r.Method, URL: path, Headers: headers, Body: body, } // Get RPC client client := wshserver.GetMainRpcClient() // Make RPC call with route to specific process route := wshutil.MakeProcRouteId(uuid) respCh := wshclient.VDomUrlRequestCommand(client, data, &wshrpc.RpcOpts{ Route: route, }) // Handle first response to set headers firstResp := true for respUnion := range respCh { if respUnion.Error != nil { http.Error(w, fmt.Sprintf("RPC error: %v", respUnion.Error), http.StatusInternalServerError) return } resp := respUnion.Response if firstResp { firstResp = false // Set status code and headers from first response if resp.StatusCode > 0 { w.WriteHeader(resp.StatusCode) } else { w.WriteHeader(http.StatusOK) } // Copy headers for key, value := range resp.Headers { w.Header().Set(key, value) } } // Write body chunk if present if len(resp.Body) > 0 { _, err = w.Write(resp.Body) if err != nil { log.Printf("Error writing response: %v", err) return } } } } ================================================ FILE: pkg/web/ws.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package web import ( "encoding/json" "fmt" "log" "net" "net/http" "sync" "time" "github.com/google/uuid" "github.com/gorilla/mux" "github.com/gorilla/websocket" "github.com/wavetermdev/waveterm/pkg/authkey" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/eventbus" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/web/webcmd" "github.com/wavetermdev/waveterm/pkg/wshutil" ) const wsReadWaitTimeout = 15 * time.Second const wsWriteWaitTimeout = 10 * time.Second const wsPingPeriodTickTime = 10 * time.Second const wsInitialPingTime = 1 * time.Second const wsMaxMessageSize = 10 * 1024 * 1024 const DefaultCommandTimeout = 2 * time.Second const WebSocketChannelSize = 128 type StableConnInfo struct { ConnId string LinkId baseds.LinkId } var GlobalLock = &sync.Mutex{} var RouteToConnMap = map[string]*StableConnInfo{} // stableid => StableConnInfo func RunWebSocketServer(listener net.Listener) { gr := mux.NewRouter() gr.HandleFunc("/ws", HandleWs) server := &http.Server{ ReadTimeout: HttpReadTimeout, WriteTimeout: HttpWriteTimeout, MaxHeaderBytes: HttpMaxHeaderBytes, Handler: gr, } server.SetKeepAlivesEnabled(false) log.Printf("[websocket] running websocket server on %s\n", listener.Addr()) err := server.Serve(listener) if err != nil { log.Printf("[websocket] error trying to run websocket server: %v\n", err) } } var WebSocketUpgrader = websocket.Upgrader{ ReadBufferSize: 4 * 1024, WriteBufferSize: 32 * 1024, HandshakeTimeout: 1 * time.Second, CheckOrigin: func(r *http.Request) bool { return true }, } func HandleWs(w http.ResponseWriter, r *http.Request) { err := HandleWsInternal(w, r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func getMessageType(jmsg map[string]any) string { if str, ok := jmsg["type"].(string); ok { return str } return "" } func getStringFromMap(jmsg map[string]any, key string) string { if str, ok := jmsg[key].(string); ok { return str } return "" } func processWSCommand(jmsg map[string]any, outputCh chan any, rpcInputCh chan baseds.RpcInputChType) { var rtnErr error var cmdType string defer func() { panicCtx := "processWSCommand" if cmdType != "" { panicCtx = fmt.Sprintf("processWSCommand:%s", cmdType) } panicErr := panichandler.PanicHandler(panicCtx, recover()) if panicErr != nil { rtnErr = panicErr } if rtnErr == nil { return } rtn := map[string]any{"type": "error", "error": rtnErr.Error()} outputCh <- rtn }() wsCommand, err := webcmd.ParseWSCommandMap(jmsg) if err != nil { rtnErr = fmt.Errorf("cannot parse wscommand: %v", err) return } cmdType = wsCommand.GetWSCommand() switch cmd := wsCommand.(type) { case *webcmd.WSRpcCommand: rpcMsg := cmd.Message if rpcMsg == nil { return } if rpcMsg.Command != "" { cmdType = fmt.Sprintf("%s:%s", cmdType, rpcMsg.Command) } msgBytes, err := json.Marshal(rpcMsg) if err != nil { // this really should never fail since we just unmarshalled this value return } rpcInputCh <- baseds.RpcInputChType{MsgBytes: msgBytes} } } func ReadLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any, rpcInputCh chan baseds.RpcInputChType, routeId string) { readWait := wsReadWaitTimeout conn.SetReadLimit(wsMaxMessageSize) conn.SetReadDeadline(time.Now().Add(readWait)) defer close(closeCh) for { _, message, err := conn.ReadMessage() if err != nil { log.Printf("[websocket] ReadPump error (%s): %v\n", routeId, err) break } jmsg := map[string]any{} err = json.Unmarshal(message, &jmsg) if err != nil { log.Printf("[websocket] error unmarshalling json: %v\n", err) break } conn.SetReadDeadline(time.Now().Add(readWait)) msgType := getMessageType(jmsg) if msgType == "pong" { // nothing continue } if msgType == "ping" { now := time.Now() pongMessage := map[string]interface{}{"type": "pong", "stime": now.UnixMilli()} outputCh <- pongMessage continue } wsCommand := getStringFromMap(jmsg, "wscommand") if wsCommand == "" { continue } processWSCommand(jmsg, outputCh, rpcInputCh) } } func WritePing(conn *websocket.Conn) error { now := time.Now() pingMessage := map[string]interface{}{"type": "ping", "stime": now.UnixMilli()} jsonVal, _ := json.Marshal(pingMessage) _ = conn.SetWriteDeadline(time.Now().Add(wsWriteWaitTimeout)) // no error err := conn.WriteMessage(websocket.TextMessage, jsonVal) if err != nil { return err } return nil } func WriteLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any, routeId string) { ticker := time.NewTicker(wsInitialPingTime) defer ticker.Stop() initialPing := true for { select { case msg := <-outputCh: var barr []byte var err error if _, ok := msg.([]byte); ok { barr = msg.([]byte) } else { barr, err = json.Marshal(msg) if err != nil { log.Printf("[websocket] cannot marshal websocket message: %v\n", err) // just loop again break } } err = conn.WriteMessage(websocket.TextMessage, barr) if err != nil { conn.Close() log.Printf("[websocket] WritePump error (%s): %v\n", routeId, err) return } case <-ticker.C: err := WritePing(conn) if err != nil { log.Printf("[websocket] WritePump error (%s): %v\n", routeId, err) return } if initialPing { initialPing = false ticker.Reset(wsPingPeriodTickTime) } case <-closeCh: return } } } func registerConn(wsConnId string, stableId string, wproxy *wshutil.WshRpcProxy) { GlobalLock.Lock() defer GlobalLock.Unlock() curConnInfo := RouteToConnMap[stableId] if curConnInfo != nil { log.Printf("[websocket] warning: replacing existing connection for stableid %q\n", stableId) if curConnInfo.LinkId != baseds.NoLinkId { wshutil.DefaultRouter.UnregisterLink(curConnInfo.LinkId) } } linkId := wshutil.DefaultRouter.RegisterTrustedRouter(wproxy) RouteToConnMap[stableId] = &StableConnInfo{ ConnId: wsConnId, LinkId: linkId, } } func unregisterConn(wsConnId string, stableId string) { GlobalLock.Lock() defer GlobalLock.Unlock() curConnInfo := RouteToConnMap[stableId] if curConnInfo == nil || curConnInfo.ConnId != wsConnId { log.Printf("[websocket] warning: trying to unregister connection %q for stableid %q but it is not the current connection (ignoring)\n", wsConnId, stableId) return } delete(RouteToConnMap, stableId) if curConnInfo.LinkId != baseds.NoLinkId { wshutil.DefaultRouter.UnregisterLink(curConnInfo.LinkId) } } func HandleWsInternal(w http.ResponseWriter, r *http.Request) error { stableId := r.URL.Query().Get("stableid") if stableId == "" { return fmt.Errorf("stableid is required") } err := authkey.ValidateIncomingRequest(r) if err != nil { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(fmt.Sprintf("error validating authkey: %v", err))) log.Printf("[websocket] error validating authkey: %v\n", err) return err } conn, err := WebSocketUpgrader.Upgrade(w, r, nil) if err != nil { return fmt.Errorf("WebSocket Upgrade Failed: %v", err) } defer conn.Close() wsConnId := uuid.New().String() outputCh := make(chan any, WebSocketChannelSize) closeCh := make(chan any) log.Printf("[websocket] new connection: connid:%s stableid:%s\n", wsConnId, stableId) eventbus.RegisterWSChannel(wsConnId, stableId, outputCh) defer eventbus.UnregisterWSChannel(wsConnId) wproxy := wshutil.MakeRpcProxyWithSize(fmt.Sprintf("ws:%s", stableId), WebSocketChannelSize, WebSocketChannelSize) defer close(wproxy.ToRemoteCh) registerConn(wsConnId, stableId, wproxy) defer unregisterConn(wsConnId, stableId) wg := &sync.WaitGroup{} wg.Add(2) go func() { defer func() { panichandler.PanicHandler("HandleWsInternal:outputCh", recover()) }() // no waitgroup add here // move values from rpcOutputCh to outputCh for msgBytes := range wproxy.ToRemoteCh { rpcWSMsg := map[string]any{ "eventtype": "rpc", // TODO don't hard code this (but def is in eventbus) "data": json.RawMessage(msgBytes), } outputCh <- rpcWSMsg } }() go func() { defer func() { panichandler.PanicHandler("HandleWsInternal:ReadLoop", recover()) }() defer wg.Done() ReadLoop(conn, outputCh, closeCh, wproxy.FromRemoteCh, stableId) }() go func() { defer func() { panichandler.PanicHandler("HandleWsInternal:WriteLoop", recover()) }() defer wg.Done() WriteLoop(conn, outputCh, closeCh, stableId) }() wg.Wait() close(wproxy.FromRemoteCh) return nil } ================================================ FILE: pkg/wps/wps.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // wave pubsub system package wps import ( "strings" "sync" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" ) // this broker interface is mostly generic // strong typing and event types can be defined elsewhere const MaxPersist = 4096 type Client interface { SendEvent(routeId string, event WaveEvent) } type BrokerSubscription struct { AllSubs []string // routeids subscribed to "all" events ScopeSubs map[string][]string // routeids subscribed to specific scopes StarSubs map[string][]string // routeids subscribed to star scope (scopes with "*" or "**" in them) } type persistKey struct { Event string Scope string } type persistEventWrap struct { Events []*WaveEvent } type BrokerType struct { Lock *sync.Mutex Client Client SubMap map[string]*BrokerSubscription PersistMap map[persistKey]*persistEventWrap } var Broker = &BrokerType{ Lock: &sync.Mutex{}, SubMap: make(map[string]*BrokerSubscription), PersistMap: make(map[persistKey]*persistEventWrap), } func scopeHasStarMatch(scope string) bool { parts := strings.Split(scope, ":") for _, part := range parts { if part == "*" || part == "**" { return true } } return false } func (b *BrokerType) SetClient(client Client) { b.Lock.Lock() defer b.Lock.Unlock() b.Client = client } func (b *BrokerType) GetClient() Client { b.Lock.Lock() defer b.Lock.Unlock() return b.Client } // if already subscribed, this will *resubscribe* with the new subscription (remove the old one, and replace with this one) func (b *BrokerType) Subscribe(subRouteId string, sub SubscriptionRequest) { // log.Printf("[wps] sub %s %s\n", subRouteId, sub.Event) if sub.Event == "" { return } b.Lock.Lock() defer b.Lock.Unlock() b.unsubscribe_nolock(subRouteId, sub.Event) bs := b.SubMap[sub.Event] if bs == nil { bs = &BrokerSubscription{ AllSubs: []string{}, ScopeSubs: make(map[string][]string), StarSubs: make(map[string][]string), } b.SubMap[sub.Event] = bs } if sub.AllScopes { bs.AllSubs = utilfn.AddElemToSliceUniq(bs.AllSubs, subRouteId) return } for _, scope := range sub.Scopes { starMatch := scopeHasStarMatch(scope) if starMatch { addStrToScopeMap(bs.StarSubs, scope, subRouteId) } else { addStrToScopeMap(bs.ScopeSubs, scope, subRouteId) } } } func (bs *BrokerSubscription) IsEmpty() bool { return len(bs.AllSubs) == 0 && len(bs.ScopeSubs) == 0 && len(bs.StarSubs) == 0 } func removeStrFromScopeMap(scopeMap map[string][]string, scope string, routeId string) { scopeSubs := scopeMap[scope] scopeSubs = utilfn.RemoveElemFromSlice(scopeSubs, routeId) if len(scopeSubs) == 0 { delete(scopeMap, scope) } else { scopeMap[scope] = scopeSubs } } func removeStrFromScopeMapAll(scopeMap map[string][]string, routeId string) { for scope, scopeSubs := range scopeMap { scopeSubs = utilfn.RemoveElemFromSlice(scopeSubs, routeId) if len(scopeSubs) == 0 { delete(scopeMap, scope) } else { scopeMap[scope] = scopeSubs } } } func addStrToScopeMap(scopeMap map[string][]string, scope string, routeId string) { scopeSubs := scopeMap[scope] scopeSubs = utilfn.AddElemToSliceUniq(scopeSubs, routeId) scopeMap[scope] = scopeSubs } func (b *BrokerType) Unsubscribe(subRouteId string, eventName string) { // log.Printf("[wps] unsub %s %s\n", subRouteId, eventName) b.Lock.Lock() defer b.Lock.Unlock() b.unsubscribe_nolock(subRouteId, eventName) } func (b *BrokerType) unsubscribe_nolock(subRouteId string, eventName string) { bs := b.SubMap[eventName] if bs == nil { return } bs.AllSubs = utilfn.RemoveElemFromSlice(bs.AllSubs, subRouteId) for scope := range bs.ScopeSubs { removeStrFromScopeMap(bs.ScopeSubs, scope, subRouteId) } for scope := range bs.StarSubs { removeStrFromScopeMap(bs.StarSubs, scope, subRouteId) } if bs.IsEmpty() { delete(b.SubMap, eventName) } } func (b *BrokerType) UnsubscribeAll(subRouteId string) { b.Lock.Lock() defer b.Lock.Unlock() for eventType, bs := range b.SubMap { bs.AllSubs = utilfn.RemoveElemFromSlice(bs.AllSubs, subRouteId) removeStrFromScopeMapAll(bs.StarSubs, subRouteId) removeStrFromScopeMapAll(bs.ScopeSubs, subRouteId) if bs.IsEmpty() { delete(b.SubMap, eventType) } } } // does not take wildcards, use "" for all func (b *BrokerType) ReadEventHistory(eventType string, scope string, maxItems int) []*WaveEvent { if maxItems <= 0 { return nil } b.Lock.Lock() defer b.Lock.Unlock() key := persistKey{Event: eventType, Scope: scope} pe := b.PersistMap[key] if pe == nil || len(pe.Events) == 0 { return nil } if maxItems > len(pe.Events) { maxItems = len(pe.Events) } // return new arr rtn := make([]*WaveEvent, maxItems) copy(rtn, pe.Events[len(pe.Events)-maxItems:]) return rtn } func (b *BrokerType) persistEvent(event WaveEvent) { if event.Persist <= 0 { return } numPersist := event.Persist if numPersist > MaxPersist { numPersist = MaxPersist } scopeMap := make(map[string]bool) for _, scope := range event.Scopes { scopeMap[scope] = true } scopeMap[""] = true b.Lock.Lock() defer b.Lock.Unlock() for scope := range scopeMap { key := persistKey{Event: event.Event, Scope: scope} pe := b.PersistMap[key] if pe == nil { pe = &persistEventWrap{ Events: make([]*WaveEvent, 0, numPersist), } b.PersistMap[key] = pe } pe.Events = append(pe.Events, &event) if len(pe.Events) > numPersist { pe.Events = pe.Events[len(pe.Events)-numPersist:] } } } func (b *BrokerType) Publish(event WaveEvent) { // log.Printf("BrokerType.Publish: %v\n", event) if event.Persist > 0 { b.persistEvent(event) } client := b.GetClient() if client == nil { return } routeIds := b.getMatchingRouteIds(event) for _, routeId := range routeIds { client.SendEvent(routeId, event) } } func (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) { for _, update := range updates { b.Publish(WaveEvent{ Event: Event_WaveObjUpdate, Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()}, Data: update, }) } } func (b *BrokerType) getMatchingRouteIds(event WaveEvent) []string { b.Lock.Lock() defer b.Lock.Unlock() bs := b.SubMap[event.Event] if bs == nil { return nil } routeIds := make(map[string]bool) for _, routeId := range bs.AllSubs { routeIds[routeId] = true } for _, scope := range event.Scopes { for _, routeId := range bs.ScopeSubs[scope] { routeIds[routeId] = true } for starScope := range bs.StarSubs { if utilfn.StarMatchString(starScope, scope, ":") { for _, routeId := range bs.StarSubs[starScope] { routeIds[routeId] = true } } } } var rtn []string for routeId := range routeIds { rtn = append(rtn, routeId) } // log.Printf("getMatchingRouteIds %v %v\n", event, rtn) return rtn } ================================================ FILE: pkg/wps/wpstypes.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wps import ( "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) // IMPORTANT: When adding a new event constant, you MUST also: // 1. Add a "// type: <TypeName>" comment (use "none" if no data is sent) // 2. Add the constant to AllEvents below // 3. Add an entry to WaveEventDataTypes in pkg/tsgen/tsgenevent.go // - Use reflect.TypeOf(YourType{}) for value types // - Use reflect.TypeOf((*YourType)(nil)) for pointer types // - Use nil if no data is sent for the event const ( Event_BlockClose = "blockclose" // type: string Event_ConnChange = "connchange" // type: wshrpc.ConnStatus Event_SysInfo = "sysinfo" // type: wshrpc.TimeSeriesData Event_ControllerStatus = "controllerstatus" // type: *blockcontroller.BlockControllerRuntimeStatus Event_BuilderStatus = "builderstatus" // type: wshrpc.BuilderStatusData Event_BuilderOutput = "builderoutput" // type: map[string]any Event_WaveObjUpdate = "waveobj:update" // type: waveobj.WaveObjUpdate Event_BlockFile = "blockfile" // type: *WSFileEventData Event_Config = "config" // type: wconfig.WatcherUpdate Event_UserInput = "userinput" // type: *userinput.UserInputRequest Event_RouteDown = "route:down" // type: none Event_RouteUp = "route:up" // type: none Event_WorkspaceUpdate = "workspace:update" // type: none Event_WaveAIRateLimit = "waveai:ratelimit" // type: *uctypes.RateLimitInfo Event_WaveAppAppGoUpdated = "waveapp:appgoupdated" // type: none Event_TsunamiUpdateMeta = "tsunami:updatemeta" // type: wshrpc.AppMeta Event_AIModeConfig = "waveai:modeconfig" // type: wconfig.AIModeConfigUpdate Event_BlockJobStatus = "block:jobstatus" // type: wshrpc.BlockJobStatusData Event_Badge = "badge" // type: baseds.BadgeEvent ) var AllEvents []string = []string{ Event_BlockClose, Event_ConnChange, Event_SysInfo, Event_ControllerStatus, Event_BuilderStatus, Event_BuilderOutput, Event_WaveObjUpdate, Event_BlockFile, Event_Config, Event_UserInput, Event_RouteDown, Event_RouteUp, Event_WorkspaceUpdate, Event_WaveAIRateLimit, Event_WaveAppAppGoUpdated, Event_TsunamiUpdateMeta, Event_AIModeConfig, Event_BlockJobStatus, Event_Badge, } type WaveEvent struct { Event string `json:"event"` Scopes []string `json:"scopes,omitempty"` Sender string `json:"sender,omitempty"` Persist int `json:"persist,omitempty"` Data any `json:"data,omitempty"` } func (e WaveEvent) HasScope(scope string) bool { return utilfn.ContainsStr(e.Scopes, scope) } type SubscriptionRequest struct { Event string `json:"event"` Scopes []string `json:"scopes,omitempty"` AllScopes bool `json:"allscopes,omitempty"` } const ( FileOp_Create = "create" FileOp_Delete = "delete" FileOp_Append = "append" FileOp_Truncate = "truncate" FileOp_Invalidate = "invalidate" ) type WSFileEventData struct { ZoneId string `json:"zoneid"` FileName string `json:"filename"` FileOp string `json:"fileop"` Data64 string `json:"data64"` } ================================================ FILE: pkg/wshrpc/wshclient/barerpcclient.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshclient import ( "fmt" "sync" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" ) type WshServer struct{} func (*WshServer) WshServerImpl() {} var WshServerImpl = WshServer{} var waveSrvClient_Singleton *wshutil.WshRpc var waveSrvClient_Once = &sync.Once{} var waveSrvClient_RouteId string func GetBareRpcClient() *wshutil.WshRpc { waveSrvClient_Once.Do(func() { waveSrvClient_Singleton = wshutil.MakeWshRpc(wshrpc.RpcContext{}, &WshServerImpl, "bare-client") waveSrvClient_RouteId = fmt.Sprintf("bare:%s", uuid.New().String()) // we can safely ignore the error from RegisterTrustedLeaf since the route is valid wshutil.DefaultRouter.RegisterTrustedLeaf(waveSrvClient_Singleton, waveSrvClient_RouteId) wps.Broker.SetClient(wshutil.DefaultRouter) }) return waveSrvClient_Singleton } func GetBareRpcClientRouteId() string { GetBareRpcClient() return waveSrvClient_RouteId } ================================================ FILE: pkg/wshrpc/wshclient/wshclient.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // Generated Code. DO NOT EDIT. package wshclient import ( "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" ) // command "activity", wshserver.ActivityCommand func ActivityCommand(w *wshutil.WshRpc, data wshrpc.ActivityUpdate, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "activity", data, opts) return err } // command "aisendmessage", wshserver.AiSendMessageCommand func AiSendMessageCommand(w *wshutil.WshRpc, data wshrpc.AiMessageData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "aisendmessage", data, opts) return err } // command "authenticate", wshserver.AuthenticateCommand func AuthenticateCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (wshrpc.CommandAuthenticateRtnData, error) { resp, err := sendRpcRequestCallHelper[wshrpc.CommandAuthenticateRtnData](w, "authenticate", data, opts) return resp, err } // command "authenticatejobmanager", wshserver.AuthenticateJobManagerCommand func AuthenticateJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthenticateJobManagerData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "authenticatejobmanager", data, opts) return err } // command "authenticatejobmanagerverify", wshserver.AuthenticateJobManagerVerifyCommand func AuthenticateJobManagerVerifyCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthenticateJobManagerData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "authenticatejobmanagerverify", data, opts) return err } // command "authenticatetojobmanager", wshserver.AuthenticateToJobManagerCommand func AuthenticateToJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthenticateToJobData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "authenticatetojobmanager", data, opts) return err } // command "authenticatetoken", wshserver.AuthenticateTokenCommand func AuthenticateTokenCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthenticateTokenData, opts *wshrpc.RpcOpts) (wshrpc.CommandAuthenticateRtnData, error) { resp, err := sendRpcRequestCallHelper[wshrpc.CommandAuthenticateRtnData](w, "authenticatetoken", data, opts) return resp, err } // command "authenticatetokenverify", wshserver.AuthenticateTokenVerifyCommand func AuthenticateTokenVerifyCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthenticateTokenData, opts *wshrpc.RpcOpts) (wshrpc.CommandAuthenticateRtnData, error) { resp, err := sendRpcRequestCallHelper[wshrpc.CommandAuthenticateRtnData](w, "authenticatetokenverify", data, opts) return resp, err } // command "badgewatchpid", wshserver.BadgeWatchPidCommand func BadgeWatchPidCommand(w *wshutil.WshRpc, data wshrpc.CommandBadgeWatchPidData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "badgewatchpid", data, opts) return err } // command "blockinfo", wshserver.BlockInfoCommand func BlockInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BlockInfoData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.BlockInfoData](w, "blockinfo", data, opts) return resp, err } // command "blockjobstatus", wshserver.BlockJobStatusCommand func BlockJobStatusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BlockJobStatusData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.BlockJobStatusData](w, "blockjobstatus", data, opts) return resp, err } // command "blockslist", wshserver.BlocksListCommand func BlocksListCommand(w *wshutil.WshRpc, data wshrpc.BlocksListRequest, opts *wshrpc.RpcOpts) ([]wshrpc.BlocksListEntry, error) { resp, err := sendRpcRequestCallHelper[[]wshrpc.BlocksListEntry](w, "blockslist", data, opts) return resp, err } // command "captureblockscreenshot", wshserver.CaptureBlockScreenshotCommand func CaptureBlockScreenshotCommand(w *wshutil.WshRpc, data wshrpc.CommandCaptureBlockScreenshotData, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "captureblockscreenshot", data, opts) return resp, err } // command "checkgoversion", wshserver.CheckGoVersionCommand func CheckGoVersionCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*wshrpc.CommandCheckGoVersionRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandCheckGoVersionRtnData](w, "checkgoversion", nil, opts) return resp, err } // command "connconnect", wshserver.ConnConnectCommand func ConnConnectCommand(w *wshutil.WshRpc, data wshrpc.ConnRequest, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "connconnect", data, opts) return err } // command "conndisconnect", wshserver.ConnDisconnectCommand func ConnDisconnectCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "conndisconnect", data, opts) return err } // command "connensure", wshserver.ConnEnsureCommand func ConnEnsureCommand(w *wshutil.WshRpc, data wshrpc.ConnExtData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "connensure", data, opts) return err } // command "connlist", wshserver.ConnListCommand func ConnListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "connlist", nil, opts) return resp, err } // command "connreinstallwsh", wshserver.ConnReinstallWshCommand func ConnReinstallWshCommand(w *wshutil.WshRpc, data wshrpc.ConnExtData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "connreinstallwsh", data, opts) return err } // command "connserverinit", wshserver.ConnServerInitCommand func ConnServerInitCommand(w *wshutil.WshRpc, data wshrpc.CommandConnServerInitData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "connserverinit", data, opts) return err } // command "connstatus", wshserver.ConnStatusCommand func ConnStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.ConnStatus, error) { resp, err := sendRpcRequestCallHelper[[]wshrpc.ConnStatus](w, "connstatus", nil, opts) return resp, err } // command "connupdatewsh", wshserver.ConnUpdateWshCommand func ConnUpdateWshCommand(w *wshutil.WshRpc, data wshrpc.RemoteInfo, opts *wshrpc.RpcOpts) (bool, error) { resp, err := sendRpcRequestCallHelper[bool](w, "connupdatewsh", data, opts) return resp, err } // command "controlgetrouteid", wshserver.ControlGetRouteIdCommand func ControlGetRouteIdCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "controlgetrouteid", nil, opts) return resp, err } // command "controllerappendoutput", wshserver.ControllerAppendOutputCommand func ControllerAppendOutputCommand(w *wshutil.WshRpc, data wshrpc.CommandControllerAppendOutputData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "controllerappendoutput", data, opts) return err } // command "controllerdestroy", wshserver.ControllerDestroyCommand func ControllerDestroyCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "controllerdestroy", data, opts) return err } // command "controllerinput", wshserver.ControllerInputCommand func ControllerInputCommand(w *wshutil.WshRpc, data wshrpc.CommandBlockInputData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "controllerinput", data, opts) return err } // command "controllerresync", wshserver.ControllerResyncCommand func ControllerResyncCommand(w *wshutil.WshRpc, data wshrpc.CommandControllerResyncData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "controllerresync", data, opts) return err } // command "createblock", wshserver.CreateBlockCommand func CreateBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateBlockData, opts *wshrpc.RpcOpts) (waveobj.ORef, error) { resp, err := sendRpcRequestCallHelper[waveobj.ORef](w, "createblock", data, opts) return resp, err } // command "createsubblock", wshserver.CreateSubBlockCommand func CreateSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateSubBlockData, opts *wshrpc.RpcOpts) (waveobj.ORef, error) { resp, err := sendRpcRequestCallHelper[waveobj.ORef](w, "createsubblock", data, opts) return resp, err } // command "debugterm", wshserver.DebugTermCommand func DebugTermCommand(w *wshutil.WshRpc, data wshrpc.CommandDebugTermData, opts *wshrpc.RpcOpts) (*wshrpc.CommandDebugTermRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandDebugTermRtnData](w, "debugterm", data, opts) return resp, err } // command "deleteappfile", wshserver.DeleteAppFileCommand func DeleteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteAppFileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deleteappfile", data, opts) return err } // command "deleteblock", wshserver.DeleteBlockCommand func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deleteblock", data, opts) return err } // command "deletebuilder", wshserver.DeleteBuilderCommand func DeleteBuilderCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deletebuilder", data, opts) return err } // command "deletesubblock", wshserver.DeleteSubBlockCommand func DeleteSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deletesubblock", data, opts) return err } // command "dismisswshfail", wshserver.DismissWshFailCommand func DismissWshFailCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "dismisswshfail", data, opts) return err } // command "dispose", wshserver.DisposeCommand func DisposeCommand(w *wshutil.WshRpc, data wshrpc.CommandDisposeData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "dispose", data, opts) return err } // command "disposesuggestions", wshserver.DisposeSuggestionsCommand func DisposeSuggestionsCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "disposesuggestions", data, opts) return err } // command "electrondecrypt", wshserver.ElectronDecryptCommand func ElectronDecryptCommand(w *wshutil.WshRpc, data wshrpc.CommandElectronDecryptData, opts *wshrpc.RpcOpts) (*wshrpc.CommandElectronDecryptRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandElectronDecryptRtnData](w, "electrondecrypt", data, opts) return resp, err } // command "electronencrypt", wshserver.ElectronEncryptCommand func ElectronEncryptCommand(w *wshutil.WshRpc, data wshrpc.CommandElectronEncryptData, opts *wshrpc.RpcOpts) (*wshrpc.CommandElectronEncryptRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandElectronEncryptRtnData](w, "electronencrypt", data, opts) return resp, err } // command "electronsystembell", wshserver.ElectronSystemBellCommand func ElectronSystemBellCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "electronsystembell", nil, opts) return err } // command "eventpublish", wshserver.EventPublishCommand func EventPublishCommand(w *wshutil.WshRpc, data wps.WaveEvent, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "eventpublish", data, opts) return err } // command "eventreadhistory", wshserver.EventReadHistoryCommand func EventReadHistoryCommand(w *wshutil.WshRpc, data wshrpc.CommandEventReadHistoryData, opts *wshrpc.RpcOpts) ([]*wps.WaveEvent, error) { resp, err := sendRpcRequestCallHelper[[]*wps.WaveEvent](w, "eventreadhistory", data, opts) return resp, err } // command "eventrecv", wshserver.EventRecvCommand func EventRecvCommand(w *wshutil.WshRpc, data wps.WaveEvent, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "eventrecv", data, opts) return err } // command "eventsub", wshserver.EventSubCommand func EventSubCommand(w *wshutil.WshRpc, data wps.SubscriptionRequest, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "eventsub", data, opts) return err } // command "eventunsub", wshserver.EventUnsubCommand func EventUnsubCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "eventunsub", data, opts) return err } // command "eventunsuball", wshserver.EventUnsubAllCommand func EventUnsubAllCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "eventunsuball", nil, opts) return err } // command "fetchsuggestions", wshserver.FetchSuggestionsCommand func FetchSuggestionsCommand(w *wshutil.WshRpc, data wshrpc.FetchSuggestionsData, opts *wshrpc.RpcOpts) (*wshrpc.FetchSuggestionsResponse, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FetchSuggestionsResponse](w, "fetchsuggestions", data, opts) return resp, err } // command "fileappend", wshserver.FileAppendCommand func FileAppendCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "fileappend", data, opts) return err } // command "filecopy", wshserver.FileCopyCommand func FileCopyCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCopyData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "filecopy", data, opts) return err } // command "filecreate", wshserver.FileCreateCommand func FileCreateCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "filecreate", data, opts) return err } // command "filedelete", wshserver.FileDeleteCommand func FileDeleteCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteFileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "filedelete", data, opts) return err } // command "fileinfo", wshserver.FileInfoCommand func FileInfoCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "fileinfo", data, opts) return resp, err } // command "filejoin", wshserver.FileJoinCommand func FileJoinCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "filejoin", data, opts) return resp, err } // command "filelist", wshserver.FileListCommand func FileListCommand(w *wshutil.WshRpc, data wshrpc.FileListData, opts *wshrpc.RpcOpts) ([]*wshrpc.FileInfo, error) { resp, err := sendRpcRequestCallHelper[[]*wshrpc.FileInfo](w, "filelist", data, opts) return resp, err } // command "fileliststream", wshserver.FileListStreamCommand func FileListStreamCommand(w *wshutil.WshRpc, data wshrpc.FileListData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] { return sendRpcRequestResponseStreamHelper[wshrpc.CommandRemoteListEntriesRtnData](w, "fileliststream", data, opts) } // command "filemkdir", wshserver.FileMkdirCommand func FileMkdirCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "filemkdir", data, opts) return err } // command "filemove", wshserver.FileMoveCommand func FileMoveCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCopyData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "filemove", data, opts) return err } // command "fileread", wshserver.FileReadCommand func FileReadCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) (*wshrpc.FileData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FileData](w, "fileread", data, opts) return resp, err } // command "filereadstream", wshserver.FileReadStreamCommand func FileReadStreamCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { return sendRpcRequestResponseStreamHelper[wshrpc.FileData](w, "filereadstream", data, opts) } // command "filerestorebackup", wshserver.FileRestoreBackupCommand func FileRestoreBackupCommand(w *wshutil.WshRpc, data wshrpc.CommandFileRestoreBackupData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "filerestorebackup", data, opts) return err } // command "filestream", wshserver.FileStreamCommand func FileStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandFileStreamData, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "filestream", data, opts) return resp, err } // command "filewrite", wshserver.FileWriteCommand func FileWriteCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "filewrite", data, opts) return err } // command "findgitbash", wshserver.FindGitBashCommand func FindGitBashCommand(w *wshutil.WshRpc, data bool, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "findgitbash", data, opts) return resp, err } // command "focuswindow", wshserver.FocusWindowCommand func FocusWindowCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "focuswindow", data, opts) return err } // command "getallbadges", wshserver.GetAllBadgesCommand func GetAllBadgesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]baseds.BadgeEvent, error) { resp, err := sendRpcRequestCallHelper[[]baseds.BadgeEvent](w, "getallbadges", nil, opts) return resp, err } // command "getallvars", wshserver.GetAllVarsCommand func GetAllVarsCommand(w *wshutil.WshRpc, data wshrpc.CommandVarData, opts *wshrpc.RpcOpts) ([]wshrpc.CommandVarResponseData, error) { resp, err := sendRpcRequestCallHelper[[]wshrpc.CommandVarResponseData](w, "getallvars", data, opts) return resp, err } // command "getbuilderoutput", wshserver.GetBuilderOutputCommand func GetBuilderOutputCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "getbuilderoutput", data, opts) return resp, err } // command "getbuilderstatus", wshserver.GetBuilderStatusCommand func GetBuilderStatusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BuilderStatusData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.BuilderStatusData](w, "getbuilderstatus", data, opts) return resp, err } // command "getfocusedblockdata", wshserver.GetFocusedBlockDataCommand func GetFocusedBlockDataCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*wshrpc.FocusedBlockData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FocusedBlockData](w, "getfocusedblockdata", nil, opts) return resp, err } // command "getfullconfig", wshserver.GetFullConfigCommand func GetFullConfigCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wconfig.FullConfigType, error) { resp, err := sendRpcRequestCallHelper[wconfig.FullConfigType](w, "getfullconfig", nil, opts) return resp, err } // command "getjwtpublickey", wshserver.GetJwtPublicKeyCommand func GetJwtPublicKeyCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "getjwtpublickey", nil, opts) return resp, err } // command "getmeta", wshserver.GetMetaCommand func GetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandGetMetaData, opts *wshrpc.RpcOpts) (waveobj.MetaMapType, error) { resp, err := sendRpcRequestCallHelper[waveobj.MetaMapType](w, "getmeta", data, opts) return resp, err } // command "getrtinfo", wshserver.GetRTInfoCommand func GetRTInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandGetRTInfoData, opts *wshrpc.RpcOpts) (*waveobj.ObjRTInfo, error) { resp, err := sendRpcRequestCallHelper[*waveobj.ObjRTInfo](w, "getrtinfo", data, opts) return resp, err } // command "getsecrets", wshserver.GetSecretsCommand func GetSecretsCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpts) (map[string]string, error) { resp, err := sendRpcRequestCallHelper[map[string]string](w, "getsecrets", data, opts) return resp, err } // command "getsecretslinuxstoragebackend", wshserver.GetSecretsLinuxStorageBackendCommand func GetSecretsLinuxStorageBackendCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "getsecretslinuxstoragebackend", nil, opts) return resp, err } // command "getsecretsnames", wshserver.GetSecretsNamesCommand func GetSecretsNamesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "getsecretsnames", nil, opts) return resp, err } // command "gettab", wshserver.GetTabCommand func GetTabCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*waveobj.Tab, error) { resp, err := sendRpcRequestCallHelper[*waveobj.Tab](w, "gettab", data, opts) return resp, err } // command "gettempdir", wshserver.GetTempDirCommand func GetTempDirCommand(w *wshutil.WshRpc, data wshrpc.CommandGetTempDirData, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "gettempdir", data, opts) return resp, err } // command "getupdatechannel", wshserver.GetUpdateChannelCommand func GetUpdateChannelCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "getupdatechannel", nil, opts) return resp, err } // command "getvar", wshserver.GetVarCommand func GetVarCommand(w *wshutil.WshRpc, data wshrpc.CommandVarData, opts *wshrpc.RpcOpts) (*wshrpc.CommandVarResponseData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandVarResponseData](w, "getvar", data, opts) return resp, err } // command "getwaveaichat", wshserver.GetWaveAIChatCommand func GetWaveAIChatCommand(w *wshutil.WshRpc, data wshrpc.CommandGetWaveAIChatData, opts *wshrpc.RpcOpts) (*uctypes.UIChat, error) { resp, err := sendRpcRequestCallHelper[*uctypes.UIChat](w, "getwaveaichat", data, opts) return resp, err } // command "getwaveaimodeconfig", wshserver.GetWaveAIModeConfigCommand func GetWaveAIModeConfigCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wconfig.AIModeConfigUpdate, error) { resp, err := sendRpcRequestCallHelper[wconfig.AIModeConfigUpdate](w, "getwaveaimodeconfig", nil, opts) return resp, err } // command "getwaveairatelimit", wshserver.GetWaveAIRateLimitCommand func GetWaveAIRateLimitCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*uctypes.RateLimitInfo, error) { resp, err := sendRpcRequestCallHelper[*uctypes.RateLimitInfo](w, "getwaveairatelimit", nil, opts) return resp, err } // command "jobcmdexited", wshserver.JobCmdExitedCommand func JobCmdExitedCommand(w *wshutil.WshRpc, data wshrpc.CommandJobCmdExitedData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "jobcmdexited", data, opts) return err } // command "jobcontrollerattachjob", wshserver.JobControllerAttachJobCommand func JobControllerAttachJobCommand(w *wshutil.WshRpc, data wshrpc.CommandJobControllerAttachJobData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "jobcontrollerattachjob", data, opts) return err } // command "jobcontrollerconnectedjobs", wshserver.JobControllerConnectedJobsCommand func JobControllerConnectedJobsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "jobcontrollerconnectedjobs", nil, opts) return resp, err } // command "jobcontrollerdeletejob", wshserver.JobControllerDeleteJobCommand func JobControllerDeleteJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "jobcontrollerdeletejob", data, opts) return err } // command "jobcontrollerdetachjob", wshserver.JobControllerDetachJobCommand func JobControllerDetachJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "jobcontrollerdetachjob", data, opts) return err } // command "jobcontrollerdisconnectjob", wshserver.JobControllerDisconnectJobCommand func JobControllerDisconnectJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "jobcontrollerdisconnectjob", data, opts) return err } // command "jobcontrollerexitjob", wshserver.JobControllerExitJobCommand func JobControllerExitJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "jobcontrollerexitjob", data, opts) return err } // command "jobcontrollergetalljobmanagerstatus", wshserver.JobControllerGetAllJobManagerStatusCommand func JobControllerGetAllJobManagerStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]*wshrpc.JobManagerStatusUpdate, error) { resp, err := sendRpcRequestCallHelper[[]*wshrpc.JobManagerStatusUpdate](w, "jobcontrollergetalljobmanagerstatus", nil, opts) return resp, err } // command "jobcontrollerlist", wshserver.JobControllerListCommand func JobControllerListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]*waveobj.Job, error) { resp, err := sendRpcRequestCallHelper[[]*waveobj.Job](w, "jobcontrollerlist", nil, opts) return resp, err } // command "jobcontrollerreconnectjob", wshserver.JobControllerReconnectJobCommand func JobControllerReconnectJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "jobcontrollerreconnectjob", data, opts) return err } // command "jobcontrollerreconnectjobsforconn", wshserver.JobControllerReconnectJobsForConnCommand func JobControllerReconnectJobsForConnCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "jobcontrollerreconnectjobsforconn", data, opts) return err } // command "jobcontrollerstartjob", wshserver.JobControllerStartJobCommand func JobControllerStartJobCommand(w *wshutil.WshRpc, data wshrpc.CommandJobControllerStartJobData, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "jobcontrollerstartjob", data, opts) return resp, err } // command "jobinput", wshserver.JobInputCommand func JobInputCommand(w *wshutil.WshRpc, data wshrpc.CommandJobInputData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "jobinput", data, opts) return err } // command "jobprepareconnect", wshserver.JobPrepareConnectCommand func JobPrepareConnectCommand(w *wshutil.WshRpc, data wshrpc.CommandJobPrepareConnectData, opts *wshrpc.RpcOpts) (*wshrpc.CommandJobConnectRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandJobConnectRtnData](w, "jobprepareconnect", data, opts) return resp, err } // command "jobstartstream", wshserver.JobStartStreamCommand func JobStartStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandJobStartStreamData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "jobstartstream", data, opts) return err } // command "listallappfiles", wshserver.ListAllAppFilesCommand func ListAllAppFilesCommand(w *wshutil.WshRpc, data wshrpc.CommandListAllAppFilesData, opts *wshrpc.RpcOpts) (*wshrpc.CommandListAllAppFilesRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandListAllAppFilesRtnData](w, "listallappfiles", data, opts) return resp, err } // command "listallapps", wshserver.ListAllAppsCommand func ListAllAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.AppInfo, error) { resp, err := sendRpcRequestCallHelper[[]wshrpc.AppInfo](w, "listallapps", nil, opts) return resp, err } // command "listalleditableapps", wshserver.ListAllEditableAppsCommand func ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.AppInfo, error) { resp, err := sendRpcRequestCallHelper[[]wshrpc.AppInfo](w, "listalleditableapps", nil, opts) return resp, err } // command "macosversion", wshserver.MacOSVersionCommand func MacOSVersionCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "macosversion", nil, opts) return resp, err } // command "makedraftfromlocal", wshserver.MakeDraftFromLocalCommand func MakeDraftFromLocalCommand(w *wshutil.WshRpc, data wshrpc.CommandMakeDraftFromLocalData, opts *wshrpc.RpcOpts) (*wshrpc.CommandMakeDraftFromLocalRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandMakeDraftFromLocalRtnData](w, "makedraftfromlocal", data, opts) return resp, err } // command "message", wshserver.MessageCommand func MessageCommand(w *wshutil.WshRpc, data wshrpc.CommandMessageData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "message", data, opts) return err } // command "networkonline", wshserver.NetworkOnlineCommand func NetworkOnlineCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (bool, error) { resp, err := sendRpcRequestCallHelper[bool](w, "networkonline", nil, opts) return resp, err } // command "notify", wshserver.NotifyCommand func NotifyCommand(w *wshutil.WshRpc, data wshrpc.WaveNotificationOptions, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "notify", data, opts) return err } // command "notifysystemresume", wshserver.NotifySystemResumeCommand func NotifySystemResumeCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "notifysystemresume", nil, opts) return err } // command "path", wshserver.PathCommand func PathCommand(w *wshutil.WshRpc, data wshrpc.PathCommandData, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "path", data, opts) return resp, err } // command "publishapp", wshserver.PublishAppCommand func PublishAppCommand(w *wshutil.WshRpc, data wshrpc.CommandPublishAppData, opts *wshrpc.RpcOpts) (*wshrpc.CommandPublishAppRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandPublishAppRtnData](w, "publishapp", data, opts) return resp, err } // command "readappfile", wshserver.ReadAppFileCommand func ReadAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandReadAppFileData, opts *wshrpc.RpcOpts) (*wshrpc.CommandReadAppFileRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandReadAppFileRtnData](w, "readappfile", data, opts) return resp, err } // command "recordtevent", wshserver.RecordTEventCommand func RecordTEventCommand(w *wshutil.WshRpc, data telemetrydata.TEvent, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "recordtevent", data, opts) return err } // command "remotedisconnectfromjobmanager", wshserver.RemoteDisconnectFromJobManagerCommand func RemoteDisconnectFromJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteDisconnectFromJobManagerData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remotedisconnectfromjobmanager", data, opts) return err } // command "remotefilecopy", wshserver.RemoteFileCopyCommand func RemoteFileCopyCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCopyData, opts *wshrpc.RpcOpts) (bool, error) { resp, err := sendRpcRequestCallHelper[bool](w, "remotefilecopy", data, opts) return resp, err } // command "remotefiledelete", wshserver.RemoteFileDeleteCommand func RemoteFileDeleteCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteFileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remotefiledelete", data, opts) return err } // command "remotefileinfo", wshserver.RemoteFileInfoCommand func RemoteFileInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "remotefileinfo", data, opts) return resp, err } // command "remotefilejoin", wshserver.RemoteFileJoinCommand func RemoteFileJoinCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "remotefilejoin", data, opts) return resp, err } // command "remotefilemove", wshserver.RemoteFileMoveCommand func RemoteFileMoveCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCopyData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remotefilemove", data, opts) return err } // command "remotefilemultiinfo", wshserver.RemoteFileMultiInfoCommand func RemoteFileMultiInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteFileMultiInfoData, opts *wshrpc.RpcOpts) (map[string]wshrpc.FileInfo, error) { resp, err := sendRpcRequestCallHelper[map[string]wshrpc.FileInfo](w, "remotefilemultiinfo", data, opts) return resp, err } // command "remotefilestream", wshserver.RemoteFileStreamCommand func RemoteFileStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteFileStreamData, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "remotefilestream", data, opts) return resp, err } // command "remotefiletouch", wshserver.RemoteFileTouchCommand func RemoteFileTouchCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remotefiletouch", data, opts) return err } // command "remotegetinfo", wshserver.RemoteGetInfoCommand func RemoteGetInfoCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wshrpc.RemoteInfo, error) { resp, err := sendRpcRequestCallHelper[wshrpc.RemoteInfo](w, "remotegetinfo", nil, opts) return resp, err } // command "remoteinstallrcfiles", wshserver.RemoteInstallRcFilesCommand func RemoteInstallRcFilesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remoteinstallrcfiles", nil, opts) return err } // command "remotelistentries", wshserver.RemoteListEntriesCommand func RemoteListEntriesCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteListEntriesData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] { return sendRpcRequestResponseStreamHelper[wshrpc.CommandRemoteListEntriesRtnData](w, "remotelistentries", data, opts) } // command "remotemkdir", wshserver.RemoteMkdirCommand func RemoteMkdirCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remotemkdir", data, opts) return err } // command "remotereconnecttojobmanager", wshserver.RemoteReconnectToJobManagerCommand func RemoteReconnectToJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteReconnectToJobManagerData, opts *wshrpc.RpcOpts) (*wshrpc.CommandRemoteReconnectToJobManagerRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandRemoteReconnectToJobManagerRtnData](w, "remotereconnecttojobmanager", data, opts) return resp, err } // command "remotestartjob", wshserver.RemoteStartJobCommand func RemoteStartJobCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteStartJobData, opts *wshrpc.RpcOpts) (*wshrpc.CommandStartJobRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandStartJobRtnData](w, "remotestartjob", data, opts) return resp, err } // command "remotestreamcpudata", wshserver.RemoteStreamCpuDataCommand func RemoteStreamCpuDataCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] { return sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, "remotestreamcpudata", nil, opts) } // command "remotestreamfile", wshserver.RemoteStreamFileCommand func RemoteStreamFileCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteStreamFileData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { return sendRpcRequestResponseStreamHelper[wshrpc.FileData](w, "remotestreamfile", data, opts) } // command "remoteterminatejobmanager", wshserver.RemoteTerminateJobManagerCommand func RemoteTerminateJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteTerminateJobManagerData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remoteterminatejobmanager", data, opts) return err } // command "remotewritefile", wshserver.RemoteWriteFileCommand func RemoteWriteFileCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remotewritefile", data, opts) return err } // command "renameappfile", wshserver.RenameAppFileCommand func RenameAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandRenameAppFileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "renameappfile", data, opts) return err } // command "resolveids", wshserver.ResolveIdsCommand func ResolveIdsCommand(w *wshutil.WshRpc, data wshrpc.CommandResolveIdsData, opts *wshrpc.RpcOpts) (wshrpc.CommandResolveIdsRtnData, error) { resp, err := sendRpcRequestCallHelper[wshrpc.CommandResolveIdsRtnData](w, "resolveids", data, opts) return resp, err } // command "restartbuilderandwait", wshserver.RestartBuilderAndWaitCommand func RestartBuilderAndWaitCommand(w *wshutil.WshRpc, data wshrpc.CommandRestartBuilderAndWaitData, opts *wshrpc.RpcOpts) (*wshrpc.RestartBuilderAndWaitResult, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.RestartBuilderAndWaitResult](w, "restartbuilderandwait", data, opts) return resp, err } // command "routeannounce", wshserver.RouteAnnounceCommand func RouteAnnounceCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "routeannounce", nil, opts) return err } // command "routeunannounce", wshserver.RouteUnannounceCommand func RouteUnannounceCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "routeunannounce", nil, opts) return err } // command "sendtelemetry", wshserver.SendTelemetryCommand func SendTelemetryCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "sendtelemetry", nil, opts) return err } // command "setblockfocus", wshserver.SetBlockFocusCommand func SetBlockFocusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setblockfocus", data, opts) return err } // command "setconfig", wshserver.SetConfigCommand func SetConfigCommand(w *wshutil.WshRpc, data wshrpc.MetaSettingsType, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setconfig", data, opts) return err } // command "setconnectionsconfig", wshserver.SetConnectionsConfigCommand func SetConnectionsConfigCommand(w *wshutil.WshRpc, data wshrpc.ConnConfigRequest, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setconnectionsconfig", data, opts) return err } // command "setmeta", wshserver.SetMetaCommand func SetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandSetMetaData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setmeta", data, opts) return err } // command "setpeerinfo", wshserver.SetPeerInfoCommand func SetPeerInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setpeerinfo", data, opts) return err } // command "setrtinfo", wshserver.SetRTInfoCommand func SetRTInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandSetRTInfoData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setrtinfo", data, opts) return err } // command "setsecrets", wshserver.SetSecretsCommand func SetSecretsCommand(w *wshutil.WshRpc, data map[string]*string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setsecrets", data, opts) return err } // command "setvar", wshserver.SetVarCommand func SetVarCommand(w *wshutil.WshRpc, data wshrpc.CommandVarData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setvar", data, opts) return err } // command "startbuilder", wshserver.StartBuilderCommand func StartBuilderCommand(w *wshutil.WshRpc, data wshrpc.CommandStartBuilderData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "startbuilder", data, opts) return err } // command "startjob", wshserver.StartJobCommand func StartJobCommand(w *wshutil.WshRpc, data wshrpc.CommandStartJobData, opts *wshrpc.RpcOpts) (*wshrpc.CommandStartJobRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandStartJobRtnData](w, "startjob", data, opts) return resp, err } // command "stopbuilder", wshserver.StopBuilderCommand func StopBuilderCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "stopbuilder", data, opts) return err } // command "streamcpudata", wshserver.StreamCpuDataCommand func StreamCpuDataCommand(w *wshutil.WshRpc, data wshrpc.CpuDataRequest, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] { return sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, "streamcpudata", data, opts) } // command "streamdata", wshserver.StreamDataCommand func StreamDataCommand(w *wshutil.WshRpc, data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "streamdata", data, opts) return err } // command "streamdataack", wshserver.StreamDataAckCommand func StreamDataAckCommand(w *wshutil.WshRpc, data wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "streamdataack", data, opts) return err } // command "streamtest", wshserver.StreamTestCommand func StreamTestCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[int] { return sendRpcRequestResponseStreamHelper[int](w, "streamtest", nil, opts) } // command "streamwaveai", wshserver.StreamWaveAiCommand func StreamWaveAiCommand(w *wshutil.WshRpc, data wshrpc.WaveAIStreamRequest, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { return sendRpcRequestResponseStreamHelper[wshrpc.WaveAIPacketType](w, "streamwaveai", data, opts) } // command "termgetscrollbacklines", wshserver.TermGetScrollbackLinesCommand func TermGetScrollbackLinesCommand(w *wshutil.WshRpc, data wshrpc.CommandTermGetScrollbackLinesData, opts *wshrpc.RpcOpts) (*wshrpc.CommandTermGetScrollbackLinesRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandTermGetScrollbackLinesRtnData](w, "termgetscrollbacklines", data, opts) return resp, err } // command "test", wshserver.TestCommand func TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "test", data, opts) return err } // command "testmultiarg", wshserver.TestMultiArgCommand func TestMultiArgCommand(w *wshutil.WshRpc, arg1 string, arg2 int, arg3 bool, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "testmultiarg", wshrpc.MultiArg{Args: []any{arg1, arg2, arg3}}, opts) return resp, err } // command "updatetabname", wshserver.UpdateTabNameCommand func UpdateTabNameCommand(w *wshutil.WshRpc, arg1 string, arg2 string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "updatetabname", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts) return err } // command "updateworkspacetabids", wshserver.UpdateWorkspaceTabIdsCommand func UpdateWorkspaceTabIdsCommand(w *wshutil.WshRpc, arg1 string, arg2 []string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "updateworkspacetabids", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts) return err } // command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts) return err } // command "vdomcreatecontext", wshserver.VDomCreateContextCommand func VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, opts *wshrpc.RpcOpts) (*waveobj.ORef, error) { resp, err := sendRpcRequestCallHelper[*waveobj.ORef](w, "vdomcreatecontext", data, opts) return resp, err } // command "vdomrender", wshserver.VDomRenderCommand func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[*vdom.VDomBackendUpdate] { return sendRpcRequestResponseStreamHelper[*vdom.VDomBackendUpdate](w, "vdomrender", data, opts) } // command "vdomurlrequest", wshserver.VDomUrlRequestCommand func VDomUrlRequestCommand(w *wshutil.WshRpc, data wshrpc.VDomUrlRequestData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] { return sendRpcRequestResponseStreamHelper[wshrpc.VDomUrlRequestResponse](w, "vdomurlrequest", data, opts) } // command "waitforroute", wshserver.WaitForRouteCommand func WaitForRouteCommand(w *wshutil.WshRpc, data wshrpc.CommandWaitForRouteData, opts *wshrpc.RpcOpts) (bool, error) { resp, err := sendRpcRequestCallHelper[bool](w, "waitforroute", data, opts) return resp, err } // command "waveaiaddcontext", wshserver.WaveAIAddContextCommand func WaveAIAddContextCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAIAddContextData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "waveaiaddcontext", data, opts) return err } // command "waveaienabletelemetry", wshserver.WaveAIEnableTelemetryCommand func WaveAIEnableTelemetryCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "waveaienabletelemetry", nil, opts) return err } // command "waveaigettooldiff", wshserver.WaveAIGetToolDiffCommand func WaveAIGetToolDiffCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAIGetToolDiffData, opts *wshrpc.RpcOpts) (*wshrpc.CommandWaveAIGetToolDiffRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandWaveAIGetToolDiffRtnData](w, "waveaigettooldiff", data, opts) return resp, err } // command "waveaitoolapprove", wshserver.WaveAIToolApproveCommand func WaveAIToolApproveCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAIToolApproveData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "waveaitoolapprove", data, opts) return err } // command "wavefilereadstream", wshserver.WaveFileReadStreamCommand func WaveFileReadStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveFileReadStreamData, opts *wshrpc.RpcOpts) (*wshrpc.WaveFileInfo, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.WaveFileInfo](w, "wavefilereadstream", data, opts) return resp, err } // command "waveinfo", wshserver.WaveInfoCommand func WaveInfoCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*wshrpc.WaveInfoData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.WaveInfoData](w, "waveinfo", nil, opts) return resp, err } // command "webselector", wshserver.WebSelectorCommand func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts) return resp, err } // command "workspacelist", wshserver.WorkspaceListCommand func WorkspaceListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.WorkspaceInfoData, error) { resp, err := sendRpcRequestCallHelper[[]wshrpc.WorkspaceInfoData](w, "workspacelist", nil, opts) return resp, err } // command "writeappfile", wshserver.WriteAppFileCommand func WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "writeappfile", data, opts) return err } // command "writeappgofile", wshserver.WriteAppGoFileCommand func WriteAppGoFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppGoFileData, opts *wshrpc.RpcOpts) (*wshrpc.CommandWriteAppGoFileRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandWriteAppGoFileRtnData](w, "writeappgofile", data, opts) return resp, err } // command "writeappsecretbindings", wshserver.WriteAppSecretBindingsCommand func WriteAppSecretBindingsCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppSecretBindingsData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "writeappsecretbindings", data, opts) return err } // command "writetempfile", wshserver.WriteTempFileCommand func WriteTempFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteTempFileData, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "writetempfile", data, opts) return resp, err } // command "wshactivity", wshserver.WshActivityCommand func WshActivityCommand(w *wshutil.WshRpc, data map[string]int, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "wshactivity", data, opts) return err } // command "wsldefaultdistro", wshserver.WslDefaultDistroCommand func WslDefaultDistroCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "wsldefaultdistro", nil, opts) return resp, err } // command "wsllist", wshserver.WslListCommand func WslListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "wsllist", nil, opts) return resp, err } // command "wslstatus", wshserver.WslStatusCommand func WslStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.ConnStatus, error) { resp, err := sendRpcRequestCallHelper[[]wshrpc.ConnStatus](w, "wslstatus", nil, opts) return resp, err } ================================================ FILE: pkg/wshrpc/wshclient/wshclientutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshclient import ( "context" "errors" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" ) func sendRpcRequestCallHelper[T any](w *wshutil.WshRpc, command string, data interface{}, opts *wshrpc.RpcOpts) (T, error) { if opts == nil { opts = &wshrpc.RpcOpts{} } var respData T if w == nil { return respData, errors.New("nil wshrpc passed to wshclient") } if opts.NoResponse { err := w.SendCommand(command, data, opts) if err != nil { return respData, err } return respData, nil } resp, err := w.SendRpcRequest(command, data, opts) if err != nil { return respData, err } err = utilfn.ReUnmarshal(&respData, resp) if err != nil { return respData, err } return respData, nil } func rtnErr[T any](ch chan wshrpc.RespOrErrorUnion[T], err error) { go func() { defer func() { panichandler.PanicHandler("wshclientutil:rtnErr", recover()) }() ch <- wshrpc.RespOrErrorUnion[T]{Error: err} close(ch) }() } func sendRpcRequestResponseStreamHelper[T any](w *wshutil.WshRpc, command string, data interface{}, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[T] { if opts == nil { opts = &wshrpc.RpcOpts{} } respChan := make(chan wshrpc.RespOrErrorUnion[T], 32) if w == nil { rtnErr(respChan, errors.New("nil wshrpc passed to wshclient")) return respChan } reqHandler, err := w.SendComplexRequest(command, data, opts) if err != nil { rtnErr(respChan, err) return respChan } opts.StreamCancelFn = func(ctx context.Context) error { return reqHandler.SendCancel(ctx) } go func() { defer func() { panichandler.PanicHandler("sendRpcRequestResponseStreamHelper", recover()) }() defer close(respChan) for { if reqHandler.ResponseDone() { break } resp, err := reqHandler.NextResponse() if err != nil { respChan <- wshrpc.RespOrErrorUnion[T]{Error: err} break } var respData T err = utilfn.ReUnmarshal(&respData, resp) if err != nil { respChan <- wshrpc.RespOrErrorUnion[T]{Error: err} break } respChan <- wshrpc.RespOrErrorUnion[T]{Response: respData} } }() return respChan } ================================================ FILE: pkg/wshrpc/wshremote/sysinfo.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshremote import ( "log" "strconv" "time" "github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/mem" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) const BYTES_PER_GB = 1073741824 func getCpuData(values map[string]float64) { percentArr, err := cpu.Percent(0, false) if err != nil { return } if len(percentArr) > 0 { values[wshrpc.TimeSeries_Cpu] = percentArr[0] } percentArr, err = cpu.Percent(0, true) if err != nil { return } for idx, percent := range percentArr { values[wshrpc.TimeSeries_Cpu+":"+strconv.Itoa(idx)] = percent } } func getMemData(values map[string]float64) { memData, err := mem.VirtualMemory() if err != nil { return } values["mem:total"] = float64(memData.Total) / BYTES_PER_GB values["mem:available"] = float64(memData.Available) / BYTES_PER_GB values["mem:used"] = float64(memData.Used) / BYTES_PER_GB values["mem:free"] = float64(memData.Free) / BYTES_PER_GB } func generateSingleServerData(client *wshutil.WshRpc, connName string) { now := time.Now() values := make(map[string]float64) getCpuData(values) getMemData(values) tsData := wshrpc.TimeSeriesData{Ts: now.UnixMilli(), Values: values} event := wps.WaveEvent{ Event: wps.Event_SysInfo, Scopes: []string{connName}, Data: tsData, Persist: 1024, } wshclient.EventPublishCommand(client, event, &wshrpc.RpcOpts{NoResponse: true}) } func RunSysInfoLoop(client *wshutil.WshRpc, connName string) { defer func() { log.Printf("sysinfo loop ended conn:%s\n", connName) }() for { generateSingleServerData(client, connName) time.Sleep(1 * time.Second) } } ================================================ FILE: pkg/wshrpc/wshremote/wshremote.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshremote import ( "context" "fmt" "io" "log" "net" "os" "path/filepath" "sync" "time" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/suggestion" "github.com/wavetermdev/waveterm/pkg/util/unixutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) type JobManagerConnection struct { JobId string Conn net.Conn WshRpc *wshutil.WshRpc CleanupFn func() } type ServerImpl struct { LogWriter io.Writer Router *wshutil.WshRouter RpcClient *wshutil.WshRpc IsLocal bool InitialEnv map[string]string JobManagerMap map[string]*JobManagerConnection SockName string Lock sync.Mutex } func MakeRemoteRpcServerImpl(logWriter io.Writer, router *wshutil.WshRouter, rpcClient *wshutil.WshRpc, isLocal bool, initialEnv map[string]string, sockName string) *ServerImpl { return &ServerImpl{ LogWriter: logWriter, Router: router, RpcClient: rpcClient, IsLocal: isLocal, InitialEnv: initialEnv, JobManagerMap: make(map[string]*JobManagerConnection), SockName: sockName, } } func (*ServerImpl) WshServerImpl() {} func (impl *ServerImpl) Log(format string, args ...interface{}) { if impl.LogWriter != nil { fmt.Fprintf(impl.LogWriter, format, args...) } else { log.Printf(format, args...) } } func (impl *ServerImpl) MessageCommand(ctx context.Context, data wshrpc.CommandMessageData) error { impl.Log("[message] %q\n", data.Message) return nil } func (impl *ServerImpl) StreamTestCommand(ctx context.Context) chan wshrpc.RespOrErrorUnion[int] { ch := make(chan wshrpc.RespOrErrorUnion[int], 16) go func() { defer close(ch) idx := 0 for { ch <- wshrpc.RespOrErrorUnion[int]{Response: idx} idx++ if idx == 1000 { break } } }() return ch } func (*ServerImpl) RemoteGetInfoCommand(ctx context.Context) (wshrpc.RemoteInfo, error) { return wshutil.GetInfo(), nil } func (*ServerImpl) RemoteInstallRcFilesCommand(ctx context.Context) error { return wshutil.InstallRcFiles() } func (*ServerImpl) FetchSuggestionsCommand(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { return suggestion.FetchSuggestions(ctx, data) } func (*ServerImpl) DisposeSuggestionsCommand(ctx context.Context, widgetId string) error { suggestion.DisposeSuggestions(ctx, widgetId) return nil } func (impl *ServerImpl) ConnServerInitCommand(ctx context.Context, data wshrpc.CommandConnServerInitData) error { if data.ClientId == "" { return fmt.Errorf("clientid is required") } if impl.SockName == "" { return fmt.Errorf("sockname not set in server impl") } symlinkPath, err := wavebase.ExpandHomeDir(wavebase.GetPersistentRemoteSockName(data.ClientId)) if err != nil { return fmt.Errorf("cannot expand symlink path: %w", err) } symlinkDir := filepath.Dir(symlinkPath) if err := os.MkdirAll(symlinkDir, 0700); err != nil { return fmt.Errorf("could not create client directory %s: %w", symlinkDir, err) } os.Remove(symlinkPath) if err := os.Symlink(impl.SockName, symlinkPath); err != nil { return fmt.Errorf("could not create symlink %s -> %s: %w", symlinkPath, impl.SockName, err) } impl.Log("created symlink %s -> %s\n", symlinkPath, impl.SockName) return nil } func (impl *ServerImpl) getWshPath() (string, error) { if impl.IsLocal { return filepath.Join(wavebase.GetWaveDataDir(), "bin", "wsh"), nil } wshPath, err := wavebase.ExpandHomeDir("~/.waveterm/bin/wsh") if err != nil { return "", fmt.Errorf("cannot expand wsh path: %w", err) } return wshPath, nil } func (impl *ServerImpl) BadgeWatchPidCommand(ctx context.Context, data wshrpc.CommandBadgeWatchPidData) error { if data.Pid <= 0 { return fmt.Errorf("invalid pid: %d", data.Pid) } if data.ORef.IsEmpty() { return fmt.Errorf("oref is required") } if data.BadgeId == "" { return fmt.Errorf("badgeid is required") } go func() { defer func() { panichandler.PanicHandler("BadgeWatchPidCommand", recover()) }() for { time.Sleep(time.Second) if unixutil.IsPidRunning(data.Pid) { continue } orefStr := data.ORef.String() event := wps.WaveEvent{ Event: wps.Event_Badge, Scopes: []string{orefStr}, Data: baseds.BadgeEvent{ ORef: orefStr, ClearById: data.BadgeId, }, } wshclient.EventPublishCommand(impl.RpcClient, event, nil) log.Printf("BadgeWatchPidCommand: pid %d gone, cleared badge %s for oref %s\n", data.Pid, data.BadgeId, orefStr) return } }() return nil } ================================================ FILE: pkg/wshrpc/wshremote/wshremote_file.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshremote import ( "context" "encoding/base64" "errors" "fmt" "io" "io/fs" "log" "os" "path/filepath" "strings" "time" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/connparse" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/fsutil" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) const RemoteFileTransferSizeLimit = 32 * 1024 * 1024 var DisableRecursiveFileOpts = true func (impl *ServerImpl) remoteStreamFileDir(ctx context.Context, path string, byteRange fileutil.ByteRangeType, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange fileutil.ByteRangeType)) error { innerFilesEntries, err := os.ReadDir(path) if err != nil { return fmt.Errorf("cannot open dir %q: %w", path, err) } if byteRange.All { if len(innerFilesEntries) > wshrpc.MaxDirSize { innerFilesEntries = innerFilesEntries[:wshrpc.MaxDirSize] } } else { if byteRange.Start < int64(len(innerFilesEntries)) { var realEnd int64 if byteRange.OpenEnd { realEnd = int64(len(innerFilesEntries)) } else { realEnd = byteRange.End + 1 if realEnd > int64(len(innerFilesEntries)) { realEnd = int64(len(innerFilesEntries)) } } innerFilesEntries = innerFilesEntries[byteRange.Start:realEnd] } else { innerFilesEntries = []os.DirEntry{} } } var fileInfoArr []*wshrpc.FileInfo for _, innerFileEntry := range innerFilesEntries { if ctx.Err() != nil { return ctx.Err() } innerFileInfoInt, err := innerFileEntry.Info() if err != nil { continue } innerFileInfo := statToFileInfo(filepath.Join(path, innerFileInfoInt.Name()), innerFileInfoInt, false) fileInfoArr = append(fileInfoArr, innerFileInfo) if len(fileInfoArr) >= wshrpc.DirChunkSize { dataCallback(fileInfoArr, nil, byteRange) fileInfoArr = nil } } if len(fileInfoArr) > 0 { dataCallback(fileInfoArr, nil, byteRange) } return nil } func (impl *ServerImpl) remoteStreamFileRegular(ctx context.Context, path string, byteRange fileutil.ByteRangeType, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange fileutil.ByteRangeType)) error { fd, err := os.Open(path) if err != nil { return fmt.Errorf("cannot open file %q: %w", path, err) } defer utilfn.GracefulClose(fd, "remoteStreamFileRegular", path) var filePos int64 if !byteRange.All && byteRange.Start > 0 { _, err := fd.Seek(byteRange.Start, io.SeekStart) if err != nil { return fmt.Errorf("seeking file %q: %w", path, err) } filePos = byteRange.Start } buf := make([]byte, wshrpc.FileChunkSize) for { if ctx.Err() != nil { return ctx.Err() } n, err := fd.Read(buf) if n > 0 { if !byteRange.All && !byteRange.OpenEnd && filePos+int64(n) > byteRange.End+1 { n = int(byteRange.End + 1 - filePos) } filePos += int64(n) dataCallback(nil, buf[:n], byteRange) } if !byteRange.All && !byteRange.OpenEnd && filePos >= byteRange.End+1 { break } if errors.Is(err, io.EOF) { break } if err != nil { return fmt.Errorf("reading file %q: %w", path, err) } } return nil } func (impl *ServerImpl) remoteStreamFileInternal(ctx context.Context, data wshrpc.CommandRemoteStreamFileData, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange fileutil.ByteRangeType)) error { byteRange, err := fileutil.ParseByteRange(data.ByteRange) if err != nil { return err } path, err := wavebase.ExpandHomeDir(data.Path) if err != nil { return err } finfo, err := impl.fileInfoInternal(path, true) if err != nil { return fmt.Errorf("cannot stat file %q: %w", path, err) } dataCallback([]*wshrpc.FileInfo{finfo}, nil, byteRange) if finfo.NotFound { return nil } if finfo.IsDir { return impl.remoteStreamFileDir(ctx, path, byteRange, dataCallback) } else { if finfo.Size > RemoteFileTransferSizeLimit { return fmt.Errorf("file %q size %d exceeds transfer limit of %d bytes", path, finfo.Size, RemoteFileTransferSizeLimit) } return impl.remoteStreamFileRegular(ctx, path, byteRange, dataCallback) } } func (impl *ServerImpl) RemoteStreamFileCommand(ctx context.Context, data wshrpc.CommandRemoteStreamFileData) chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { ch := make(chan wshrpc.RespOrErrorUnion[wshrpc.FileData], 16) go func() { defer func() { panichandler.PanicHandler("RemoteStreamFileCommand", recover()) }() defer close(ch) firstPk := true err := impl.remoteStreamFileInternal(ctx, data, func(fileInfo []*wshrpc.FileInfo, data []byte, byteRange fileutil.ByteRangeType) { resp := wshrpc.FileData{} fileInfoLen := len(fileInfo) if fileInfoLen > 1 || !firstPk { resp.Entries = fileInfo } else if fileInfoLen == 1 { resp.Info = fileInfo[0] } if firstPk { firstPk = false } if len(data) > 0 { resp.Data64 = base64.StdEncoding.EncodeToString(data) resp.At = &wshrpc.FileDataAt{Offset: byteRange.Start, Size: len(data)} } ch <- wshrpc.RespOrErrorUnion[wshrpc.FileData]{Response: resp} }) if err != nil { ch <- wshutil.RespErr[wshrpc.FileData](err) } }() return ch } // prepareDestForCopy resolves the final destination path and handles overwrite logic. // destPath is the raw destination path (may be a directory or file path). // srcBaseName is the basename of the source file (used when dest is a directory or ends with slash). // destHasSlash indicates if the original URI ended with a slash (forcing directory interpretation). // Returns the resolved path ready for writing. func prepareDestForCopy(destPath string, srcBaseName string, destHasSlash bool, overwrite bool) (string, error) { destInfo, err := os.Stat(destPath) if err != nil && !errors.Is(err, fs.ErrNotExist) { return "", fmt.Errorf("cannot stat destination %q: %w", destPath, err) } destExists := destInfo != nil destIsDir := destExists && destInfo.IsDir() var finalPath string if destHasSlash || destIsDir { finalPath = filepath.Join(destPath, srcBaseName) } else { finalPath = destPath } finalInfo, err := os.Stat(finalPath) if err != nil && !errors.Is(err, fs.ErrNotExist) { return "", fmt.Errorf("cannot stat file %q: %w", finalPath, err) } if finalInfo != nil { if !overwrite { return "", fmt.Errorf(wshfs.OverwriteRequiredError, finalPath) } if err := os.Remove(finalPath); err != nil { return "", fmt.Errorf("cannot remove file %q: %w", finalPath, err) } } return finalPath, nil } // remoteCopyFileInternal copies FROM local (this host) TO local (this host) // Only supports copying files, not directories func remoteCopyFileInternal(srcUri, destUri string, srcPathCleaned, destPathCleaned string, destHasSlash bool, overwrite bool) error { srcFileStat, err := os.Stat(srcPathCleaned) if err != nil { return fmt.Errorf("cannot stat file %q: %w", srcPathCleaned, err) } if srcFileStat.IsDir() { return fmt.Errorf("copying directories is not supported") } if srcFileStat.Size() > RemoteFileTransferSizeLimit { return fmt.Errorf("file %q size %d exceeds transfer limit of %d bytes", srcPathCleaned, srcFileStat.Size(), RemoteFileTransferSizeLimit) } destFilePath, err := prepareDestForCopy(destPathCleaned, filepath.Base(srcPathCleaned), destHasSlash, overwrite) if err != nil { return err } srcFile, err := os.Open(srcPathCleaned) if err != nil { return fmt.Errorf("cannot open file %q: %w", srcPathCleaned, err) } defer srcFile.Close() destFile, err := os.OpenFile(destFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcFileStat.Mode()) if err != nil { return fmt.Errorf("cannot create file %q: %w", destFilePath, err) } defer destFile.Close() if _, err = io.Copy(destFile, srcFile); err != nil { return fmt.Errorf("cannot copy %q to %q: %w", srcUri, destUri, err) } return nil } // RemoteFileCopyCommand copies a file FROM somewhere TO here func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.CommandFileCopyData) (bool, error) { log.Printf("RemoteFileCopyCommand: src=%s, dest=%s\n", data.SrcUri, data.DestUri) opts := data.Opts if opts == nil { opts = &wshrpc.FileCopyOpts{} } if opts.Overwrite && opts.Merge { return false, fmt.Errorf("cannot specify both overwrite and merge") } if opts.Recursive { return false, fmt.Errorf("directory copying is not supported") } srcConn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, data.SrcUri) if err != nil { return false, fmt.Errorf("cannot parse source URI %q: %w", data.SrcUri, err) } destConn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, data.DestUri) if err != nil { return false, fmt.Errorf("cannot parse destination URI %q: %w", data.DestUri, err) } destPathCleaned := filepath.Clean(wavebase.ExpandHomeDirSafe(destConn.Path)) destHasSlash := strings.HasSuffix(data.DestUri, "/") if srcConn.Host == destConn.Host { srcPathCleaned := filepath.Clean(wavebase.ExpandHomeDirSafe(srcConn.Path)) err := remoteCopyFileInternal(data.SrcUri, data.DestUri, srcPathCleaned, destPathCleaned, destHasSlash, opts.Overwrite) return false, err } // FROM external TO here - only supports single file copying timeout := wshfs.DefaultTimeout if opts.Timeout > 0 { timeout = time.Duration(opts.Timeout) * time.Millisecond } readCtx, timeoutCancel := context.WithTimeoutCause(ctx, timeout, fmt.Errorf("timeout copying file %q to %q", data.SrcUri, data.DestUri)) defer timeoutCancel() copyStart := time.Now() srcFileInfo, err := wshclient.RemoteFileInfoCommand(wshfs.RpcClient, srcConn.Path, &wshrpc.RpcOpts{Timeout: opts.Timeout, Route: wshutil.MakeConnectionRouteId(srcConn.Host)}) if err != nil { return false, fmt.Errorf("cannot get info for source file %q: %w", data.SrcUri, err) } if srcFileInfo.IsDir { return false, fmt.Errorf("copying directories is not supported") } if srcFileInfo.Size > RemoteFileTransferSizeLimit { return false, fmt.Errorf("file %q size %d exceeds transfer limit of %d bytes", data.SrcUri, srcFileInfo.Size, RemoteFileTransferSizeLimit) } destFilePath, err := prepareDestForCopy(destPathCleaned, filepath.Base(srcConn.Path), destHasSlash, opts.Overwrite) if err != nil { return false, err } destFile, err := os.OpenFile(destFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcFileInfo.Mode) if err != nil { return false, fmt.Errorf("cannot create destination file %q: %w", destFilePath, err) } defer destFile.Close() streamChan := wshclient.RemoteStreamFileCommand(wshfs.RpcClient, wshrpc.CommandRemoteStreamFileData{Path: srcConn.Path}, &wshrpc.RpcOpts{Timeout: opts.Timeout, Route: wshutil.MakeConnectionRouteId(srcConn.Host)}) if err = fsutil.ReadFileStreamToWriter(readCtx, streamChan, destFile); err != nil { return false, fmt.Errorf("error copying file %q to %q: %w", data.SrcUri, data.DestUri, err) } totalTime := time.Since(copyStart).Seconds() totalMegaBytes := float64(srcFileInfo.Size) / 1024 / 1024 rate := float64(0) if totalTime > 0 { rate = totalMegaBytes / totalTime } log.Printf("RemoteFileCopyCommand: done; 1 file copied in %.3fs, total of %.4f MB, %.2f MB/s\n", totalTime, totalMegaBytes, rate) return false, nil } func (impl *ServerImpl) RemoteListEntriesCommand(ctx context.Context, data wshrpc.CommandRemoteListEntriesData) chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] { ch := make(chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData], 16) go func() { defer func() { panichandler.PanicHandler("RemoteListEntriesCommand", recover()) }() defer close(ch) path, err := wavebase.ExpandHomeDir(data.Path) if err != nil { ch <- wshutil.RespErr[wshrpc.CommandRemoteListEntriesRtnData](err) return } innerFilesEntries := []os.DirEntry{} seen := 0 if data.Opts.Limit == 0 { data.Opts.Limit = wshrpc.MaxDirSize } if data.Opts.All { if DisableRecursiveFileOpts { ch <- wshutil.RespErr[wshrpc.CommandRemoteListEntriesRtnData](fmt.Errorf("recursive directory listings are not supported")) return } fs.WalkDir(os.DirFS(path), ".", func(path string, d fs.DirEntry, err error) error { defer func() { seen++ }() if seen < data.Opts.Offset { return nil } if seen >= data.Opts.Offset+data.Opts.Limit { return io.EOF } if err != nil { return err } if d.IsDir() { return nil } innerFilesEntries = append(innerFilesEntries, d) return nil }) } else { innerFilesEntries, err = os.ReadDir(path) if err != nil { ch <- wshutil.RespErr[wshrpc.CommandRemoteListEntriesRtnData](fmt.Errorf("cannot open dir %q: %w", path, err)) return } } var fileInfoArr []*wshrpc.FileInfo for _, innerFileEntry := range innerFilesEntries { if ctx.Err() != nil { ch <- wshutil.RespErr[wshrpc.CommandRemoteListEntriesRtnData](ctx.Err()) return } innerFileInfoInt, err := innerFileEntry.Info() if err != nil { log.Printf("cannot stat file %q: %v\n", innerFileEntry.Name(), err) continue } innerFileInfo := statToFileInfo(filepath.Join(path, innerFileInfoInt.Name()), innerFileInfoInt, false) fileInfoArr = append(fileInfoArr, innerFileInfo) if len(fileInfoArr) >= wshrpc.DirChunkSize { resp := wshrpc.CommandRemoteListEntriesRtnData{FileInfo: fileInfoArr} ch <- wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]{Response: resp} fileInfoArr = nil } } if len(fileInfoArr) > 0 { resp := wshrpc.CommandRemoteListEntriesRtnData{FileInfo: fileInfoArr} ch <- wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]{Response: resp} } }() return ch } func statToFileInfo(fullPath string, finfo fs.FileInfo, extended bool) *wshrpc.FileInfo { mimeType := fileutil.DetectMimeType(fullPath, finfo, extended) rtn := &wshrpc.FileInfo{ Path: wavebase.ReplaceHomeDir(fullPath), Dir: computeDirPart(fullPath), Name: finfo.Name(), Size: finfo.Size(), Mode: finfo.Mode(), ModeStr: finfo.Mode().String(), ModTime: finfo.ModTime().UnixMilli(), IsDir: finfo.IsDir(), MimeType: mimeType, SupportsMkdir: true, } if finfo.IsDir() { rtn.Size = -1 } return rtn } // fileInfo might be null func checkIsReadOnly(path string, fileInfo fs.FileInfo, exists bool) bool { if !exists || fileInfo.Mode().IsDir() { dirName := filepath.Dir(path) randHexStr, err := utilfn.RandomHexString(12) if err != nil { // we're not sure, just return false return false } tmpFileName := filepath.Join(dirName, "wsh-tmp-"+randHexStr) fd, err := os.Create(tmpFileName) if err != nil { return true } utilfn.GracefulClose(fd, "checkIsReadOnly", tmpFileName) os.Remove(tmpFileName) return false } // try to open for writing, if this fails then it is read-only file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0666) if err != nil { return true } utilfn.GracefulClose(file, "checkIsReadOnly", path) return false } func computeDirPart(path string) string { path = filepath.Clean(wavebase.ExpandHomeDirSafe(path)) path = filepath.ToSlash(path) if path == "/" { return "/" } return filepath.Dir(path) } func (*ServerImpl) fileInfoInternal(path string, extended bool) (*wshrpc.FileInfo, error) { cleanedPath := filepath.Clean(wavebase.ExpandHomeDirSafe(path)) finfo, err := os.Stat(cleanedPath) if os.IsNotExist(err) { return &wshrpc.FileInfo{ Path: wavebase.ReplaceHomeDir(path), Dir: computeDirPart(path), NotFound: true, ReadOnly: checkIsReadOnly(cleanedPath, finfo, false), SupportsMkdir: true, }, nil } if err != nil { return nil, fmt.Errorf("cannot stat file %q: %w", path, err) } rtn := statToFileInfo(cleanedPath, finfo, extended) if extended { rtn.ReadOnly = checkIsReadOnly(cleanedPath, finfo, true) } return rtn, nil } func resolvePaths(paths []string) string { if len(paths) == 0 { return wavebase.ExpandHomeDirSafe("~") } rtnPath := wavebase.ExpandHomeDirSafe(paths[0]) for _, path := range paths[1:] { path = wavebase.ExpandHomeDirSafe(path) if filepath.IsAbs(path) { rtnPath = path continue } rtnPath = filepath.Join(rtnPath, path) } return rtnPath } func (impl *ServerImpl) RemoteFileJoinCommand(ctx context.Context, paths []string) (*wshrpc.FileInfo, error) { rtnPath := resolvePaths(paths) return impl.fileInfoInternal(rtnPath, true) } func (impl *ServerImpl) RemoteFileInfoCommand(ctx context.Context, path string) (*wshrpc.FileInfo, error) { return impl.fileInfoInternal(path, true) } func (impl *ServerImpl) RemoteFileMultiInfoCommand(ctx context.Context, data wshrpc.CommandRemoteFileMultiInfoData) (map[string]wshrpc.FileInfo, error) { cwd := data.Cwd if cwd == "" { cwd = "~" } cwd = filepath.Clean(wavebase.ExpandHomeDirSafe(cwd)) rtn := make(map[string]wshrpc.FileInfo, len(data.Paths)) for _, path := range data.Paths { if _, found := rtn[path]; found { continue } if ctx.Err() != nil { return nil, ctx.Err() } cleanedPath := wavebase.ExpandHomeDirSafe(path) if !filepath.IsAbs(cleanedPath) { cleanedPath = filepath.Join(cwd, cleanedPath) } fileInfo, err := impl.fileInfoInternal(cleanedPath, false) if err != nil { rtn[path] = wshrpc.FileInfo{ Path: wavebase.ReplaceHomeDir(cleanedPath), Dir: computeDirPart(cleanedPath), Name: filepath.Base(cleanedPath), StatError: err.Error(), SupportsMkdir: true, } continue } rtn[path] = *fileInfo } return rtn, nil } func (impl *ServerImpl) RemoteFileTouchCommand(ctx context.Context, path string) error { cleanedPath := filepath.Clean(wavebase.ExpandHomeDirSafe(path)) if _, err := os.Stat(cleanedPath); err == nil { return fmt.Errorf("file %q already exists", path) } if err := os.MkdirAll(filepath.Dir(cleanedPath), 0755); err != nil { return fmt.Errorf("cannot create directory %q: %w", filepath.Dir(cleanedPath), err) } if err := os.WriteFile(cleanedPath, []byte{}, 0644); err != nil { return fmt.Errorf("cannot create file %q: %w", cleanedPath, err) } return nil } func (impl *ServerImpl) RemoteFileMoveCommand(ctx context.Context, data wshrpc.CommandFileCopyData) error { destUri := data.DestUri srcUri := data.SrcUri destConn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, destUri) if err != nil { return fmt.Errorf("cannot parse destination URI %q: %w", srcUri, err) } destPathCleaned := filepath.Clean(wavebase.ExpandHomeDirSafe(destConn.Path)) _, err = os.Stat(destPathCleaned) if err == nil { return fmt.Errorf("destination %q already exists", destUri) } else if !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("cannot stat destination %q: %w", destUri, err) } srcConn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, srcUri) if err != nil { return fmt.Errorf("cannot parse source URI %q: %w", srcUri, err) } if srcConn.Host != destConn.Host { return fmt.Errorf("cannot move file %q to %q: different hosts", srcUri, destUri) } srcPathCleaned := filepath.Clean(wavebase.ExpandHomeDirSafe(srcConn.Path)) err = os.Rename(srcPathCleaned, destPathCleaned) if err != nil { return fmt.Errorf("cannot move file %q to %q: %w", srcPathCleaned, destPathCleaned, err) } return nil } func (impl *ServerImpl) RemoteMkdirCommand(ctx context.Context, path string) error { cleanedPath := filepath.Clean(wavebase.ExpandHomeDirSafe(path)) if stat, err := os.Stat(cleanedPath); err == nil { if stat.IsDir() { return fmt.Errorf("directory %q already exists", path) } else { return fmt.Errorf("cannot create directory %q, file exists at path", path) } } if err := os.MkdirAll(cleanedPath, 0755); err != nil { return fmt.Errorf("cannot create directory %q: %w", cleanedPath, err) } return nil } func (*ServerImpl) RemoteWriteFileCommand(ctx context.Context, data wshrpc.FileData) error { var truncate, append bool var atOffset int64 if data.Info != nil && data.Info.Opts != nil { truncate = data.Info.Opts.Truncate append = data.Info.Opts.Append } if data.At != nil { atOffset = data.At.Offset } if truncate && atOffset > 0 { return fmt.Errorf("cannot specify non-zero offset with truncate option") } if append && atOffset > 0 { return fmt.Errorf("cannot specify non-zero offset with append option") } path, err := wavebase.ExpandHomeDir(data.Info.Path) if err != nil { return err } createMode := os.FileMode(0644) if data.Info != nil && data.Info.Mode > 0 { createMode = data.Info.Mode } dataSize := base64.StdEncoding.DecodedLen(len(data.Data64)) dataBytes := make([]byte, dataSize) n, err := base64.StdEncoding.Decode(dataBytes, []byte(data.Data64)) if err != nil { return fmt.Errorf("cannot decode base64 data: %w", err) } finfo, err := os.Stat(path) if err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("cannot stat file %q: %w", path, err) } fileSize := int64(0) if finfo != nil { if finfo.IsDir() { return fmt.Errorf("cannot use write file to overwrite a directory %q", path) } fileSize = finfo.Size() } if atOffset > fileSize { return fmt.Errorf("cannot write at offset %d, file size is %d", atOffset, fileSize) } openFlags := os.O_CREATE | os.O_WRONLY if truncate { openFlags |= os.O_TRUNC } if append { openFlags |= os.O_APPEND } file, err := os.OpenFile(path, openFlags, createMode) if err != nil { return fmt.Errorf("cannot open file %q: %w", path, err) } defer utilfn.GracefulClose(file, "RemoteWriteFileCommand", path) if atOffset > 0 && !append { n, err = file.WriteAt(dataBytes[:n], atOffset) } else { n, err = file.Write(dataBytes[:n]) } if err != nil { return fmt.Errorf("cannot write to file %q: %w", path, err) } return nil } func (impl *ServerImpl) RemoteFileStreamCommand(ctx context.Context, data wshrpc.CommandRemoteFileStreamData) (*wshrpc.FileInfo, error) { wshRpc := wshutil.GetWshRpcFromContext(ctx) if wshRpc == nil || wshRpc.StreamBroker == nil { return nil, fmt.Errorf("no stream broker available") } writer, err := wshRpc.StreamBroker.CreateStreamWriter(&data.StreamMeta) if err != nil { return nil, fmt.Errorf("error creating stream writer: %w", err) } path, err := wavebase.ExpandHomeDir(data.Path) if err != nil { writer.CloseWithError(err) return nil, err } cleanedPath := filepath.Clean(path) finfo, err := os.Stat(cleanedPath) if err != nil { writer.CloseWithError(err) return nil, fmt.Errorf("cannot stat file %q: %w", data.Path, err) } if finfo.IsDir() { writer.CloseWithError(fmt.Errorf("path is a directory")) return nil, fmt.Errorf("cannot stream directory %q", data.Path) } byteRange, err := fileutil.ParseByteRange(data.ByteRange) if err != nil { writer.CloseWithError(err) return nil, err } fileInfo := statToFileInfo(cleanedPath, finfo, true) fileInfo.Path = data.Path go func() { defer func() { panichandler.PanicHandler("RemoteFileStreamCommand", recover()) }() defer writer.Close() file, err := os.Open(cleanedPath) if err != nil { writer.CloseWithError(fmt.Errorf("cannot open file %q: %w", data.Path, err)) return } defer utilfn.GracefulClose(file, "RemoteFileStreamCommand", cleanedPath) if !byteRange.All && byteRange.Start > 0 { if _, err := file.Seek(byteRange.Start, io.SeekStart); err != nil { writer.CloseWithError(fmt.Errorf("cannot seek in file %q: %w", data.Path, err)) return } } var src io.Reader = file if !byteRange.All && !byteRange.OpenEnd { src = io.LimitReader(file, byteRange.End-byteRange.Start+1) } buf := make([]byte, 32*1024) for { n, readErr := src.Read(buf) if n > 0 { if _, writeErr := writer.Write(buf[:n]); writeErr != nil { return } } if readErr == io.EOF { return } if readErr != nil { writer.CloseWithError(fmt.Errorf("error reading file %q: %w", data.Path, readErr)) return } } }() return fileInfo, nil } func (*ServerImpl) RemoteFileDeleteCommand(ctx context.Context, data wshrpc.CommandDeleteFileData) error { expandedPath, err := wavebase.ExpandHomeDir(data.Path) if err != nil { return fmt.Errorf("cannot delete file %q: %w", data.Path, err) } cleanedPath := filepath.Clean(expandedPath) if data.Recursive { err = os.RemoveAll(cleanedPath) if err != nil { return fmt.Errorf("cannot delete %q: %w", data.Path, err) } return nil } err = os.Remove(cleanedPath) if err != nil { finfo, statErr := os.Stat(cleanedPath) if statErr == nil && finfo.IsDir() { return fmt.Errorf(wshfs.RecursiveRequiredError) } return fmt.Errorf("cannot delete file %q: %w", data.Path, err) } return nil } ================================================ FILE: pkg/wshrpc/wshremote/wshremote_job.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshremote import ( "bufio" "context" "fmt" "log" "net" "os" "os/exec" "strings" "sync" "syscall" "time" "github.com/shirou/gopsutil/v4/process" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) func isProcessRunning(pid int, pidStartTs int64) (*process.Process, error) { if pid <= 0 { return nil, nil } proc, err := process.NewProcess(int32(pid)) if err != nil { return nil, nil } createTime, err := proc.CreateTime() if err != nil { return nil, err } if createTime != pidStartTs { return nil, nil } return proc, nil } // returns jobRouteId, cleanupFunc, error func (impl *ServerImpl) connectToJobManager(ctx context.Context, jobId string, mainServerJwtToken string) (string, func(), error) { socketPath := wavebase.GetRemoteJobSocketPath(jobId) log.Printf("connectToJobManager: connecting to socket: %s\n", socketPath) conn, err := net.Dial("unix", socketPath) if err != nil { log.Printf("connectToJobManager: error connecting to socket: %v\n", err) return "", nil, fmt.Errorf("cannot connect to job manager socket: %w", err) } log.Printf("connectToJobManager: connected to socket\n") proxy := wshutil.MakeRpcProxy("jobmanager") linkId := impl.Router.RegisterUntrustedLink(proxy) var cleanupOnce sync.Once cleanup := func() { cleanupOnce.Do(func() { conn.Close() impl.Router.UnregisterLink(linkId) impl.removeJobManagerConnection(jobId) }) } go func() { writeErr := wshutil.AdaptOutputChToStream(proxy.ToRemoteCh, conn) if writeErr != nil { log.Printf("connectToJobManager: error writing to job manager socket: %v\n", writeErr) } }() go func() { defer func() { close(proxy.FromRemoteCh) cleanup() }() wshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh, nil) }() routeId := wshutil.MakeLinkRouteId(linkId) authData := wshrpc.CommandAuthenticateToJobData{ JobAccessToken: mainServerJwtToken, } err = wshclient.AuthenticateToJobManagerCommand(impl.RpcClient, authData, &wshrpc.RpcOpts{Route: routeId}) if err != nil { cleanup() return "", nil, fmt.Errorf("authentication to job manager failed: %w", err) } jobRouteId := wshutil.MakeJobRouteId(jobId) waitCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() err = impl.Router.WaitForRegister(waitCtx, jobRouteId) if err != nil { cleanup() return "", nil, fmt.Errorf("timeout waiting for job route to register: %w", err) } jobConn := &JobManagerConnection{ JobId: jobId, Conn: conn, CleanupFn: cleanup, } impl.addJobManagerConnection(jobConn) log.Printf("connectToJobManager: successfully connected and authenticated\n") return jobRouteId, cleanup, nil } func (impl *ServerImpl) addJobManagerConnection(conn *JobManagerConnection) { impl.Lock.Lock() defer impl.Lock.Unlock() impl.JobManagerMap[conn.JobId] = conn log.Printf("addJobManagerConnection: added job manager connection for jobid=%s\n", conn.JobId) } func (impl *ServerImpl) removeJobManagerConnection(jobId string) { impl.Lock.Lock() defer impl.Lock.Unlock() if _, exists := impl.JobManagerMap[jobId]; exists { delete(impl.JobManagerMap, jobId) log.Printf("removeJobManagerConnection: removed job manager connection for jobid=%s\n", jobId) } } func (impl *ServerImpl) getJobManagerConnection(jobId string) *JobManagerConnection { impl.Lock.Lock() defer impl.Lock.Unlock() return impl.JobManagerMap[jobId] } func (impl *ServerImpl) RemoteStartJobCommand(ctx context.Context, data wshrpc.CommandRemoteStartJobData) (*wshrpc.CommandStartJobRtnData, error) { log.Printf("RemoteStartJobCommand: starting, jobid=%s, clientid=%s\n", data.JobId, data.ClientId) if impl.Router == nil { return nil, fmt.Errorf("cannot start remote job: no router available") } wshPath, err := impl.getWshPath() if err != nil { return nil, err } log.Printf("RemoteStartJobCommand: wshPath=%s\n", wshPath) readyPipeRead, readyPipeWrite, err := os.Pipe() if err != nil { return nil, fmt.Errorf("cannot create ready pipe: %w", err) } defer readyPipeRead.Close() defer readyPipeWrite.Close() cmd := exec.Command(wshPath, "jobmanager", "--jobid", data.JobId, "--clientid", data.ClientId) if data.PublicKeyBase64 != "" { cmd.Env = append(os.Environ(), "WAVETERM_PUBLICKEY="+data.PublicKeyBase64) } cmd.ExtraFiles = []*os.File{readyPipeWrite} stdin, err := cmd.StdinPipe() if err != nil { return nil, fmt.Errorf("cannot create stdin pipe: %w", err) } stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("cannot create stdout pipe: %w", err) } stderr, err := cmd.StderrPipe() if err != nil { return nil, fmt.Errorf("cannot create stderr pipe: %w", err) } log.Printf("RemoteStartJobCommand: created pipes\n") if err := cmd.Start(); err != nil { return nil, fmt.Errorf("cannot start job manager: %w", err) } readyPipeWrite.Close() log.Printf("RemoteStartJobCommand: job manager process started\n") jobAuthTokenLine := fmt.Sprintf("Wave-JobAccessToken:%s\n", data.JobAuthToken) if _, err := stdin.Write([]byte(jobAuthTokenLine)); err != nil { cmd.Process.Kill() return nil, fmt.Errorf("cannot write job auth token: %w", err) } stdin.Close() log.Printf("RemoteStartJobCommand: wrote auth token to stdin\n") go func() { scanner := bufio.NewScanner(stderr) for scanner.Scan() { line := scanner.Text() log.Printf("RemoteStartJobCommand: stderr: %s\n", line) } if err := scanner.Err(); err != nil { log.Printf("RemoteStartJobCommand: error reading stderr: %v\n", err) } else { log.Printf("RemoteStartJobCommand: stderr EOF\n") } }() go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() log.Printf("RemoteStartJobCommand: stdout: %s\n", line) } if err := scanner.Err(); err != nil { log.Printf("RemoteStartJobCommand: error reading stdout: %v\n", err) } else { log.Printf("RemoteStartJobCommand: stdout EOF\n") } }() startCh := make(chan error, 1) go func() { scanner := bufio.NewScanner(readyPipeRead) for scanner.Scan() { line := scanner.Text() log.Printf("RemoteStartJobCommand: ready pipe line: %s\n", line) if strings.Contains(line, "Wave-JobManagerStart") { startCh <- nil return } } if err := scanner.Err(); err != nil { startCh <- fmt.Errorf("error reading ready pipe: %w", err) } else { log.Printf("RemoteStartJobCommand: ready pipe EOF\n") startCh <- fmt.Errorf("job manager exited without start signal") } }() timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() log.Printf("RemoteStartJobCommand: waiting for start signal\n") select { case err := <-startCh: if err != nil { cmd.Process.Kill() log.Printf("RemoteStartJobCommand: error from start signal: %v\n", err) return nil, err } log.Printf("RemoteStartJobCommand: received start signal\n") case <-timeoutCtx.Done(): cmd.Process.Kill() log.Printf("RemoteStartJobCommand: timeout waiting for start signal\n") return nil, fmt.Errorf("timeout waiting for job manager to start") } go func() { cmd.Wait() }() jobRouteId, cleanup, err := impl.connectToJobManager(ctx, data.JobId, data.MainServerJwtToken) if err != nil { return nil, err } combinedEnv := make(map[string]string) for k, v := range impl.InitialEnv { combinedEnv[k] = v } for k, v := range data.Env { combinedEnv[k] = v } startJobData := wshrpc.CommandStartJobData{ Cmd: data.Cmd, Args: data.Args, Env: combinedEnv, TermSize: data.TermSize, StreamMeta: data.StreamMeta, } rtnData, err := wshclient.StartJobCommand(impl.RpcClient, startJobData, &wshrpc.RpcOpts{Route: jobRouteId}) if err != nil { cleanup() return nil, fmt.Errorf("failed to start job: %w", err) } return rtnData, nil } func (impl *ServerImpl) RemoteReconnectToJobManagerCommand(ctx context.Context, data wshrpc.CommandRemoteReconnectToJobManagerData) (*wshrpc.CommandRemoteReconnectToJobManagerRtnData, error) { log.Printf("RemoteReconnectToJobManagerCommand: reconnecting, jobid=%s\n", data.JobId) if impl.Router == nil { return &wshrpc.CommandRemoteReconnectToJobManagerRtnData{ Success: false, Error: "cannot reconnect to job manager: no router available", }, nil } proc, err := isProcessRunning(data.JobManagerPid, data.JobManagerStartTs) if err != nil { return &wshrpc.CommandRemoteReconnectToJobManagerRtnData{ Success: false, Error: fmt.Sprintf("error checking job manager process: %v", err), }, nil } if proc == nil { return &wshrpc.CommandRemoteReconnectToJobManagerRtnData{ Success: false, JobManagerGone: true, Error: fmt.Sprintf("job manager process (pid=%d) is not running", data.JobManagerPid), }, nil } existingConn := impl.getJobManagerConnection(data.JobId) if existingConn != nil { log.Printf("RemoteReconnectToJobManagerCommand: closing existing connection for jobid=%s\n", data.JobId) if existingConn.CleanupFn != nil { existingConn.CleanupFn() } } _, _, err = impl.connectToJobManager(ctx, data.JobId, data.MainServerJwtToken) if err != nil { return &wshrpc.CommandRemoteReconnectToJobManagerRtnData{ Success: false, Error: err.Error(), }, nil } log.Printf("RemoteReconnectToJobManagerCommand: successfully reconnected to job manager\n") return &wshrpc.CommandRemoteReconnectToJobManagerRtnData{ Success: true, }, nil } func (impl *ServerImpl) RemoteDisconnectFromJobManagerCommand(ctx context.Context, data wshrpc.CommandRemoteDisconnectFromJobManagerData) error { log.Printf("RemoteDisconnectFromJobManagerCommand: disconnecting, jobid=%s\n", data.JobId) conn := impl.getJobManagerConnection(data.JobId) if conn == nil { log.Printf("RemoteDisconnectFromJobManagerCommand: no connection found for jobid=%s\n", data.JobId) return nil } if conn.CleanupFn != nil { conn.CleanupFn() log.Printf("RemoteDisconnectFromJobManagerCommand: cleanup completed for jobid=%s\n", data.JobId) } return nil } func (impl *ServerImpl) RemoteTerminateJobManagerCommand(ctx context.Context, data wshrpc.CommandRemoteTerminateJobManagerData) error { log.Printf("RemoteTerminateJobManagerCommand: terminating job manager, jobid=%s, pid=%d\n", data.JobId, data.JobManagerPid) proc, err := isProcessRunning(data.JobManagerPid, data.JobManagerStartTs) if err != nil { return fmt.Errorf("error checking job manager process: %w", err) } if proc == nil { log.Printf("RemoteTerminateJobManagerCommand: job manager process not running, jobid=%s\n", data.JobId) return nil } err = proc.SendSignal(syscall.SIGTERM) if err != nil { log.Printf("failed to send SIGTERM to job manager: %v", err) } else { log.Printf("RemoteTerminateJobManagerCommand: sent SIGTERM to job manager process, jobid=%s, pid=%d\n", data.JobId, data.JobManagerPid) } return nil } ================================================ FILE: pkg/wshrpc/wshrpcmeta.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshrpc import ( "context" "fmt" "log" "reflect" "strings" ) type WshRpcMethodDecl struct { Command string CommandType string MethodName string CommandDataTypes []reflect.Type DefaultResponseDataType reflect.Type } func (decl *WshRpcMethodDecl) GetCommandDataTypes() []reflect.Type { return decl.CommandDataTypes } var contextRType = reflect.TypeOf((*context.Context)(nil)).Elem() var wshRpcInterfaceRType = reflect.TypeOf((*WshRpcInterface)(nil)).Elem() func getWshCommandType(method reflect.Method) string { if method.Type.NumOut() == 1 { outType := method.Type.Out(0) if outType.Kind() == reflect.Chan { return RpcType_ResponseStream } } return RpcType_Call } func getWshMethodResponseType(commandType string, method reflect.Method) reflect.Type { switch commandType { case RpcType_ResponseStream: if method.Type.NumOut() != 1 { panic(fmt.Sprintf("method %q has invalid number of return values for response stream", method.Name)) } outType := method.Type.Out(0) if outType.Kind() != reflect.Chan { panic(fmt.Sprintf("method %q has invalid return type %s for response stream", method.Name, outType)) } elemType := outType.Elem() if !strings.HasPrefix(elemType.Name(), "RespOrErrorUnion") { panic(fmt.Sprintf("method %q has invalid return element type %s for response stream (should be RespOrErrorUnion)", method.Name, elemType)) } respField, found := elemType.FieldByName("Response") if !found { panic(fmt.Sprintf("method %q has invalid return element type %s for response stream (missing Response field)", method.Name, elemType)) } return respField.Type case RpcType_Call: if method.Type.NumOut() > 1 { return method.Type.Out(0) } return nil default: panic(fmt.Sprintf("unsupported command type %q", commandType)) } } func generateWshCommandDecl(method reflect.Method) *WshRpcMethodDecl { if method.Type.NumIn() == 0 || method.Type.In(0) != contextRType { panic(fmt.Sprintf("method %q does not have context as first argument", method.Name)) } cmdStr := method.Name decl := &WshRpcMethodDecl{} // remove Command suffix if !strings.HasSuffix(cmdStr, "Command") { panic(fmt.Sprintf("method %q does not have Command suffix", cmdStr)) } cmdStr = cmdStr[:len(cmdStr)-len("Command")] decl.Command = strings.ToLower(cmdStr) decl.CommandType = getWshCommandType(method) decl.MethodName = method.Name var cdataTypes []reflect.Type for idx := 1; idx < method.Type.NumIn(); idx++ { cdataTypes = append(cdataTypes, method.Type.In(idx)) } decl.CommandDataTypes = cdataTypes decl.DefaultResponseDataType = getWshMethodResponseType(decl.CommandType, method) return decl } func MakeMethodMapForImpl(impl any, declMap map[string]*WshRpcMethodDecl) map[string]reflect.Method { rtype := reflect.TypeOf(impl) rtnMap := make(map[string]reflect.Method) for midx := 0; midx < rtype.NumMethod(); midx++ { method := rtype.Method(midx) if !strings.HasSuffix(method.Name, "Command") { continue } commandName := strings.ToLower(method.Name[:len(method.Name)-len("Command")]) decl := declMap[commandName] if decl == nil { log.Printf("WARNING: method %q does not match a command method", method.Name) continue } rtnMap[commandName] = method } return rtnMap } func GenerateWshCommandDeclMap() map[string]*WshRpcMethodDecl { rtype := wshRpcInterfaceRType rtnMap := make(map[string]*WshRpcMethodDecl) for midx := 0; midx < rtype.NumMethod(); midx++ { method := rtype.Method(midx) decl := generateWshCommandDecl(method) rtnMap[decl.Command] = decl } return rtnMap } ================================================ FILE: pkg/wshrpc/wshrpcmeta_test.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshrpc import ( "context" "reflect" "testing" ) type testRpcInterfaceForDecls interface { NoArgCommand(ctx context.Context) error OneArgCommand(ctx context.Context, data string) error TwoArgCommand(ctx context.Context, arg1 string, arg2 int) error } func TestGenerateWshCommandDecl_MultiArgs(t *testing.T) { rtype := reflect.TypeOf((*testRpcInterfaceForDecls)(nil)).Elem() method, ok := rtype.MethodByName("TwoArgCommand") if !ok { t.Fatalf("TwoArgCommand method not found") } decl := generateWshCommandDecl(method) if decl.Command != "twoarg" { t.Fatalf("expected command twoarg, got %q", decl.Command) } if len(decl.CommandDataTypes) != 2 { t.Fatalf("expected 2 command data types, got %d", len(decl.CommandDataTypes)) } if decl.CommandDataTypes[0].Kind() != reflect.String || decl.CommandDataTypes[1].Kind() != reflect.Int { t.Fatalf("unexpected command data types: %#v", decl.CommandDataTypes) } if len(decl.GetCommandDataTypes()) != 2 { t.Fatalf("expected helper to return two command data types") } } func TestGenerateWshCommandDeclMap_TestMultiArgCommand(t *testing.T) { decl := GenerateWshCommandDeclMap()["testmultiarg"] if decl == nil { t.Fatalf("expected testmultiarg command declaration") } if decl.MethodName != "TestMultiArgCommand" { t.Fatalf("expected TestMultiArgCommand method name, got %q", decl.MethodName) } if len(decl.GetCommandDataTypes()) != 3 { t.Fatalf("expected 3 command args, got %d", len(decl.GetCommandDataTypes())) } } ================================================ FILE: pkg/wshrpc/wshrpctypes.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // types and methods for wsh rpc calls package wshrpc import ( "bytes" "context" "encoding/json" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" ) type RespOrErrorUnion[T any] struct { Response T Error error } type MultiArg struct { Args []any `json:"args"` } // Instructions for adding a new RPC call // * methods must end with Command // * methods must take context as their first parameter // * methods may take additional typed parameters, and may return either just an error, or one return value plus an error // * after modifying WshRpcInterface, run `task generate` to regnerate bindings type WshRpcInterface interface { AuthenticateCommand(ctx context.Context, data string) (CommandAuthenticateRtnData, error) AuthenticateTokenCommand(ctx context.Context, data CommandAuthenticateTokenData) (CommandAuthenticateRtnData, error) AuthenticateTokenVerifyCommand(ctx context.Context, data CommandAuthenticateTokenData) (CommandAuthenticateRtnData, error) // (special) validates token without binding, root router only AuthenticateJobManagerCommand(ctx context.Context, data CommandAuthenticateJobManagerData) error AuthenticateJobManagerVerifyCommand(ctx context.Context, data CommandAuthenticateJobManagerData) error // (special) validates job auth token without binding, root router only DisposeCommand(ctx context.Context, data CommandDisposeData) error RouteAnnounceCommand(ctx context.Context) error // (special) announces a new route to the main router RouteUnannounceCommand(ctx context.Context) error // (special) unannounces a route to the main router ControlGetRouteIdCommand(ctx context.Context) (string, error) // (special) gets the route for the link that we're on SetPeerInfoCommand(ctx context.Context, peerInfo string) error GetJwtPublicKeyCommand(ctx context.Context) (string, error) // (special) gets the public JWT signing key MessageCommand(ctx context.Context, data CommandMessageData) error GetMetaCommand(ctx context.Context, data CommandGetMetaData) (waveobj.MetaMapType, error) SetMetaCommand(ctx context.Context, data CommandSetMetaData) error ControllerInputCommand(ctx context.Context, data CommandBlockInputData) error ControllerDestroyCommand(ctx context.Context, blockId string) error ControllerResyncCommand(ctx context.Context, data CommandControllerResyncData) error ControllerAppendOutputCommand(ctx context.Context, data CommandControllerAppendOutputData) error ResolveIdsCommand(ctx context.Context, data CommandResolveIdsData) (CommandResolveIdsRtnData, error) CreateBlockCommand(ctx context.Context, data CommandCreateBlockData) (waveobj.ORef, error) CreateSubBlockCommand(ctx context.Context, data CommandCreateSubBlockData) (waveobj.ORef, error) DeleteBlockCommand(ctx context.Context, data CommandDeleteBlockData) error DeleteSubBlockCommand(ctx context.Context, data CommandDeleteBlockData) error WaitForRouteCommand(ctx context.Context, data CommandWaitForRouteData) (bool, error) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error EventUnsubCommand(ctx context.Context, data string) error EventUnsubAllCommand(ctx context.Context) error EventReadHistoryCommand(ctx context.Context, data CommandEventReadHistoryData) ([]*wps.WaveEvent, error) FileRestoreBackupCommand(ctx context.Context, data CommandFileRestoreBackupData) error GetTempDirCommand(ctx context.Context, data CommandGetTempDirData) (string, error) WriteTempFileCommand(ctx context.Context, data CommandWriteTempFileData) (string, error) StreamTestCommand(ctx context.Context) chan RespOrErrorUnion[int] StreamWaveAiCommand(ctx context.Context, request WaveAIStreamRequest) chan RespOrErrorUnion[WaveAIPacketType] StreamCpuDataCommand(ctx context.Context, request CpuDataRequest) chan RespOrErrorUnion[TimeSeriesData] TestCommand(ctx context.Context, data string) error TestMultiArgCommand(ctx context.Context, arg1 string, arg2 int, arg3 bool) (string, error) SetConfigCommand(ctx context.Context, data MetaSettingsType) error SetConnectionsConfigCommand(ctx context.Context, data ConnConfigRequest) error GetFullConfigCommand(ctx context.Context) (wconfig.FullConfigType, error) GetWaveAIModeConfigCommand(ctx context.Context) (wconfig.AIModeConfigUpdate, error) BlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error) DebugTermCommand(ctx context.Context, data CommandDebugTermData) (*CommandDebugTermRtnData, error) BlocksListCommand(ctx context.Context, data BlocksListRequest) ([]BlocksListEntry, error) WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) MacOSVersionCommand(ctx context.Context) (string, error) WshActivityCommand(ct context.Context, data map[string]int) error ActivityCommand(ctx context.Context, data ActivityUpdate) error RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error GetVarCommand(ctx context.Context, data CommandVarData) (*CommandVarResponseData, error) GetAllVarsCommand(ctx context.Context, data CommandVarData) ([]CommandVarResponseData, error) SetVarCommand(ctx context.Context, data CommandVarData) error PathCommand(ctx context.Context, data PathCommandData) (string, error) SendTelemetryCommand(ctx context.Context) error FetchSuggestionsCommand(ctx context.Context, data FetchSuggestionsData) (*FetchSuggestionsResponse, error) DisposeSuggestionsCommand(ctx context.Context, widgetId string) error GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error) UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) // connection functions ConnStatusCommand(ctx context.Context) ([]ConnStatus, error) WslStatusCommand(ctx context.Context) ([]ConnStatus, error) ConnEnsureCommand(ctx context.Context, data ConnExtData) error ConnReinstallWshCommand(ctx context.Context, data ConnExtData) error ConnConnectCommand(ctx context.Context, connRequest ConnRequest) error ConnDisconnectCommand(ctx context.Context, connName string) error ConnListCommand(ctx context.Context) ([]string, error) WslListCommand(ctx context.Context) ([]string, error) WslDefaultDistroCommand(ctx context.Context) (string, error) DismissWshFailCommand(ctx context.Context, connName string) error ConnUpdateWshCommand(ctx context.Context, remoteInfo RemoteInfo) (bool, error) FindGitBashCommand(ctx context.Context, rescan bool) (string, error) ConnServerInitCommand(ctx context.Context, data CommandConnServerInitData) error NotifySystemResumeCommand(ctx context.Context) error // eventrecv is special, it's handled internally by WshRpc with EventListener EventRecvCommand(ctx context.Context, data wps.WaveEvent) error // remotes WshRpcRemoteFileInterface RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData] RemoteGetInfoCommand(ctx context.Context) (RemoteInfo, error) RemoteInstallRcFilesCommand(ctx context.Context) error RemoteStartJobCommand(ctx context.Context, data CommandRemoteStartJobData) (*CommandStartJobRtnData, error) RemoteReconnectToJobManagerCommand(ctx context.Context, data CommandRemoteReconnectToJobManagerData) (*CommandRemoteReconnectToJobManagerRtnData, error) RemoteDisconnectFromJobManagerCommand(ctx context.Context, data CommandRemoteDisconnectFromJobManagerData) error RemoteTerminateJobManagerCommand(ctx context.Context, data CommandRemoteTerminateJobManagerData) error BadgeWatchPidCommand(ctx context.Context, data CommandBadgeWatchPidData) error // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error FocusWindowCommand(ctx context.Context, windowId string) error ElectronEncryptCommand(ctx context.Context, data CommandElectronEncryptData) (*CommandElectronEncryptRtnData, error) ElectronDecryptCommand(ctx context.Context, data CommandElectronDecryptData) (*CommandElectronDecryptRtnData, error) NetworkOnlineCommand(ctx context.Context) (bool, error) ElectronSystemBellCommand(ctx context.Context) error // secrets GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) GetSecretsNamesCommand(ctx context.Context) ([]string, error) SetSecretsCommand(ctx context.Context, secrets map[string]*string) error GetSecretsLinuxStorageBackendCommand(ctx context.Context) (string, error) WorkspaceListCommand(ctx context.Context) ([]WorkspaceInfoData, error) GetUpdateChannelCommand(ctx context.Context) (string, error) // terminal VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) (*waveobj.ORef, error) VDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error // ai AiSendMessageCommand(ctx context.Context, data AiMessageData) error WaveAIEnableTelemetryCommand(ctx context.Context) error GetWaveAIChatCommand(ctx context.Context, data CommandGetWaveAIChatData) (*uctypes.UIChat, error) GetWaveAIRateLimitCommand(ctx context.Context) (*uctypes.RateLimitInfo, error) WaveAIToolApproveCommand(ctx context.Context, data CommandWaveAIToolApproveData) error WaveAIAddContextCommand(ctx context.Context, data CommandWaveAIAddContextData) error WaveAIGetToolDiffCommand(ctx context.Context, data CommandWaveAIGetToolDiffData) (*CommandWaveAIGetToolDiffRtnData, error) // screenshot CaptureBlockScreenshotCommand(ctx context.Context, data CommandCaptureBlockScreenshotData) (string, error) // block focus SetBlockFocusCommand(ctx context.Context, blockId string) error GetFocusedBlockDataCommand(ctx context.Context) (*FocusedBlockData, error) // rtinfo GetRTInfoCommand(ctx context.Context, data CommandGetRTInfoData) (*waveobj.ObjRTInfo, error) SetRTInfoCommand(ctx context.Context, data CommandSetRTInfoData) error // terminal TermGetScrollbackLinesCommand(ctx context.Context, data CommandTermGetScrollbackLinesData) (*CommandTermGetScrollbackLinesRtnData, error) // file WshRpcFileInterface WaveFileReadStreamCommand(ctx context.Context, data CommandWaveFileReadStreamData) (*WaveFileInfo, error) // builder WshRpcBuilderInterface // proc VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate] VDomUrlRequestCommand(ctx context.Context, data VDomUrlRequestData) chan RespOrErrorUnion[VDomUrlRequestResponse] // streams StreamDataCommand(ctx context.Context, data CommandStreamData) error StreamDataAckCommand(ctx context.Context, data CommandStreamAckData) error // jobs AuthenticateToJobManagerCommand(ctx context.Context, data CommandAuthenticateToJobData) error StartJobCommand(ctx context.Context, data CommandStartJobData) (*CommandStartJobRtnData, error) JobPrepareConnectCommand(ctx context.Context, data CommandJobPrepareConnectData) (*CommandJobConnectRtnData, error) JobStartStreamCommand(ctx context.Context, data CommandJobStartStreamData) error JobInputCommand(ctx context.Context, data CommandJobInputData) error JobCmdExitedCommand(ctx context.Context, data CommandJobCmdExitedData) error // this is sent FROM the job manager => main server // job controller JobControllerDeleteJobCommand(ctx context.Context, jobId string) error JobControllerListCommand(ctx context.Context) ([]*waveobj.Job, error) JobControllerStartJobCommand(ctx context.Context, data CommandJobControllerStartJobData) (string, error) JobControllerExitJobCommand(ctx context.Context, jobId string) error JobControllerDisconnectJobCommand(ctx context.Context, jobId string) error JobControllerReconnectJobCommand(ctx context.Context, jobId string) error JobControllerReconnectJobsForConnCommand(ctx context.Context, connName string) error JobControllerConnectedJobsCommand(ctx context.Context) ([]string, error) JobControllerAttachJobCommand(ctx context.Context, data CommandJobControllerAttachJobData) error JobControllerDetachJobCommand(ctx context.Context, jobId string) error JobControllerGetAllJobManagerStatusCommand(ctx context.Context) ([]*JobManagerStatusUpdate, error) BlockJobStatusCommand(ctx context.Context, blockId string) (*BlockJobStatusData, error) } // for frontend type WshServerCommandMeta struct { CommandType string `json:"commandtype"` } type RpcOpts struct { Timeout int64 `json:"timeout,omitempty"` NoResponse bool `json:"noresponse,omitempty"` Route string `json:"route,omitempty"` StreamCancelFn func(context.Context) error `json:"-"` // this is an *output* parameter, set by the handler } type RpcContext struct { SockName string `json:"sockname,omitempty"` // the domain socket name RouteId string `json:"routeid"` // the routeid from the jwt ProcRoute bool `json:"procroute,omitempty"` // use a random procid for route BlockId string `json:"blockid,omitempty"` // blockid for this rpc Conn string `json:"conn,omitempty"` // the conn name IsRouter bool `json:"isrouter,omitempty"` // if this is for a sub-router } func (rc RpcContext) GenerateRouteId() string { if rc.RouteId != "" { return rc.RouteId } return "proc:" + uuid.New().String() } type CommandAuthenticateRtnData struct { RouteId string `json:"routeid"` // these fields are only set when doing a token swap Env map[string]string `json:"env,omitempty"` InitScriptText string `json:"initscripttext,omitempty"` RpcContext *RpcContext `json:"rpccontext,omitempty"` } type CommandAuthenticateTokenData struct { Token string `json:"token"` } type CommandDisposeData struct { RouteId string `json:"routeid"` // auth token travels in the packet directly } type CommandMessageData struct { Message string `json:"message"` } type CommandGetMetaData struct { ORef waveobj.ORef `json:"oref"` } type CommandSetMetaData struct { ORef waveobj.ORef `json:"oref"` Meta waveobj.MetaMapType `json:"meta"` } type CommandResolveIdsData struct { BlockId string `json:"blockid"` Ids []string `json:"ids"` } type CommandResolveIdsRtnData struct { ResolvedIds map[string]waveobj.ORef `json:"resolvedids"` } type CommandCreateBlockData struct { TabId string `json:"tabid"` BlockDef *waveobj.BlockDef `json:"blockdef"` RtOpts *waveobj.RuntimeOpts `json:"rtopts,omitempty"` Magnified bool `json:"magnified,omitempty"` Ephemeral bool `json:"ephemeral,omitempty"` Focused bool `json:"focused,omitempty"` TargetBlockId string `json:"targetblockid,omitempty"` TargetAction string `json:"targetaction,omitempty"` // "replace", "splitright", "splitdown", "splitleft", "splitup" } type CommandCreateSubBlockData struct { ParentBlockId string `json:"parentblockid"` BlockDef *waveobj.BlockDef `json:"blockdef"` } type CommandControllerResyncData struct { ForceRestart bool `json:"forcerestart,omitempty"` TabId string `json:"tabid"` BlockId string `json:"blockid"` RtOpts *waveobj.RuntimeOpts `json:"rtopts,omitempty"` } type CommandControllerAppendOutputData struct { BlockId string `json:"blockid"` Data64 string `json:"data64"` } type CommandBlockInputData struct { BlockId string `json:"blockid"` InputData64 string `json:"inputdata64,omitempty"` SigName string `json:"signame,omitempty"` TermSize *waveobj.TermSize `json:"termsize,omitempty"` } type CommandJobInputData struct { JobId string `json:"jobid"` InputSessionId string `json:"inputsessionid,omitempty"` SeqNum int `json:"seqnum,omitempty"` InputData64 string `json:"inputdata64,omitempty"` SigName string `json:"signame,omitempty"` TermSize *waveobj.TermSize `json:"termsize,omitempty"` } type CommandWaitForRouteData struct { RouteId string `json:"routeid"` WaitMs int `json:"waitms"` } type CommandDeleteBlockData struct { BlockId string `json:"blockid"` } type CommandEventReadHistoryData struct { Event string `json:"event"` Scope string `json:"scope"` MaxItems int `json:"maxitems"` } type WaveAIStreamRequest struct { ClientId string `json:"clientid,omitempty"` Opts *WaveAIOptsType `json:"opts"` Prompt []WaveAIPromptMessageType `json:"prompt"` } type WaveAIPromptMessageType struct { Role string `json:"role"` Content string `json:"content"` Name string `json:"name,omitempty"` } type WaveAIOptsType struct { Model string `json:"model"` APIType string `json:"apitype,omitempty"` APIToken string `json:"apitoken"` OrgID string `json:"orgid,omitempty"` APIVersion string `json:"apiversion,omitempty"` BaseURL string `json:"baseurl,omitempty"` ProxyURL string `json:"proxyurl,omitempty"` MaxTokens int `json:"maxtokens,omitempty"` MaxChoices int `json:"maxchoices,omitempty"` TimeoutMs int `json:"timeoutms,omitempty"` } type WaveAIPacketType struct { Type string `json:"type"` Model string `json:"model,omitempty"` Created int64 `json:"created,omitempty"` FinishReason string `json:"finish_reason,omitempty"` Usage *WaveAIUsageType `json:"usage,omitempty"` Index int `json:"index,omitempty"` Text string `json:"text,omitempty"` Error string `json:"error,omitempty"` } type WaveAIUsageType struct { PromptTokens int `json:"prompt_tokens,omitempty"` CompletionTokens int `json:"completion_tokens,omitempty"` TotalTokens int `json:"total_tokens,omitempty"` } type CpuDataRequest struct { Id string `json:"id"` Count int `json:"count"` } type CpuDataType struct { Time int64 `json:"time"` Value float64 `json:"value"` } type CommandFileRestoreBackupData struct { BackupFilePath string `json:"backupfilepath"` RestoreToFileName string `json:"restoretofilename"` } type CommandGetTempDirData struct { FileName string `json:"filename,omitempty"` } type CommandWriteTempFileData struct { FileName string `json:"filename"` Data64 string `json:"data64"` } type ConnRequest struct { Host string `json:"host"` Keywords wconfig.ConnKeywords `json:"keywords,omitempty"` LogBlockId string `json:"logblockid,omitempty"` } type RemoteInfo struct { ClientArch string `json:"clientarch"` ClientOs string `json:"clientos"` ClientVersion string `json:"clientversion"` Shell string `json:"shell"` HomeDir string `json:"homedir"` } const ( TimeSeries_Cpu = "cpu" ) type TimeSeriesData struct { Ts int64 `json:"ts"` Values map[string]float64 `json:"values"` } type MetaSettingsType struct { waveobj.MetaMapType } func (m *MetaSettingsType) UnmarshalJSON(data []byte) error { var metaMap waveobj.MetaMapType decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() if err := decoder.Decode(&metaMap); err != nil { return err } *m = MetaSettingsType{MetaMapType: metaMap} return nil } func (m MetaSettingsType) MarshalJSON() ([]byte, error) { return json.Marshal(m.MetaMapType) } type ConnConfigRequest struct { Host string `json:"host"` MetaMapType waveobj.MetaMapType `json:"metamaptype"` } type ConnStatus struct { Status string `json:"status"` ConnHealthStatus string `json:"connhealthstatus,omitempty"` WshEnabled bool `json:"wshenabled"` Connection string `json:"connection"` Connected bool `json:"connected"` HasConnected bool `json:"hasconnected"` // true if it has *ever* connected successfully ActiveConnNum int `json:"activeconnnum"` Error string `json:"error,omitempty"` WshError string `json:"wsherror,omitempty"` NoWshReason string `json:"nowshreason,omitempty"` WshVersion string `json:"wshversion,omitempty"` LastActivityBeforeStalledTime int64 `json:"lastactivitybeforestalledtime,omitempty"` KeepAliveSentTime int64 `json:"keepalivesenttime,omitempty"` } type WebSelectorOpts struct { All bool `json:"all,omitempty"` Inner bool `json:"inner,omitempty"` } type CommandWebSelectorData struct { WorkspaceId string `json:"workspaceid"` BlockId string `json:"blockid"` TabId string `json:"tabid"` Selector string `json:"selector"` Opts *WebSelectorOpts `json:"opts,omitempty"` } type BlockInfoData struct { BlockId string `json:"blockid"` TabId string `json:"tabid"` WorkspaceId string `json:"workspaceid"` Block *waveobj.Block `json:"block"` Files []*WaveFileInfo `json:"files"` } type WaveNotificationOptions struct { Title string `json:"title,omitempty"` Body string `json:"body,omitempty"` Silent bool `json:"silent,omitempty"` } type VDomUrlRequestData struct { Method string `json:"method"` URL string `json:"url"` Headers map[string]string `json:"headers"` Body []byte `json:"body,omitempty"` } type VDomUrlRequestResponse struct { StatusCode int `json:"statuscode,omitempty"` Headers map[string]string `json:"headers,omitempty"` Body []byte `json:"body,omitempty"` } type WaveInfoData struct { Version string `json:"version"` ClientId string `json:"clientid"` BuildTime string `json:"buildtime"` ConfigDir string `json:"configdir"` DataDir string `json:"datadir"` } type WorkspaceInfoData struct { WindowId string `json:"windowid"` WorkspaceData *waveobj.Workspace `json:"workspacedata"` } type BlocksListRequest struct { WindowId string `json:"windowid,omitempty"` WorkspaceId string `json:"workspaceid,omitempty"` } type BlocksListEntry struct { WindowId string `json:"windowid"` WorkspaceId string `json:"workspaceid"` TabId string `json:"tabid"` BlockId string `json:"blockid"` Meta waveobj.MetaMapType `json:"meta"` } type AiMessageData struct { Message string `json:"message,omitempty"` } type CommandGetWaveAIChatData struct { ChatId string `json:"chatid"` } type CommandWaveAIToolApproveData struct { ToolCallId string `json:"toolcallid"` Approval string `json:"approval,omitempty"` } type AIAttachedFile struct { Name string `json:"name"` Type string `json:"type"` Size int `json:"size"` Data64 string `json:"data64"` } type CommandWaveAIAddContextData struct { Files []AIAttachedFile `json:"files,omitempty"` Text string `json:"text,omitempty"` Submit bool `json:"submit,omitempty"` NewChat bool `json:"newchat,omitempty"` } type CommandWaveAIGetToolDiffData struct { ChatId string `json:"chatid"` ToolCallId string `json:"toolcallid"` } type CommandWaveAIGetToolDiffRtnData struct { OriginalContents64 string `json:"originalcontents64"` ModifiedContents64 string `json:"modifiedcontents64"` } type CommandCaptureBlockScreenshotData struct { BlockId string `json:"blockid"` } type CommandVarData struct { Key string `json:"key"` Val string `json:"val,omitempty"` Remove bool `json:"remove,omitempty"` ZoneId string `json:"zoneid"` FileName string `json:"filename"` } type CommandVarResponseData struct { Key string `json:"key"` Val string `json:"val"` Exists bool `json:"exists"` } type CommandDebugTermData struct { BlockId string `json:"blockid"` Size int64 `json:"size"` } type CommandDebugTermRtnData struct { Offset int64 `json:"offset"` Data64 string `json:"data64"` } type PathCommandData struct { PathType string `json:"pathtype"` Open bool `json:"open"` OpenExternal bool `json:"openexternal"` TabId string `json:"tabid"` } type ActivityDisplayType struct { Width int `json:"width"` Height int `json:"height"` DPR float64 `json:"dpr"` Internal bool `json:"internal,omitempty"` } type ActivityUpdate struct { FgMinutes int `json:"fgminutes,omitempty"` ActiveMinutes int `json:"activeminutes,omitempty"` OpenMinutes int `json:"openminutes,omitempty"` WaveAIFgMinutes int `json:"waveaifgminutes,omitempty"` WaveAIActiveMinutes int `json:"waveaiactiveminutes,omitempty"` NumTabs int `json:"numtabs,omitempty"` NewTab int `json:"newtab,omitempty"` NumBlocks int `json:"numblocks,omitempty"` NumWindows int `json:"numwindows,omitempty"` NumWS int `json:"numws,omitempty"` NumWSNamed int `json:"numwsnamed,omitempty"` NumSSHConn int `json:"numsshconn,omitempty"` NumWSLConn int `json:"numwslconn,omitempty"` NumMagnify int `json:"nummagnify,omitempty"` TermCommandsRun int `json:"termcommandsrun,omitempty"` NumPanics int `json:"numpanics,omitempty"` NumAIReqs int `json:"numaireqs,omitempty"` Startup int `json:"startup,omitempty"` Shutdown int `json:"shutdown,omitempty"` SetTabTheme int `json:"settabtheme,omitempty"` BuildTime string `json:"buildtime,omitempty"` Displays []ActivityDisplayType `json:"displays,omitempty"` Renderers map[string]int `json:"renderers,omitempty"` Blocks map[string]int `json:"blocks,omitempty"` WshCmds map[string]int `json:"wshcmds,omitempty"` Conn map[string]int `json:"conn,omitempty"` } type ConnExtData struct { ConnName string `json:"connname"` LogBlockId string `json:"logblockid,omitempty"` } type CommandConnServerInitData struct { ClientId string `json:"clientid"` } type FetchSuggestionsData struct { SuggestionType string `json:"suggestiontype"` Query string `json:"query"` WidgetId string `json:"widgetid"` ReqNum int `json:"reqnum"` FileCwd string `json:"file:cwd,omitempty"` FileDirOnly bool `json:"file:dironly,omitempty"` FileConnection string `json:"file:connection,omitempty"` } type FetchSuggestionsResponse struct { ReqNum int `json:"reqnum"` Suggestions []SuggestionType `json:"suggestions"` } type SuggestionType struct { Type string `json:"type"` SuggestionId string `json:"suggestionid"` Display string `json:"display"` SubText string `json:"subtext,omitempty"` Icon string `json:"icon,omitempty"` IconColor string `json:"iconcolor,omitempty"` IconSrc string `json:"iconsrc,omitempty"` MatchPos []int `json:"matchpos,omitempty"` SubMatchPos []int `json:"submatchpos,omitempty"` Score int `json:"score,omitempty"` FileMimeType string `json:"file:mimetype,omitempty"` FilePath string `json:"file:path,omitempty"` FileName string `json:"file:name,omitempty"` UrlUrl string `json:"url:url,omitempty"` } type CommandGetRTInfoData struct { ORef waveobj.ORef `json:"oref"` } type CommandSetRTInfoData struct { ORef waveobj.ORef `json:"oref"` Data map[string]any `json:"data" tstype:"ObjRTInfo"` Delete bool `json:"delete,omitempty"` } type CommandTermGetScrollbackLinesData struct { LineStart int `json:"linestart"` LineEnd int `json:"lineend"` LastCommand bool `json:"lastcommand"` } type CommandTermGetScrollbackLinesRtnData struct { TotalLines int `json:"totallines"` LineStart int `json:"linestart"` Lines []string `json:"lines"` LastUpdated int64 `json:"lastupdated"` } type CommandTermUpdateAttachedJobData struct { BlockId string `json:"blockid"` JobId string `json:"jobid,omitempty"` } type CommandElectronEncryptData struct { PlainText string `json:"plaintext"` } type CommandElectronEncryptRtnData struct { CipherText string `json:"ciphertext"` StorageBackend string `json:"storagebackend"` // only returned for linux } type CommandElectronDecryptData struct { CipherText string `json:"ciphertext"` } type CommandElectronDecryptRtnData struct { PlainText string `json:"plaintext"` StorageBackend string `json:"storagebackend"` // only returned for linux } type CommandStreamData struct { Id string `json:"id"` // streamid Seq int64 `json:"seq"` // start offset (bytes) Data64 string `json:"data64,omitempty"` Eof bool `json:"eof,omitempty"` // can be set with data or without Error string `json:"error,omitempty"` // stream terminated with error } type CommandStreamAckData struct { Id string `json:"id"` // streamid Seq int64 `json:"seq"` // next expected byte RWnd int64 `json:"rwnd"` // receive window size Fin bool `json:"fin,omitempty"` // observed end-of-stream (eof or error) Delay int64 `json:"delay,omitempty"` // ack delay in microseconds (from when data was received to when we sent out ack -- monotonic clock) Cancel bool `json:"cancel,omitempty"` // used to cancel the stream Error string `json:"error,omitempty"` // reason for cancel (may only be set if cancel is true) } type StreamMeta struct { Id string `json:"id"` // streamid RWnd int64 `json:"rwnd"` // initial receive window size ReaderRouteId string `json:"readerrouteid"` WriterRouteId string `json:"writerrouteid"` } type CommandAuthenticateToJobData struct { JobAccessToken string `json:"jobaccesstoken"` } type CommandAuthenticateJobManagerData struct { JobId string `json:"jobid"` JobAuthToken string `json:"jobauthtoken"` } type CommandStartJobData struct { Cmd string `json:"cmd"` Args []string `json:"args"` Env map[string]string `json:"env"` TermSize waveobj.TermSize `json:"termsize"` StreamMeta *StreamMeta `json:"streammeta,omitempty"` } type CommandRemoteStartJobData struct { Cmd string `json:"cmd"` Args []string `json:"args"` Env map[string]string `json:"env"` TermSize waveobj.TermSize `json:"termsize"` StreamMeta *StreamMeta `json:"streammeta,omitempty"` JobAuthToken string `json:"jobauthtoken"` JobId string `json:"jobid"` MainServerJwtToken string `json:"mainserverjwttoken"` ClientId string `json:"clientid"` PublicKeyBase64 string `json:"publickeybase64"` } type CommandRemoteReconnectToJobManagerData struct { JobId string `json:"jobid"` JobAuthToken string `json:"jobauthtoken"` MainServerJwtToken string `json:"mainserverjwttoken"` JobManagerPid int `json:"jobmanagerpid"` JobManagerStartTs int64 `json:"jobmanagerstartts"` } type CommandRemoteReconnectToJobManagerRtnData struct { Success bool `json:"success"` JobManagerGone bool `json:"jobmanagergone"` Error string `json:"error,omitempty"` } type CommandRemoteDisconnectFromJobManagerData struct { JobId string `json:"jobid"` } type CommandRemoteTerminateJobManagerData struct { JobId string `json:"jobid"` JobManagerPid int `json:"jobmanagerpid"` JobManagerStartTs int64 `json:"jobmanagerstartts"` } type CommandStartJobRtnData struct { CmdPid int `json:"cmdpid"` CmdStartTs int64 `json:"cmdstartts"` JobManagerPid int `json:"jobmanagerpid"` JobManagerStartTs int64 `json:"jobmanagerstartts"` } type CommandJobPrepareConnectData struct { StreamMeta StreamMeta `json:"streammeta"` Seq int64 `json:"seq"` TermSize waveobj.TermSize `json:"termsize"` } type CommandJobStartStreamData struct { } type CommandJobConnectRtnData struct { Seq int64 `json:"seq"` StreamDone bool `json:"streamdone,omitempty"` StreamError string `json:"streamerror,omitempty"` HasExited bool `json:"hasexited,omitempty"` ExitCode *int `json:"exitcode,omitempty"` ExitSignal string `json:"exitsignal,omitempty"` ExitErr string `json:"exiterr,omitempty"` } type CommandJobCmdExitedData struct { JobId string `json:"jobid"` ExitCode *int `json:"exitcode,omitempty"` ExitSignal string `json:"exitsignal,omitempty"` ExitErr string `json:"exiterr,omitempty"` ExitTs int64 `json:"exitts,omitempty"` } type CommandJobControllerStartJobData struct { ConnName string `json:"connname"` JobKind string `json:"jobkind"` Cmd string `json:"cmd"` Args []string `json:"args"` Env map[string]string `json:"env"` TermSize *waveobj.TermSize `json:"termsize,omitempty"` } type CommandJobControllerAttachJobData struct { JobId string `json:"jobid"` BlockId string `json:"blockid"` } type JobManagerStatusUpdate struct { JobId string `json:"jobid"` JobManagerStatus string `json:"jobmanagerstatus"` } type CommandWaveFileReadStreamData struct { ZoneId string `json:"zoneid"` Name string `json:"name"` StreamMeta StreamMeta `json:"streammeta"` } // see blockstore.go (WaveFile) type WaveFileInfo struct { ZoneId string `json:"zoneid"` Name string `json:"name"` Opts FileOpts `json:"opts"` CreatedTs int64 `json:"createdts"` Size int64 `json:"size"` ModTs int64 `json:"modts"` Meta FileMeta `json:"meta"` } type CommandBadgeWatchPidData struct { Pid int `json:"pid"` ORef waveobj.ORef `json:"oref"` BadgeId string `json:"badgeid"` } type BlockJobStatusData struct { BlockId string `json:"blockid"` JobId string `json:"jobid"` Status string `json:"status,omitempty" tstype:"null | \"init\" | \"connected\" | \"disconnected\" | \"done\""` VersionTs int64 `json:"versionts"` DoneReason string `json:"donereason,omitempty"` StartupError string `json:"startuperror,omitempty"` CmdExitTs int64 `json:"cmdexitts,omitempty"` CmdExitCode *int `json:"cmdexitcode,omitempty"` CmdExitSignal string `json:"cmdexitsignal,omitempty"` } type FocusedBlockData struct { BlockId string `json:"blockid"` ViewType string `json:"viewtype"` Controller string `json:"controller"` ConnName string `json:"connname"` BlockMeta waveobj.MetaMapType `json:"blockmeta"` TermJobStatus *BlockJobStatusData `json:"termjobstatus,omitempty"` ConnStatus *ConnStatus `json:"connstatus,omitempty"` TermShellIntegrationStatus string `json:"termshellintegrationstatus,omitempty"` TermLastCommand string `json:"termlastcommand,omitempty"` } ================================================ FILE: pkg/wshrpc/wshrpctypes_builder.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // builder-related types and methods for wsh rpc calls package wshrpc import ( "context" ) type WshRpcBuilderInterface interface { ListAllAppsCommand(ctx context.Context) ([]AppInfo, error) ListAllEditableAppsCommand(ctx context.Context) ([]AppInfo, error) ListAllAppFilesCommand(ctx context.Context, data CommandListAllAppFilesData) (*CommandListAllAppFilesRtnData, error) ReadAppFileCommand(ctx context.Context, data CommandReadAppFileData) (*CommandReadAppFileRtnData, error) WriteAppFileCommand(ctx context.Context, data CommandWriteAppFileData) error WriteAppGoFileCommand(ctx context.Context, data CommandWriteAppGoFileData) (*CommandWriteAppGoFileRtnData, error) DeleteAppFileCommand(ctx context.Context, data CommandDeleteAppFileData) error RenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error WriteAppSecretBindingsCommand(ctx context.Context, data CommandWriteAppSecretBindingsData) error DeleteBuilderCommand(ctx context.Context, builderId string) error StartBuilderCommand(ctx context.Context, data CommandStartBuilderData) error StopBuilderCommand(ctx context.Context, builderId string) error RestartBuilderAndWaitCommand(ctx context.Context, data CommandRestartBuilderAndWaitData) (*RestartBuilderAndWaitResult, error) GetBuilderStatusCommand(ctx context.Context, builderId string) (*BuilderStatusData, error) GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) CheckGoVersionCommand(ctx context.Context) (*CommandCheckGoVersionRtnData, error) PublishAppCommand(ctx context.Context, data CommandPublishAppData) (*CommandPublishAppRtnData, error) MakeDraftFromLocalCommand(ctx context.Context, data CommandMakeDraftFromLocalData) (*CommandMakeDraftFromLocalRtnData, error) } type AppInfo struct { AppId string `json:"appid"` ModTime int64 `json:"modtime"` Manifest *AppManifest `json:"manifest,omitempty"` } type CommandListAllAppFilesData struct { AppId string `json:"appid"` } type CommandListAllAppFilesRtnData struct { Path string `json:"path"` AbsolutePath string `json:"absolutepath"` ParentDir string `json:"parentdir,omitempty"` Entries []DirEntryOut `json:"entries"` EntryCount int `json:"entrycount"` TotalEntries int `json:"totalentries"` Truncated bool `json:"truncated,omitempty"` } type DirEntryOut struct { Name string `json:"name"` Dir bool `json:"dir,omitempty"` Symlink bool `json:"symlink,omitempty"` Size int64 `json:"size,omitempty"` Mode string `json:"mode"` Modified string `json:"modified"` ModifiedTime string `json:"modifiedtime"` } type CommandReadAppFileData struct { AppId string `json:"appid"` FileName string `json:"filename"` } type CommandReadAppFileRtnData struct { Data64 string `json:"data64"` NotFound bool `json:"notfound,omitempty"` ModTs int64 `json:"modts,omitempty"` } type CommandWriteAppFileData struct { AppId string `json:"appid"` FileName string `json:"filename"` Data64 string `json:"data64"` } type CommandWriteAppGoFileData struct { AppId string `json:"appid"` Data64 string `json:"data64"` } type CommandWriteAppGoFileRtnData struct { Data64 string `json:"data64"` } type CommandDeleteAppFileData struct { AppId string `json:"appid"` FileName string `json:"filename"` } type CommandRenameAppFileData struct { AppId string `json:"appid"` FromFileName string `json:"fromfilename"` ToFileName string `json:"tofilename"` } type CommandWriteAppSecretBindingsData struct { AppId string `json:"appid"` Bindings map[string]string `json:"bindings"` } type CommandStartBuilderData struct { BuilderId string `json:"builderid"` } type CommandRestartBuilderAndWaitData struct { BuilderId string `json:"builderid"` } type RestartBuilderAndWaitResult struct { Success bool `json:"success"` ErrorMessage string `json:"errormessage,omitempty"` BuildOutput string `json:"buildoutput"` } type AppMeta struct { Title string `json:"title"` ShortDesc string `json:"shortdesc"` Icon string `json:"icon"` IconColor string `json:"iconcolor"` } type SecretMeta struct { Desc string `json:"desc"` Optional bool `json:"optional"` } type AppManifest struct { AppMeta AppMeta `json:"appmeta"` ConfigSchema map[string]any `json:"configschema"` DataSchema map[string]any `json:"dataschema"` Secrets map[string]SecretMeta `json:"secrets"` } type BuilderStatusData struct { Status string `json:"status"` Port int `json:"port,omitempty"` ExitCode int `json:"exitcode,omitempty"` ErrorMsg string `json:"errormsg,omitempty"` Version int `json:"version"` Manifest *AppManifest `json:"manifest,omitempty"` SecretBindings map[string]string `json:"secretbindings,omitempty"` SecretBindingsComplete bool `json:"secretbindingscomplete"` } type CommandCheckGoVersionRtnData struct { GoStatus string `json:"gostatus"` GoPath string `json:"gopath"` GoVersion string `json:"goversion"` ErrorString string `json:"errorstring,omitempty"` } type CommandPublishAppData struct { AppId string `json:"appid"` } type CommandPublishAppRtnData struct { PublishedAppId string `json:"publishedappid"` } type CommandMakeDraftFromLocalData struct { LocalAppId string `json:"localappid"` } type CommandMakeDraftFromLocalRtnData struct { DraftAppId string `json:"draftappid"` } ================================================ FILE: pkg/wshrpc/wshrpctypes_const.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // types and methods for wsh rpc calls package wshrpc const ( // MaxFileSize is the maximum file size that can be read MaxFileSize = 50 * 1024 * 1024 // 50M // MaxDirSize is the maximum number of entries that can be read in a directory MaxDirSize = 1024 // FileChunkSize is the size of the file chunk to read FileChunkSize = 64 * 1024 // DirChunkSize is the size of the directory chunk to read DirChunkSize = 128 ) const LocalConnName = "local" const ( RpcType_Call = "call" // single response (regular rpc) RpcType_ResponseStream = "responsestream" // stream of responses (streaming rpc) RpcType_StreamingRequest = "streamingrequest" // streaming request RpcType_Complex = "complex" // streaming request/response ) const ( CreateBlockAction_Replace = "replace" CreateBlockAction_SplitUp = "splitup" CreateBlockAction_SplitDown = "splitdown" CreateBlockAction_SplitLeft = "splitleft" CreateBlockAction_SplitRight = "splitright" ) // we only need consts for special commands handled in the router or // in the RPC code / WPS code directly. other commands go through the clients const ( Command_Authenticate = "authenticate" // $control Command_AuthenticateToken = "authenticatetoken" // $control Command_AuthenticateTokenVerify = "authenticatetokenverify" // $control:root (internal, for token validation only) Command_AuthenticateJobManagerVerify = "authenticatejobmanagerverify" // $control:root (internal, for job auth token validation only) Command_RouteAnnounce = "routeannounce" // $control (for routing) Command_RouteUnannounce = "routeunannounce" // $control (for routing) Command_Ping = "ping" // $control Command_ControllerInput = "controllerinput" Command_EventRecv = "eventrecv" Command_Message = "message" Command_StreamData = "streamdata" Command_StreamDataAck = "streamdataack" ) ================================================ FILE: pkg/wshrpc/wshrpctypes_file.go ================================================ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 // file-related types and methods for wsh rpc calls package wshrpc import ( "context" "os" "github.com/wavetermdev/waveterm/pkg/ijson" ) type WshRpcFileInterface interface { FileMkdirCommand(ctx context.Context, data FileData) error FileCreateCommand(ctx context.Context, data FileData) error FileDeleteCommand(ctx context.Context, data CommandDeleteFileData) error FileAppendCommand(ctx context.Context, data FileData) error FileWriteCommand(ctx context.Context, data FileData) error FileReadCommand(ctx context.Context, data FileData) (*FileData, error) FileReadStreamCommand(ctx context.Context, data FileData) <-chan RespOrErrorUnion[FileData] FileMoveCommand(ctx context.Context, data CommandFileCopyData) error FileCopyCommand(ctx context.Context, data CommandFileCopyData) error FileInfoCommand(ctx context.Context, data FileData) (*FileInfo, error) FileListCommand(ctx context.Context, data FileListData) ([]*FileInfo, error) FileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) FileListStreamCommand(ctx context.Context, data FileListData) <-chan RespOrErrorUnion[CommandRemoteListEntriesRtnData] // modern streaming interface FileStreamCommand(ctx context.Context, data CommandFileStreamData) (*FileInfo, error) } type WshRpcRemoteFileInterface interface { // old streaming inferface RemoteStreamFileCommand(ctx context.Context, data CommandRemoteStreamFileData) chan RespOrErrorUnion[FileData] // modern streaming interface RemoteFileStreamCommand(ctx context.Context, data CommandRemoteFileStreamData) (*FileInfo, error) RemoteFileCopyCommand(ctx context.Context, data CommandFileCopyData) (bool, error) RemoteListEntriesCommand(ctx context.Context, data CommandRemoteListEntriesData) chan RespOrErrorUnion[CommandRemoteListEntriesRtnData] RemoteFileInfoCommand(ctx context.Context, path string) (*FileInfo, error) RemoteFileMultiInfoCommand(ctx context.Context, data CommandRemoteFileMultiInfoData) (map[string]FileInfo, error) RemoteFileTouchCommand(ctx context.Context, path string) error RemoteFileMoveCommand(ctx context.Context, data CommandFileCopyData) error RemoteFileDeleteCommand(ctx context.Context, data CommandDeleteFileData) error RemoteWriteFileCommand(ctx context.Context, data FileData) error RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) RemoteMkdirCommand(ctx context.Context, path string) error } type FileDataAt struct { Offset int64 `json:"offset"` Size int `json:"size,omitempty"` } type FileData struct { Info *FileInfo `json:"info,omitempty"` Data64 string `json:"data64,omitempty"` Entries []*FileInfo `json:"entries,omitempty"` At *FileDataAt `json:"at,omitempty"` // if set, this turns read/write ops to ReadAt/WriteAt ops (len is only used for ReadAt) } type FileInfo struct { Path string `json:"path"` // cleaned path (may have "~") Dir string `json:"dir,omitempty"` // returns the directory part of the path (if this is a a directory, it will be equal to Path). "~" will be expanded, and separators will be normalized to "/" Name string `json:"name,omitempty"` StatError string `json:"staterror,omitempty"` NotFound bool `json:"notfound,omitempty"` Opts *FileOpts `json:"opts,omitempty"` Size int64 `json:"size,omitempty"` Meta *FileMeta `json:"meta,omitempty"` Mode os.FileMode `json:"mode,omitempty"` ModeStr string `json:"modestr,omitempty"` ModTime int64 `json:"modtime,omitempty"` IsDir bool `json:"isdir,omitempty"` SupportsMkdir bool `json:"supportsmkdir,omitempty"` MimeType string `json:"mimetype,omitempty"` ReadOnly bool `json:"readonly,omitempty"` // this is not set for fileinfo's returned from directory listings } type FileOpts struct { MaxSize int64 `json:"maxsize,omitempty"` Circular bool `json:"circular,omitempty"` IJson bool `json:"ijson,omitempty"` IJsonBudget int `json:"ijsonbudget,omitempty"` Truncate bool `json:"truncate,omitempty"` Append bool `json:"append,omitempty"` } type FileMeta = map[string]any type FileListStreamResponse <-chan RespOrErrorUnion[CommandRemoteListEntriesRtnData] type FileListData struct { Path string `json:"path"` Opts *FileListOpts `json:"opts,omitempty"` } type FileListOpts struct { All bool `json:"all,omitempty"` Offset int `json:"offset,omitempty"` Limit int `json:"limit,omitempty"` } type FileCreateData struct { Path string `json:"path"` Meta map[string]any `json:"meta,omitempty"` Opts *FileOpts `json:"opts,omitempty"` } type CommandAppendIJsonData struct { ZoneId string `json:"zoneid"` FileName string `json:"filename"` Data ijson.Command `json:"data"` } type CommandDeleteFileData struct { Path string `json:"path"` Recursive bool `json:"recursive"` } type CommandFileCopyData struct { SrcUri string `json:"srcuri"` DestUri string `json:"desturi"` Opts *FileCopyOpts `json:"opts,omitempty"` } type FileCopyOpts struct { Overwrite bool `json:"overwrite,omitempty"` Recursive bool `json:"recursive,omitempty"` // only used for move, always true for copy Merge bool `json:"merge,omitempty"` Timeout int64 `json:"timeout,omitempty"` } type CommandRemoteStreamFileData struct { Path string `json:"path"` ByteRange string `json:"byterange,omitempty"` } type CommandRemoteFileStreamData struct { Path string `json:"path"` ByteRange string `json:"byterange,omitempty"` StreamMeta StreamMeta `json:"streammeta"` } type CommandFileStreamData struct { Info *FileInfo `json:"info"` ByteRange string `json:"byterange,omitempty"` StreamMeta StreamMeta `json:"streammeta"` } type CommandRemoteListEntriesData struct { Path string `json:"path"` Opts *FileListOpts `json:"opts,omitempty"` } type CommandRemoteFileMultiInfoData struct { Cwd string `json:"cwd"` Paths []string `json:"paths"` } type CommandRemoteListEntriesRtnData struct { FileInfo []*FileInfo `json:"fileinfo,omitempty"` } ================================================ FILE: pkg/wshrpc/wshserver/resolvers.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshserver import ( "context" "fmt" "regexp" "strconv" "strings" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" ) const ( SimpleId_This = "this" SimpleId_Block = "block" SimpleId_Tab = "tab" SimpleId_Ws = "ws" SimpleId_Workspace = "workspace" SimpleId_Client = "client" SimpleId_Global = "global" SimpleId_Temp = "temp" ) var ( simpleTabNumRe = regexp.MustCompile(`^tab:(\d{1,3})$`) shortUUIDRe = regexp.MustCompile(`^[0-9a-f]{8}$`) viewBlockRe = regexp.MustCompile(`^([a-z]+)(?::(\d+))?$`) // Matches "ai" or "ai:2" ) // First function: detect/choose discriminator func parseSimpleId(simpleId string) (discriminator string, value string, err error) { // Check for explicit discriminator with @ if parts := strings.SplitN(simpleId, "@", 2); len(parts) == 2 { return parts[0], parts[1], nil } // Handle special keywords if simpleId == SimpleId_This || simpleId == SimpleId_Block || simpleId == SimpleId_Tab || simpleId == SimpleId_Ws || simpleId == SimpleId_Workspace || simpleId == SimpleId_Client || simpleId == SimpleId_Global || simpleId == SimpleId_Temp { return "this", simpleId, nil } // Check if it's a simple ORef (type:uuid) if _, err := waveobj.ParseORef(simpleId); err == nil { return "oref", simpleId, nil } // Check for tab:N format if simpleTabNumRe.MatchString(simpleId) { return "tabnum", simpleId, nil } // check for [view]:N format if viewBlockRe.MatchString(simpleId) { return "view", simpleId, nil } // Check for plain number (block reference) if _, err := strconv.Atoi(simpleId); err == nil { return "blocknum", simpleId, nil } // Check for UUIDs if _, err := uuid.Parse(simpleId); err == nil { return "uuid", simpleId, nil } if shortUUIDRe.MatchString(strings.ToLower(simpleId)) { return "uuid8", simpleId, nil } return "", "", fmt.Errorf("invalid simple id format: %s", simpleId) } // Individual resolvers func resolveThis(ctx context.Context, data wshrpc.CommandResolveIdsData, value string) (*waveobj.ORef, error) { if data.BlockId == "" { return nil, fmt.Errorf("no blockid in request") } if value == SimpleId_This || value == SimpleId_Block { return &waveobj.ORef{OType: waveobj.OType_Block, OID: data.BlockId}, nil } if value == SimpleId_Tab { tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) if err != nil { return nil, fmt.Errorf("error finding tab: %v", err) } return &waveobj.ORef{OType: waveobj.OType_Tab, OID: tabId}, nil } if value == SimpleId_Ws || value == SimpleId_Workspace { tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) if err != nil { return nil, fmt.Errorf("error finding tab: %v", err) } wsId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId) if err != nil { return nil, fmt.Errorf("error finding workspace: %v", err) } return &waveobj.ORef{OType: waveobj.OType_Workspace, OID: wsId}, nil } if value == SimpleId_Client || value == SimpleId_Global { clientId := wstore.GetClientId() return &waveobj.ORef{OType: waveobj.OType_Client, OID: clientId}, nil } if value == SimpleId_Temp { client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) if err != nil { return nil, fmt.Errorf("error getting client: %v", err) } return &waveobj.ORef{OType: "temp", OID: client.TempOID}, nil } return nil, fmt.Errorf("invalid value for 'this' resolver: %s", value) } func resolveORef(_ context.Context, value string) (*waveobj.ORef, error) { parsedORef, err := waveobj.ParseORef(value) if err != nil { return nil, fmt.Errorf("error parsing oref: %v", err) } return &parsedORef, nil } func resolveTabNum(ctx context.Context, data wshrpc.CommandResolveIdsData, value string) (*waveobj.ORef, error) { m := simpleTabNumRe.FindStringSubmatch(value) if m == nil { return nil, fmt.Errorf("error parsing simple tab id: %s", value) } tabNum, err := strconv.Atoi(m[1]) if err != nil { return nil, fmt.Errorf("error parsing simple tab num: %v", err) } curTabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) if err != nil { return nil, fmt.Errorf("error finding tab for block: %v", err) } wsId, err := wstore.DBFindWorkspaceForTabId(ctx, curTabId) if err != nil { return nil, fmt.Errorf("error finding current workspace: %v", err) } ws, err := wstore.DBMustGet[*waveobj.Workspace](ctx, wsId) if err != nil { return nil, fmt.Errorf("error getting workspace: %v", err) } numTabs := len(ws.TabIds) if tabNum < 1 || tabNum > numTabs { return nil, fmt.Errorf("tab num out of range, workspace has %d tabs", numTabs) } tabIdx := tabNum - 1 resolvedTabId := ws.TabIds[tabIdx] return &waveobj.ORef{OType: waveobj.OType_Tab, OID: resolvedTabId}, nil } func resolveBlock(ctx context.Context, data wshrpc.CommandResolveIdsData, value string) (*waveobj.ORef, error) { blockNum, err := strconv.Atoi(value) if err != nil { return nil, fmt.Errorf("error parsing block number: %v", err) } tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) if err != nil { return nil, fmt.Errorf("error finding tab for blockid %s: %w", data.BlockId, err) } tab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) if err != nil { return nil, fmt.Errorf("error retrieving tab %s: %w", tabId, err) } layout, err := wstore.DBGet[*waveobj.LayoutState](ctx, tab.LayoutState) if err != nil { return nil, fmt.Errorf("error retrieving layout state %s: %w", tab.LayoutState, err) } if layout.LeafOrder == nil { return nil, fmt.Errorf("could not resolve block num %v, leaf order is empty", blockNum) } leafIndex := blockNum - 1 // block nums are 1-indexed if len(*layout.LeafOrder) <= leafIndex { return nil, fmt.Errorf("could not find a node in the layout matching blockNum %v", blockNum) } leafEntry := (*layout.LeafOrder)[leafIndex] return &waveobj.ORef{OType: waveobj.OType_Block, OID: leafEntry.BlockId}, nil } func resolveView(ctx context.Context, data wshrpc.CommandResolveIdsData, value string) (*waveobj.ORef, error) { matches := viewBlockRe.FindStringSubmatch(value) if matches == nil { return nil, fmt.Errorf("invalid view format: %s", value) } // Default to first instance if no number specified viewType := matches[1] instanceNum := 1 if matches[2] != "" { num, err := strconv.Atoi(matches[2]) if err != nil { return nil, fmt.Errorf("invalid view instance number: %v", err) } instanceNum = num } if instanceNum < 1 { return nil, fmt.Errorf("invalid view instance number: %d", instanceNum) } // Get current tab tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) if err != nil { return nil, fmt.Errorf("error finding tab: %v", err) } tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId) if err != nil { return nil, fmt.Errorf("error retrieving tab: %v", err) } layout, err := wstore.DBMustGet[*waveobj.LayoutState](ctx, tab.LayoutState) if err != nil { return nil, fmt.Errorf("error retrieving layout: %v", err) } if layout.LeafOrder == nil { return nil, fmt.Errorf("no blocks in layout") } // Find nth instance of view type count := 0 for _, leaf := range *layout.LeafOrder { leafBlockId := leaf.BlockId leafBlock, err := wstore.DBMustGet[*waveobj.Block](ctx, leafBlockId) if err != nil { continue } if leafBlock.Meta.GetString("view", "") == viewType { count++ if count == instanceNum { return &waveobj.ORef{OType: waveobj.OType_Block, OID: leaf.BlockId}, nil } } } return nil, fmt.Errorf("could not find block %d of type %s (found %d)", instanceNum, viewType, count) } func resolveUUID(ctx context.Context, value string) (*waveobj.ORef, error) { return wstore.DBResolveEasyOID(ctx, value) } // Main resolver function func resolveSimpleId(ctx context.Context, data wshrpc.CommandResolveIdsData, simpleId string) (*waveobj.ORef, error) { discriminator, value, err := parseSimpleId(simpleId) if err != nil { return nil, err } switch discriminator { case "this": return resolveThis(ctx, data, value) case "oref": return resolveORef(ctx, value) case "tabnum": return resolveTabNum(ctx, data, value) case "blocknum": return resolveBlock(ctx, data, value) case "view": return resolveView(ctx, data, value) case "uuid", "uuid8": return resolveUUID(ctx, value) default: return nil, fmt.Errorf("unknown discriminator: %s", discriminator) } } ================================================ FILE: pkg/wshrpc/wshserver/wshserver.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshserver // this file contains the implementation of the wsh server methods import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "io/fs" "log" "os" "path/filepath" "regexp" "sort" "strings" "time" "github.com/skratchdot/open-golang/open" "github.com/wavetermdev/waveterm/pkg/aiusechat" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/buildercontroller" "github.com/wavetermdev/waveterm/pkg/filebackup" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/suggestion" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveai" "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcloud" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wsl" "github.com/wavetermdev/waveterm/pkg/wslconn" "github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/tsunami/build" ) var InvalidWslDistroNames = []string{"docker-desktop", "docker-desktop-data"} type WshServer struct{} func (*WshServer) WshServerImpl() {} var WshServerImpl = WshServer{} func (ws *WshServer) GetJwtPublicKeyCommand(ctx context.Context) (string, error) { return wavejwt.GetPublicKeyBase64(), nil } func (ws *WshServer) TestCommand(ctx context.Context, data string) error { defer func() { panichandler.PanicHandler("TestCommand", recover()) }() rpcSource := wshutil.GetRpcSourceFromContext(ctx) log.Printf("TEST src:%s | %s\n", rpcSource, data) return nil } func (ws *WshServer) TestMultiArgCommand(ctx context.Context, arg1 string, arg2 int, arg3 bool) (string, error) { defer func() { panichandler.PanicHandler("TestMultiArgCommand", recover()) }() rpcSource := wshutil.GetRpcSourceFromContext(ctx) rtn := fmt.Sprintf("src:%s arg1:%q arg2:%d arg3:%t", rpcSource, arg1, arg2, arg3) log.Printf("TESTMULTI %s\n", rtn) return rtn, nil } // for testing func (ws *WshServer) MessageCommand(ctx context.Context, data wshrpc.CommandMessageData) error { log.Printf("MESSAGE: %s\n", data.Message) return nil } // for testing func (ws *WshServer) StreamTestCommand(ctx context.Context) chan wshrpc.RespOrErrorUnion[int] { rtn := make(chan wshrpc.RespOrErrorUnion[int]) go func() { defer func() { panichandler.PanicHandler("StreamTestCommand", recover()) }() for i := 1; i <= 5; i++ { rtn <- wshrpc.RespOrErrorUnion[int]{Response: i} time.Sleep(1 * time.Second) } close(rtn) }() return rtn } func (ws *WshServer) StreamWaveAiCommand(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { return waveai.RunAICommand(ctx, request) } func MakePlotData(ctx context.Context, blockId string) error { block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { return err } viewName := block.Meta.GetString(waveobj.MetaKey_View, "") if viewName != "cpuplot" && viewName != "sysinfo" { return fmt.Errorf("invalid view type: %s", viewName) } return filestore.WFS.MakeFile(ctx, blockId, "cpuplotdata", nil, wshrpc.FileOpts{}) } func SavePlotData(ctx context.Context, blockId string, history string) error { block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { return err } viewName := block.Meta.GetString(waveobj.MetaKey_View, "") if viewName != "cpuplot" && viewName != "sysinfo" { return fmt.Errorf("invalid view type: %s", viewName) } // todo: interpret the data being passed // for now, this is just to throw an error if the block was closed historyBytes, err := json.Marshal(history) if err != nil { return fmt.Errorf("unable to serialize plot data: %v", err) } // ignore MakeFile error (already exists is ok) return filestore.WFS.WriteFile(ctx, blockId, "cpuplotdata", historyBytes) } func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetMetaData) (waveobj.MetaMapType, error) { obj, err := wstore.DBGetORef(ctx, data.ORef) if err != nil { return nil, fmt.Errorf("error getting object: %w", err) } if obj == nil { return nil, fmt.Errorf("object not found: %s", data.ORef) } return waveobj.GetMeta(obj), nil } func (ws *WshServer) UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error { oref := waveobj.ORef{OType: waveobj.OType_Tab, OID: tabId} err := wstore.UpdateTabName(ctx, tabId, newName) if err != nil { return fmt.Errorf("error updating tab name: %w", err) } wcore.SendWaveObjUpdate(oref) return nil } func (ws *WshServer) UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error { oref := waveobj.ORef{OType: waveobj.OType_Workspace, OID: workspaceId} err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds) if err != nil { return fmt.Errorf("error updating workspace tab ids: %w", err) } wcore.SendWaveObjUpdate(oref) return nil } func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error { log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta) oref := data.ORef err := wstore.UpdateObjectMeta(ctx, oref, data.Meta, false) if err != nil { return fmt.Errorf("error updating object meta: %w", err) } wcore.SendWaveObjUpdate(oref) return nil } func (ws *WshServer) GetRTInfoCommand(ctx context.Context, data wshrpc.CommandGetRTInfoData) (*waveobj.ObjRTInfo, error) { return wstore.GetRTInfo(data.ORef), nil } func (ws *WshServer) SetRTInfoCommand(ctx context.Context, data wshrpc.CommandSetRTInfoData) error { if data.Delete { wstore.DeleteRTInfo(data.ORef) return nil } wstore.SetRTInfo(data.ORef, data.Data) return nil } func (ws *WshServer) ResolveIdsCommand(ctx context.Context, data wshrpc.CommandResolveIdsData) (wshrpc.CommandResolveIdsRtnData, error) { rtn := wshrpc.CommandResolveIdsRtnData{} rtn.ResolvedIds = make(map[string]waveobj.ORef) var firstErr error for _, simpleId := range data.Ids { oref, err := resolveSimpleId(ctx, data, simpleId) if err != nil { if firstErr == nil { firstErr = err } continue } if oref == nil { continue } rtn.ResolvedIds[simpleId] = *oref } if firstErr != nil && len(data.Ids) == 1 { return rtn, firstErr } return rtn, nil } func (ws *WshServer) CreateBlockCommand(ctx context.Context, data wshrpc.CommandCreateBlockData) (*waveobj.ORef, error) { ctx = waveobj.ContextWithUpdates(ctx) tabId := data.TabId blockData, err := wcore.CreateBlock(ctx, tabId, data.BlockDef, data.RtOpts) if err != nil { return nil, fmt.Errorf("error creating block: %w", err) } var layoutAction *waveobj.LayoutActionData if data.TargetBlockId != "" { switch data.TargetAction { case "replace": layoutAction = &waveobj.LayoutActionData{ ActionType: wcore.LayoutActionDataType_Replace, TargetBlockId: data.TargetBlockId, BlockId: blockData.OID, Focused: data.Focused, } err = wcore.DeleteBlock(ctx, data.TargetBlockId, false) if err != nil { return nil, fmt.Errorf("error deleting block (trying to do block replace): %w", err) } case "splitright": layoutAction = &waveobj.LayoutActionData{ ActionType: wcore.LayoutActionDataType_SplitHorizontal, BlockId: blockData.OID, TargetBlockId: data.TargetBlockId, Position: "after", Focused: data.Focused, } case "splitleft": layoutAction = &waveobj.LayoutActionData{ ActionType: wcore.LayoutActionDataType_SplitHorizontal, BlockId: blockData.OID, TargetBlockId: data.TargetBlockId, Position: "before", Focused: data.Focused, } case "splitup": layoutAction = &waveobj.LayoutActionData{ ActionType: wcore.LayoutActionDataType_SplitVertical, BlockId: blockData.OID, TargetBlockId: data.TargetBlockId, Position: "before", Focused: data.Focused, } case "splitdown": layoutAction = &waveobj.LayoutActionData{ ActionType: wcore.LayoutActionDataType_SplitVertical, BlockId: blockData.OID, TargetBlockId: data.TargetBlockId, Position: "after", Focused: data.Focused, } default: return nil, fmt.Errorf("invalid target action: %s", data.TargetAction) } } else { layoutAction = &waveobj.LayoutActionData{ ActionType: wcore.LayoutActionDataType_Insert, BlockId: blockData.OID, Magnified: data.Magnified, Ephemeral: data.Ephemeral, Focused: data.Focused, } } err = wcore.QueueLayoutActionForTab(ctx, tabId, *layoutAction) if err != nil { return nil, fmt.Errorf("error queuing layout action: %w", err) } updates := waveobj.ContextGetUpdatesRtn(ctx) wps.Broker.SendUpdateEvents(updates) return &waveobj.ORef{OType: waveobj.OType_Block, OID: blockData.OID}, nil } func (ws *WshServer) CreateSubBlockCommand(ctx context.Context, data wshrpc.CommandCreateSubBlockData) (*waveobj.ORef, error) { parentBlockId := data.ParentBlockId blockData, err := wcore.CreateSubBlock(ctx, parentBlockId, data.BlockDef) if err != nil { return nil, fmt.Errorf("error creating block: %w", err) } blockRef := &waveobj.ORef{OType: waveobj.OType_Block, OID: blockData.OID} return blockRef, nil } func (ws *WshServer) ControllerDestroyCommand(ctx context.Context, blockId string) error { blockcontroller.DestroyBlockController(blockId) return nil } func (ws *WshServer) ControllerResyncCommand(ctx context.Context, data wshrpc.CommandControllerResyncData) error { ctx = genconn.ContextWithConnData(ctx, data.BlockId) ctx = termCtxWithLogBlockId(ctx, data.BlockId) return blockcontroller.ResyncController(ctx, data.TabId, data.BlockId, data.RtOpts, data.ForceRestart) } func (ws *WshServer) ControllerInputCommand(ctx context.Context, data wshrpc.CommandBlockInputData) error { inputUnion := &blockcontroller.BlockInputUnion{ SigName: data.SigName, TermSize: data.TermSize, } if len(data.InputData64) > 0 { inputBuf := make([]byte, base64.StdEncoding.DecodedLen(len(data.InputData64))) nw, err := base64.StdEncoding.Decode(inputBuf, []byte(data.InputData64)) if err != nil { return fmt.Errorf("error decoding input data: %w", err) } inputUnion.InputData = inputBuf[:nw] } return blockcontroller.SendInput(data.BlockId, inputUnion) } func (ws *WshServer) ControllerAppendOutputCommand(ctx context.Context, data wshrpc.CommandControllerAppendOutputData) error { outputBuf := make([]byte, base64.StdEncoding.DecodedLen(len(data.Data64))) nw, err := base64.StdEncoding.Decode(outputBuf, []byte(data.Data64)) if err != nil { return fmt.Errorf("error decoding output data: %w", err) } err = blockcontroller.HandleAppendBlockFile(data.BlockId, wavebase.BlockFile_Term, outputBuf[:nw]) if err != nil { return fmt.Errorf("error appending to block file: %w", err) } return nil } func (ws *WshServer) FileCreateCommand(ctx context.Context, data wshrpc.FileData) error { data.Data64 = "" err := wshfs.PutFile(ctx, data) if err != nil { return fmt.Errorf("error creating file: %w", err) } return nil } func (ws *WshServer) FileMkdirCommand(ctx context.Context, data wshrpc.FileData) error { return wshfs.Mkdir(ctx, data.Info.Path) } func (ws *WshServer) FileDeleteCommand(ctx context.Context, data wshrpc.CommandDeleteFileData) error { return wshfs.Delete(ctx, data) } func (ws *WshServer) FileInfoCommand(ctx context.Context, data wshrpc.FileData) (*wshrpc.FileInfo, error) { return wshfs.Stat(ctx, data.Info.Path) } func (ws *WshServer) FileListCommand(ctx context.Context, data wshrpc.FileListData) ([]*wshrpc.FileInfo, error) { return wshfs.ListEntries(ctx, data.Path, data.Opts) } func (ws *WshServer) FileListStreamCommand(ctx context.Context, data wshrpc.FileListData) <-chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] { return wshfs.ListEntriesStream(ctx, data.Path, data.Opts) } func (ws *WshServer) FileWriteCommand(ctx context.Context, data wshrpc.FileData) error { return wshfs.PutFile(ctx, data) } func (ws *WshServer) FileReadCommand(ctx context.Context, data wshrpc.FileData) (*wshrpc.FileData, error) { return wshfs.Read(ctx, data) } func (ws *WshServer) FileReadStreamCommand(ctx context.Context, data wshrpc.FileData) <-chan wshrpc.RespOrErrorUnion[wshrpc.FileData] { return wshfs.ReadStream(ctx, data) } func (ws *WshServer) FileStreamCommand(ctx context.Context, data wshrpc.CommandFileStreamData) (*wshrpc.FileInfo, error) { return wshfs.FileStream(ctx, data) } func (ws *WshServer) FileCopyCommand(ctx context.Context, data wshrpc.CommandFileCopyData) error { return wshfs.Copy(ctx, data) } func (ws *WshServer) FileMoveCommand(ctx context.Context, data wshrpc.CommandFileCopyData) error { return wshfs.Move(ctx, data) } func (ws *WshServer) FileAppendCommand(ctx context.Context, data wshrpc.FileData) error { return wshfs.Append(ctx, data) } func (ws *WshServer) FileJoinCommand(ctx context.Context, paths []string) (*wshrpc.FileInfo, error) { if len(paths) < 2 { if len(paths) == 0 { return nil, fmt.Errorf("no paths provided") } return wshfs.Stat(ctx, paths[0]) } return wshfs.Join(ctx, paths[0], paths[1:]...) } func (ws *WshServer) FileRestoreBackupCommand(ctx context.Context, data wshrpc.CommandFileRestoreBackupData) error { expandedBackupPath, err := wavebase.ExpandHomeDir(data.BackupFilePath) if err != nil { return fmt.Errorf("failed to expand backup file path: %w", err) } expandedRestorePath, err := wavebase.ExpandHomeDir(data.RestoreToFileName) if err != nil { return fmt.Errorf("failed to expand restore file path: %w", err) } return filebackup.RestoreBackup(expandedBackupPath, expandedRestorePath) } func (ws *WshServer) GetTempDirCommand(ctx context.Context, data wshrpc.CommandGetTempDirData) (string, error) { tempDir := os.TempDir() if data.FileName != "" { // Reduce to a simple file name to avoid absolute paths or traversal name := filepath.Base(data.FileName) // Normalize/trim any stray separators and whitespace name = strings.Trim(name, `/\`+" ") if name == "" || name == "." { return tempDir, nil } return filepath.Join(tempDir, name), nil } return tempDir, nil } func (ws *WshServer) WriteTempFileCommand(ctx context.Context, data wshrpc.CommandWriteTempFileData) (string, error) { if data.FileName == "" { return "", fmt.Errorf("filename is required") } name := filepath.Base(data.FileName) if name == "" || name == "." || name == ".." { return "", fmt.Errorf("invalid filename") } tempDir, err := os.MkdirTemp("", "waveterm-") if err != nil { return "", fmt.Errorf("error creating temp directory: %w", err) } decoded, err := base64.StdEncoding.DecodeString(data.Data64) if err != nil { return "", fmt.Errorf("error decoding base64 data: %w", err) } tempPath := filepath.Join(tempDir, name) err = os.WriteFile(tempPath, decoded, 0600) if err != nil { return "", fmt.Errorf("error writing temp file: %w", err) } return tempPath, nil } func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { if data.BlockId == "" { return fmt.Errorf("blockid is required") } err := wcore.DeleteBlock(ctx, data.BlockId, false) if err != nil { return fmt.Errorf("error deleting block: %w", err) } return nil } func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { if data.BlockId == "" { return fmt.Errorf("blockid is required") } ctx = waveobj.ContextWithUpdates(ctx) tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) if err != nil { return fmt.Errorf("error finding tab for block: %w", err) } if tabId == "" { return fmt.Errorf("no tab found for block") } err = wcore.DeleteBlock(ctx, data.BlockId, true) if err != nil { return fmt.Errorf("error deleting block: %w", err) } wcore.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{ ActionType: wcore.LayoutActionDataType_Remove, BlockId: data.BlockId, }) updates := waveobj.ContextGetUpdatesRtn(ctx) wps.Broker.SendUpdateEvents(updates) return nil } func (ws *WshServer) WaitForRouteCommand(ctx context.Context, data wshrpc.CommandWaitForRouteData) (bool, error) { waitCtx, cancelFn := context.WithTimeout(ctx, time.Duration(data.WaitMs)*time.Millisecond) defer cancelFn() err := wshutil.DefaultRouter.WaitForRegister(waitCtx, data.RouteId) return err == nil, nil } func (ws *WshServer) EventRecvCommand(ctx context.Context, data wps.WaveEvent) error { return nil } func (ws *WshServer) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error { rpcSource := wshutil.GetRpcSourceFromContext(ctx) if rpcSource == "" { return fmt.Errorf("no rpc source set") } if data.Sender == "" { data.Sender = rpcSource } wps.Broker.Publish(data) return nil } func (ws *WshServer) EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error { rpcSource := wshutil.GetRpcSourceFromContext(ctx) if rpcSource == "" { return fmt.Errorf("no rpc source set") } wps.Broker.Subscribe(rpcSource, data) return nil } func (ws *WshServer) EventUnsubCommand(ctx context.Context, data string) error { rpcSource := wshutil.GetRpcSourceFromContext(ctx) if rpcSource == "" { return fmt.Errorf("no rpc source set") } wps.Broker.Unsubscribe(rpcSource, data) return nil } func (ws *WshServer) EventUnsubAllCommand(ctx context.Context) error { rpcSource := wshutil.GetRpcSourceFromContext(ctx) if rpcSource == "" { return fmt.Errorf("no rpc source set") } wps.Broker.UnsubscribeAll(rpcSource) return nil } func (ws *WshServer) EventReadHistoryCommand(ctx context.Context, data wshrpc.CommandEventReadHistoryData) ([]*wps.WaveEvent, error) { events := wps.Broker.ReadEventHistory(data.Event, data.Scope, data.MaxItems) return events, nil } func (ws *WshServer) SetConfigCommand(ctx context.Context, data wshrpc.MetaSettingsType) error { return wconfig.SetBaseConfigValue(data.MetaMapType) } func (ws *WshServer) SetConnectionsConfigCommand(ctx context.Context, data wshrpc.ConnConfigRequest) error { return wconfig.SetConnectionsConfigValue(data.Host, data.MetaMapType) } func (ws *WshServer) GetFullConfigCommand(ctx context.Context) (wconfig.FullConfigType, error) { watcher := wconfig.GetWatcher() return watcher.GetFullConfig(), nil } func (ws *WshServer) GetWaveAIModeConfigCommand(ctx context.Context) (wconfig.AIModeConfigUpdate, error) { fullConfig := wconfig.GetWatcher().GetFullConfig() resolvedConfigs := aiusechat.ComputeResolvedAIModeConfigs(fullConfig) return wconfig.AIModeConfigUpdate{Configs: resolvedConfigs}, nil } func (ws *WshServer) ConnStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) { rtn := conncontroller.GetAllConnStatus() return rtn, nil } func (ws *WshServer) WslStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) { rtn := wslconn.GetAllConnStatus() return rtn, nil } func termCtxWithLogBlockId(ctx context.Context, logBlockId string) context.Context { if logBlockId == "" { return ctx } block, err := wstore.DBMustGet[*waveobj.Block](ctx, logBlockId) if err != nil { return ctx } connDebug := block.Meta.GetString(waveobj.MetaKey_TermConnDebug, "") if connDebug == "" { return ctx } return blocklogger.ContextWithLogBlockId(ctx, logBlockId, connDebug == "debug") } func (ws *WshServer) ConnEnsureCommand(ctx context.Context, data wshrpc.ConnExtData) error { ctx = genconn.ContextWithConnData(ctx, data.LogBlockId) ctx = termCtxWithLogBlockId(ctx, data.LogBlockId) if strings.HasPrefix(data.ConnName, "wsl://") { distroName := strings.TrimPrefix(data.ConnName, "wsl://") return wslconn.EnsureConnection(ctx, distroName) } return conncontroller.EnsureConnection(ctx, data.ConnName) } func (ws *WshServer) ConnDisconnectCommand(ctx context.Context, connName string) error { if conncontroller.IsLocalConnName(connName) { return nil } if strings.HasPrefix(connName, "wsl://") { distroName := strings.TrimPrefix(connName, "wsl://") conn := wslconn.GetWslConn(distroName) if conn == nil { return fmt.Errorf("distro not found: %s", connName) } return conn.Close() } connOpts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) } conn := conncontroller.MaybeGetConn(connOpts) if conn == nil { return fmt.Errorf("connection not found: %s", connName) } return conn.Close() } func (ws *WshServer) ConnConnectCommand(ctx context.Context, connRequest wshrpc.ConnRequest) error { if conncontroller.IsLocalConnName(connRequest.Host) { return nil } ctx = genconn.ContextWithConnData(ctx, connRequest.LogBlockId) ctx = termCtxWithLogBlockId(ctx, connRequest.LogBlockId) connName := connRequest.Host if strings.HasPrefix(connName, "wsl://") { distroName := strings.TrimPrefix(connName, "wsl://") conn := wslconn.GetWslConn(distroName) if conn == nil { return fmt.Errorf("connection not found: %s", connName) } return conn.Connect(ctx) } connOpts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) } conn := conncontroller.GetConn(connOpts) if conn == nil { return fmt.Errorf("connection not found: %s", connName) } return conn.Connect(ctx, &connRequest.Keywords) } func (ws *WshServer) ConnReinstallWshCommand(ctx context.Context, data wshrpc.ConnExtData) error { if conncontroller.IsLocalConnName(data.ConnName) { return nil } ctx = genconn.ContextWithConnData(ctx, data.LogBlockId) ctx = termCtxWithLogBlockId(ctx, data.LogBlockId) connName := data.ConnName if strings.HasPrefix(connName, "wsl://") { distroName := strings.TrimPrefix(connName, "wsl://") conn := wslconn.GetWslConn(distroName) if conn == nil { return fmt.Errorf("connection not found: %s", connName) } return conn.InstallWsh(ctx, "") } connOpts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) } conn := conncontroller.GetConn(connOpts) if conn == nil { return fmt.Errorf("connection not found: %s", connName) } return conn.InstallWsh(ctx, "") } func (ws *WshServer) ConnUpdateWshCommand(ctx context.Context, remoteInfo wshrpc.RemoteInfo) (bool, error) { handler := wshutil.GetRpcResponseHandlerFromContext(ctx) if handler == nil { return false, fmt.Errorf("could not determine handler from context") } connName := handler.GetRpcContext().Conn if connName == "" { return false, fmt.Errorf("invalid remote info: missing connection name") } log.Printf("checking wsh version for connection %s (current: %s)", connName, remoteInfo.ClientVersion) upToDate, _, _, err := conncontroller.IsWshVersionUpToDate(ctx, remoteInfo.ClientVersion) if err != nil { return false, fmt.Errorf("unable to compare wsh version: %w", err) } if upToDate { // no need to update log.Printf("wsh is already up to date for connection %s", connName) return false, nil } // todo: need to add user input code here for validation if strings.HasPrefix(connName, "wsl://") { return false, fmt.Errorf("connupdatewshcommand is not supported for wsl connections") } connOpts, err := remote.ParseOpts(connName) if err != nil { return false, fmt.Errorf("error parsing connection name: %w", err) } conn := conncontroller.GetConn(connOpts) if conn == nil { return false, fmt.Errorf("connection not found: %s", connName) } err = conn.UpdateWsh(ctx, connName, &remoteInfo) if err != nil { return false, fmt.Errorf("wsh update failed for connection %s: %w", connName, err) } // todo: need to add code for modifying configs? return true, nil } func (ws *WshServer) ConnListCommand(ctx context.Context) ([]string, error) { return conncontroller.GetConnectionsList() } func (ws *WshServer) WslListCommand(ctx context.Context) ([]string, error) { distros, err := wsl.RegisteredDistros(ctx) if err != nil { return nil, err } var distroNames []string for _, distro := range distros { distroName := distro.Name() if utilfn.ContainsStr(InvalidWslDistroNames, distroName) { continue } distroNames = append(distroNames, distroName) } return distroNames, nil } func (ws *WshServer) WslDefaultDistroCommand(ctx context.Context) (string, error) { distro, ok, err := wsl.DefaultDistro(ctx) if err != nil { return "", fmt.Errorf("unable to determine default distro: %w", err) } if !ok { return "", fmt.Errorf("unable to determine default distro") } return distro.Name(), nil } /** * Dismisses the WshFail Command in runtime memory on the backend */ func (ws *WshServer) DismissWshFailCommand(ctx context.Context, connName string) error { if strings.HasPrefix(connName, "wsl://") { distroName := strings.TrimPrefix(connName, "wsl://") conn := wslconn.GetWslConn(distroName) if conn == nil { return fmt.Errorf("connection not found: %s", connName) } conn.ClearWshError() conn.FireConnChangeEvent() return nil } opts, err := remote.ParseOpts(connName) if err != nil { return err } conn := conncontroller.GetConn(opts) if conn == nil { return fmt.Errorf("connection %s not found", connName) } conn.ClearWshError() conn.FireConnChangeEvent() return nil } func (ws *WshServer) NotifySystemResumeCommand(ctx context.Context) error { log.Printf("NotifySystemResumeCommand called\n") return nil } func (ws *WshServer) FindGitBashCommand(ctx context.Context, rescan bool) (string, error) { fullConfig := wconfig.GetWatcher().GetFullConfig() return shellutil.FindGitBash(&fullConfig, rescan), nil } func waveFileToWaveFileInfo(wf *filestore.WaveFile) *wshrpc.WaveFileInfo { return &wshrpc.WaveFileInfo{ ZoneId: wf.ZoneId, Name: wf.Name, Opts: wf.Opts, CreatedTs: wf.CreatedTs, Size: wf.Size, ModTs: wf.ModTs, Meta: wf.Meta, } } func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wshrpc.BlockInfoData, error) { blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { return nil, fmt.Errorf("error getting block: %w", err) } tabId, err := wstore.DBFindTabForBlockId(ctx, blockId) if err != nil { return nil, fmt.Errorf("error finding tab for block: %w", err) } workspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId) if err != nil { return nil, fmt.Errorf("error finding window for tab: %w", err) } fileList, err := filestore.WFS.ListFiles(ctx, blockId) if err != nil { return nil, fmt.Errorf("error listing blockfiles: %w", err) } var fileInfoList []*wshrpc.WaveFileInfo for _, wf := range fileList { fileInfoList = append(fileInfoList, waveFileToWaveFileInfo(wf)) } return &wshrpc.BlockInfoData{ BlockId: blockId, TabId: tabId, WorkspaceId: workspaceId, Block: blockData, Files: fileInfoList, }, nil } func (ws *WshServer) DebugTermCommand(ctx context.Context, data wshrpc.CommandDebugTermData) (*wshrpc.CommandDebugTermRtnData, error) { if data.BlockId == "" { return nil, fmt.Errorf("blockid is required") } if data.Size <= 0 { return nil, fmt.Errorf("size must be greater than 0") } waveFile, err := filestore.WFS.Stat(ctx, data.BlockId, wavebase.BlockFile_Term) if err == fs.ErrNotExist { return &wshrpc.CommandDebugTermRtnData{}, nil } if err != nil { return nil, fmt.Errorf("error statting term file: %w", err) } readSize := data.Size dataLength := waveFile.DataLength() if readSize > dataLength { readSize = dataLength } readOffset := waveFile.Size - readSize readOffset, readData, err := filestore.WFS.ReadAt(ctx, data.BlockId, wavebase.BlockFile_Term, readOffset, readSize) if err != nil { return nil, fmt.Errorf("error reading term file: %w", err) } return &wshrpc.CommandDebugTermRtnData{ Offset: readOffset, Data64: base64.StdEncoding.EncodeToString(readData), }, nil } func (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData, error) { return &wshrpc.WaveInfoData{ Version: wavebase.WaveVersion, ClientId: wstore.GetClientId(), BuildTime: wavebase.BuildTime, ConfigDir: wavebase.GetWaveConfigDir(), DataDir: wavebase.GetWaveDataDir(), }, nil } func (ws *WshServer) MacOSVersionCommand(ctx context.Context) (string, error) { return wavebase.ClientMacOSVersion(), nil } // BlocksListCommand returns every block visible in the requested // scope (current workspace by default). func (ws *WshServer) BlocksListCommand( ctx context.Context, req wshrpc.BlocksListRequest) ([]wshrpc.BlocksListEntry, error) { var results []wshrpc.BlocksListEntry // Resolve the set of workspaces to inspect var workspaceIDs []string if req.WorkspaceId != "" { workspaceIDs = []string{req.WorkspaceId} } else if req.WindowId != "" { win, err := wcore.GetWindow(ctx, req.WindowId) if err != nil { return nil, err } workspaceIDs = []string{win.WorkspaceId} } else { // "current" == first workspace in client focus list client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) if err != nil { return nil, err } if len(client.WindowIds) == 0 { return nil, fmt.Errorf("no active window") } win, err := wcore.GetWindow(ctx, client.WindowIds[0]) if err != nil { return nil, err } workspaceIDs = []string{win.WorkspaceId} } for _, wsID := range workspaceIDs { wsData, err := wcore.GetWorkspace(ctx, wsID) if err != nil { return nil, err } windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, wsID) if err != nil { log.Printf("error finding window for workspace %s: %v", wsID, err) } for _, tabID := range wsData.TabIds { tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabID) if err != nil { return nil, err } for _, blkID := range tab.BlockIds { blk, err := wstore.DBMustGet[*waveobj.Block](ctx, blkID) if err != nil { return nil, err } results = append(results, wshrpc.BlocksListEntry{ WindowId: windowId, WorkspaceId: wsID, TabId: tabID, BlockId: blkID, Meta: blk.Meta, }) } } } return results, nil } func (ws *WshServer) WorkspaceListCommand(ctx context.Context) ([]wshrpc.WorkspaceInfoData, error) { workspaceList, err := wcore.ListWorkspaces(ctx) if err != nil { return nil, fmt.Errorf("error listing workspaces: %w", err) } var rtn []wshrpc.WorkspaceInfoData for _, workspaceEntry := range workspaceList { workspaceData, err := wcore.GetWorkspace(ctx, workspaceEntry.WorkspaceId) if err != nil { return nil, fmt.Errorf("error getting workspace: %w", err) } rtn = append(rtn, wshrpc.WorkspaceInfoData{ WindowId: workspaceEntry.WindowId, WorkspaceData: workspaceData, }) } return rtn, nil } func (ws *WshServer) ListAllAppsCommand(ctx context.Context) ([]wshrpc.AppInfo, error) { return waveappstore.ListAllApps() } func (ws *WshServer) ListAllEditableAppsCommand(ctx context.Context) ([]wshrpc.AppInfo, error) { return waveappstore.ListAllEditableApps() } func (ws *WshServer) ListAllAppFilesCommand(ctx context.Context, data wshrpc.CommandListAllAppFilesData) (*wshrpc.CommandListAllAppFilesRtnData, error) { if data.AppId == "" { return nil, fmt.Errorf("must provide an appId to ListAllAppFilesCommand") } result, err := waveappstore.ListAllAppFiles(data.AppId) if err != nil { return nil, err } entries := make([]wshrpc.DirEntryOut, len(result.Entries)) for i, entry := range result.Entries { entries[i] = wshrpc.DirEntryOut{ Name: entry.Name, Dir: entry.Dir, Symlink: entry.Symlink, Size: entry.Size, Mode: entry.Mode, Modified: entry.Modified, ModifiedTime: entry.ModifiedTime, } } return &wshrpc.CommandListAllAppFilesRtnData{ Path: result.Path, AbsolutePath: result.AbsolutePath, ParentDir: result.ParentDir, Entries: entries, EntryCount: result.EntryCount, TotalEntries: result.TotalEntries, Truncated: result.Truncated, }, nil } func (ws *WshServer) ReadAppFileCommand(ctx context.Context, data wshrpc.CommandReadAppFileData) (*wshrpc.CommandReadAppFileRtnData, error) { if data.AppId == "" { return nil, fmt.Errorf("must provide an appId to ReadAppFileCommand") } fileData, err := waveappstore.ReadAppFile(data.AppId, data.FileName) if err != nil { if errors.Is(err, os.ErrNotExist) { return &wshrpc.CommandReadAppFileRtnData{ NotFound: true, }, nil } return nil, fmt.Errorf("failed to read app file: %w", err) } return &wshrpc.CommandReadAppFileRtnData{ Data64: base64.StdEncoding.EncodeToString(fileData.Contents), ModTs: fileData.ModTs, }, nil } func (ws *WshServer) WriteAppFileCommand(ctx context.Context, data wshrpc.CommandWriteAppFileData) error { if data.AppId == "" { return fmt.Errorf("must provide an appId to WriteAppFileCommand") } contents, err := base64.StdEncoding.DecodeString(data.Data64) if err != nil { return fmt.Errorf("failed to decode data64: %w", err) } return waveappstore.WriteAppFile(data.AppId, data.FileName, contents) } func (ws *WshServer) WaveFileReadStreamCommand(ctx context.Context, data wshrpc.CommandWaveFileReadStreamData) (*wshrpc.WaveFileInfo, error) { const maxStreamFileSize = 5 * 1024 * 1024 waveFile, err := filestore.WFS.Stat(ctx, data.ZoneId, data.Name) if err != nil { return nil, fmt.Errorf("error statting wavefile: %w", err) } dataLength := waveFile.DataLength() if dataLength > maxStreamFileSize { return nil, fmt.Errorf("file size %d exceeds maximum streaming size of %d bytes", dataLength, maxStreamFileSize) } wshRpc := wshutil.GetWshRpcFromContext(ctx) if wshRpc == nil || wshRpc.StreamBroker == nil { return nil, fmt.Errorf("no stream broker available") } writer, err := wshRpc.StreamBroker.CreateStreamWriter(&data.StreamMeta) if err != nil { return nil, fmt.Errorf("error creating stream writer: %w", err) } _, fileData, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.Name) if err != nil { writer.Close() return nil, fmt.Errorf("error reading wavefile: %w", err) } go func() { defer func() { panichandler.PanicHandler("WaveFileReadStreamCommand", recover()) }() defer writer.Close() _, err := writer.Write(fileData) if err != nil { log.Printf("error writing to stream for wavefile %s:%s: %v\n", data.ZoneId, data.Name, err) } }() rtnInfo := &wshrpc.WaveFileInfo{ ZoneId: waveFile.ZoneId, Name: waveFile.Name, Opts: waveFile.Opts, CreatedTs: waveFile.CreatedTs, Size: waveFile.Size, ModTs: waveFile.ModTs, Meta: waveFile.Meta, } return rtnInfo, nil } func (ws *WshServer) WriteAppGoFileCommand(ctx context.Context, data wshrpc.CommandWriteAppGoFileData) (*wshrpc.CommandWriteAppGoFileRtnData, error) { if data.AppId == "" { return nil, fmt.Errorf("must provide an appId to WriteAppGoFileCommand") } contents, err := base64.StdEncoding.DecodeString(data.Data64) if err != nil { return nil, fmt.Errorf("failed to decode data64: %w", err) } formattedOutput := waveapputil.FormatGoCode(contents) err = waveappstore.WriteAppFile(data.AppId, "app.go", formattedOutput) if err != nil { return nil, err } encoded := base64.StdEncoding.EncodeToString(formattedOutput) return &wshrpc.CommandWriteAppGoFileRtnData{Data64: encoded}, nil } func (ws *WshServer) DeleteAppFileCommand(ctx context.Context, data wshrpc.CommandDeleteAppFileData) error { if data.AppId == "" { return fmt.Errorf("must provide an appId to DeleteAppFileCommand") } return waveappstore.DeleteAppFile(data.AppId, data.FileName) } func (ws *WshServer) RenameAppFileCommand(ctx context.Context, data wshrpc.CommandRenameAppFileData) error { if data.AppId == "" { return fmt.Errorf("must provide an appId to RenameAppFileCommand") } return waveappstore.RenameAppFile(data.AppId, data.FromFileName, data.ToFileName) } func (ws *WshServer) WriteAppSecretBindingsCommand(ctx context.Context, data wshrpc.CommandWriteAppSecretBindingsData) error { if data.AppId == "" { return fmt.Errorf("must provide an appId to WriteAppSecretBindingsCommand") } return waveappstore.WriteAppSecretBindings(data.AppId, data.Bindings) } func (ws *WshServer) DeleteBuilderCommand(ctx context.Context, builderId string) error { if builderId == "" { return fmt.Errorf("must provide a builderId to DeleteBuilderCommand") } buildercontroller.DeleteController(builderId) return nil } func (ws *WshServer) StartBuilderCommand(ctx context.Context, data wshrpc.CommandStartBuilderData) error { if data.BuilderId == "" { return fmt.Errorf("must provide a builderId to StartBuilderCommand") } bc := buildercontroller.GetOrCreateController(data.BuilderId) rtInfo := wstore.GetRTInfo(waveobj.MakeORef("builder", data.BuilderId)) if rtInfo == nil { return fmt.Errorf("builder rtinfo not found for builderid: %s", data.BuilderId) } appId := rtInfo.BuilderAppId if appId == "" { return fmt.Errorf("builder appid not set for builderid: %s", data.BuilderId) } return bc.Start(ctx, appId, rtInfo.BuilderEnv) } func (ws *WshServer) StopBuilderCommand(ctx context.Context, builderId string) error { if builderId == "" { return fmt.Errorf("must provide a builderId to StopBuilderCommand") } bc := buildercontroller.GetController(builderId) if bc == nil { return nil } return bc.Stop() } func (ws *WshServer) RestartBuilderAndWaitCommand(ctx context.Context, data wshrpc.CommandRestartBuilderAndWaitData) (*wshrpc.RestartBuilderAndWaitResult, error) { if data.BuilderId == "" { return nil, fmt.Errorf("must provide a builderId to RestartBuilderAndWaitCommand") } bc := buildercontroller.GetOrCreateController(data.BuilderId) rtInfo := wstore.GetRTInfo(waveobj.MakeORef("builder", data.BuilderId)) if rtInfo == nil { return nil, fmt.Errorf("builder rtinfo not found for builderid: %s", data.BuilderId) } appId := rtInfo.BuilderAppId if appId == "" { return nil, fmt.Errorf("builder appid not set for builderid: %s", data.BuilderId) } result, err := bc.RestartAndWaitForBuild(ctx, appId, rtInfo.BuilderEnv) if err != nil { return nil, err } return &wshrpc.RestartBuilderAndWaitResult{ Success: result.Success, ErrorMessage: result.ErrorMessage, BuildOutput: result.BuildOutput, }, nil } func (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId string) (*wshrpc.BuilderStatusData, error) { if builderId == "" { return nil, fmt.Errorf("must provide a builderId to GetBuilderStatusCommand") } bc := buildercontroller.GetOrCreateController(builderId) status := bc.GetStatus() return &status, nil } func (ws *WshServer) GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) { if builderId == "" { return nil, fmt.Errorf("must provide a builderId to GetBuilderOutputCommand") } bc := buildercontroller.GetOrCreateController(builderId) return bc.GetOutput(), nil } func (ws *WshServer) CheckGoVersionCommand(ctx context.Context) (*wshrpc.CommandCheckGoVersionRtnData, error) { watcher := wconfig.GetWatcher() fullConfig := watcher.GetFullConfig() goPath := fullConfig.Settings.TsunamiGoPath result := build.CheckGoVersion(goPath) return &wshrpc.CommandCheckGoVersionRtnData{ GoStatus: result.GoStatus, GoPath: result.GoPath, GoVersion: result.GoVersion, ErrorString: result.ErrorString, }, nil } func (ws *WshServer) PublishAppCommand(ctx context.Context, data wshrpc.CommandPublishAppData) (*wshrpc.CommandPublishAppRtnData, error) { publishedAppId, err := waveappstore.PublishDraft(data.AppId) if err != nil { return nil, fmt.Errorf("error publishing app: %w", err) } return &wshrpc.CommandPublishAppRtnData{ PublishedAppId: publishedAppId, }, nil } func (ws *WshServer) MakeDraftFromLocalCommand(ctx context.Context, data wshrpc.CommandMakeDraftFromLocalData) (*wshrpc.CommandMakeDraftFromLocalRtnData, error) { draftAppId, err := waveappstore.MakeDraftFromLocal(data.LocalAppId) if err != nil { return nil, fmt.Errorf("error making draft from local: %w", err) } return &wshrpc.CommandMakeDraftFromLocalRtnData{ DraftAppId: draftAppId, }, nil } func (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error { err := telemetry.RecordTEvent(ctx, &data) if err != nil { log.Printf("error recording telemetry event: %v", err) } return err } func (ws WshServer) SendTelemetryCommand(ctx context.Context) error { return wcloud.SendAllTelemetry(wstore.GetClientId()) } func (ws *WshServer) WaveAIEnableTelemetryCommand(ctx context.Context) error { // Enable telemetry in config meta := waveobj.MetaMapType{ wconfig.ConfigKey_TelemetryEnabled: true, } err := wconfig.SetBaseConfigValue(meta) if err != nil { return fmt.Errorf("error setting telemetry enabled: %w", err) } // Record the telemetry event event := telemetrydata.MakeTEvent("waveai:enabletelemetry", telemetrydata.TEventProps{}) err = telemetry.RecordTEvent(ctx, event) if err != nil { log.Printf("error recording waveai:enabletelemetry event: %v", err) } // Immediately send telemetry to cloud err = wcloud.SendAllTelemetry(wstore.GetClientId()) if err != nil { log.Printf("error sending telemetry after enabling: %v", err) } return nil } func (ws *WshServer) GetWaveAIChatCommand(ctx context.Context, data wshrpc.CommandGetWaveAIChatData) (*uctypes.UIChat, error) { aiChat := chatstore.DefaultChatStore.Get(data.ChatId) if aiChat == nil { return nil, nil } uiChat, err := aiusechat.ConvertAIChatToUIChat(aiChat) if err != nil { return nil, fmt.Errorf("error converting AI chat to UI chat: %w", err) } return uiChat, nil } func (ws *WshServer) GetWaveAIRateLimitCommand(ctx context.Context) (*uctypes.RateLimitInfo, error) { return aiusechat.GetGlobalRateLimit(), nil } func (ws *WshServer) WaveAIToolApproveCommand(ctx context.Context, data wshrpc.CommandWaveAIToolApproveData) error { return aiusechat.UpdateToolApproval(data.ToolCallId, data.Approval) } func (ws *WshServer) WaveAIGetToolDiffCommand(ctx context.Context, data wshrpc.CommandWaveAIGetToolDiffData) (*wshrpc.CommandWaveAIGetToolDiffRtnData, error) { originalContent, modifiedContent, err := aiusechat.CreateWriteTextFileDiff(ctx, data.ChatId, data.ToolCallId) if err != nil { return nil, err } return &wshrpc.CommandWaveAIGetToolDiffRtnData{ OriginalContents64: base64.StdEncoding.EncodeToString(originalContent), ModifiedContents64: base64.StdEncoding.EncodeToString(modifiedContent), }, nil } var wshActivityRe = regexp.MustCompile(`^[a-z:#]+$`) func (ws *WshServer) WshActivityCommand(ctx context.Context, data map[string]int) error { if len(data) == 0 { return nil } props := telemetrydata.TEventProps{} for key, value := range data { if len(key) > 20 { delete(data, key) } if !wshActivityRe.MatchString(key) { delete(data, key) } if value != 1 { delete(data, key) } if strings.HasSuffix(key, "#error") { props.WshHadError = true } else { props.WshCmd = key } } activityUpdate := wshrpc.ActivityUpdate{ WshCmds: data, } telemetry.GoUpdateActivityWrap(activityUpdate, "wsh-activity") telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "wsh:run", Props: props, }) return nil } func (ws *WshServer) ActivityCommand(ctx context.Context, activity wshrpc.ActivityUpdate) error { telemetry.GoUpdateActivityWrap(activity, "wshrpc-activity") return nil } func (ws *WshServer) GetVarCommand(ctx context.Context, data wshrpc.CommandVarData) (*wshrpc.CommandVarResponseData, error) { _, fileData, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName) if err == fs.ErrNotExist { return &wshrpc.CommandVarResponseData{Key: data.Key, Exists: false}, nil } if err != nil { return nil, fmt.Errorf("error reading blockfile: %w", err) } envMap := envutil.EnvToMap(string(fileData)) value, ok := envMap[data.Key] return &wshrpc.CommandVarResponseData{Key: data.Key, Exists: ok, Val: value}, nil } func (ws *WshServer) GetAllVarsCommand(ctx context.Context, data wshrpc.CommandVarData) ([]wshrpc.CommandVarResponseData, error) { _, fileData, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName) if err == fs.ErrNotExist { return []wshrpc.CommandVarResponseData{}, nil } if err != nil { return nil, fmt.Errorf("error reading blockfile: %w", err) } envMap := envutil.EnvToMap(string(fileData)) keys := make([]string, 0, len(envMap)) for k := range envMap { keys = append(keys, k) } sort.Strings(keys) result := make([]wshrpc.CommandVarResponseData, 0, len(keys)) for _, k := range keys { result = append(result, wshrpc.CommandVarResponseData{ Key: k, Val: envMap[k], Exists: true, }) } return result, nil } func (ws *WshServer) SetVarCommand(ctx context.Context, data wshrpc.CommandVarData) error { _, fileData, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName) if err == fs.ErrNotExist { fileData = []byte{} err = filestore.WFS.MakeFile(ctx, data.ZoneId, data.FileName, nil, wshrpc.FileOpts{}) if err != nil { return fmt.Errorf("error creating blockfile: %w", err) } } else if err != nil { return fmt.Errorf("error reading blockfile: %w", err) } envMap := envutil.EnvToMap(string(fileData)) if data.Remove { delete(envMap, data.Key) } else { envMap[data.Key] = data.Val } envStr := envutil.MapToEnv(envMap) return filestore.WFS.WriteFile(ctx, data.ZoneId, data.FileName, []byte(envStr)) } func (ws *WshServer) PathCommand(ctx context.Context, data wshrpc.PathCommandData) (string, error) { pathType := data.PathType openInternal := data.Open openExternal := data.OpenExternal var path string switch pathType { case "config": path = wavebase.GetWaveConfigDir() case "data": path = wavebase.GetWaveDataDir() case "log": path = filepath.Join(wavebase.GetWaveDataDir(), "waveapp.log") } if openInternal && openExternal { return "", fmt.Errorf("open and openExternal cannot both be true") } if openInternal { _, err := ws.CreateBlockCommand(ctx, wshrpc.CommandCreateBlockData{ TabId: data.TabId, BlockDef: &waveobj.BlockDef{Meta: map[string]any{ waveobj.MetaKey_View: "preview", waveobj.MetaKey_File: path, }}, Ephemeral: true, Focused: true, }) if err != nil { return path, fmt.Errorf("error opening path: %w", err) } } else if openExternal { err := open.Run(path) if err != nil { return path, fmt.Errorf("error opening path: %w", err) } } return path, nil } func (ws *WshServer) FetchSuggestionsCommand(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { return suggestion.FetchSuggestions(ctx, data) } func (ws *WshServer) DisposeSuggestionsCommand(ctx context.Context, widgetId string) error { suggestion.DisposeSuggestions(ctx, widgetId) return nil } func (ws *WshServer) GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error) { tab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) if err != nil { return nil, fmt.Errorf("error getting tab: %w", err) } return tab, nil } func (ws *WshServer) GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) { return wcore.GetAllBadges(), nil } func (ws *WshServer) GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) { result := make(map[string]string) for _, name := range names { value, exists, err := secretstore.GetSecret(name) if err != nil { return nil, fmt.Errorf("error getting secret %q: %w", name, err) } if exists { result[name] = value } } return result, nil } func (ws *WshServer) GetSecretsNamesCommand(ctx context.Context) ([]string, error) { names, err := secretstore.GetSecretNames() if err != nil { return nil, fmt.Errorf("error getting secret names: %w", err) } return names, nil } func (ws *WshServer) SetSecretsCommand(ctx context.Context, secrets map[string]*string) error { for name, value := range secrets { if value == nil { err := secretstore.DeleteSecret(name) if err != nil { return fmt.Errorf("error deleting secret %q: %w", name, err) } } else { err := secretstore.SetSecret(name, *value) if err != nil { return fmt.Errorf("error setting secret %q: %w", name, err) } } } return nil } func (ws *WshServer) GetSecretsLinuxStorageBackendCommand(ctx context.Context) (string, error) { backend, err := secretstore.GetLinuxStorageBackend() if err != nil { return "", fmt.Errorf("error getting linux storage backend: %w", err) } return backend, nil } func (ws *WshServer) JobCmdExitedCommand(ctx context.Context, data wshrpc.CommandJobCmdExitedData) error { return jobcontroller.HandleCmdJobExited(ctx, data.JobId, data) } func (ws *WshServer) JobControllerListCommand(ctx context.Context) ([]*waveobj.Job, error) { return wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) } func (ws *WshServer) JobControllerDeleteJobCommand(ctx context.Context, jobId string) error { return jobcontroller.DeleteJob(ctx, jobId) } func (ws *WshServer) JobControllerStartJobCommand(ctx context.Context, data wshrpc.CommandJobControllerStartJobData) (string, error) { params := jobcontroller.StartJobParams{ ConnName: data.ConnName, JobKind: data.JobKind, Cmd: data.Cmd, Args: data.Args, Env: data.Env, TermSize: data.TermSize, } return jobcontroller.StartJob(ctx, params) } func (ws *WshServer) JobControllerExitJobCommand(ctx context.Context, jobId string) error { return jobcontroller.TerminateJobManager(ctx, jobId) } func (ws *WshServer) JobControllerDisconnectJobCommand(ctx context.Context, jobId string) error { return jobcontroller.DisconnectJob(ctx, jobId) } func (ws *WshServer) JobControllerReconnectJobCommand(ctx context.Context, jobId string) error { return jobcontroller.ReconnectJob(ctx, jobId, nil) } func (ws *WshServer) JobControllerReconnectJobsForConnCommand(ctx context.Context, connName string) error { return jobcontroller.ReconnectJobsForConn(ctx, connName) } func (ws *WshServer) JobControllerConnectedJobsCommand(ctx context.Context) ([]string, error) { return jobcontroller.GetConnectedJobIds(), nil } func (ws *WshServer) JobControllerAttachJobCommand(ctx context.Context, data wshrpc.CommandJobControllerAttachJobData) error { return jobcontroller.AttachJobToBlock(ctx, data.JobId, data.BlockId) } func (ws *WshServer) JobControllerDetachJobCommand(ctx context.Context, jobId string) error { return jobcontroller.DetachJobFromBlock(ctx, jobId, true) } func (ws *WshServer) BlockJobStatusCommand(ctx context.Context, blockId string) (*wshrpc.BlockJobStatusData, error) { return jobcontroller.GetBlockJobStatus(ctx, blockId) } ================================================ FILE: pkg/wshrpc/wshserver/wshserverutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshserver import ( "sync" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" ) const ( DefaultOutputChSize = 32 DefaultInputChSize = 32 ) var waveSrvClient_Singleton *wshutil.WshRpc var waveSrvClient_Once = &sync.Once{} // returns the wavesrv main rpc client singleton func GetMainRpcClient() *wshutil.WshRpc { waveSrvClient_Once.Do(func() { waveSrvClient_Singleton = wshutil.MakeWshRpc(wshrpc.RpcContext{}, &WshServerImpl, "main-client") }) return waveSrvClient_Singleton } ================================================ FILE: pkg/wshutil/wshadapter.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshutil import ( "fmt" "reflect" "strings" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) var WshCommandDeclMap = wshrpc.GenerateWshCommandDeclMap() var multiArgRType = reflect.TypeOf(wshrpc.MultiArg{}) func findCmdMethod(impl any, cmd string) *reflect.Method { rtype := reflect.TypeOf(impl) methodName := cmd + "command" for i := 0; i < rtype.NumMethod(); i++ { method := rtype.Method(i) if strings.ToLower(method.Name) == methodName { return &method } } return nil } func decodeRtnVals(rtnVals []reflect.Value) (any, error) { switch len(rtnVals) { case 0: return nil, nil case 1: errIf := rtnVals[0].Interface() if errIf == nil { return nil, nil } return nil, errIf.(error) case 2: errIf := rtnVals[1].Interface() if errIf == nil { return rtnVals[0].Interface(), nil } return rtnVals[0].Interface(), errIf.(error) default: return nil, fmt.Errorf("too many return values: %d", len(rtnVals)) } } func noImplHandler(handler *RpcResponseHandler) bool { handler.SendResponseError(fmt.Errorf("command %q not implemented", handler.GetCommand())) return true } func recodeCommandData(command string, data any, commandDataType reflect.Type) (any, error) { if command == "" || commandDataType == nil { return data, nil } methodDecl := WshCommandDeclMap[command] if methodDecl == nil { return data, fmt.Errorf("command %q not found", command) } commandDataPtr := reflect.New(commandDataType).Interface() if data != nil { err := utilfn.ReUnmarshal(commandDataPtr, data) if err != nil { return data, fmt.Errorf("error re-marshalling command data: %w", err) } } return reflect.ValueOf(commandDataPtr).Elem().Interface(), nil } func serverImplAdapter(impl any) func(*RpcResponseHandler) bool { if impl == nil { return noImplHandler } rtype := reflect.TypeOf(impl) if rtype.Kind() != reflect.Ptr && rtype.Elem().Kind() != reflect.Struct { panic(fmt.Sprintf("expected struct pointer, got %s", rtype)) } // returns isAsync return func(handler *RpcResponseHandler) bool { cmd := handler.GetCommand() methodDecl := WshCommandDeclMap[cmd] if methodDecl == nil { handler.SendResponseError(fmt.Errorf("command %q not found", cmd)) return true } rmethod := findCmdMethod(impl, cmd) if rmethod == nil { if !handler.NeedsResponse() && cmd != wshrpc.Command_Message { // we also send an out of band message here since this is likely unexpected and will require debugging handler.SendMessage(fmt.Sprintf("command %q method %q not found", handler.GetCommand(), methodDecl.MethodName)) } handler.SendResponseError(fmt.Errorf("command not implemented %q", cmd)) return true } implMethod := reflect.ValueOf(impl).MethodByName(rmethod.Name) var callParams []reflect.Value callParams = append(callParams, reflect.ValueOf(handler.Context())) commandDataTypes := methodDecl.GetCommandDataTypes() if len(commandDataTypes) == 1 { cmdData, err := recodeCommandData(cmd, handler.GetCommandRawData(), commandDataTypes[0]) if err != nil { handler.SendResponseError(err) return true } callParams = append(callParams, reflect.ValueOf(cmdData)) } else if len(commandDataTypes) > 1 { multiArgAny, err := recodeCommandData(cmd, handler.GetCommandRawData(), multiArgRType) if err != nil { handler.SendResponseError(err) return true } multiArg, ok := multiArgAny.(wshrpc.MultiArg) if !ok { handler.SendResponseError(fmt.Errorf("command %q invalid multi arg payload", cmd)) return true } if len(multiArg.Args) != len(commandDataTypes) { handler.SendResponseError(fmt.Errorf("command %q expected %d args, got %d", cmd, len(commandDataTypes), len(multiArg.Args))) return true } for idx, commandDataType := range commandDataTypes { cmdData, err := recodeCommandData(cmd, multiArg.Args[idx], commandDataType) if err != nil { handler.SendResponseError(err) return true } callParams = append(callParams, reflect.ValueOf(cmdData)) } } if methodDecl.CommandType == wshrpc.RpcType_Call { rtnVals := implMethod.Call(callParams) rtnData, rtnErr := decodeRtnVals(rtnVals) if rtnErr != nil { handler.SendResponseError(rtnErr) return true } handler.SendResponse(rtnData, true) return true } else if methodDecl.CommandType == wshrpc.RpcType_ResponseStream { rtnVals := implMethod.Call(callParams) rtnChVal := rtnVals[0] if rtnChVal.IsNil() { handler.SendResponse(nil, true) return true } go func() { defer func() { panichandler.PanicHandler("serverImplAdapter:responseStream", recover()) }() defer handler.Finalize() // must use reflection here because we don't know the generic type of RespOrErrorUnion for { respVal, ok := rtnChVal.Recv() if !ok { break } errorVal := respVal.FieldByName("Error") if !errorVal.IsNil() { handler.SendResponseError(errorVal.Interface().(error)) break } respData := respVal.FieldByName("Response").Interface() handler.SendResponse(respData, false) } }() return false } else { handler.SendResponseError(fmt.Errorf("unsupported command type %q", methodDecl.CommandType)) return true } } } ================================================ FILE: pkg/wshutil/wshcmdreader.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshutil import ( "bytes" "fmt" "io" "sync" "github.com/wavetermdev/waveterm/pkg/baseds" ) const ( Mode_Normal = "normal" Mode_Esc = "esc" Mode_WaveEsc = "waveesc" ) const MaxBufferedDataSize = 256 * 1024 type PtyBuffer struct { CVar *sync.Cond DataBuf *bytes.Buffer EscMode string EscSeqBuf []byte OSCPrefix string InputReader io.Reader MessageCh chan baseds.RpcInputChType AtEOF bool Err error } // closes messageCh when input is closed (or error) func MakePtyBuffer(oscPrefix string, input io.Reader, messageCh chan baseds.RpcInputChType) *PtyBuffer { if len(oscPrefix) != WaveOSCPrefixLen { panic(fmt.Sprintf("invalid OSC prefix length: %d", len(oscPrefix))) } b := &PtyBuffer{ CVar: sync.NewCond(&sync.Mutex{}), DataBuf: &bytes.Buffer{}, OSCPrefix: oscPrefix, EscMode: Mode_Normal, InputReader: input, MessageCh: messageCh, } go b.run() return b } func (b *PtyBuffer) setErr(err error) { b.CVar.L.Lock() defer b.CVar.L.Unlock() if b.Err == nil { b.Err = err } b.CVar.Broadcast() } func (b *PtyBuffer) setEOF() { b.CVar.L.Lock() defer b.CVar.L.Unlock() b.AtEOF = true b.CVar.Broadcast() } func (b *PtyBuffer) processWaveEscSeq(escSeq []byte) { b.MessageCh <- baseds.RpcInputChType{MsgBytes: escSeq} } func (b *PtyBuffer) run() { defer close(b.MessageCh) buf := make([]byte, 4096) for { n, err := b.InputReader.Read(buf) b.processData(buf[:n]) if err == io.EOF { b.setEOF() return } if err != nil { b.setErr(fmt.Errorf("error reading input: %w", err)) return } } } func (b *PtyBuffer) processData(data []byte) { outputBuf := make([]byte, 0, len(data)) for _, ch := range data { if b.EscMode == Mode_WaveEsc { if ch == ESC { // terminates the escape sequence (and the rest was invalid) b.EscMode = Mode_Normal outputBuf = append(outputBuf, b.EscSeqBuf...) outputBuf = append(outputBuf, ch) b.EscSeqBuf = nil } else if ch == BEL || ch == ST { // terminates the escpae sequence (is a valid Wave OSC command) b.EscMode = Mode_Normal waveEscSeq := b.EscSeqBuf[WaveOSCPrefixLen:] b.EscSeqBuf = nil b.processWaveEscSeq(waveEscSeq) } else { b.EscSeqBuf = append(b.EscSeqBuf, ch) } continue } if b.EscMode == Mode_Esc { if ch == ESC || ch == BEL || ch == ST { // these all terminate the escape sequence (invalid, not a Wave OSC) b.EscMode = Mode_Normal outputBuf = append(outputBuf, b.EscSeqBuf...) outputBuf = append(outputBuf, ch) b.EscSeqBuf = nil continue } if ch != b.OSCPrefix[len(b.EscSeqBuf)] { // this is not a Wave OSC sequence, just an escape sequence b.EscMode = Mode_Normal outputBuf = append(outputBuf, b.EscSeqBuf...) outputBuf = append(outputBuf, ch) b.EscSeqBuf = nil continue } // we're still building what could be a Wave OSC sequence b.EscSeqBuf = append(b.EscSeqBuf, ch) // check to see if we have a full Wave OSC prefix if len(b.EscSeqBuf) == len(b.OSCPrefix) { b.EscMode = Mode_WaveEsc } continue } // Mode_Normal if ch == ESC { b.EscMode = Mode_Esc b.EscSeqBuf = []byte{ch} continue } outputBuf = append(outputBuf, ch) } if len(outputBuf) > 0 { b.writeData(outputBuf) } } func (b *PtyBuffer) writeData(data []byte) { b.CVar.L.Lock() defer b.CVar.L.Unlock() // only wait if buffer is currently over max size, otherwise allow this append to go through for b.DataBuf.Len() > MaxBufferedDataSize { b.CVar.Wait() } b.DataBuf.Write(data) b.CVar.Broadcast() } func (b *PtyBuffer) Read(p []byte) (n int, err error) { b.CVar.L.Lock() defer b.CVar.L.Unlock() for b.DataBuf.Len() == 0 { if b.Err != nil { return 0, b.Err } if b.AtEOF { return 0, io.EOF } b.CVar.Wait() } b.CVar.Broadcast() return b.DataBuf.Read(p) } ================================================ FILE: pkg/wshutil/wshevent.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshutil import ( "sync" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/wps" ) // event inverter. converts WaveEvents to a listener.On() API type singleListener struct { Id string Fn func(*wps.WaveEvent) } type EventListener struct { Lock *sync.Mutex Listeners map[string][]singleListener } func MakeEventListener() *EventListener { return &EventListener{ Lock: &sync.Mutex{}, Listeners: make(map[string][]singleListener), } } func (el *EventListener) On(eventName string, fn func(*wps.WaveEvent)) string { id := uuid.New().String() el.Lock.Lock() defer el.Lock.Unlock() larr := el.Listeners[eventName] larr = append(larr, singleListener{Id: id, Fn: fn}) el.Listeners[eventName] = larr return id } func (el *EventListener) Unregister(eventName string, id string) { el.Lock.Lock() defer el.Lock.Unlock() larr := el.Listeners[eventName] newArr := make([]singleListener, 0) for _, sl := range larr { if sl.Id == id { continue } newArr = append(newArr, sl) } el.Listeners[eventName] = newArr } func (el *EventListener) getListeners(eventName string) []singleListener { el.Lock.Lock() defer el.Lock.Unlock() return el.Listeners[eventName] } func (el *EventListener) RecvEvent(e *wps.WaveEvent) { larr := el.getListeners(e.Event) for _, sl := range larr { sl.Fn(e) } } ================================================ FILE: pkg/wshutil/wshproxy.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshutil import ( "fmt" "sync" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type WshRpcProxy struct { Lock *sync.Mutex RpcContext *wshrpc.RpcContext ToRemoteCh chan []byte FromRemoteCh chan baseds.RpcInputChType PeerInfo string } func MakeRpcProxy(peerInfo string) *WshRpcProxy { return MakeRpcProxyWithSize(peerInfo, DefaultInputChSize, DefaultOutputChSize) } func MakeRpcProxyWithSize(peerInfo string, inputChSize int, outputChSize int) *WshRpcProxy { return &WshRpcProxy{ Lock: &sync.Mutex{}, ToRemoteCh: make(chan []byte, inputChSize), FromRemoteCh: make(chan baseds.RpcInputChType, outputChSize), PeerInfo: peerInfo, } } func (p *WshRpcProxy) GetPeerInfo() string { return p.PeerInfo } func (p *WshRpcProxy) SetPeerInfo(peerInfo string) { p.Lock.Lock() defer p.Lock.Unlock() p.PeerInfo = peerInfo } func (p *WshRpcProxy) SendRpcMessage(msg []byte, ingressLinkId baseds.LinkId, debugStr string) bool { defer func() { panicCtx := "WshRpcProxy.SendRpcMessage" if debugStr != "" { panicCtx = fmt.Sprintf("%s:%s", panicCtx, debugStr) } panichandler.PanicHandler(panicCtx, recover()) }() select { case p.ToRemoteCh <- msg: return true default: return false } } func (p *WshRpcProxy) RecvRpcMessage() ([]byte, bool) { inputVal, more := <-p.FromRemoteCh return inputVal.MsgBytes, more } ================================================ FILE: pkg/wshutil/wshrouter.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshutil import ( "context" "encoding/json" "errors" "fmt" "log" "strconv" "strings" "sync" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const ( DefaultRoute = "wavesrv" ElectronRoute = "electron" ControlRoute = "$control" // control plane route ControlRootRoute = "$control:root" // control plane route to root router ControlPrefix = "$" RoutePrefix_Conn = "conn:" RoutePrefix_Controller = "controller:" RoutePrefix_Proc = "proc:" RoutePrefix_Tab = "tab:" RoutePrefix_FeBlock = "feblock:" RoutePrefix_Builder = "builder:" RoutePrefix_Link = "link:" RoutePrefix_Job = "job:" RoutePrefix_Bare = "bare:" ) const RouterInputChQueueSize = 100 var BacklogLogThresholds = map[int]bool{1: true, 5: true, 10: true, 20: true, 30: true, 40: true, 50: true, 100: true, 200: true, 500: true, 1000: true} // this works like a network switch // TODO maybe move the wps integration here instead of in wshserver type routeInfo struct { RpcId string SourceRouteId string DestRouteId string } const LinkKind_Leaf = "leaf" const LinkKind_Router = "router" type linkMeta struct { linkId baseds.LinkId trusted bool linkKind string sourceRouteId string client AbstractRpcClient } func (lm *linkMeta) Name() string { return fmt.Sprintf("%d#[%s]", lm.linkId, lm.client.GetPeerInfo()) } type rpcRoutingInfo struct { rpcId string sourceLinkId baseds.LinkId destRouteId string } type messageWrap struct { msgBytes []byte debugStr string } type backlogMessageWrap struct { msgBytes []byte ingressLinkId baseds.LinkId debugStr string } type WshRouter struct { lock *sync.Mutex isRootRouter bool nextLinkId baseds.LinkId upstreamLinkId baseds.LinkId inputCh chan baseds.RpcInputChType rpcMap map[string]rpcRoutingInfo // rpcid => routeinfo routeMap map[string]baseds.LinkId // routeid => linkid linkMap map[baseds.LinkId]*linkMeta upstreamBufLock sync.Mutex upstreamBufCond *sync.Cond upstreamBuf []messageWrap upstreamLoopStarted bool linkBacklogCond *sync.Cond linkMsgBacklog map[baseds.LinkId][]backlogMessageWrap backlogHighWaterMark map[baseds.LinkId]int controlRpc *WshRpc } func MakeConnectionRouteId(connId string) string { return "conn:" + connId } func MakeControllerRouteId(blockId string) string { return "controller:" + blockId } func MakeProcRouteId(procId string) string { return "proc:" + procId } func MakeRandomProcRouteId() string { return MakeProcRouteId(uuid.New().String()) } func MakeTabRouteId(tabId string) string { return "tab:" + tabId } func MakeFeBlockRouteId(blockId string) string { return "feblock:" + blockId } func MakeBuilderRouteId(builderId string) string { return "builder:" + builderId } func MakeJobRouteId(jobId string) string { return "job:" + jobId } func MakeLinkRouteId(linkId baseds.LinkId) string { return fmt.Sprintf("%s%d", RoutePrefix_Link, linkId) } var DefaultRouter *WshRouter func NewWshRouter() *WshRouter { rtn := &WshRouter{ lock: &sync.Mutex{}, nextLinkId: 0, upstreamLinkId: baseds.NoLinkId, inputCh: make(chan baseds.RpcInputChType, RouterInputChQueueSize), rpcMap: make(map[string]rpcRoutingInfo), linkMap: make(map[baseds.LinkId]*linkMeta), routeMap: make(map[string]baseds.LinkId), linkMsgBacklog: make(map[baseds.LinkId][]backlogMessageWrap), backlogHighWaterMark: make(map[baseds.LinkId]int), } rtn.upstreamBufCond = sync.NewCond(&rtn.upstreamBufLock) rtn.linkBacklogCond = sync.NewCond(rtn.lock) rtn.registerControlPlane() go rtn.runServer() go rtn.processBacklog() return rtn } func (router *WshRouter) IsRootRouter() bool { router.lock.Lock() defer router.lock.Unlock() return router.isRootRouter } func (router *WshRouter) GetControlRpc() *WshRpc { return router.controlRpc } func (router *WshRouter) SetAsRootRouter() { router.lock.Lock() defer router.lock.Unlock() router.isRootRouter = true // also bind $control:root to the control RPC linkId := router.routeMap[ControlRoute] if linkId != baseds.NoLinkId { router.routeMap[ControlRootRoute] = linkId log.Printf("wshrouter registered control:root route linkid=%d", linkId) } } func noRouteErr(routeId string) error { if routeId == "" { return errors.New("no default route") } return fmt.Errorf("no route for %q", routeId) } func (router *WshRouter) SendEvent(routeId string, event wps.WaveEvent) { defer func() { panichandler.PanicHandler("WshRouter.SendEvent", recover()) }() lm := router.getLinkForRoute(routeId) if lm == nil { return } msg := RpcMessage{ Command: wshrpc.Command_EventRecv, Route: routeId, Data: event, } msgBytes, err := json.Marshal(msg) if err != nil { // nothing to do return } router.sendRpcMessageToLink(lm.linkId, lm.client, msgBytes, baseds.NoLinkId, "eventrecv") } func (router *WshRouter) handleNoRoute(msg RpcMessage, ingressLinkId baseds.LinkId) { lm := router.getLinkMeta(ingressLinkId) if lm == nil { return } nrErr := noRouteErr(msg.Route) if msg.ReqId == "" { if msg.Command == wshrpc.Command_Message { // to prevent infinite loops return } // no response needed, but send message back to source respMsg := RpcMessage{ Command: wshrpc.Command_Message, Route: msg.Source, Source: ControlRoute, Data: wshrpc.CommandMessageData{Message: nrErr.Error()}, } respBytes, _ := json.Marshal(respMsg) router.sendRpcMessageToLink(lm.linkId, lm.client, respBytes, baseds.NoLinkId, "no-route-err") return } // send error response response := RpcMessage{ ResId: msg.ReqId, Error: nrErr.Error(), } respBytes, _ := json.Marshal(response) router.sendRoutedMessage(respBytes, msg.Source, msg.Command, baseds.NoLinkId) } func (router *WshRouter) registerRouteInfo(rpcId string, sourceLinkId baseds.LinkId, destRouteId string) { if rpcId == "" { return } router.lock.Lock() defer router.lock.Unlock() router.rpcMap[rpcId] = rpcRoutingInfo{ rpcId: rpcId, sourceLinkId: sourceLinkId, destRouteId: destRouteId, } } func (router *WshRouter) unregisterRouteInfo(rpcId string) { router.lock.Lock() defer router.lock.Unlock() delete(router.rpcMap, rpcId) } func (router *WshRouter) getRouteInfo(rpcId string) *rpcRoutingInfo { router.lock.Lock() defer router.lock.Unlock() rtn, ok := router.rpcMap[rpcId] if !ok { return nil } return &rtn } // returns true if message was sent, false if failed func (router *WshRouter) sendRoutedMessage(msgBytes []byte, routeId string, commandName string, ingressLinkId baseds.LinkId) bool { if strings.HasPrefix(routeId, RoutePrefix_Link) { linkIdStr := strings.TrimPrefix(routeId, RoutePrefix_Link) linkIdInt, err := strconv.ParseInt(linkIdStr, 10, 32) if err == nil { return router.sendMessageToLink(msgBytes, baseds.LinkId(linkIdInt), ingressLinkId) } } lm := router.getLinkForRoute(routeId) if lm != nil { router.sendRpcMessageToLink(lm.linkId, lm.client, msgBytes, ingressLinkId, "route") return true } upstreamLinkId, upstream := router.getUpstreamClient() if upstream != nil { router.sendRpcMessageToLink(upstreamLinkId, upstream, msgBytes, ingressLinkId, "route-upstream") return true } if commandName != "" { log.Printf("[router] no rpc for route id %q command:%s\n", routeId, commandName) } else { log.Printf("[router] no rpc for route id %q\n", routeId) } return false } func (router *WshRouter) sendMessageToLink(msgBytes []byte, linkId baseds.LinkId, ingressLinkId baseds.LinkId) bool { lm := router.getLinkMeta(linkId) if lm == nil { return false } router.sendRpcMessageToLink(lm.linkId, lm.client, msgBytes, ingressLinkId, "link") return true } func (router *WshRouter) addToBacklog_withlock(linkId baseds.LinkId, msgBytes []byte, ingressLinkId baseds.LinkId, debugStr string) { mapWasEmpty := len(router.linkMsgBacklog) == 0 backlog := router.linkMsgBacklog[linkId] backlog = append(backlog, backlogMessageWrap{msgBytes: msgBytes, ingressLinkId: ingressLinkId, debugStr: debugStr}) router.linkMsgBacklog[linkId] = backlog newLen := len(backlog) highWater := router.backlogHighWaterMark[linkId] if BacklogLogThresholds[newLen] && highWater < newLen { log.Printf("[router] backlog for linkid=%d reached %d messages\n", linkId, newLen) } if newLen > highWater { router.backlogHighWaterMark[linkId] = newLen } if mapWasEmpty { router.linkBacklogCond.Signal() } } func (router *WshRouter) sendRpcMessageToLink(linkId baseds.LinkId, client AbstractRpcClient, msgBytes []byte, ingressLinkId baseds.LinkId, debugStr string) { router.lock.Lock() defer router.lock.Unlock() sent := false backlog := router.linkMsgBacklog[linkId] if len(backlog) == 0 { sent = client.SendRpcMessage(msgBytes, ingressLinkId, debugStr) } if !sent { router.addToBacklog_withlock(linkId, msgBytes, ingressLinkId, debugStr) } } func (router *WshRouter) runServer() { for input := range router.inputCh { msgBytes := input.MsgBytes var msg RpcMessage err := json.Unmarshal(msgBytes, &msg) if err != nil { fmt.Println("error unmarshalling message: ", err) continue } routeId := msg.Route if msg.Command != "" { // new comand, setup new rpc ok := router.sendRoutedMessage(msgBytes, routeId, msg.Command, input.IngressLinkId) if !ok { router.handleNoRoute(msg, input.IngressLinkId) continue } router.registerRouteInfo(msg.ReqId, input.IngressLinkId, routeId) continue } // look at reqid or resid to route correctly if msg.ReqId != "" { routeInfo := router.getRouteInfo(msg.ReqId) if routeInfo == nil { // no route info, nothing to do continue } // no need to check the return value here (noop if failed) router.sendRoutedMessage(msgBytes, routeInfo.destRouteId, "", input.IngressLinkId) continue } else if msg.ResId != "" { routeInfo := router.getRouteInfo(msg.ResId) if routeInfo == nil { // no route info, nothing to do continue } router.sendMessageToLink(msgBytes, routeInfo.sourceLinkId, input.IngressLinkId) if !msg.Cont { router.unregisterRouteInfo(msg.ResId) } continue } else { // this is a bad message (no command, reqid, or resid) continue } } } func (router *WshRouter) WaitForRegister(ctx context.Context, routeId string) error { for { if router.getLinkForRoute(routeId) != nil { return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(30 * time.Millisecond): continue } } } // this will never block, can be called while holding router.Lock func (router *WshRouter) queueUpstreamMessage(msgBytes []byte, debugStr string) { _, upstream := router.getUpstreamClient() if upstream == nil { return } router.upstreamBufLock.Lock() defer router.upstreamBufLock.Unlock() router.upstreamBuf = append(router.upstreamBuf, messageWrap{msgBytes: msgBytes, debugStr: debugStr}) if !router.upstreamLoopStarted { router.upstreamLoopStarted = true go router.runUpstreamBufferLoop() } router.upstreamBufCond.Signal() } func (router *WshRouter) runUpstreamBufferLoop() { defer func() { panichandler.PanicHandler("WshRouter:runUpstreamBufferLoop", recover()) }() for { router.upstreamBufLock.Lock() for len(router.upstreamBuf) == 0 { router.upstreamBufCond.Wait() } msg := router.upstreamBuf[0] router.upstreamBuf = router.upstreamBuf[1:] router.upstreamBufLock.Unlock() upstreamLinkId, upstream := router.getUpstreamClient() if upstream != nil { router.sendRpcMessageToLink(upstreamLinkId, upstream, msg.msgBytes, baseds.NoLinkId, msg.debugStr) } } } func (router *WshRouter) drainLinkBacklog_withLock(linkId baseds.LinkId, lm *linkMeta, backlog []backlogMessageWrap) []backlogMessageWrap { for len(backlog) > 0 { msg := backlog[0] sent := lm.client.SendRpcMessage(msg.msgBytes, msg.ingressLinkId, msg.debugStr) if !sent { return backlog } backlog = backlog[1:] } return backlog } func (router *WshRouter) processOneBacklogRound() { router.lock.Lock() defer router.lock.Unlock() for linkId, backlog := range router.linkMsgBacklog { lm := router.linkMap[linkId] if lm == nil { highWater := router.backlogHighWaterMark[linkId] if highWater > 0 { log.Printf("[router] backlog for linkid=%d cleared, link gone (highwater mark was %d messages)\n", linkId, highWater) } delete(router.linkMsgBacklog, linkId) delete(router.backlogHighWaterMark, linkId) continue } newBacklog := router.drainLinkBacklog_withLock(linkId, lm, backlog) if len(newBacklog) == 0 { highWater := router.backlogHighWaterMark[linkId] if highWater > 0 { log.Printf("[router] backlog for linkid=%d cleared (highwater mark was %d messages)\n", linkId, highWater) } delete(router.linkMsgBacklog, linkId) delete(router.backlogHighWaterMark, linkId) continue } router.linkMsgBacklog[linkId] = newBacklog } } func (router *WshRouter) processBacklog() { defer func() { panichandler.PanicHandler("WshRouter:processBacklog", recover()) }() for { router.lock.Lock() for len(router.linkMsgBacklog) == 0 { router.linkBacklogCond.Wait() } router.lock.Unlock() router.processOneBacklogRound() time.Sleep(50 * time.Millisecond) } } func (router *WshRouter) RegisterUntrustedLink(client AbstractRpcClient) baseds.LinkId { router.lock.Lock() defer router.lock.Unlock() router.nextLinkId++ linkId := router.nextLinkId lm := &linkMeta{ linkId: linkId, trusted: false, client: client, } log.Printf("wshrouter register link %s", lm.Name()) router.linkMap[linkId] = lm go router.runLinkClientRecvLoop(linkId, client) return linkId } func (router *WshRouter) trustLink(linkId baseds.LinkId, linkKind string) { router.lock.Lock() defer router.lock.Unlock() lm := router.linkMap[linkId] if lm == nil { return } log.Printf("wshrouter trust link %s kind=%s", lm.Name(), linkKind) lm.trusted = true lm.linkKind = linkKind } func (router *WshRouter) runLinkClientRecvLoop(linkId baseds.LinkId, client AbstractRpcClient) { defer func() { panichandler.PanicHandler("WshRouter:runLinkClientRecvLoop", recover()) }() exitReason := "unknown" lmForLog := router.getLinkMeta(linkId) linkName := fmt.Sprintf("%d", linkId) if lmForLog != nil { linkName = lmForLog.Name() } log.Printf("link recvloop start for %s", linkName) defer log.Printf("link recvloop done for %s (%s)", linkName, exitReason) for { msgBytes, ok := client.RecvRpcMessage() if !ok { exitReason = "recv-eof" break } var rpcMsg RpcMessage err := json.Unmarshal(msgBytes, &rpcMsg) if err != nil { continue } lm := router.getLinkMeta(linkId) if lm == nil { exitReason = "link-gone" break } if rpcMsg.IsRpcRequest() { if lm.sourceRouteId != "" { rpcMsg.Source = lm.sourceRouteId } if rpcMsg.Route == "" { rpcMsg.Route = DefaultRoute } msgBytes, err = json.Marshal(rpcMsg) if err != nil { continue } // allow control routes even for untrusted links (for authentication) isControlRoute := rpcMsg.Route == ControlRoute || rpcMsg.Route == ControlRootRoute if !lm.trusted { if !isControlRoute { sendControlUnauthenticatedErrorResponse(rpcMsg, *lm, router) continue } log.Printf("wshrouter control-msg route=%s link=%s command=%s source=%s", rpcMsg.Route, lm.Name(), rpcMsg.Command, rpcMsg.Source) } } else { // non-request messages (responses) if !lm.trusted { // allow responses to RPCs we initiated if rpcMsg.ResId == "" || router.getRouteInfo(rpcMsg.ResId) == nil { continue } } } router.inputCh <- baseds.RpcInputChType{MsgBytes: msgBytes, IngressLinkId: linkId} } } // synchronized, returns a copy func (router *WshRouter) getLinkMeta(linkId baseds.LinkId) *linkMeta { if linkId == baseds.NoLinkId { return nil } router.lock.Lock() defer router.lock.Unlock() lm := router.linkMap[linkId] if lm == nil { return nil } lmCopy := *lm return &lmCopy } // synchronized, returns a copy func (router *WshRouter) getLinkForRoute(routeId string) *linkMeta { if routeId == "" { return nil } router.lock.Lock() defer router.lock.Unlock() linkId := router.routeMap[routeId] if linkId == baseds.NoLinkId { return nil } lm := router.linkMap[linkId] if lm == nil { return nil } lmCopy := *lm return &lmCopy } func (router *WshRouter) GetLinkIdForRoute(routeId string) baseds.LinkId { lm := router.getLinkForRoute(routeId) if lm == nil { return baseds.NoLinkId } return lm.linkId } // only for leaves func (router *WshRouter) RegisterTrustedLeaf(rpc AbstractRpcClient, routeId string) (baseds.LinkId, error) { if !isBindableRouteId(routeId) { return 0, fmt.Errorf("invalid routeid %q", routeId) } linkId := router.RegisterUntrustedLink(rpc) router.trustLink(linkId, LinkKind_Leaf) router.bindRoute(linkId, routeId, true) return linkId, nil } // only for routers func (router *WshRouter) RegisterTrustedRouter(rpc AbstractRpcClient) baseds.LinkId { linkId := router.RegisterUntrustedLink(rpc) router.trustLink(linkId, LinkKind_Router) return linkId } func (router *WshRouter) RegisterUpstream(rpc AbstractRpcClient) baseds.LinkId { if router.IsRootRouter() { panic("cannot register upstream for root router") } linkId := router.RegisterUntrustedLink(rpc) router.trustLink(linkId, LinkKind_Router) router.lock.Lock() defer router.lock.Unlock() router.upstreamLinkId = linkId return linkId } func (router *WshRouter) registerControlPlane() { controlImpl := &WshRouterControlImpl{Router: router} controlRpcCtx := wshrpc.RpcContext{RouteId: ControlRoute} router.controlRpc = MakeWshRpc(controlRpcCtx, controlImpl, "control") linkId := router.RegisterUntrustedLink(router.controlRpc) router.trustLink(linkId, LinkKind_Leaf) router.lock.Lock() defer router.lock.Unlock() lm := router.linkMap[linkId] if lm != nil { lm.sourceRouteId = ControlRoute router.routeMap[ControlRoute] = linkId log.Printf("wshrouter registered control route %q linkid=%d", ControlRoute, linkId) } } func (router *WshRouter) announceUpstream(routeId string) { msg := RpcMessage{ Command: wshrpc.Command_RouteAnnounce, Route: ControlRoute, Source: routeId, } msgBytes, _ := json.Marshal(msg) router.queueUpstreamMessage(msgBytes, "upstream-announce") } func (router *WshRouter) unannounceUpstream(routeId string) { msg := RpcMessage{ Command: wshrpc.Command_RouteUnannounce, Route: ControlRoute, Source: routeId, } msgBytes, _ := json.Marshal(msg) router.queueUpstreamMessage(msgBytes, "upstream-unannounce") } func (router *WshRouter) getRoutesForLink(linkId baseds.LinkId) []string { router.lock.Lock() defer router.lock.Unlock() var routes []string for routeId, mappedLinkId := range router.routeMap { if mappedLinkId == linkId { routes = append(routes, routeId) } } return routes } func (router *WshRouter) UnregisterLink(linkId baseds.LinkId) { routes := router.getRoutesForLink(linkId) for _, routeId := range routes { router.unbindRoute(linkId, routeId) } router.lock.Lock() defer router.lock.Unlock() lm := router.linkMap[linkId] if lm != nil { log.Printf("wshrouter unregister link %s", lm.Name()) } delete(router.linkMap, linkId) if router.upstreamLinkId == linkId { router.upstreamLinkId = baseds.NoLinkId } } func isBindableRouteId(routeId string) bool { if routeId == "" || strings.HasPrefix(routeId, ControlPrefix) || strings.HasPrefix(routeId, RoutePrefix_Link) { return false } return true } func (router *WshRouter) unbindRouteLocally(linkId baseds.LinkId, routeId string) error { if linkId == baseds.NoLinkId { return fmt.Errorf("cannot unbind %q to NoLinkId", routeId) } router.lock.Lock() defer router.lock.Unlock() if router.routeMap[routeId] == linkId { delete(router.routeMap, routeId) } return nil } func (router *WshRouter) unbindRoute(linkId baseds.LinkId, routeId string) error { err := router.unbindRouteLocally(linkId, routeId) if err != nil { return err } lm := router.getLinkMeta(linkId) if lm != nil { log.Printf("wshrouter unbind route %q from %s", routeId, lm.Name()) } router.unannounceUpstream(routeId) if router.IsRootRouter() { router.unsubscribeFromBroker(routeId) } return nil } func (router *WshRouter) bindRouteLocally(linkId baseds.LinkId, routeId string, isSourceRoute bool) error { if linkId == baseds.NoLinkId { return fmt.Errorf("cannot bindroute %q to NoLinkId", routeId) } if !isBindableRouteId(routeId) { return fmt.Errorf("router cannot register %q route (invalid routeid)", routeId) } router.lock.Lock() defer router.lock.Unlock() lm := router.linkMap[linkId] if lm == nil { return fmt.Errorf("cannot bind route %q, no link with id %d found", routeId, linkId) } if !lm.trusted { return fmt.Errorf("cannot bind route %q, link %d is not trusted", routeId, linkId) } if isSourceRoute { if lm.linkKind != LinkKind_Leaf { return fmt.Errorf("cannot bind source route %q to link %d (link is not a leaf)", routeId, linkId) } if lm.sourceRouteId != "" && lm.sourceRouteId != routeId { return fmt.Errorf("cannot bind source route %q to link %d (link already has source route %q)", routeId, linkId, lm.sourceRouteId) } lm.sourceRouteId = routeId } else { if lm.linkKind != LinkKind_Router { return fmt.Errorf("cannot bind route %q to link %d (link is not a router)", routeId, linkId) } } router.routeMap[routeId] = linkId return nil } func (router *WshRouter) bindRoute(linkId baseds.LinkId, routeId string, isSourceRoute bool) error { err := router.bindRouteLocally(linkId, routeId, isSourceRoute) if err != nil { return err } lm := router.getLinkMeta(linkId) if lm != nil { log.Printf("wshrouter bind route %q to %s", routeId, lm.Name()) } // don't announce control routes upstream (they are local only) if !strings.HasPrefix(routeId, ControlPrefix) { router.announceUpstream(routeId) } if router.IsRootRouter() { router.publishRouteToBroker(routeId) } return nil } func (router *WshRouter) getUpstreamClient() (baseds.LinkId, AbstractRpcClient) { router.lock.Lock() defer router.lock.Unlock() if router.upstreamLinkId == baseds.NoLinkId { return baseds.NoLinkId, nil } lm := router.linkMap[router.upstreamLinkId] if lm == nil { return baseds.NoLinkId, nil } return router.upstreamLinkId, lm.client } func (router *WshRouter) publishRouteToBroker(routeId string) { defer func() { panichandler.PanicHandler("WshRouter:publishRouteToBroker", recover()) }() wps.Broker.Publish(wps.WaveEvent{Event: wps.Event_RouteUp, Scopes: []string{routeId}}) } func (router *WshRouter) unsubscribeFromBroker(routeId string) { defer func() { panichandler.PanicHandler("WshRouter:unregisterRoute:routedown", recover()) }() wps.Broker.UnsubscribeAll(routeId) wps.Broker.Publish(wps.WaveEvent{Event: wps.Event_RouteDown, Scopes: []string{routeId}}) } func sendControlUnauthenticatedErrorResponse(cmdMsg RpcMessage, linkMeta linkMeta, router *WshRouter) { if cmdMsg.ReqId == "" { return } rtnMsg := RpcMessage{ Source: ControlRoute, ResId: cmdMsg.ReqId, Error: fmt.Sprintf("link is unauthenticated (%s), cannot call %q", linkMeta.Name(), cmdMsg.Command), } rtnBytes, _ := json.Marshal(rtnMsg) router.sendRpcMessageToLink(linkMeta.linkId, linkMeta.client, rtnBytes, baseds.NoLinkId, "unauthenticated") } ================================================ FILE: pkg/wshutil/wshrouter_controlimpl.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshutil import ( "context" "fmt" "log" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" ) type WshRouterControlImpl struct { Router *WshRouter } func (impl *WshRouterControlImpl) WshServerImpl() {} func (impl *WshRouterControlImpl) RouteAnnounceCommand(ctx context.Context) error { source := GetRpcSourceFromContext(ctx) if source == "" { return fmt.Errorf("no source in routeannounce") } handler := GetRpcResponseHandlerFromContext(ctx) if handler == nil { return fmt.Errorf("no response handler in context") } linkId := handler.GetIngressLinkId() if linkId == baseds.NoLinkId { return fmt.Errorf("no ingress link found") } return impl.Router.bindRoute(linkId, source, false) } func (impl *WshRouterControlImpl) RouteUnannounceCommand(ctx context.Context) error { source := GetRpcSourceFromContext(ctx) if source == "" { return fmt.Errorf("no source in routeunannounce") } handler := GetRpcResponseHandlerFromContext(ctx) if handler == nil { return fmt.Errorf("no response handler in context") } linkId := handler.GetIngressLinkId() if linkId == baseds.NoLinkId { return fmt.Errorf("no ingress link found") } return impl.Router.unbindRoute(linkId, source) } func (impl *WshRouterControlImpl) ControlGetRouteIdCommand(ctx context.Context) (string, error) { handler := GetRpcResponseHandlerFromContext(ctx) if handler == nil { return "", nil } linkId := handler.GetIngressLinkId() if linkId == baseds.NoLinkId { return "", nil } lm := impl.Router.getLinkMeta(linkId) if lm == nil { return "", nil } return lm.sourceRouteId, nil } func (impl *WshRouterControlImpl) SetPeerInfoCommand(ctx context.Context, peerInfo string) error { source := GetRpcSourceFromContext(ctx) linkId := impl.Router.GetLinkIdForRoute(source) if linkId == baseds.NoLinkId { return fmt.Errorf("no link found for source route %q", source) } lm := impl.Router.getLinkMeta(linkId) if lm == nil { return fmt.Errorf("no link meta found for linkId %d", linkId) } if proxy, ok := lm.client.(*WshRpcProxy); ok { proxy.SetPeerInfo(peerInfo) return nil } return fmt.Errorf("setpeerinfo only valid for proxy connections") } func (impl *WshRouterControlImpl) AuthenticateCommand(ctx context.Context, data string) (wshrpc.CommandAuthenticateRtnData, error) { handler := GetRpcResponseHandlerFromContext(ctx) if handler == nil { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no response handler in context") } linkId := handler.GetIngressLinkId() if linkId == baseds.NoLinkId { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no ingress link found") } newCtx, err := ValidateAndExtractRpcContextFromToken(data) if err != nil { log.Printf("wshrouter authenticate error linkid=%d: %v", linkId, err) return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("error validating token: %w", err) } routeId, err := validateRpcContextFromAuth(newCtx) if err != nil { return wshrpc.CommandAuthenticateRtnData{}, err } rtnData := wshrpc.CommandAuthenticateRtnData{RouteId: routeId} if newCtx.IsRouter { log.Printf("wshrouter authenticate success linkid=%d (router)", linkId) impl.Router.trustLink(linkId, LinkKind_Router) } else { log.Printf("wshrouter authenticate success linkid=%d routeid=%q", linkId, routeId) impl.Router.trustLink(linkId, LinkKind_Leaf) impl.Router.bindRoute(linkId, routeId, true) } return rtnData, nil } func extractTokenData(token string) (wshrpc.CommandAuthenticateRtnData, error) { entry := shellutil.GetAndRemoveTokenSwapEntry(token) if entry == nil { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no token entry found") } _, err := validateRpcContextFromAuth(entry.RpcContext) if err != nil { return wshrpc.CommandAuthenticateRtnData{}, err } if entry.RpcContext.IsRouter { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("cannot auth router via token") } routeId := entry.RpcContext.GenerateRouteId() if routeId == "" { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no routeid") } return wshrpc.CommandAuthenticateRtnData{ RouteId: routeId, Env: entry.Env, InitScriptText: entry.ScriptText, RpcContext: entry.RpcContext, }, nil } func (impl *WshRouterControlImpl) AuthenticateTokenVerifyCommand(ctx context.Context, data wshrpc.CommandAuthenticateTokenData) (wshrpc.CommandAuthenticateRtnData, error) { if !impl.Router.IsRootRouter() { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("authenticatetokenverify can only be called on root router") } if data.Token == "" { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no token in authenticatetoken message") } rtnData, err := extractTokenData(data.Token) if err != nil { log.Printf("wshrouter authenticate-token-verify error: %v", err) return wshrpc.CommandAuthenticateRtnData{}, err } log.Printf("wshrouter authenticate-token-verify success routeid=%q", rtnData.RouteId) return rtnData, nil } func (impl *WshRouterControlImpl) AuthenticateTokenCommand(ctx context.Context, data wshrpc.CommandAuthenticateTokenData) (wshrpc.CommandAuthenticateRtnData, error) { handler := GetRpcResponseHandlerFromContext(ctx) if handler == nil { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no response handler in context") } linkId := handler.GetIngressLinkId() if linkId == baseds.NoLinkId { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no ingress link found") } if data.Token == "" { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no token in authenticatetoken message") } var rtnData wshrpc.CommandAuthenticateRtnData var err error if impl.Router.IsRootRouter() { rtnData, err = extractTokenData(data.Token) if err != nil { log.Printf("wshrouter authenticate-token error linkid=%d: %v", linkId, err) return wshrpc.CommandAuthenticateRtnData{}, err } } else { wshRpc := GetWshRpcFromContext(ctx) if wshRpc == nil { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no wshrpc in context") } respData, err := wshRpc.SendRpcRequest(wshrpc.Command_AuthenticateTokenVerify, data, &wshrpc.RpcOpts{Route: ControlRootRoute}) if err != nil { log.Printf("wshrouter authenticate-token error linkid=%d: failed to verify token: %v", linkId, err) return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("failed to verify token: %w", err) } err = utilfn.ReUnmarshal(&rtnData, respData) if err != nil { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("failed to unmarshal response: %w", err) } } if rtnData.RpcContext == nil { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no rpccontext in token response") } if rtnData.RouteId == "" { return wshrpc.CommandAuthenticateRtnData{}, fmt.Errorf("no routeid in token response") } log.Printf("wshrouter authenticate-token success linkid=%d routeid=%q", linkId, rtnData.RouteId) impl.Router.trustLink(linkId, LinkKind_Leaf) impl.Router.bindRoute(linkId, rtnData.RouteId, true) return rtnData, nil } func (impl *WshRouterControlImpl) AuthenticateJobManagerVerifyCommand(ctx context.Context, data wshrpc.CommandAuthenticateJobManagerData) error { if !impl.Router.IsRootRouter() { return fmt.Errorf("authenticatejobmanagerverify can only be called on root router") } if data.JobId == "" { return fmt.Errorf("no jobid in authenticatejobmanager message") } if data.JobAuthToken == "" { return fmt.Errorf("no jobauthtoken in authenticatejobmanager message") } job, err := wstore.DBMustGet[*waveobj.Job](ctx, data.JobId) if err != nil { log.Printf("wshrouter authenticate-jobmanager-verify error jobid=%q: failed to get job: %v", data.JobId, err) return fmt.Errorf("failed to get job: %w", err) } if job.JobAuthToken != data.JobAuthToken { log.Printf("wshrouter authenticate-jobmanager-verify error jobid=%q: invalid jobauthtoken", data.JobId) return fmt.Errorf("invalid jobauthtoken") } log.Printf("wshrouter authenticate-jobmanager-verify success jobid=%q", data.JobId) return nil } func (impl *WshRouterControlImpl) AuthenticateJobManagerCommand(ctx context.Context, data wshrpc.CommandAuthenticateJobManagerData) error { handler := GetRpcResponseHandlerFromContext(ctx) if handler == nil { return fmt.Errorf("no response handler in context") } linkId := handler.GetIngressLinkId() if linkId == baseds.NoLinkId { return fmt.Errorf("no ingress link found") } if data.JobId == "" { return fmt.Errorf("no jobid in authenticatejobmanager message") } if data.JobAuthToken == "" { return fmt.Errorf("no jobauthtoken in authenticatejobmanager message") } if impl.Router.IsRootRouter() { job, err := wstore.DBMustGet[*waveobj.Job](ctx, data.JobId) if err != nil { log.Printf("wshrouter authenticate-jobmanager error linkid=%d jobid=%q: failed to get job: %v", linkId, data.JobId, err) return fmt.Errorf("failed to get job: %w", err) } if job.JobAuthToken != data.JobAuthToken { log.Printf("wshrouter authenticate-jobmanager error linkid=%d jobid=%q: invalid jobauthtoken", linkId, data.JobId) return fmt.Errorf("invalid jobauthtoken") } } else { wshRpc := GetWshRpcFromContext(ctx) if wshRpc == nil { return fmt.Errorf("no wshrpc in context") } _, err := wshRpc.SendRpcRequest(wshrpc.Command_AuthenticateJobManagerVerify, data, &wshrpc.RpcOpts{Route: ControlRootRoute}) if err != nil { log.Printf("wshrouter authenticate-jobmanager error linkid=%d jobid=%q: failed to verify job auth token: %v", linkId, data.JobId, err) return fmt.Errorf("failed to verify job auth token: %w", err) } } routeId := MakeJobRouteId(data.JobId) log.Printf("wshrouter authenticate-jobmanager success linkid=%d jobid=%q routeid=%q", linkId, data.JobId, routeId) impl.Router.trustLink(linkId, LinkKind_Leaf) impl.Router.bindRoute(linkId, routeId, true) return nil } func validateRpcContextFromAuth(newCtx *wshrpc.RpcContext) (string, error) { if newCtx == nil { return "", fmt.Errorf("no context found in jwt token") } if newCtx.IsRouter && newCtx.RouteId != "" { return "", fmt.Errorf("invalid context, router cannot have a routeid") } if newCtx.IsRouter && newCtx.ProcRoute { return "", fmt.Errorf("invalid context, router cannot have a proc-route") } if !newCtx.IsRouter && newCtx.RouteId == "" && !newCtx.ProcRoute { return "", fmt.Errorf("invalid context, must have a routeid") } if newCtx.IsRouter { return "", nil } return newCtx.GenerateRouteId(), nil } ================================================ FILE: pkg/wshutil/wshrpc.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshutil import ( "context" "encoding/json" "errors" "fmt" "log" "reflect" "runtime/pprof" "sync" "sync/atomic" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/streamclient" "github.com/wavetermdev/waveterm/pkg/util/ds" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const DefaultTimeoutMs = 5000 const RespChSize = 32 const DefaultMessageChSize = 32 const CtxDoneChSize = 10 var blockingExpMap = ds.MakeExpMap[bool]() type ResponseFnType = func(any) error // returns true if handler is complete, false for an async handler type CommandHandlerFnType = func(*RpcResponseHandler) bool type ServerImpl interface { WshServerImpl() } type AbstractRpcClient interface { GetPeerInfo() string SendRpcMessage(msg []byte, ingressLinkId baseds.LinkId, debugStr string) bool RecvRpcMessage() ([]byte, bool) // blocking } type WshRpc struct { Lock *sync.Mutex InputCh chan baseds.RpcInputChType OutputCh chan []byte CtxDoneCh chan string // for context cancellation, value is ResId RpcContext *atomic.Pointer[wshrpc.RpcContext] RpcMap map[string]*rpcData ServerImpl ServerImpl EventListener *EventListener ResponseHandlerMap map[string]*RpcResponseHandler // reqId => handler StreamBroker *streamclient.Broker Debug bool DebugName string ServerDone bool } type wshRpcContextKey struct{} type wshRpcRespHandlerContextKey struct{} func withWshRpcContext(ctx context.Context, wshRpc *WshRpc) context.Context { return context.WithValue(ctx, wshRpcContextKey{}, wshRpc) } func withRespHandler(ctx context.Context, handler *RpcResponseHandler) context.Context { return context.WithValue(ctx, wshRpcRespHandlerContextKey{}, handler) } func GetWshRpcFromContext(ctx context.Context) *WshRpc { rtn := ctx.Value(wshRpcContextKey{}) if rtn == nil { return nil } return rtn.(*WshRpc) } func GetRpcSourceFromContext(ctx context.Context) string { rtn := ctx.Value(wshRpcRespHandlerContextKey{}) if rtn == nil { return "" } return rtn.(*RpcResponseHandler).GetSource() } func GetIsCanceledFromContext(ctx context.Context) bool { rtn := ctx.Value(wshRpcRespHandlerContextKey{}) if rtn == nil { return false } return rtn.(*RpcResponseHandler).IsCanceled() } func GetRpcResponseHandlerFromContext(ctx context.Context) *RpcResponseHandler { rtn := ctx.Value(wshRpcRespHandlerContextKey{}) if rtn == nil { return nil } return rtn.(*RpcResponseHandler) } func (w *WshRpc) GetPeerInfo() string { return w.DebugName } func (w *WshRpc) SendRpcMessage(msg []byte, ingressLinkId baseds.LinkId, debugStr string) bool { select { case w.InputCh <- baseds.RpcInputChType{MsgBytes: msg, IngressLinkId: ingressLinkId}: return true default: return false } } func (w *WshRpc) RecvRpcMessage() ([]byte, bool) { msg, more := <-w.OutputCh return msg, more } type RpcMessage struct { Command string `json:"command,omitempty"` ReqId string `json:"reqid,omitempty"` ResId string `json:"resid,omitempty"` Timeout int64 `json:"timeout,omitempty"` Route string `json:"route,omitempty"` // to route/forward requests to alternate servers Source string `json:"source,omitempty"` // source route id Cont bool `json:"cont,omitempty"` // flag if additional requests/responses are forthcoming Cancel bool `json:"cancel,omitempty"` // used to cancel a streaming request or response (sent from the side that is not streaming) Error string `json:"error,omitempty"` DataType string `json:"datatype,omitempty"` Data any `json:"data,omitempty"` } func (r *RpcMessage) IsRpcRequest() bool { return r.Command != "" || r.ReqId != "" } func (r *RpcMessage) Validate() error { if r.ReqId != "" && r.ResId != "" { return fmt.Errorf("request packets may not have both reqid and resid set") } if r.Cancel { if r.Command != "" { return fmt.Errorf("cancel packets may not have command set") } if r.ReqId == "" && r.ResId == "" { return fmt.Errorf("cancel packets must have reqid or resid set") } if r.Data != nil { return fmt.Errorf("cancel packets may not have data set") } return nil } if r.Command != "" { if r.ResId != "" { return fmt.Errorf("command packets may not have resid set") } if r.Error != "" { return fmt.Errorf("command packets may not have error set") } if r.DataType != "" { return fmt.Errorf("command packets may not have datatype set") } return nil } if r.ReqId != "" { if r.ResId == "" { return fmt.Errorf("request packets must have resid set") } if r.Timeout != 0 { return fmt.Errorf("non-command request packets may not have timeout set") } return nil } if r.ResId != "" { if r.Command != "" { return fmt.Errorf("response packets may not have command set") } if r.ReqId == "" { return fmt.Errorf("response packets must have reqid set") } if r.Timeout != 0 { return fmt.Errorf("response packets may not have timeout set") } return nil } return fmt.Errorf("invalid packet: must have command, reqid, or resid set") } type rpcData struct { Command string Route string ResCh chan *RpcMessage Handler *RpcRequestHandler } func validateServerImpl(serverImpl ServerImpl) { if serverImpl == nil { return } serverType := reflect.TypeOf(serverImpl) if serverType.Kind() != reflect.Pointer && serverType.Elem().Kind() != reflect.Struct { panic(fmt.Sprintf("serverImpl must be a pointer to struct, got %v", serverType)) } } // closes outputCh when inputCh is closed/done func MakeWshRpcWithChannels(inputCh chan baseds.RpcInputChType, outputCh chan []byte, rpcCtx wshrpc.RpcContext, serverImpl ServerImpl, debugName string) *WshRpc { if inputCh == nil { inputCh = make(chan baseds.RpcInputChType, DefaultInputChSize) } if outputCh == nil { outputCh = make(chan []byte, DefaultOutputChSize) } validateServerImpl(serverImpl) rtn := &WshRpc{ Lock: &sync.Mutex{}, DebugName: debugName, InputCh: inputCh, OutputCh: outputCh, CtxDoneCh: make(chan string, CtxDoneChSize), RpcMap: make(map[string]*rpcData), RpcContext: &atomic.Pointer[wshrpc.RpcContext]{}, EventListener: MakeEventListener(), ServerImpl: serverImpl, ResponseHandlerMap: make(map[string]*RpcResponseHandler), } rtn.RpcContext.Store(&rpcCtx) rtn.StreamBroker = streamclient.NewBroker(AdaptWshRpc(rtn)) go rtn.runServer() return rtn } func MakeWshRpc(rpcCtx wshrpc.RpcContext, serverImpl ServerImpl, debugName string) *WshRpc { return MakeWshRpcWithChannels(nil, nil, rpcCtx, serverImpl, debugName) } func (w *WshRpc) GetRpcContext() wshrpc.RpcContext { rtnPtr := w.RpcContext.Load() return *rtnPtr } func (w *WshRpc) SetRpcContext(ctx wshrpc.RpcContext) { w.RpcContext.Store(&ctx) } func (w *WshRpc) registerResponseHandler(reqId string, handler *RpcResponseHandler) { w.Lock.Lock() defer w.Lock.Unlock() w.ResponseHandlerMap[reqId] = handler } func (w *WshRpc) unregisterResponseHandler(reqId string) { w.Lock.Lock() defer w.Lock.Unlock() delete(w.ResponseHandlerMap, reqId) } func (w *WshRpc) cancelRequest(reqId string) { if reqId == "" { return } w.Lock.Lock() defer w.Lock.Unlock() handler := w.ResponseHandlerMap[reqId] if handler != nil { handler.canceled.Store(true) } } func (w *WshRpc) handleRequest(req *RpcMessage, ingressLinkId baseds.LinkId) { pprof.Do(context.Background(), pprof.Labels("rpc", req.Command), func(pprofCtx context.Context) { w.handleRequestInternal(req, ingressLinkId, pprofCtx) }) } func (w *WshRpc) handleEventRecv(req *RpcMessage) { if req.Data == nil { return } var waveEvent wps.WaveEvent err := utilfn.ReUnmarshal(&waveEvent, req.Data) if err != nil { return } w.EventListener.RecvEvent(&waveEvent) } func (w *WshRpc) handleStreamData(req *RpcMessage) { if w.StreamBroker == nil { return } if req.Data == nil { return } var dataPk wshrpc.CommandStreamData err := utilfn.ReUnmarshal(&dataPk, req.Data) if err != nil { return } w.StreamBroker.RecvData(dataPk) } func (w *WshRpc) handleStreamAck(req *RpcMessage) { if w.StreamBroker == nil { return } if req.Data == nil { return } var ackPk wshrpc.CommandStreamAckData err := utilfn.ReUnmarshal(&ackPk, req.Data) if err != nil { return } w.StreamBroker.RecvAck(ackPk) } func (w *WshRpc) handleRequestInternal(req *RpcMessage, ingressLinkId baseds.LinkId, pprofCtx context.Context) { if req.Command == wshrpc.Command_EventRecv { w.handleEventRecv(req) return } var respHandler *RpcResponseHandler timeoutMs := req.Timeout if timeoutMs <= 0 { timeoutMs = DefaultTimeoutMs } ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) ctx = withWshRpcContext(ctx, w) respHandler = &RpcResponseHandler{ w: w, ctx: ctx, reqId: req.ReqId, command: req.Command, commandData: req.Data, source: req.Source, ingressLinkId: ingressLinkId, done: &atomic.Bool{}, canceled: &atomic.Bool{}, contextCancelFn: &atomic.Pointer[context.CancelFunc]{}, rpcCtx: w.GetRpcContext(), } respHandler.contextCancelFn.Store(&cancelFn) respHandler.ctx = withRespHandler(ctx, respHandler) if req.ReqId != "" { w.registerResponseHandler(req.ReqId, respHandler) } isAsync := false defer func() { panicErr := panichandler.PanicHandler("handleRequest", recover()) if panicErr != nil { respHandler.SendResponseError(panicErr) } if isAsync { go func() { defer func() { panichandler.PanicHandler("handleRequest:finalize", recover()) }() <-ctx.Done() respHandler.Finalize() }() } else { cancelFn() respHandler.Finalize() } }() handlerFn := serverImplAdapter(w.ServerImpl) isAsync = !handlerFn(respHandler) } func (w *WshRpc) runServer() { defer func() { panichandler.PanicHandler("wshrpc.runServer", recover()) close(w.OutputCh) w.setServerDone() }() outer: for { var inputVal baseds.RpcInputChType var inputChMore bool var resIdTimeout string select { case inputVal, inputChMore = <-w.InputCh: if !inputChMore { break outer } if w.Debug { log.Printf("[%s] received message: %s\n", w.DebugName, string(inputVal.MsgBytes)) } case resIdTimeout = <-w.CtxDoneCh: if w.Debug { log.Printf("[%s] received request timeout: %s\n", w.DebugName, resIdTimeout) } w.unregisterRpc(resIdTimeout, fmt.Errorf("EC-TIME: timeout waiting for response")) continue } var msg RpcMessage err := json.Unmarshal(inputVal.MsgBytes, &msg) if err != nil { log.Printf("wshrpc received bad message: %v\n", err) continue } if msg.Cancel { if msg.ReqId != "" { w.cancelRequest(msg.ReqId) } continue } if msg.IsRpcRequest() { // Handle stream commands synchronously since the broker is designed to be non-blocking. // RecvData/RecvAck just enqueue to work queues, so there's no risk of blocking the main loop. if msg.Command == wshrpc.Command_StreamData { w.handleStreamData(&msg) continue } if msg.Command == wshrpc.Command_StreamDataAck { w.handleStreamAck(&msg) continue } ingressLinkId := inputVal.IngressLinkId go func() { defer func() { panichandler.PanicHandler("handleRequest:goroutine", recover()) }() w.handleRequest(&msg, ingressLinkId) }() } else { w.sendRespWithBlockMessage(msg) if !msg.Cont { w.unregisterRpc(msg.ResId, nil) } } } } func (w *WshRpc) getResponseCh(resId string) (chan *RpcMessage, *rpcData) { if resId == "" { return nil, nil } w.Lock.Lock() defer w.Lock.Unlock() rd := w.RpcMap[resId] if rd == nil { return nil, nil } return rd.ResCh, rd } func (w *WshRpc) SetServerImpl(serverImpl ServerImpl) { validateServerImpl(serverImpl) w.Lock.Lock() defer w.Lock.Unlock() w.ServerImpl = serverImpl } func (w *WshRpc) registerRpc(handler *RpcRequestHandler, command string, route string, reqId string) chan *RpcMessage { w.Lock.Lock() defer w.Lock.Unlock() rpcCh := make(chan *RpcMessage, RespChSize) w.RpcMap[reqId] = &rpcData{ Handler: handler, Command: command, Route: route, ResCh: rpcCh, } go func() { defer func() { panichandler.PanicHandler("registerRpc:timeout", recover()) }() <-handler.ctx.Done() w.retrySendTimeout(reqId) }() return rpcCh } func (w *WshRpc) unregisterRpc(reqId string, err error) { w.Lock.Lock() defer w.Lock.Unlock() rd := w.RpcMap[reqId] if rd == nil { return } if err != nil { errResp := &RpcMessage{ ResId: reqId, Error: err.Error(), } // non-blocking send since we're about to close anyway // likely the channel isn't being actively read // this also prevents us from blocking the main loop (and holding the lock) select { case rd.ResCh <- errResp: default: } } delete(w.RpcMap, reqId) close(rd.ResCh) rd.Handler.callContextCancelFn() } // no response func (w *WshRpc) SendCommand(command string, data any, opts *wshrpc.RpcOpts) error { var optsCopy wshrpc.RpcOpts if opts != nil { optsCopy = *opts } optsCopy.NoResponse = true optsCopy.Timeout = 0 handler, err := w.SendComplexRequest(command, data, &optsCopy) if err != nil { return err } handler.finalize() return nil } // single response func (w *WshRpc) SendRpcRequest(command string, data any, opts *wshrpc.RpcOpts) (any, error) { var optsCopy wshrpc.RpcOpts if opts != nil { optsCopy = *opts } optsCopy.NoResponse = false handler, err := w.SendComplexRequest(command, data, &optsCopy) if err != nil { return nil, err } defer handler.finalize() return handler.NextResponse() } type RpcRequestHandler struct { w *WshRpc ctx context.Context ctxCancelFn *atomic.Pointer[context.CancelFunc] reqId string respCh chan *RpcMessage cachedResp *RpcMessage } func (handler *RpcRequestHandler) Context() context.Context { return handler.ctx } func (handler *RpcRequestHandler) SendCancel(ctx context.Context) error { defer func() { panichandler.PanicHandler("SendCancel", recover()) }() msg := &RpcMessage{ Cancel: true, ReqId: handler.reqId, } barr, _ := json.Marshal(msg) // will never fail select { case handler.w.OutputCh <- barr: handler.finalize() return nil case <-ctx.Done(): handler.finalize() return fmt.Errorf("timeout sending cancel") } } func (handler *RpcRequestHandler) ResponseDone() bool { if handler.cachedResp != nil { return false } select { case msg, more := <-handler.respCh: if !more { return true } handler.cachedResp = msg return false default: return false } } func (handler *RpcRequestHandler) NextResponse() (any, error) { var resp *RpcMessage if handler.cachedResp != nil { resp = handler.cachedResp handler.cachedResp = nil } else { resp = <-handler.respCh } if resp == nil { return nil, errors.New("response channel closed") } if resp.Error != "" { return nil, errors.New(resp.Error) } return resp.Data, nil } func (handler *RpcRequestHandler) finalize() { handler.callContextCancelFn() if handler.reqId != "" { handler.w.unregisterRpc(handler.reqId, nil) } } func (handler *RpcRequestHandler) callContextCancelFn() { cancelFnPtr := handler.ctxCancelFn.Swap(nil) if cancelFnPtr != nil && *cancelFnPtr != nil { (*cancelFnPtr)() } } type RpcResponseHandler struct { w *WshRpc ctx context.Context contextCancelFn *atomic.Pointer[context.CancelFunc] reqId string source string command string commandData any rpcCtx wshrpc.RpcContext ingressLinkId baseds.LinkId canceled *atomic.Bool // canceled by requestor done *atomic.Bool } func (handler *RpcResponseHandler) Context() context.Context { return handler.ctx } func (handler *RpcResponseHandler) GetCommand() string { return handler.command } func (handler *RpcResponseHandler) GetCommandRawData() any { return handler.commandData } func (handler *RpcResponseHandler) GetRpcContext() wshrpc.RpcContext { return handler.rpcCtx } func (handler *RpcResponseHandler) GetSource() string { return handler.source } func (handler *RpcResponseHandler) GetIngressLinkId() baseds.LinkId { return handler.ingressLinkId } func (handler *RpcResponseHandler) NeedsResponse() bool { return handler.reqId != "" } func (handler *RpcResponseHandler) SendMessage(msg string) { rpcMsg := &RpcMessage{ Command: wshrpc.Command_Message, Data: wshrpc.CommandMessageData{ Message: msg, }, Route: handler.source, // send back to source } msgBytes, _ := json.Marshal(rpcMsg) // will never fail select { case handler.w.OutputCh <- msgBytes: case <-handler.ctx.Done(): } } func (handler *RpcResponseHandler) SendResponse(data any, done bool) error { defer func() { panichandler.PanicHandler("SendResponse", recover()) }() if handler.done.Load() { return fmt.Errorf("request already done, cannot send additional response") } if done { defer handler.close() } if handler.reqId == "" { return nil } msg := &RpcMessage{ ResId: handler.reqId, Data: data, Cont: !done, } barr, err := json.Marshal(msg) if err != nil { return err } select { case handler.w.OutputCh <- barr: return nil case <-handler.ctx.Done(): return fmt.Errorf("timeout sending response") } } func (handler *RpcResponseHandler) SendResponseError(err error) { defer func() { panichandler.PanicHandler("SendResponseError", recover()) }() if handler.done.Load() { return } defer handler.close() if handler.reqId == "" { return } msg := &RpcMessage{ ResId: handler.reqId, Error: err.Error(), } barr, _ := json.Marshal(msg) // will never fail select { case handler.w.OutputCh <- barr: case <-handler.ctx.Done(): } } func (handler *RpcResponseHandler) IsCanceled() bool { return handler.canceled.Load() } func (handler *RpcResponseHandler) close() { cancelFn := handler.contextCancelFn.Load() if cancelFn != nil && *cancelFn != nil { (*cancelFn)() handler.contextCancelFn.Store(nil) } handler.done.Store(true) } // if async, caller must call finalize func (handler *RpcResponseHandler) Finalize() { // Always unregister the handler from the map, even if already done if handler.reqId != "" { handler.w.unregisterResponseHandler(handler.reqId) } if handler.done.Load() { return } // SendResponse with done=true will call close() via defer, even when reqId is empty handler.SendResponse(nil, true) } func (handler *RpcResponseHandler) IsDone() bool { return handler.done.Load() } func (w *WshRpc) SendComplexRequest(command string, data any, opts *wshrpc.RpcOpts) (rtnHandler *RpcRequestHandler, rtnErr error) { if w.IsServerDone() { return nil, errors.New("server is no longer running, cannot send new requests") } if opts == nil { opts = &wshrpc.RpcOpts{} } timeoutMs := opts.Timeout if timeoutMs <= 0 { timeoutMs = DefaultTimeoutMs } defer func() { panichandler.PanicHandler("SendComplexRequest", recover()) }() if command == "" { return nil, fmt.Errorf("command cannot be empty") } handler := &RpcRequestHandler{ w: w, ctxCancelFn: &atomic.Pointer[context.CancelFunc]{}, } var cancelFn context.CancelFunc handler.ctx, cancelFn = context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) handler.ctxCancelFn.Store(&cancelFn) if !opts.NoResponse { handler.reqId = uuid.New().String() } req := &RpcMessage{ Command: command, ReqId: handler.reqId, Data: data, Timeout: timeoutMs, Route: opts.Route, } barr, err := json.Marshal(req) if err != nil { return nil, err } handler.respCh = w.registerRpc(handler, command, opts.Route, handler.reqId) select { case w.OutputCh <- barr: return handler, nil case <-handler.ctx.Done(): handler.finalize() return nil, fmt.Errorf("timeout sending request") } } func (w *WshRpc) IsServerDone() bool { w.Lock.Lock() defer w.Lock.Unlock() return w.ServerDone } func (w *WshRpc) setServerDone() { w.Lock.Lock() defer w.Lock.Unlock() w.ServerDone = true close(w.CtxDoneCh) utilfn.DrainChannelSafe(w.InputCh, "wshrpc.setServerDone") } func (w *WshRpc) retrySendTimeout(resId string) { done := func() bool { w.Lock.Lock() defer w.Lock.Unlock() if w.ServerDone { return true } select { case w.CtxDoneCh <- resId: return true default: return false } } for { if done() { return } time.Sleep(100 * time.Millisecond) } } func (w *WshRpc) sendRespWithBlockMessage(msg RpcMessage) { respCh, rd := w.getResponseCh(msg.ResId) if respCh == nil { return } select { case respCh <- &msg: // normal case, message got sent, just return! return default: // channel is full, we would block... } // log the fact that we're blocking _, noLog := blockingExpMap.Get(msg.ResId) if !noLog { log.Printf("[rpc:%s] blocking on response command:%s route:%s resid:%s\n", w.DebugName, rd.Command, rd.Route, msg.ResId) blockingExpMap.Set(msg.ResId, true, time.Now().Add(time.Second)) } ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() select { case respCh <- &msg: // message got sent, just return! return case <-ctx.Done(): } log.Printf("[rpc:%s] failed to clear response channel (waited 1s), will fail RPC command:%s route:%s resid:%s\n", w.DebugName, rd.Command, rd.Route, msg.ResId) w.unregisterRpc(msg.ResId, nil) // we don't pass an error because the channel is full, it won't work anyway... } ================================================ FILE: pkg/wshutil/wshrpcio.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshutil import ( "fmt" "io" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) // special I/O wrappers for wshrpc // * terminal (wrap with OSC codes) // * stream (json lines) // * websocket (json packets) func AdaptStreamToMsgCh(input io.Reader, output chan baseds.RpcInputChType, readCallback func()) error { return utilfn.StreamToLines(input, func(line []byte) { output <- baseds.RpcInputChType{MsgBytes: line} }, readCallback) } func AdaptOutputChToStream(outputCh chan []byte, output io.Writer) error { drain := false defer func() { if drain { utilfn.DrainChannelSafe(outputCh, "AdaptOutputChToStream") } }() for msg := range outputCh { if _, err := output.Write(msg); err != nil { drain = true return fmt.Errorf("error writing to output (AdaptOutputChToStream): %w", err) } // write trailing newline if _, err := output.Write([]byte{'\n'}); err != nil { drain = true return fmt.Errorf("error writing trailing newline to output (AdaptOutputChToStream): %w", err) } } return nil } func AdaptMsgChToPty(outputCh chan []byte, oscEsc string, output io.Writer) error { if len(oscEsc) != 5 { panic("oscEsc must be 5 characters") } for msg := range outputCh { barr, err := EncodeWaveOSCBytes(oscEsc, msg) if err != nil { return fmt.Errorf("error encoding osc message (AdaptMsgChToPty): %w", err) } if _, err := output.Write(barr); err != nil { return fmt.Errorf("error writing osc message (AdaptMsgChToPty): %w", err) } } return nil } ================================================ FILE: pkg/wshutil/wshstreamadapter.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshutil import ( "github.com/wavetermdev/waveterm/pkg/wshrpc" ) type WshRpcStreamClientAdapter struct { rpc *WshRpc } func (a *WshRpcStreamClientAdapter) StreamDataAckCommand(data wshrpc.CommandStreamAckData, opts *wshrpc.RpcOpts) error { return a.rpc.SendCommand("streamdataack", data, opts) } func (a *WshRpcStreamClientAdapter) StreamDataCommand(data wshrpc.CommandStreamData, opts *wshrpc.RpcOpts) error { return a.rpc.SendCommand("streamdata", data, opts) } func AdaptWshRpc(rpc *WshRpc) *WshRpcStreamClientAdapter { return &WshRpcStreamClientAdapter{rpc: rpc} } ================================================ FILE: pkg/wshutil/wshutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshutil import ( "bytes" "encoding/json" "fmt" "io" "log" "net" "os" "path/filepath" "runtime" "strings" "sync" "sync/atomic" "github.com/golang-jwt/jwt/v5" "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/util/packetparser" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) // these should both be 5 characters const WaveOSC = "23198" const WaveServerOSC = "23199" const WaveOSCPrefixLen = 5 + 3 // \x1b] + WaveOSC + ; + \x07 const WaveOSCPrefix = "\x1b]" + WaveOSC + ";" const WaveServerOSCPrefix = "\x1b]" + WaveServerOSC + ";" const HexChars = "0123456789ABCDEF" const BEL = 0x07 const ST = 0x9c const ESC = 0x1b const DefaultOutputChSize = 32 const DefaultInputChSize = 32 const WaveJwtTokenVarName = wavebase.WaveJwtTokenVarName // OSC escape types // OSC 23198 ; (JSON | base64-JSON) ST // JSON = must escape all ASCII control characters ([\x00-\x1F\x7F]) // we can tell the difference between JSON and base64-JSON by the first character: '{' or not // for responses (terminal -> program), we'll use OSC 23199 // same json format func copyOscPrefix(dst []byte, oscNum string) { dst[0] = ESC dst[1] = ']' copy(dst[2:], oscNum) dst[len(oscNum)+2] = ';' } func oscPrefixLen(oscNum string) int { return 3 + len(oscNum) } func makeOscPrefix(oscNum string) []byte { output := make([]byte, oscPrefixLen(oscNum)) copyOscPrefix(output, oscNum) return output } func EncodeWaveOSCBytes(oscNum string, barr []byte) ([]byte, error) { if len(oscNum) != 5 { return nil, fmt.Errorf("oscNum must be 5 characters") } const maxSize = 64 * 1024 * 1024 // 64 MB if len(barr) > maxSize { return nil, fmt.Errorf("input data too large") } hasControlChars := false for _, b := range barr { if b < 0x20 || b == 0x7F { hasControlChars = true break } } if !hasControlChars { // If no control characters, directly construct the output // \x1b] (2) + WaveOSC + ; (1) + message + \x07 (1) output := make([]byte, oscPrefixLen(oscNum)+len(barr)+1) copyOscPrefix(output, oscNum) copy(output[oscPrefixLen(oscNum):], barr) output[len(output)-1] = BEL return output, nil } var buf bytes.Buffer buf.Write(makeOscPrefix(oscNum)) escSeq := [6]byte{'\\', 'u', '0', '0', '0', '0'} for _, b := range barr { if b < 0x20 || b == 0x7f { escSeq[4] = HexChars[b>>4] escSeq[5] = HexChars[b&0x0f] buf.Write(escSeq[:]) } else { buf.WriteByte(b) } } buf.WriteByte(BEL) return buf.Bytes(), nil } func EncodeWaveOSCMessageEx(oscNum string, msg *RpcMessage) ([]byte, error) { if msg == nil { return nil, fmt.Errorf("nil message") } barr, err := json.Marshal(msg) if err != nil { return nil, fmt.Errorf("error marshalling message to json: %w", err) } return EncodeWaveOSCBytes(oscNum, barr) } var shutdownOnce sync.Once func DoShutdown(reason string, exitCode int, quiet bool) { shutdownOnce.Do(func() { defer os.Exit(exitCode) if !quiet && reason != "" { log.Printf("shutting down: %s\n", reason) } }) } func SetupPacketRpcClient(input io.Reader, output io.Writer, serverImpl ServerImpl, debugStr string) (*WshRpc, chan []byte) { messageCh := make(chan baseds.RpcInputChType, DefaultInputChSize) outputCh := make(chan []byte, DefaultOutputChSize) rawCh := make(chan []byte, DefaultOutputChSize) rpcClient := MakeWshRpcWithChannels(messageCh, outputCh, wshrpc.RpcContext{}, serverImpl, debugStr) go packetparser.Parse(input, messageCh, rawCh) go func() { defer func() { panichandler.PanicHandler("SetupPacketRpcClient:outputloop", recover()) }() for msg := range outputCh { packetparser.WritePacket(output, msg) } }() return rpcClient, rawCh } func SetupConnRpcClient(conn net.Conn, serverImpl ServerImpl, debugStr string) (*WshRpc, chan error, error) { inputCh := make(chan baseds.RpcInputChType, DefaultInputChSize) outputCh := make(chan []byte, DefaultOutputChSize) writeErrCh := make(chan error, 1) go func() { defer func() { panichandler.PanicHandler("SetupConnRpcClient:AdaptOutputChToStream", recover()) }() writeErr := AdaptOutputChToStream(outputCh, conn) if writeErr != nil { writeErrCh <- writeErr close(writeErrCh) } }() go func() { defer func() { panichandler.PanicHandler("SetupConnRpcClient:AdaptStreamToMsgCh", recover()) }() // when input is closed, close the connection defer conn.Close() AdaptStreamToMsgCh(conn, inputCh, nil) }() rtn := MakeWshRpcWithChannels(inputCh, outputCh, wshrpc.RpcContext{}, serverImpl, debugStr) return rtn, writeErrCh, nil } func tryTcpSocket(sockName string) (net.Conn, error) { addr, err := net.ResolveTCPAddr("tcp", sockName) if err != nil { return nil, err } return net.DialTCP("tcp", nil, addr) } func SetupDomainSocketRpcClient(sockName string, serverImpl ServerImpl, debugName string) (*WshRpc, error) { sockName = wavebase.ExpandHomeDirSafe(sockName) resolvedPath, err := filepath.EvalSymlinks(sockName) if err == nil { sockName = resolvedPath } if !filepath.IsAbs(sockName) { return nil, fmt.Errorf("socket path must be absolute: %s", sockName) } conn, tcpErr := tryTcpSocket(sockName) var unixErr error if tcpErr != nil { conn, unixErr = net.Dial("unix", sockName) } if tcpErr != nil && unixErr != nil { return nil, fmt.Errorf("failed to connect to tcp or unix domain socket: tcp err:%w: unix socket err: %w", tcpErr, unixErr) } rtn, errCh, err := SetupConnRpcClient(conn, serverImpl, debugName) go func() { defer func() { panichandler.PanicHandler("SetupDomainSocketRpcClient:closeConn", recover()) }() defer conn.Close() err := <-errCh if err != nil && err != io.EOF { log.Printf("error in domain socket connection: %v\n", err) } }() return rtn, err } func MakeClientJWTToken(rpcCtx wshrpc.RpcContext) (string, error) { if wavebase.IsDevMode() { if rpcCtx.IsRouter && (rpcCtx.RouteId != "" || rpcCtx.ProcRoute) { panic("Invalid RpcCtx, router w/ routeid") } if !rpcCtx.IsRouter && (rpcCtx.RouteId == "" && !rpcCtx.ProcRoute) { panic("Invalid RpcCtx, no routeid") } } claims := &wavejwt.WaveJwtClaims{ Sock: rpcCtx.SockName, RouteId: rpcCtx.RouteId, ProcRoute: rpcCtx.ProcRoute, BlockId: rpcCtx.BlockId, Conn: rpcCtx.Conn, Router: rpcCtx.IsRouter, } return wavejwt.Sign(claims) } func claimsToRpcCtx(claims *wavejwt.WaveJwtClaims) *wshrpc.RpcContext { return &wshrpc.RpcContext{ SockName: claims.Sock, RouteId: claims.RouteId, ProcRoute: claims.ProcRoute, BlockId: claims.BlockId, Conn: claims.Conn, IsRouter: claims.Router, } } func ValidateAndExtractRpcContextFromToken(tokenStr string) (*wshrpc.RpcContext, error) { claims, err := wavejwt.ValidateAndExtract(tokenStr) if err != nil { return nil, err } return claimsToRpcCtx(claims), nil } func RunWshRpcOverListener(listener net.Listener, readCallback func()) { defer log.Printf("domain socket listener shutting down\n") for { conn, err := listener.Accept() if err == io.EOF { break } if err != nil { log.Printf("error accepting connection: %v\n", err) break } log.Print("got domain socket connection\n") go handleDomainSocketClient(conn, readCallback) } } type WriteFlusher interface { Write([]byte) (int, error) Flush() error } // blocking, returns if there is an error, or on EOF of input func HandleStdIOClient(logName string, input chan utilfn.LineOutput, output io.Writer) { proxy := MakeRpcProxy(logName) linkId := DefaultRouter.RegisterTrustedRouter(proxy) rawCh := make(chan []byte, DefaultInputChSize) go func() { defer func() { panichandler.PanicHandler("HandleStdIOClient:ParseWithLinesChan", recover()) }() packetparser.ParseWithLinesChan(input, proxy.FromRemoteCh, rawCh) }() doneCh := make(chan struct{}) var doneOnce sync.Once closeDoneCh := func() { doneOnce.Do(func() { close(doneCh) DefaultRouter.UnregisterLink(linkId) close(proxy.FromRemoteCh) }) } go func() { defer func() { panichandler.PanicHandler("HandleStdIOClient:ToRemoteChLoop", recover()) }() defer closeDoneCh() for msg := range proxy.ToRemoteCh { err := packetparser.WritePacket(output, msg) if err != nil { log.Printf("[%s] error writing to output: %v\n", logName, err) break } } }() go func() { defer func() { panichandler.PanicHandler("HandleStdIOClient:RawChLoop", recover()) }() defer closeDoneCh() for msg := range rawCh { if !bytes.HasSuffix(msg, []byte{'\n'}) { msg = append(msg, '\n') } log.Printf("[%s:stdout] %s", logName, msg) } }() <-doneCh } func handleDomainSocketClient(conn net.Conn, readCallback func()) { var linkIdContainer atomic.Int32 proxy := MakeRpcProxy("domain") go func() { defer func() { panichandler.PanicHandler("handleDomainSocketClient:AdaptOutputChToStream", recover()) }() writeErr := AdaptOutputChToStream(proxy.ToRemoteCh, conn) if writeErr != nil { log.Printf("error writing to domain socket: %v\n", writeErr) } }() go func() { // when input is closed, close the connection defer func() { panichandler.PanicHandler("handleDomainSocketClient:AdaptStreamToMsgCh", recover()) }() defer func() { conn.Close() close(proxy.FromRemoteCh) close(proxy.ToRemoteCh) linkId := linkIdContainer.Load() if linkId != baseds.NoLinkId { DefaultRouter.UnregisterLink(baseds.LinkId(linkId)) } }() AdaptStreamToMsgCh(conn, proxy.FromRemoteCh, readCallback) }() linkId := DefaultRouter.RegisterUntrustedLink(proxy) linkIdContainer.Store(int32(linkId)) } // only for use on client func ExtractUnverifiedRpcContext(tokenStr string) (*wshrpc.RpcContext, error) { token, _, err := new(jwt.Parser).ParseUnverified(tokenStr, &wavejwt.WaveJwtClaims{}) if err != nil { return nil, fmt.Errorf("error parsing token: %w", err) } claims, ok := token.Claims.(*wavejwt.WaveJwtClaims) if !ok { return nil, fmt.Errorf("error getting claims from token") } return claimsToRpcCtx(claims), nil } // only for use on client func ExtractUnverifiedSocketName(tokenStr string) (string, error) { token, _, err := new(jwt.Parser).ParseUnverified(tokenStr, &wavejwt.WaveJwtClaims{}) if err != nil { return "", fmt.Errorf("error parsing token: %w", err) } claims, ok := token.Claims.(*wavejwt.WaveJwtClaims) if !ok { return "", fmt.Errorf("error getting claims from token") } sockName := claims.Sock if sockName == "" { return "", fmt.Errorf("sock claim is missing or invalid") } sockName = wavebase.ExpandHomeDirSafe(sockName) return sockName, nil } func getShell() string { if runtime.GOOS == "darwin" { return shellutil.GetMacUserShell() } shell := os.Getenv("SHELL") if shell == "" { return "/bin/bash" } return strings.TrimSpace(shell) } func GetInfo() wshrpc.RemoteInfo { return wshrpc.RemoteInfo{ ClientArch: runtime.GOARCH, ClientOs: runtime.GOOS, ClientVersion: wavebase.WaveVersion, Shell: getShell(), HomeDir: wavebase.GetHomeDir(), } } func InstallRcFiles() error { home := wavebase.GetHomeDir() waveDir := filepath.Join(home, wavebase.RemoteWaveHomeDirName) wshBinDir := filepath.Join(waveDir, wavebase.RemoteWshBinDirName) return shellutil.InitRcFiles(waveDir, wshBinDir) } func SendErrCh[T any](err error) <-chan wshrpc.RespOrErrorUnion[T] { ch := make(chan wshrpc.RespOrErrorUnion[T], 1) ch <- RespErr[T](err) close(ch) return ch } func RespErr[T any](err error) wshrpc.RespOrErrorUnion[T] { return wshrpc.RespOrErrorUnion[T]{Error: err} } ================================================ FILE: pkg/wsl/wsl-unix.go ================================================ //go:build !windows // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wsl import ( "context" "fmt" "io" "os" "os/exec" ) type WslName struct { Distro string `json:"distro"` } func RegisteredDistros(ctx context.Context) (distros []Distro, err error) { return nil, fmt.Errorf("RegisteredDistros not implemented on this system") } func DefaultDistro(ctx context.Context) (d Distro, ok bool, err error) { return d, false, fmt.Errorf("DefaultDistro not implemented on this system") } type Distro struct{} func (d *Distro) Name() string { return "" } func (d *Distro) WslCommand(ctx context.Context, cmd string) *WslCmd { return nil } // just use the regular cmd since it's // similar enough to not cause issues // type WslCmd = exec.Cmd type WslCmd struct { exec.Cmd } func (wc *WslCmd) GetProcess() *os.Process { return nil } func (wc *WslCmd) GetProcessState() *os.ProcessState { return nil } func (wc *WslCmd) ExitCode() int { return -1 } func (wc *WslCmd) ExitSignal() string { return "" } func (c *WslCmd) SetStdin(stdin io.Reader) { c.Stdin = stdin } func (c *WslCmd) SetStdout(stdout io.Writer) { c.Stdout = stdout } func (c *WslCmd) SetStderr(stderr io.Writer) { c.Stderr = stderr } func GetDistroCmd(ctx context.Context, wslDistroName string, cmd string) (*WslCmd, error) { return nil, fmt.Errorf("GetDistroCmd not implemented on this system") } func GetDistro(ctx context.Context, wslDistroName WslName) (*Distro, error) { return nil, fmt.Errorf("GetDistro not implemented on this system") } ================================================ FILE: pkg/wsl/wsl-win.go ================================================ //go:build windows // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wsl import ( "context" "fmt" "io" "os" "sync" "github.com/ubuntu/gowsl" ) var RegisteredDistros = gowsl.RegisteredDistros var DefaultDistro = gowsl.DefaultDistro type WslName struct { Distro string `json:"distro"` } type Distro struct { gowsl.Distro } type WslCmd struct { c *gowsl.Cmd wg *sync.WaitGroup once *sync.Once lock *sync.Mutex waitErr error } func (d *Distro) WslCommand(ctx context.Context, cmd string) *WslCmd { if ctx == nil { panic("nil Context") } innerCmd := d.Command(ctx, cmd) var wg sync.WaitGroup var lock *sync.Mutex return &WslCmd{innerCmd, &wg, new(sync.Once), lock, nil} } func (c *WslCmd) CombinedOutput() (out []byte, err error) { return c.c.CombinedOutput() } func (c *WslCmd) Output() (out []byte, err error) { return c.c.Output() } func (c *WslCmd) Run() error { return c.c.Run() } func (c *WslCmd) Start() (err error) { return c.c.Start() } func (c *WslCmd) StderrPipe() (r io.ReadCloser, err error) { return c.c.StderrPipe() } func (c *WslCmd) StdinPipe() (w io.WriteCloser, err error) { return c.c.StdinPipe() } func (c *WslCmd) StdoutPipe() (r io.ReadCloser, err error) { return c.c.StdoutPipe() } func (c *WslCmd) Wait() (err error) { c.wg.Add(1) c.once.Do(func() { c.waitErr = c.c.Wait() }) c.wg.Done() c.wg.Wait() if c.waitErr != nil && c.waitErr.Error() == "not started" { c.once = new(sync.Once) return c.waitErr } return c.waitErr } func (c *WslCmd) ExitCode() int { state := c.c.ProcessState if state == nil { return -1 } return state.ExitCode() } func (c *WslCmd) ExitSignal() string { return "" } func (c *WslCmd) GetProcess() *os.Process { return c.c.Process } func (c *WslCmd) GetProcessState() *os.ProcessState { return c.c.ProcessState } func (c *WslCmd) SetStdin(stdin io.Reader) { c.c.Stdin = stdin } func (c *WslCmd) SetStdout(stdout io.Writer) { c.c.Stdout = stdout } func (c *WslCmd) SetStderr(stderr io.Writer) { c.c.Stderr = stderr } func GetDistroCmd(ctx context.Context, wslDistroName string, cmd string) (*WslCmd, error) { distros, err := RegisteredDistros(ctx) if err != nil { return nil, err } for _, distro := range distros { if distro.Name() != wslDistroName { continue } wrappedDistro := Distro{distro} return wrappedDistro.WslCommand(ctx, cmd), nil } return nil, fmt.Errorf("wsl distro %s not found", wslDistroName) } func GetDistro(ctx context.Context, wslDistroName WslName) (*Distro, error) { distros, err := RegisteredDistros(ctx) if err != nil { return nil, err } for _, distro := range distros { if distro.Name() != wslDistroName.Distro { continue } wrappedDistro := Distro{distro} return &wrappedDistro, nil } return nil, fmt.Errorf("wsl distro %s not found", wslDistroName) } ================================================ FILE: pkg/wslconn/wsl-util.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wslconn import ( "bytes" "context" "errors" "fmt" "io" "log" "os" "path/filepath" "strings" "text/template" "time" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wsl" ) func hasBashInstalled(ctx context.Context, client *wsl.Distro) (bool, error) { cmd := client.WslCommand(ctx, "which bash") out, whichErr := cmd.Output() if whichErr == nil && len(out) != 0 { return true, nil } cmd = client.WslCommand(ctx, "where.exe bash") out, whereErr := cmd.Output() if whereErr == nil && len(out) != 0 { return true, nil } // note: we could also check in /bin/bash explicitly // just in case that wasn't added to the path. but if // that's true, we will most likely have worse // problems going forward return false, nil } func normalizeOs(os string) string { os = strings.ToLower(strings.TrimSpace(os)) return os } func normalizeArch(arch string) string { arch = strings.ToLower(strings.TrimSpace(arch)) switch arch { case "x86_64", "amd64": arch = "x64" case "arm64", "aarch64": arch = "arm64" } return arch } // returns (os, arch, error) // guaranteed to return a supported platform func GetClientPlatform(ctx context.Context, shell genconn.ShellClient) (string, string, error) { blocklogger.Infof(ctx, "[conndebug] running `uname -sm` to detect client platform\n") stdout, stderr, err := genconn.RunSimpleCommand(ctx, shell, genconn.CommandSpec{ Cmd: "uname -sm", }) if err != nil { return "", "", fmt.Errorf("error running uname -sm: %w, stderr: %s", err, stderr) } // Parse and normalize output parts := strings.Fields(strings.ToLower(strings.TrimSpace(stdout))) if len(parts) != 2 { return "", "", fmt.Errorf("unexpected output from uname: %s", stdout) } os, arch := normalizeOs(parts[0]), normalizeArch(parts[1]) if err := wavebase.ValidateWshSupportedArch(os, arch); err != nil { return "", "", err } return os, arch, nil } func GetClientPlatformFromOsArchStr(ctx context.Context, osArchStr string) (string, string, error) { parts := strings.Fields(strings.TrimSpace(osArchStr)) if len(parts) != 2 { return "", "", fmt.Errorf("unexpected output from uname: %s", osArchStr) } os, arch := normalizeOs(parts[0]), normalizeArch(parts[1]) if err := wavebase.ValidateWshSupportedArch(os, arch); err != nil { return "", "", err } return os, arch, nil } type CancellableCmd struct { Cmd *wsl.WslCmd Cancel func() } var installTemplatesRawBash = map[string]string{ "mkdir": `bash -c 'mkdir -p {{.installDir}}'`, "cat": `bash -c 'cat > {{.tempPath}}'`, "mv": `bash -c 'mv {{.tempPath}} {{.installPath}}'`, "chmod": `bash -c 'chmod a+x {{.installPath}}'`, } var installTemplatesRawDefault = map[string]string{ "mkdir": `mkdir -p {{.installDir}}`, "cat": `cat > {{.tempPath}}`, "mv": `mv {{.tempPath}} {{.installPath}}`, "chmod": `chmod a+x {{.installPath}}`, } func makeCancellableCommand(ctx context.Context, client *wsl.Distro, cmdTemplateRaw string, words map[string]string) (*CancellableCmd, error) { cmdContext, cmdCancel := context.WithCancel(ctx) cmdStr := &bytes.Buffer{} cmdTemplate, err := template.New("").Parse(cmdTemplateRaw) if err != nil { cmdCancel() return nil, err } cmdTemplate.Execute(cmdStr, words) cmd := client.WslCommand(cmdContext, cmdStr.String()) return &CancellableCmd{cmd, cmdCancel}, nil } func CpWshToRemote(ctx context.Context, client *wsl.Distro, clientOs string, clientArch string) error { wshLocalPath, err := shellutil.GetLocalWshBinaryPath(wavebase.WaveVersion, clientOs, clientArch) if err != nil { return err } // warning: does not work on windows remote yet bashInstalled, err := hasBashInstalled(ctx, client) if err != nil { return err } var selectedTemplatesRaw map[string]string if bashInstalled { selectedTemplatesRaw = installTemplatesRawBash } else { log.Printf("bash is not installed on remote. attempting with default shell") selectedTemplatesRaw = installTemplatesRawDefault } // I need to use toSlash here to force unix keybindings // this means we can't guarantee it will work on a remote windows machine var installWords = map[string]string{ "installDir": filepath.ToSlash(filepath.Dir(wavebase.RemoteFullWshBinPath)), "tempPath": wavebase.RemoteFullWshBinPath + ".temp", "installPath": wavebase.RemoteFullWshBinPath, } blocklogger.Infof(ctx, "[conndebug] copying %q to remote server %q\n", wshLocalPath, wavebase.RemoteFullWshBinPath) installStepCmds := make(map[string]*CancellableCmd) for cmdName, selectedTemplateRaw := range selectedTemplatesRaw { cancellableCmd, err := makeCancellableCommand(ctx, client, selectedTemplateRaw, installWords) if err != nil { return err } installStepCmds[cmdName] = cancellableCmd } _, err = installStepCmds["mkdir"].Cmd.Output() if err != nil { return err } // the cat part of this is complicated since it requires stdin catCmd := installStepCmds["cat"].Cmd catStdin, err := catCmd.StdinPipe() if err != nil { return err } err = catCmd.Start() if err != nil { return err } input, err := os.Open(wshLocalPath) if err != nil { return fmt.Errorf("cannot open local file %s to send to host: %v", wshLocalPath, err) } go func() { defer func() { panichandler.PanicHandler("wslutil:cpHostToRemote:catStdin", recover()) }() io.Copy(catStdin, input) installStepCmds["cat"].Cancel() // backup just in case something weird happens // could cause potential race condition, but very // unlikely time.Sleep(time.Second * 1) process := catCmd.GetProcess() if process != nil { process.Kill() } }() catErr := catCmd.Wait() if catErr != nil && !errors.Is(catErr, context.Canceled) { return catErr } _, err = installStepCmds["mv"].Cmd.Output() if err != nil { return err } _, err = installStepCmds["chmod"].Cmd.Output() if err != nil { return err } return nil } func IsPowershell(shellPath string) bool { // get the base path, and then check contains shellBase := filepath.Base(shellPath) return strings.Contains(shellBase, "powershell") || strings.Contains(shellBase, "pwsh") } ================================================ FILE: pkg/wslconn/wslconn.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wslconn import ( "context" "fmt" "io" "log" "net" "os" "strings" "sync" "sync/atomic" "time" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wsl" ) const ( Status_Init = "init" Status_Connecting = "connecting" Status_Connected = "connected" Status_Disconnected = "disconnected" Status_Error = "error" ) const DefaultConnectionTimeout = 60 * time.Second var globalLock = &sync.Mutex{} var clientControllerMap = make(map[string]*WslConn) var activeConnCounter = &atomic.Int32{} type WslConn struct { Lock *sync.Mutex Status string WshEnabled *atomic.Bool Name wsl.WslName Client *wsl.Distro DomainSockName string // if "", then no domain socket DomainSockListener net.Listener ConnController *wsl.WslCmd Error string WshError string NoWshReason string WshVersion string HasWaiter *atomic.Bool LastConnectTime int64 ActiveConnNum int cancelFn func() } var ConnServerCmdTemplate = strings.TrimSpace( strings.Join([]string{ "%s version 2> /dev/null || (echo -n \"not-installed \"; uname -sm);", "exec %s connserver --router --conn %s %s", }, "\n")) func GetAllConnStatus() []wshrpc.ConnStatus { globalLock.Lock() defer globalLock.Unlock() var connStatuses []wshrpc.ConnStatus for _, conn := range clientControllerMap { connStatuses = append(connStatuses, conn.DeriveConnStatus()) } return connStatuses } func GetNumWSLHasConnected() int { globalLock.Lock() defer globalLock.Unlock() var connectedCount int for _, conn := range clientControllerMap { if conn.LastConnectTime > 0 { connectedCount++ } } return connectedCount } func (conn *WslConn) DeriveConnStatus() wshrpc.ConnStatus { conn.Lock.Lock() defer conn.Lock.Unlock() return wshrpc.ConnStatus{ Status: conn.Status, Connected: conn.Status == Status_Connected, WshEnabled: conn.WshEnabled.Load(), Connection: conn.GetName(), HasConnected: (conn.LastConnectTime > 0), ActiveConnNum: conn.ActiveConnNum, Error: conn.Error, WshError: conn.WshError, NoWshReason: conn.NoWshReason, WshVersion: conn.WshVersion, } } func (conn *WslConn) Infof(ctx context.Context, format string, args ...any) { log.Print(fmt.Sprintf("[conn:%s] ", conn.GetName()) + fmt.Sprintf(format, args...)) blocklogger.Infof(ctx, "[conndebug] "+format, args...) } func (conn *WslConn) Debugf(ctx context.Context, format string, args ...any) { blocklogger.Infof(ctx, "[conndebug] "+format, args...) } func (conn *WslConn) FireConnChangeEvent() { status := conn.DeriveConnStatus() event := wps.WaveEvent{ Event: wps.Event_ConnChange, Scopes: []string{ fmt.Sprintf("connection:%s", conn.GetName()), }, Data: status, } log.Printf("sending event: %+#v", event) wps.Broker.Publish(event) } func (conn *WslConn) Close() error { defer conn.FireConnChangeEvent() conn.WithLock(func() { if conn.Status == Status_Connected || conn.Status == Status_Connecting { // if status is init, disconnected, or error don't change it conn.Status = Status_Disconnected } conn.close_nolock() }) // we must wait for the waiter to complete startTime := time.Now() for conn.HasWaiter.Load() { time.Sleep(10 * time.Millisecond) if time.Since(startTime) > 2*time.Second { return fmt.Errorf("timeout waiting for waiter to complete") } } return nil } func (conn *WslConn) close_nolock() { // does not set status (that should happen at another level) if conn.DomainSockListener != nil { conn.DomainSockListener.Close() conn.DomainSockListener = nil conn.DomainSockName = "" } if conn.ConnController != nil { conn.cancelFn() // this suspends the conn controller conn.ConnController = nil } if conn.Client != nil { // conn.Client.Close() is not relevant here // we do not want to completely close the wsl in case // other applications are using it conn.Client = nil } } func (conn *WslConn) GetDomainSocketName() string { conn.Lock.Lock() defer conn.Lock.Unlock() return conn.DomainSockName } func (conn *WslConn) GetStatus() string { conn.Lock.Lock() defer conn.Lock.Unlock() return conn.Status } func (conn *WslConn) GetName() string { // no lock required because opts is immutable return "wsl://" + conn.Name.Distro } /** * This function is does not set a listener for WslConn * It is still required in order to set SockName **/ func (conn *WslConn) OpenDomainSocketListener(ctx context.Context) error { conn.Infof(ctx, "running OpenDomainSocketListener...\n") allowed := WithLockRtn(conn, func() bool { return conn.Status == Status_Connecting }) if !allowed { return fmt.Errorf("cannot open domain socket for %q when status is %q", conn.GetName(), conn.GetStatus()) } /* listener, err := client.ListenUnix(sockName) if err != nil { return fmt.Errorf("unable to request connection domain socket: %v", err) } */ conn.Infof(ctx, "setting domain socket to %s\n", wavebase.RemoteFullDomainSocketPath) conn.WithLock(func() { conn.DomainSockName = wavebase.RemoteFullDomainSocketPath //conn.DomainSockListener = listener }) conn.Infof(ctx, "successfully connected domain socket\n") /* go func() { defer func() { panichandler.PanicHandler("wslconn:OpenDomainSocketListener", recover()) }() defer conn.WithLock(func() { conn.DomainSockListener = nil conn.DomainSockName = "" }) wshutil.RunWshRpcOverListener(listener) }() */ return nil } func (conn *WslConn) getWshPath() string { config, ok := conn.getConnectionConfig() if ok && config.ConnWshPath != "" { return config.ConnWshPath } return wavebase.RemoteFullWshBinPath } func (conn *WslConn) GetConfigShellPath() string { config, ok := conn.getConnectionConfig() if !ok { return "" } return config.ConnShellPath } // returns (needsInstall, clientVersion, osArchStr, error) // if wsh is not installed, the clientVersion will be "not-installed", and it will also return an osArchStr // if clientVersion is set, then no osArchStr will be returned func (conn *WslConn) StartConnServer(ctx context.Context, afterUpdate bool) (bool, string, string, error) { conn.Infof(ctx, "running StartConnServer...\n") allowed := WithLockRtn(conn, func() bool { return conn.Status == Status_Connecting }) if !allowed { return false, "", "", fmt.Errorf("cannot start conn server for %q when status is %q", conn.GetName(), conn.GetStatus()) } client := conn.GetClient() wshPath := conn.getWshPath() conn.Infof(ctx, "WSL-NEWSESSION (StartConnServer)\n") connServerCtx, cancelFn := context.WithCancel(context.Background()) conn.WithLock(func() { if conn.cancelFn != nil { conn.cancelFn() } conn.cancelFn = cancelFn }) devFlag := "" if wavebase.IsDevMode() { devFlag = "--dev" } cmdStr := fmt.Sprintf(ConnServerCmdTemplate, wshPath, wshPath, shellutil.HardQuote(conn.GetName()), devFlag) shWrappedCmdStr := fmt.Sprintf("sh -c %s", shellutil.HardQuote(cmdStr)) cmd := client.WslCommand(connServerCtx, shWrappedCmdStr) pipeRead, pipeWrite := io.Pipe() inputPipeRead, inputPipeWrite := io.Pipe() cmd.SetStdout(pipeWrite) cmd.SetStderr(pipeWrite) cmd.SetStdin(inputPipeRead) log.Printf("starting conn controller: %q\n", cmdStr) blocklogger.Debugf(ctx, "[conndebug] wrapped command:\n%s\n", shWrappedCmdStr) err := cmd.Start() if err != nil { return false, "", "", fmt.Errorf("unable to start conn controller cmd: %w", err) } linesChan := utilfn.StreamToLinesChan(pipeRead) versionLine, err := utilfn.ReadLineWithTimeout(linesChan, 30*time.Second) if err != nil { cancelFn() return false, "", "", fmt.Errorf("error reading wsh version: %w", err) } conn.Infof(ctx, "got connserver version: %s\n", strings.TrimSpace(versionLine)) isUpToDate, clientVersion, osArchStr, err := conncontroller.IsWshVersionUpToDate(ctx, versionLine) if err != nil { cancelFn() return false, "", "", fmt.Errorf("error checking wsh version: %w", err) } if isUpToDate && !afterUpdate && os.Getenv(wavebase.WaveWshForceUpdateVarName) != "" { isUpToDate = false conn.Infof(ctx, "%s set, forcing wsh update\n", wavebase.WaveWshForceUpdateVarName) } conn.Infof(ctx, "connserver up-to-date: %v\n", isUpToDate) if !isUpToDate { cancelFn() return true, clientVersion, osArchStr, nil } conn.WithLock(func() { conn.ConnController = cmd }) // service the I/O go func() { defer func() { panichandler.PanicHandler("wslconn:cmd.Wait", recover()) }() // wait for termination, clear the controller var waitErr error defer conn.WithLock(func() { if conn.ConnController != nil { conn.WshEnabled.Store(false) conn.NoWshReason = "connserver terminated" if waitErr != nil { conn.WshError = fmt.Sprintf("connserver terminated unexpectedly with error: %v", waitErr) } } conn.ConnController = nil }) waitErr = cmd.Wait() log.Printf("conn controller (%q) terminated: %v", conn.GetName(), waitErr) }() go func() { defer func() { panichandler.PanicHandler("wsl:StartConnServer:handleStdIOClient", recover()) }() logName := fmt.Sprintf("wslconn:%s", conn.GetName()) wshutil.HandleStdIOClient(logName, linesChan, inputPipeWrite) }() conn.Infof(ctx, "connserver started, waiting for route to be registered\n") regCtx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() err = wshutil.DefaultRouter.WaitForRegister(regCtx, wshutil.MakeConnectionRouteId(conn.GetName())) if err != nil { return false, clientVersion, "", fmt.Errorf("timeout waiting for connserver to register") } time.Sleep(300 * time.Millisecond) // TODO remove this sleep (but we need to wait until connserver is "ready") conn.Infof(ctx, "connserver is registered and ready\n") return false, clientVersion, "", nil } type WshInstallOpts struct { Force bool NoUserPrompt bool } var queryTextTemplate = strings.TrimSpace(` Wave requires Wave Shell Extensions to be installed on %q to ensure a seamless experience. Would you like to install them? `) func (conn *WslConn) UpdateWsh(ctx context.Context, clientDisplayName string, remoteInfo *wshrpc.RemoteInfo) error { conn.Infof(ctx, "attempting to update wsh for connection %s (os:%s arch:%s version:%s)\n", conn.GetName(), remoteInfo.ClientOs, remoteInfo.ClientArch, remoteInfo.ClientVersion) client := conn.GetClient() if client == nil { return fmt.Errorf("cannot update wsh: ssh client is not connected") } err := CpWshToRemote(ctx, client, remoteInfo.ClientOs, remoteInfo.ClientArch) if err != nil { return fmt.Errorf("error installing wsh to remote: %w", err) } conn.Infof(ctx, "successfully updated wsh on %s\n", conn.GetName()) return nil } // returns (allowed, error) func (conn *WslConn) getPermissionToInstallWsh(ctx context.Context, clientDisplayName string) (bool, error) { conn.Infof(ctx, "running getPermissionToInstallWsh...\n") queryText := fmt.Sprintf(queryTextTemplate, clientDisplayName) title := "Install Wave Shell Extensions" request := &userinput.UserInputRequest{ ResponseType: "confirm", QueryText: queryText, Title: title, Markdown: true, CheckBoxMsg: "Automatically install for all connections", OkLabel: "Install wsh", CancelLabel: "No wsh", } conn.Infof(ctx, "requesting user confirmation...\n") response, err := userinput.GetUserInput(ctx, request) if err != nil { conn.Infof(ctx, "error getting user input: %v\n", err) return false, err } conn.Infof(ctx, "user response to allowing wsh: %v\n", response.Confirm) meta := make(map[string]any) meta["conn:wshenabled"] = response.Confirm conn.Infof(ctx, "writing conn:wshenabled=%v to connections.json\n", response.Confirm) err = wconfig.SetConnectionsConfigValue(conn.GetName(), meta) if err != nil { log.Printf("warning: error writing to connections file: %v", err) } if !response.Confirm { return false, nil } if response.CheckboxStat { conn.Infof(ctx, "writing conn:askbeforewshinstall=false to settings.json\n") meta := waveobj.MetaMapType{ wconfig.ConfigKey_ConnAskBeforeWshInstall: false, } setConfigErr := wconfig.SetBaseConfigValue(meta) if setConfigErr != nil { // this is not a critical error, just log and continue log.Printf("warning: error writing to base config file: %v", err) } } return true, nil } func (conn *WslConn) InstallWsh(ctx context.Context, osArchStr string) error { conn.Infof(ctx, "running installWsh...\n") client := conn.GetClient() if client == nil { conn.Infof(ctx, "ERROR ssh client is not connected, cannot install\n") return fmt.Errorf("ssh client is not connected, cannot install") } var clientOs, clientArch string var err error if osArchStr != "" { clientOs, clientArch, err = GetClientPlatformFromOsArchStr(ctx, osArchStr) } else { clientOs, clientArch, err = GetClientPlatform(ctx, genconn.MakeWSLShellClient(client)) } if err != nil { conn.Infof(ctx, "ERROR detecting client platform: %v\n", err) } conn.Infof(ctx, "detected remote platform os:%s arch:%s\n", clientOs, clientArch) err = CpWshToRemote(ctx, client, clientOs, clientArch) if err != nil { conn.Infof(ctx, "ERROR copying wsh binary to remote: %v\n", err) return fmt.Errorf("error copying wsh binary to remote: %w", err) } conn.Infof(ctx, "successfully installed wsh\n") return nil } func (conn *WslConn) GetClient() *wsl.Distro { conn.Lock.Lock() defer conn.Lock.Unlock() return conn.Client } func (conn *WslConn) Reconnect(ctx context.Context) error { err := conn.Close() if err != nil { return err } return conn.Connect(ctx) } func (conn *WslConn) WaitForConnect(ctx context.Context) error { for { status := conn.DeriveConnStatus() if status.Status == Status_Connected { return nil } if status.Status == Status_Connecting { select { case <-ctx.Done(): return fmt.Errorf("context timeout") case <-time.After(100 * time.Millisecond): continue } } if status.Status == Status_Init || status.Status == Status_Disconnected { return fmt.Errorf("disconnected") } if status.Status == Status_Error { return fmt.Errorf("error: %v", status.Error) } return fmt.Errorf("unknown status: %q", status.Status) } } // does not return an error since that error is stored inside of WslConn func (conn *WslConn) Connect(ctx context.Context) error { var connectAllowed bool conn.WithLock(func() { if conn.Status == Status_Connecting || conn.Status == Status_Connected { connectAllowed = false } else { conn.Status = Status_Connecting conn.Error = "" connectAllowed = true } }) log.Printf("Connect %s\n", conn.GetName()) if !connectAllowed { conn.Infof(ctx, "cannot connect to %q when status is %q\n", conn.GetName(), conn.GetStatus()) return fmt.Errorf("cannot connect to %q when status is %q", conn.GetName(), conn.GetStatus()) } conn.FireConnChangeEvent() err := conn.connectInternal(ctx) conn.WithLock(func() { if err != nil { conn.Infof(ctx, "ERROR %v\n\n", err) conn.Status = Status_Error conn.Error = err.Error() conn.close_nolock() telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{ Conn: map[string]int{"wsl:connecterror": 1}, }, "wsl-connconnect") telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "conn:connecterror", Props: telemetrydata.TEventProps{ ConnType: "wsl", }, }) } else { conn.Infof(ctx, "successfully connected (wsh:%v)\n\n", conn.WshEnabled.Load()) conn.Status = Status_Connected conn.LastConnectTime = time.Now().UnixMilli() if conn.ActiveConnNum == 0 { conn.ActiveConnNum = int(activeConnCounter.Add(1)) } telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{ Conn: map[string]int{"wsl:connect": 1}, }, "wsl-connconnect") telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ Event: "conn:connect", Props: telemetrydata.TEventProps{ ConnType: "wsl", }, }) } }) conn.FireConnChangeEvent() return err } func (conn *WslConn) WithLock(fn func()) { conn.Lock.Lock() defer conn.Lock.Unlock() fn() } func WithLockRtn[T any](conn *WslConn, fn func() T) T { conn.Lock.Lock() defer conn.Lock.Unlock() return fn() } // returns (enable-wsh, ask-before-install) func (conn *WslConn) getConnWshSettings() (bool, bool) { config := wconfig.GetWatcher().GetFullConfig() enableWsh := config.Settings.ConnWshEnabled askBeforeInstall := wconfig.DefaultBoolPtr(config.Settings.ConnAskBeforeWshInstall, true) connSettings, ok := conn.getConnectionConfig() if ok { if connSettings.ConnWshEnabled != nil { enableWsh = *connSettings.ConnWshEnabled } // if the connection object exists, and conn:askbeforewshinstall is not set, the user must have allowed it // TODO: in v0.12+ this should be removed. we'll explicitly write a "false" into the connection object on successful connection if connSettings.ConnAskBeforeWshInstall == nil { askBeforeInstall = false } else { askBeforeInstall = *connSettings.ConnAskBeforeWshInstall } } return enableWsh, askBeforeInstall } type WshCheckResult struct { WshEnabled bool ClientVersion string NoWshReason string WshError error } // returns (wsh-enabled, clientVersion, text-reason, wshError) func (conn *WslConn) tryEnableWsh(ctx context.Context, clientDisplayName string) WshCheckResult { conn.Infof(ctx, "running tryEnableWsh...\n") enableWsh, askBeforeInstall := conn.getConnWshSettings() conn.Infof(ctx, "wsh settings enable:%v ask:%v\n", enableWsh, askBeforeInstall) if !enableWsh { return WshCheckResult{NoWshReason: "conn:wshenabled set to false"} } if askBeforeInstall { allowInstall, err := conn.getPermissionToInstallWsh(ctx, clientDisplayName) if err != nil { log.Printf("error getting permission to install wsh: %v\n", err) return WshCheckResult{NoWshReason: "error getting user permission to install", WshError: err} } if !allowInstall { return WshCheckResult{NoWshReason: "user selected not to install wsh extensions"} } } err := conn.OpenDomainSocketListener(ctx) if err != nil { conn.Infof(ctx, "ERROR opening domain socket listener: %v\n", err) err = fmt.Errorf("error opening domain socket listener: %w", err) return WshCheckResult{NoWshReason: "error opening domain socket", WshError: err} } needsInstall, clientVersion, osArchStr, err := conn.StartConnServer(ctx, false) if err != nil { conn.Infof(ctx, "ERROR starting conn server: %v\n", err) err = fmt.Errorf("error starting conn server: %w", err) return WshCheckResult{NoWshReason: "error starting connserver", WshError: err} } if needsInstall { conn.Infof(ctx, "connserver needs to be (re)installed\n") err = conn.InstallWsh(ctx, osArchStr) if err != nil { conn.Infof(ctx, "ERROR installing wsh: %v\n", err) err = fmt.Errorf("error installing wsh: %w", err) return WshCheckResult{NoWshReason: "error installing wsh/connserver", WshError: err} } needsInstall, clientVersion, _, err = conn.StartConnServer(ctx, true) if err != nil { conn.Infof(ctx, "ERROR starting conn server (after install): %v\n", err) err = fmt.Errorf("error starting conn server (after install): %w", err) return WshCheckResult{NoWshReason: "error starting connserver", WshError: err} } if needsInstall { conn.Infof(ctx, "conn server not installed correctly (after install)\n") err = fmt.Errorf("conn server not installed correctly (after install)") return WshCheckResult{NoWshReason: "connserver not installed properly", WshError: err} } return WshCheckResult{WshEnabled: true, ClientVersion: clientVersion} } else { return WshCheckResult{WshEnabled: true, ClientVersion: clientVersion} } } func (conn *WslConn) getConnectionConfig() (wconfig.ConnKeywords, bool) { config := wconfig.GetWatcher().GetFullConfig() connSettings, ok := config.Connections[conn.GetName()] if !ok { return wconfig.ConnKeywords{}, false } return connSettings, true } func (conn *WslConn) persistWshInstalled(ctx context.Context, result WshCheckResult) { conn.WshEnabled.Store(result.WshEnabled) conn.SetWshError(result.WshError) conn.WithLock(func() { conn.NoWshReason = result.NoWshReason conn.WshVersion = result.ClientVersion }) connConfig, ok := conn.getConnectionConfig() if ok && connConfig.ConnWshEnabled != nil { return } meta := make(map[string]any) meta["conn:wshenabled"] = result.WshEnabled err := wconfig.SetConnectionsConfigValue(conn.GetName(), meta) if err != nil { conn.Infof(ctx, "WARN could not write conn:wshenabled=%v to connections.json: %v\n", result.WshEnabled, err) log.Printf("warning: error writing to connections file: %v", err) } // doesn't return an error since none of this is required for connection to work } func (conn *WslConn) connectInternal(ctx context.Context) error { conn.Infof(ctx, "connectInternal %s\n", conn.GetName()) client, err := wsl.GetDistro(ctx, conn.Name) if err != nil { conn.Infof(ctx, "ERROR GetDistro: %s\n", err) log.Printf("error: failed to get distro %s: %s\n", conn.GetName(), err) return err } conn.WithLock(func() { conn.Client = client }) go func() { defer func() { panichandler.PanicHandler("wsl-waitForDisconnect", recover()) }() conn.waitForDisconnect() }() wshResult := conn.tryEnableWsh(ctx, conn.GetName()) if !wshResult.WshEnabled { if wshResult.WshError != nil { conn.Infof(ctx, "ERROR enabling wsh: %v\n", wshResult.WshError) conn.Infof(ctx, "will connect with wsh disabled\n") } else { conn.Infof(ctx, "wsh not enabled: %s\n", wshResult.NoWshReason) } } conn.persistWshInstalled(ctx, wshResult) return nil } func (conn *WslConn) waitForDisconnect() { log.Printf("wait for disconnect in %+#v", conn) defer conn.FireConnChangeEvent() defer conn.HasWaiter.Store(false) if conn.ConnController == nil { return } err := conn.ConnController.Wait() conn.WithLock(func() { // disconnects happen for a variety of reasons (like network, etc. and are typically transient) // so we just set the status to "disconnected" here (not error) // don't overwrite any existing error (or error status) if err != nil && conn.Error == "" { conn.Error = err.Error() } if conn.Status != Status_Error { conn.Status = Status_Disconnected } conn.close_nolock() }) } func (conn *WslConn) SetWshError(err error) { conn.WithLock(func() { if err == nil { conn.WshError = "" } else { conn.WshError = err.Error() } }) } func (conn *WslConn) ClearWshError() { conn.WithLock(func() { conn.WshError = "" }) } func getConnInternal(name string) *WslConn { globalLock.Lock() defer globalLock.Unlock() connName := wsl.WslName{Distro: name} rtn := clientControllerMap[name] if rtn == nil { rtn = &WslConn{Lock: &sync.Mutex{}, Status: Status_Init, Name: connName, WshEnabled: &atomic.Bool{}, HasWaiter: &atomic.Bool{}, cancelFn: nil} clientControllerMap[name] = rtn } return rtn } func GetWslConn(name string) *WslConn { conn := getConnInternal(name) return conn } // Convenience function for ensuring a connection is established func EnsureConnection(ctx context.Context, connName string) error { if connName == "" { return nil } conn := GetWslConn(connName) if conn == nil { return fmt.Errorf("connection not found: %s", connName) } connStatus := conn.DeriveConnStatus() switch connStatus.Status { case Status_Connected: return nil case Status_Connecting: return conn.WaitForConnect(ctx) case Status_Init, Status_Disconnected: return conn.Connect(ctx) case Status_Error: return fmt.Errorf("connection error: %s", connStatus.Error) default: return fmt.Errorf("unknown connection status %q", connStatus.Status) } } func DisconnectClient(connName string) error { conn := getConnInternal(connName) if conn == nil { return fmt.Errorf("client %q not found", connName) } err := conn.Close() return err } ================================================ FILE: pkg/wstore/wstore.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wstore import ( "context" "fmt" "sync" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" ) func init() { for _, rtype := range waveobj.AllWaveObjTypes() { waveobj.RegisterType(rtype) } } var ( clientIdLock sync.Mutex cachedClientId string ) func SetClientId(clientId string) { clientIdLock.Lock() defer clientIdLock.Unlock() cachedClientId = clientId } // in the main server, this will not return empty string // it does return empty in wsh, but all wstore methods are invalid in wsh mode, so that shouldn't be an issue func GetClientId() string { clientIdLock.Lock() defer clientIdLock.Unlock() if wavebase.IsDevMode() && cachedClientId == "" { panic("cachedClientId is empty") } return cachedClientId } func UpdateTabName(ctx context.Context, tabId, name string) error { return WithTx(ctx, func(tx *TxWrap) error { tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) if tab == nil { return fmt.Errorf("tab not found: %q", tabId) } if tabId != "" { tab.Name = name DBUpdate(tx.Context(), tab) } return nil }) } func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaMapType, mergeSpecial bool) error { return WithTx(ctx, func(tx *TxWrap) error { if oref.IsEmpty() { return fmt.Errorf("empty object reference") } obj, _ := DBGetORef(tx.Context(), oref) if obj == nil { return ErrNotFound } objMeta := waveobj.GetMeta(obj) if objMeta == nil { objMeta = make(map[string]any) } newMeta := waveobj.MergeMeta(objMeta, meta, mergeSpecial) waveobj.SetMeta(obj, newMeta) DBUpdate(tx.Context(), obj) return nil }) } ================================================ FILE: pkg/wstore/wstore_dboldmigration.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wstore import ( "context" "fmt" "log" "time" "github.com/jmoiron/sqlx" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" ) const OldDBName = "~/.waveterm/waveterm.db" func GetOldDBName() string { return wavebase.ExpandHomeDirSafe(OldDBName) } func MakeOldDB(ctx context.Context) (*sqlx.DB, error) { dbName := GetOldDBName() rtn, err := sqlx.Open("sqlite3", fmt.Sprintf("file:%s?mode=ro&_busy_timeout=5000", dbName)) if err != nil { return nil, err } rtn.DB.SetMaxOpenConns(1) return rtn, nil } type OldHistoryType struct { HistoryId string Ts int64 RemoteName string HadError bool CmdStr string ExitCode int DurationMs int64 } func GetAllOldHistory() ([]*OldHistoryType, error) { query := ` SELECT h.historyid, h.ts, COALESCE(r.remotecanonicalname, '') as remotename, h.haderror, h.cmdstr, COALESCE(h.exitcode, 0) as exitcode, COALESCE(h.durationms, 0) as durationms FROM history h, remote r WHERE h.remoteid = r.remoteid AND NOT h.ismetacmd ` db, err := MakeOldDB(context.Background()) if err != nil { return nil, err } defer db.Close() var rtn []*OldHistoryType err = db.Select(&rtn, query) if err != nil { return nil, err } return rtn, nil } func ReplaceOldHistory(ctx context.Context, hist []*OldHistoryType) error { return WithTx(ctx, func(tx *TxWrap) error { query := `DELETE FROM history_migrated` tx.Exec(query) query = `INSERT INTO history_migrated (historyid, ts, remotename, haderror, cmdstr, exitcode, durationms) VALUES (?, ?, ?, ?, ?, ?, ?)` for _, hobj := range hist { tx.Exec(query, hobj.HistoryId, hobj.Ts, hobj.RemoteName, hobj.HadError, hobj.CmdStr, hobj.ExitCode, hobj.DurationMs) } return nil }) } func TryMigrateOldHistory() error { ctx, cancelFn := context.WithTimeout(context.Background(), 10*time.Second) defer cancelFn() hist, err := GetAllOldHistory() if err != nil { return err } if len(hist) == 0 { return nil } err = ReplaceOldHistory(ctx, hist) if err != nil { return err } log.Printf("migrated %d old wave history records\n", len(hist)) client, err := DBGetSingleton[*waveobj.Client](ctx) if err != nil { return err } client.HasOldHistory = true err = DBUpdate(ctx, client) if err != nil { return err } return nil } ================================================ FILE: pkg/wstore/wstore_dbops.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wstore import ( "context" "fmt" "log" "reflect" "regexp" "time" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/util/dbutil" "github.com/wavetermdev/waveterm/pkg/waveobj" ) var ErrNotFound = fmt.Errorf("not found") func waveObjTableName(w waveobj.WaveObj) string { return "db_" + w.GetOType() } func tableNameFromOType(otype string) string { return "db_" + otype } func tableNameGen[T waveobj.WaveObj]() string { var zeroObj T return tableNameFromOType(zeroObj.GetOType()) } func getOTypeGen[T waveobj.WaveObj]() string { var zeroObj T return zeroObj.GetOType() } func DBGetCount[T waveobj.WaveObj](ctx context.Context) (int, error) { return WithTxRtn(ctx, func(tx *TxWrap) (int, error) { table := tableNameGen[T]() query := fmt.Sprintf("SELECT count(*) FROM %s", table) return tx.GetInt(query), nil }) } // returns (num named workespaces, num total workspaces, error) func DBGetWSCounts(ctx context.Context) (int, int, error) { var named, total int err := WithTx(ctx, func(tx *TxWrap) error { query := `SELECT count(*) FROM db_workspace WHERE COALESCE(json_extract(data, '$.name'), '') <> ''` named = tx.GetInt(query) query = `SELECT count(*) FROM db_workspace` total = tx.GetInt(query) return nil }) if err != nil { return 0, 0, err } return named, total, nil } var viewRe = regexp.MustCompile(`^[a-z0-9]{1,20}$`) func DBGetBlockViewCounts(ctx context.Context) (map[string]int, error) { return WithTxRtn(ctx, func(tx *TxWrap) (map[string]int, error) { query := `SELECT COALESCE(json_extract(data, '$.meta.view'), '') AS view FROM db_block` views := tx.SelectStrings(query) rtn := make(map[string]int) for _, view := range views { if view == "" { continue } if !viewRe.MatchString(view) { continue } rtn[view]++ } return rtn, nil }) } type idDataType struct { OId string Version int Data []byte } func genericCastWithErr[T any](v any, err error) (T, error) { if err != nil { var zeroVal T return zeroVal, err } if v == nil { var zeroVal T return zeroVal, nil } return v.(T), err } func DBGetSingleton[T waveobj.WaveObj](ctx context.Context) (T, error) { rtn, err := DBGetSingletonByType(ctx, getOTypeGen[T]()) return genericCastWithErr[T](rtn, err) } func DBGetSingletonByType(ctx context.Context, otype string) (waveobj.WaveObj, error) { return WithTxRtn(ctx, func(tx *TxWrap) (waveobj.WaveObj, error) { table := tableNameFromOType(otype) query := fmt.Sprintf("SELECT oid, version, data FROM %s LIMIT 1", table) var row idDataType found := tx.Get(&row, query) if !found { return nil, ErrNotFound } rtn, err := waveobj.FromJson(row.Data) if err != nil { return rtn, err } waveobj.SetVersion(rtn, row.Version) return rtn, nil }) } func DBExistsORef(ctx context.Context, oref waveobj.ORef) (bool, error) { return WithTxRtn(ctx, func(tx *TxWrap) (bool, error) { table := tableNameFromOType(oref.OType) query := fmt.Sprintf("SELECT oid FROM %s WHERE oid = ?", table) return tx.Exists(query, oref.OID), nil }) } func DBGet[T waveobj.WaveObj](ctx context.Context, id string) (T, error) { rtn, err := DBGetORef(ctx, waveobj.ORef{OType: getOTypeGen[T](), OID: id}) return genericCastWithErr[T](rtn, err) } func DBMustGet[T waveobj.WaveObj](ctx context.Context, id string) (T, error) { rtn, err := DBGetORef(ctx, waveobj.ORef{OType: getOTypeGen[T](), OID: id}) if err != nil { var zeroVal T return zeroVal, err } if rtn == nil { var zeroVal T return zeroVal, ErrNotFound } return rtn.(T), nil } func DBGetORef(ctx context.Context, oref waveobj.ORef) (waveobj.WaveObj, error) { return WithTxRtn(ctx, func(tx *TxWrap) (waveobj.WaveObj, error) { table := tableNameFromOType(oref.OType) query := fmt.Sprintf("SELECT oid, version, data FROM %s WHERE oid = ?", table) var row idDataType found := tx.Get(&row, query, oref.OID) if !found { return nil, nil } rtn, err := waveobj.FromJson(row.Data) if err != nil { return rtn, err } waveobj.SetVersion(rtn, row.Version) return rtn, nil }) } func dbSelectOIDs(ctx context.Context, otype string, oids []string) ([]waveobj.WaveObj, error) { return WithTxRtn(ctx, func(tx *TxWrap) ([]waveobj.WaveObj, error) { table := tableNameFromOType(otype) query := fmt.Sprintf("SELECT oid, version, data FROM %s WHERE oid IN (SELECT value FROM json_each(?))", table) var rows []idDataType tx.Select(&rows, query, dbutil.QuickJson(oids)) rtn := make([]waveobj.WaveObj, 0, len(rows)) for _, row := range rows { waveObj, err := waveobj.FromJson(row.Data) if err != nil { return nil, err } waveobj.SetVersion(waveObj, row.Version) rtn = append(rtn, waveObj) } return rtn, nil }) } func DBSelectORefs(ctx context.Context, orefs []waveobj.ORef) ([]waveobj.WaveObj, error) { oidsByType := make(map[string][]string) for _, oref := range orefs { oidsByType[oref.OType] = append(oidsByType[oref.OType], oref.OID) } return WithTxRtn(ctx, func(tx *TxWrap) ([]waveobj.WaveObj, error) { rtn := make([]waveobj.WaveObj, 0, len(orefs)) for otype, oids := range oidsByType { rtnArr, err := dbSelectOIDs(tx.Context(), otype, oids) if err != nil { return nil, err } rtn = append(rtn, rtnArr...) } return rtn, nil }) } func DBGetAllOIDsByType(ctx context.Context, otype string) ([]string, error) { return WithTxRtn(ctx, func(tx *TxWrap) ([]string, error) { rtn := make([]string, 0) table := tableNameFromOType(otype) query := fmt.Sprintf("SELECT oid FROM %s", table) var rows []idDataType tx.Select(&rows, query) for _, row := range rows { rtn = append(rtn, row.OId) } return rtn, nil }) } func DBGetAllObjsByType[T waveobj.WaveObj](ctx context.Context, otype string) ([]T, error) { return WithTxRtn(ctx, func(tx *TxWrap) ([]T, error) { rtn := make([]T, 0) table := tableNameFromOType(otype) query := fmt.Sprintf("SELECT oid, version, data FROM %s", table) var rows []idDataType tx.Select(&rows, query) for _, row := range rows { waveObj, err := waveobj.FromJson(row.Data) if err != nil { return nil, err } waveobj.SetVersion(waveObj, row.Version) rtn = append(rtn, waveObj.(T)) } return rtn, nil }) } func DBResolveEasyOID(ctx context.Context, oid string) (*waveobj.ORef, error) { return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.ORef, error) { for _, rtype := range waveobj.AllWaveObjTypes() { otype := reflect.Zero(rtype).Interface().(waveobj.WaveObj).GetOType() table := tableNameFromOType(otype) var fullOID string if len(oid) == 8 { query := fmt.Sprintf("SELECT oid FROM %s WHERE oid LIKE ?", table) fullOID = tx.GetString(query, oid+"%") } else { query := fmt.Sprintf("SELECT oid FROM %s WHERE oid = ?", table) fullOID = tx.GetString(query, oid) } if fullOID != "" { oref := waveobj.MakeORef(otype, fullOID) return &oref, nil } } return nil, ErrNotFound }) } func DBSelectMap[T waveobj.WaveObj](ctx context.Context, ids []string) (map[string]T, error) { rtnArr, err := dbSelectOIDs(ctx, getOTypeGen[T](), ids) if err != nil { return nil, err } rtnMap := make(map[string]T) for _, obj := range rtnArr { rtnMap[waveobj.GetOID(obj)] = obj.(T) } return rtnMap, nil } func DBDelete(ctx context.Context, otype string, id string) error { err := WithTx(ctx, func(tx *TxWrap) error { table := tableNameFromOType(otype) query := fmt.Sprintf("DELETE FROM %s WHERE oid = ?", table) tx.Exec(query, id) waveobj.ContextAddUpdate(ctx, waveobj.WaveObjUpdate{UpdateType: waveobj.UpdateType_Delete, OType: otype, OID: id}) return nil }) if err != nil { return err } go func() { defer func() { panichandler.PanicHandler("DBDelete:filestore.DeleteZone", recover()) }() // we spawn a go routine here because we don't want to reuse the DB connection // since DBDelete is called in a transaction from DeleteTab deleteCtx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() err := filestore.WFS.DeleteZone(deleteCtx, id) if err != nil { log.Printf("error deleting filestore zone (after deleting block): %v", err) } }() return nil } func DBUpdate(ctx context.Context, val waveobj.WaveObj) error { oid := waveobj.GetOID(val) if oid == "" { return fmt.Errorf("cannot update %T value with empty id", val) } jsonData, err := waveobj.ToJson(val) if err != nil { return err } return WithTx(ctx, func(tx *TxWrap) error { table := waveObjTableName(val) query := fmt.Sprintf("UPDATE %s SET data = ?, version = version+1 WHERE oid = ? RETURNING version", table) newVersion := tx.GetInt(query, jsonData, oid) waveobj.SetVersion(val, newVersion) waveobj.ContextAddUpdate(ctx, waveobj.WaveObjUpdate{UpdateType: waveobj.UpdateType_Update, OType: val.GetOType(), OID: oid, Obj: val}) return nil }) } func DBUpdateFn[T waveobj.WaveObj](ctx context.Context, id string, updateFn func(T)) error { return WithTx(ctx, func(tx *TxWrap) error { val, err := DBMustGet[T](tx.Context(), id) if err != nil { return err } updateFn(val) return DBUpdate(tx.Context(), val) }) } func DBUpdateFnErr[T waveobj.WaveObj](ctx context.Context, id string, updateFn func(T) error) error { return WithTx(ctx, func(tx *TxWrap) error { val, err := DBMustGet[T](tx.Context(), id) if err != nil { return err } err = updateFn(val) if err != nil { return err } return DBUpdate(tx.Context(), val) }) } func DBInsert(ctx context.Context, val waveobj.WaveObj) error { oid := waveobj.GetOID(val) if oid == "" { return fmt.Errorf("cannot insert %T value with empty id", val) } jsonData, err := waveobj.ToJson(val) if err != nil { return err } return WithTx(ctx, func(tx *TxWrap) error { table := waveObjTableName(val) waveobj.SetVersion(val, 1) query := fmt.Sprintf("INSERT INTO %s (oid, version, data) VALUES (?, ?, ?)", table) tx.Exec(query, oid, 1, jsonData) waveobj.ContextAddUpdate(ctx, waveobj.WaveObjUpdate{UpdateType: waveobj.UpdateType_Update, OType: val.GetOType(), OID: oid, Obj: val}) return nil }) } func DBFindTabForBlockId(ctx context.Context, blockId string) (string, error) { return WithTxRtn(ctx, func(tx *TxWrap) (string, error) { iterNum := 1 for { if iterNum > 5 { return "", fmt.Errorf("too many iterations looking for tab in block parents") } query := ` SELECT json_extract(b.data, '$.parentoref') AS parentoref FROM db_block b WHERE b.oid = ?;` parentORef := tx.GetString(query, blockId) oref, err := waveobj.ParseORef(parentORef) if err != nil { return "", fmt.Errorf("bad block parent oref: %v", err) } if oref.OType == "tab" { return oref.OID, nil } if oref.OType == "block" { blockId = oref.OID iterNum++ continue } return "", fmt.Errorf("bad parent oref type: %v", oref.OType) } }) } func DBFindWorkspaceForTabId(ctx context.Context, tabId string) (string, error) { return WithTxRtn(ctx, func(tx *TxWrap) (string, error) { query := ` WITH variable(value) AS ( SELECT ? ) SELECT w.oid FROM db_workspace w, variable WHERE EXISTS ( SELECT 1 FROM json_each(w.data, '$.tabids') AS je WHERE je.value = variable.value ); ` wsId := tx.GetString(query, tabId) return wsId, nil }) } func DBFindWindowForWorkspaceId(ctx context.Context, workspaceId string) (string, error) { return WithTxRtn(ctx, func(tx *TxWrap) (string, error) { query := ` SELECT w.oid FROM db_window w WHERE json_extract(data, '$.workspaceid') = ?` return tx.GetString(query, workspaceId), nil }) } ================================================ FILE: pkg/wstore/wstore_dbsetup.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wstore import ( "context" "fmt" "log" "path/filepath" "time" "github.com/jmoiron/sqlx" "github.com/sawka/txwrap" "github.com/wavetermdev/waveterm/pkg/util/migrateutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" dbfs "github.com/wavetermdev/waveterm/db" ) const WStoreDBName = "waveterm.db" type TxWrap = txwrap.TxWrap var globalDB *sqlx.DB func InitWStore() error { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() var err error globalDB, err = MakeDB(ctx) if err != nil { return err } err = migrateutil.Migrate("wstore", globalDB.DB, dbfs.WStoreMigrationFS, "migrations-wstore") if err != nil { return err } log.Printf("wstore initialized\n") return nil } func GetDBName() string { waveHome := wavebase.GetWaveDataDir() return filepath.Join(waveHome, wavebase.WaveDBDir, WStoreDBName) } func MakeDB(ctx context.Context) (*sqlx.DB, error) { dbName := GetDBName() rtn, err := sqlx.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc&_journal_mode=WAL&_busy_timeout=5000", dbName)) if err != nil { return nil, err } rtn.DB.SetMaxOpenConns(1) return rtn, nil } func WithTx(ctx context.Context, fn func(tx *TxWrap) error) (rtnErr error) { waveobj.ContextUpdatesBeginTx(ctx) defer func() { if rtnErr != nil { waveobj.ContextUpdatesRollbackTx(ctx) } else { waveobj.ContextUpdatesCommitTx(ctx) } }() return txwrap.WithTx(ctx, globalDB, fn) } func WithTxRtn[RT any](ctx context.Context, fn func(tx *TxWrap) (RT, error)) (rtnVal RT, rtnErr error) { waveobj.ContextUpdatesBeginTx(ctx) defer func() { if rtnErr != nil { waveobj.ContextUpdatesRollbackTx(ctx) } else { waveobj.ContextUpdatesCommitTx(ctx) } }() return txwrap.WithTxRtn(ctx, globalDB, fn) } ================================================ FILE: pkg/wstore/wstore_rtinfo.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wstore import ( "reflect" "strings" "sync" "github.com/wavetermdev/waveterm/pkg/waveobj" ) var ( rtInfoStore = make(map[waveobj.ORef]*waveobj.ObjRTInfo) rtInfoMutex sync.RWMutex ) func setFieldValue(fieldValue reflect.Value, value any) { if value == nil { fieldValue.Set(reflect.Zero(fieldValue.Type())) return } if valueStr, ok := value.(string); ok && fieldValue.Kind() == reflect.String { fieldValue.SetString(valueStr) return } if valueBool, ok := value.(bool); ok && fieldValue.Kind() == reflect.Bool { fieldValue.SetBool(valueBool) return } if fieldValue.Kind() == reflect.Int { switch v := value.(type) { case int: fieldValue.SetInt(int64(v)) case int64: fieldValue.SetInt(v) case float64: fieldValue.SetInt(int64(v)) } return } if fieldValue.Kind() == reflect.Map { if fieldValue.Type().Key().Kind() == reflect.String && fieldValue.Type().Elem().Kind() == reflect.Float64 { if inputMap, ok := value.(map[string]any); ok { outputMap := make(map[string]float64) for k, v := range inputMap { if floatVal, ok := v.(float64); ok { outputMap[k] = floatVal } } fieldValue.Set(reflect.ValueOf(outputMap)) } return } if fieldValue.Type().Key().Kind() == reflect.String && fieldValue.Type().Elem().Kind() == reflect.String { if inputMap, ok := value.(map[string]any); ok { outputMap := make(map[string]string) for k, v := range inputMap { if strVal, ok := v.(string); ok { outputMap[k] = strVal } } fieldValue.Set(reflect.ValueOf(outputMap)) } return } return } if fieldValue.Kind() == reflect.Interface { fieldValue.Set(reflect.ValueOf(value)) } } // SetRTInfo merges the provided info map into the ObjRTInfo for the given ORef. // Only updates fields that exist in the ObjRTInfo struct. // Removes fields that have nil values. func SetRTInfo(oref waveobj.ORef, info map[string]any) { rtInfoMutex.Lock() defer rtInfoMutex.Unlock() rtInfo, exists := rtInfoStore[oref] if !exists { rtInfo = &waveobj.ObjRTInfo{} rtInfoStore[oref] = rtInfo } rtInfoValue := reflect.ValueOf(rtInfo).Elem() rtInfoType := rtInfoValue.Type() // Build a map of json tags to field indices for quick lookup jsonTagToField := make(map[string]int) for i := 0; i < rtInfoType.NumField(); i++ { field := rtInfoType.Field(i) jsonTag := field.Tag.Get("json") if jsonTag != "" { // Remove omitempty and other options tagParts := strings.Split(jsonTag, ",") if len(tagParts) > 0 && tagParts[0] != "" { jsonTagToField[tagParts[0]] = i } } } // Merge the info map into the struct for key, value := range info { fieldIndex, exists := jsonTagToField[key] if !exists { continue // Skip keys that don't exist in the struct } fieldValue := rtInfoValue.Field(fieldIndex) if !fieldValue.CanSet() { continue } setFieldValue(fieldValue, value) } } // GetRTInfo returns the ObjRTInfo for the given ORef, or nil if not found func GetRTInfo(oref waveobj.ORef) *waveobj.ObjRTInfo { rtInfoMutex.RLock() defer rtInfoMutex.RUnlock() if rtInfo, exists := rtInfoStore[oref]; exists { // Return a copy to avoid external modification copy := *rtInfo return © } return nil } // DeleteRTInfo removes the ObjRTInfo for the given ORef func DeleteRTInfo(oref waveobj.ORef) { rtInfoMutex.Lock() defer rtInfoMutex.Unlock() delete(rtInfoStore, oref) } ================================================ FILE: postinstall.cjs ================================================ const skip = process.env.WAVETERM_SKIP_APP_DEPS === "1" || process.env.CF_PAGES === "1" || process.env.CF_PAGES === "true"; if (skip) { console.log("postinstall: skipping electron-builder install-app-deps"); process.exit(0); } import("child_process").then(({ execSync }) => { execSync("electron-builder install-app-deps", { stdio: "inherit" }); }); ================================================ FILE: prettier.config.cjs ================================================ /** @type {import("prettier").Config} */ module.exports = { plugins: ["prettier-plugin-jsdoc", "prettier-plugin-organize-imports"], printWidth: 120, trailingComma: "es5", useTabs: false, jsdocVerticalAlignment: true, jsdocSeparateReturnsFromParam: true, jsdocSeparateTagGroups: true, jsdocPreferCodeFences: true, }; ================================================ FILE: schema/aipresets.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "AiSettingsType": { "properties": { "ai:*": { "type": "boolean" }, "ai:preset": { "type": "string" }, "ai:apitype": { "type": "string" }, "ai:baseurl": { "type": "string" }, "ai:apitoken": { "type": "string" }, "ai:name": { "type": "string" }, "ai:model": { "type": "string" }, "ai:orgid": { "type": "string" }, "ai:apiversion": { "type": "string" }, "ai:maxtokens": { "type": "number" }, "ai:timeoutms": { "type": "number" }, "ai:proxyurl": { "type": "string" }, "ai:fontsize": { "type": "number" }, "ai:fixedfontsize": { "type": "number" }, "display:name": { "type": "string" }, "display:order": { "type": "number" } }, "additionalProperties": false, "type": "object" } }, "additionalProperties": { "$ref": "#/$defs/AiSettingsType" }, "type": "object" } ================================================ FILE: schema/bgpresets.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "BgPresetsType": { "properties": { "bg:*": { "type": "boolean" }, "bg": { "type": "string", "description": "CSS background property value" }, "bg:opacity": { "type": "number", "description": "Background opacity (0.0-1.0)" }, "bg:blendmode": { "type": "string", "description": "CSS background-blend-mode property value" }, "bg:bordercolor": { "type": "string", "description": "Block frame border color" }, "bg:activebordercolor": { "type": "string", "description": "Block frame focused border color" }, "display:name": { "type": "string", "description": "The name shown in the context menu" }, "display:order": { "type": "number", "description": "Determines the order of the background in the context menu" } }, "additionalProperties": false, "type": "object" } }, "additionalProperties": { "$ref": "#/$defs/BgPresetsType" }, "type": "object" } ================================================ FILE: schema/connections.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "ConnKeywords": { "properties": { "conn:wshenabled": { "type": "boolean" }, "conn:askbeforewshinstall": { "type": "boolean" }, "conn:wshpath": { "type": "string" }, "conn:shellpath": { "type": "string" }, "conn:ignoresshconfig": { "type": "boolean" }, "display:hidden": { "type": "boolean" }, "display:order": { "type": "number" }, "term:*": { "type": "boolean" }, "term:fontsize": { "type": "number" }, "term:fontfamily": { "type": "string" }, "term:theme": { "type": "string" }, "term:durable": { "type": "boolean" }, "cmd:env": { "additionalProperties": { "type": "string" }, "type": "object" }, "cmd:initscript": { "type": "string" }, "cmd:initscript.sh": { "type": "string" }, "cmd:initscript.bash": { "type": "string" }, "cmd:initscript.zsh": { "type": "string" }, "cmd:initscript.pwsh": { "type": "string" }, "cmd:initscript.fish": { "type": "string" }, "ssh:user": { "type": "string" }, "ssh:hostname": { "type": "string" }, "ssh:port": { "type": "string" }, "ssh:identityfile": { "items": { "type": "string" }, "type": "array" }, "ssh:passwordsecretname": { "type": "string" }, "ssh:batchmode": { "type": "boolean" }, "ssh:pubkeyauthentication": { "type": "boolean" }, "ssh:passwordauthentication": { "type": "boolean" }, "ssh:kbdinteractiveauthentication": { "type": "boolean" }, "ssh:preferredauthentications": { "items": { "type": "string" }, "type": "array" }, "ssh:addkeystoagent": { "type": "boolean" }, "ssh:identityagent": { "type": "string" }, "ssh:identitiesonly": { "type": "boolean" }, "ssh:proxyjump": { "items": { "type": "string" }, "type": "array" }, "ssh:userknownhostsfile": { "items": { "type": "string" }, "type": "array" }, "ssh:globalknownhostsfile": { "items": { "type": "string" }, "type": "array" } }, "additionalProperties": false, "type": "object" } }, "additionalProperties": { "$ref": "#/$defs/ConnKeywords" }, "type": "object" } ================================================ FILE: schema/settings.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/wavetermdev/waveterm/pkg/wconfig/settings-type", "$ref": "#/$defs/SettingsType", "$defs": { "SettingsType": { "properties": { "app:*": { "type": "boolean" }, "app:globalhotkey": { "type": "string" }, "app:dismissarchitecturewarning": { "type": "boolean" }, "app:defaultnewblock": { "type": "string" }, "app:showoverlayblocknums": { "type": "boolean" }, "app:ctrlvpaste": { "type": "boolean" }, "app:confirmquit": { "type": "boolean" }, "app:hideaibutton": { "type": "boolean" }, "app:disablectrlshiftarrows": { "type": "boolean" }, "app:disablectrlshiftdisplay": { "type": "boolean" }, "app:focusfollowscursor": { "type": "string", "enum": [ "off", "on", "term" ] }, "app:tabbar": { "type": "string", "enum": [ "top", "left" ] }, "feature:waveappbuilder": { "type": "boolean" }, "ai:*": { "type": "boolean" }, "ai:preset": { "type": "string" }, "ai:apitype": { "type": "string" }, "ai:baseurl": { "type": "string" }, "ai:apitoken": { "type": "string" }, "ai:name": { "type": "string" }, "ai:model": { "type": "string" }, "ai:orgid": { "type": "string" }, "ai:apiversion": { "type": "string" }, "ai:maxtokens": { "type": "number" }, "ai:timeoutms": { "type": "number" }, "ai:proxyurl": { "type": "string" }, "ai:fontsize": { "type": "number" }, "ai:fixedfontsize": { "type": "number" }, "waveai:showcloudmodes": { "type": "boolean" }, "waveai:defaultmode": { "type": "string" }, "term:*": { "type": "boolean" }, "term:fontsize": { "type": "number" }, "term:fontfamily": { "type": "string" }, "term:theme": { "type": "string" }, "term:disablewebgl": { "type": "boolean" }, "term:localshellpath": { "type": "string" }, "term:localshellopts": { "items": { "type": "string" }, "type": "array" }, "term:gitbashpath": { "type": "string" }, "term:scrollback": { "type": "integer" }, "term:copyonselect": { "type": "boolean" }, "term:transparency": { "type": "number" }, "term:allowbracketedpaste": { "type": "boolean" }, "term:shiftenternewline": { "type": "boolean" }, "term:macoptionismeta": { "type": "boolean" }, "term:cursor": { "type": "string" }, "term:cursorblink": { "type": "boolean" }, "term:bellsound": { "type": "boolean" }, "term:bellindicator": { "type": "boolean" }, "term:osc52": { "type": "string", "enum": [ "focus", "always" ] }, "term:durable": { "type": "boolean" }, "editor:minimapenabled": { "type": "boolean" }, "editor:stickyscrollenabled": { "type": "boolean" }, "editor:wordwrap": { "type": "boolean" }, "editor:fontsize": { "type": "number" }, "editor:inlinediff": { "type": "boolean" }, "web:*": { "type": "boolean" }, "web:openlinksinternally": { "type": "boolean" }, "web:defaulturl": { "type": "string" }, "web:defaultsearch": { "type": "string" }, "autoupdate:*": { "type": "boolean" }, "autoupdate:enabled": { "type": "boolean" }, "autoupdate:intervalms": { "type": "number" }, "autoupdate:installonquit": { "type": "boolean" }, "autoupdate:channel": { "type": "string" }, "markdown:fontsize": { "type": "number" }, "markdown:fixedfontsize": { "type": "number" }, "preview:showhiddenfiles": { "type": "boolean" }, "preview:defaultsort": { "type": "string", "enum": [ "name", "modtime" ] }, "tab:preset": { "type": "string" }, "tab:confirmclose": { "type": "boolean" }, "widget:*": { "type": "boolean" }, "widget:showhelp": { "type": "boolean" }, "window:*": { "type": "boolean" }, "window:fullscreenonlaunch": { "type": "boolean" }, "window:transparent": { "type": "boolean" }, "window:blur": { "type": "boolean" }, "window:opacity": { "type": "number" }, "window:bgcolor": { "type": "string" }, "window:reducedmotion": { "type": "boolean" }, "window:tilegapsize": { "type": "integer" }, "window:showmenubar": { "type": "boolean" }, "window:nativetitlebar": { "type": "boolean" }, "window:disablehardwareacceleration": { "type": "boolean" }, "window:maxtabcachesize": { "type": "integer" }, "window:magnifiedblockopacity": { "type": "number" }, "window:magnifiedblocksize": { "type": "number" }, "window:magnifiedblockblurprimarypx": { "type": "integer" }, "window:magnifiedblockblursecondarypx": { "type": "integer" }, "window:confirmclose": { "type": "boolean" }, "window:savelastwindow": { "type": "boolean" }, "window:dimensions": { "type": "string" }, "window:zoom": { "type": "number" }, "telemetry:*": { "type": "boolean" }, "telemetry:enabled": { "type": "boolean" }, "conn:*": { "type": "boolean" }, "conn:askbeforewshinstall": { "type": "boolean" }, "conn:wshenabled": { "type": "boolean" }, "conn:localhostdisplayname": { "type": "string" }, "debug:*": { "type": "boolean" }, "debug:pprofport": { "type": "integer" }, "debug:pprofmemprofilerate": { "type": "integer" }, "debug:webglstatus": { "type": "boolean" }, "tsunami:*": { "type": "boolean" }, "tsunami:scaffoldpath": { "type": "string" }, "tsunami:sdkreplacepath": { "type": "string" }, "tsunami:sdkversion": { "type": "string" }, "tsunami:gopath": { "type": "string" } }, "additionalProperties": false, "type": "object" } } } ================================================ FILE: schema/waveai.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "AIModeConfigType": { "properties": { "display:name": { "type": "string" }, "display:order": { "type": "number" }, "display:icon": { "type": "string" }, "display:description": { "type": "string" }, "ai:provider": { "type": "string", "enum": [ "wave", "google", "groq", "openrouter", "nanogpt", "openai", "azure", "azure-legacy", "custom" ] }, "ai:apitype": { "type": "string", "enum": [ "google-gemini", "openai-responses", "openai-chat" ] }, "ai:model": { "type": "string" }, "ai:thinkinglevel": { "type": "string", "enum": [ "low", "medium", "high" ] }, "ai:verbosity": { "type": "string", "enum": [ "low", "medium", "high" ], "description": "Text verbosity level (OpenAI Responses API only)" }, "ai:endpoint": { "type": "string" }, "ai:proxyurl": { "type": "string" }, "ai:azureapiversion": { "type": "string" }, "ai:apitoken": { "type": "string" }, "ai:apitokensecretname": { "type": "string" }, "ai:azureresourcename": { "type": "string" }, "ai:azuredeployment": { "type": "string" }, "ai:capabilities": { "items": { "type": "string", "enum": [ "pdfs", "images", "tools" ] }, "type": "array" }, "ai:switchcompat": { "items": { "type": "string" }, "type": "array" }, "waveai:cloud": { "type": "boolean" }, "waveai:premium": { "type": "boolean" } }, "additionalProperties": false, "type": "object", "required": [ "display:name" ] } }, "additionalProperties": { "$ref": "#/$defs/AIModeConfigType" }, "type": "object" } ================================================ FILE: schema/widgets.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "BlockDef": { "properties": { "files": { "additionalProperties": { "$ref": "#/$defs/FileDef" }, "type": "object" }, "meta": { "properties": { "view": { "anyOf": [ { "enum": [ "term", "preview", "web", "sysinfo", "launcher" ] }, { "type": "string" } ] }, "file": { "type": "string" }, "url": { "type": "string" }, "controller": { "anyOf": [ { "enum": [ "shell", "cmd" ] }, { "type": "string" } ] }, "cmd": { "type": "string" }, "cmd:interactive": { "type": "boolean" }, "cmd:login": { "type": "boolean" }, "cmd:persistent": { "type": "boolean" }, "cmd:runonstart": { "type": "boolean" }, "cmd:clearonstart": { "type": "boolean" }, "cmd:runonce": { "type": "boolean" }, "cmd:closeonexit": { "type": "boolean" }, "cmd:closeonexitforce": { "type": "boolean" }, "cmd:closeonexitdelay": { "type": "number" }, "cmd:nowsh": { "type": "boolean" }, "cmd:args": { "items": { "type": "string" }, "type": "array" }, "cmd:shell": { "type": "boolean" }, "cmd:allowconnchange": { "type": "boolean" }, "cmd:env": { "additionalProperties": { "type": "string" }, "type": "object" }, "cmd:cwd": { "type": "string" }, "cmd:initscript": { "type": "string" }, "cmd:initscript.sh": { "type": "string" }, "cmd:initscript.bash": { "type": "string" }, "cmd:initscript.zsh": { "type": "string" }, "cmd:initscript.pwsh": { "type": "string" }, "cmd:initscript.fish": { "type": "string" }, "term:fontsize": { "type": "integer" }, "term:fontfamily": { "type": "string" }, "term:mode": { "type": "string" }, "term:theme": { "type": "string" }, "term:localshellpath": { "type": "string" }, "term:localshellopts": { "items": { "type": "string" }, "type": "array" }, "term:scrollback": { "type": "integer" }, "term:transparency": { "type": "number" }, "term:allowbracketedpaste": { "type": "boolean" }, "term:shiftenternewline": { "type": "boolean" }, "term:macoptionismeta": { "type": "boolean" }, "term:bellsound": { "type": "boolean" }, "term:bellindicator": { "type": "boolean" }, "term:durable": { "type": "boolean" } }, "additionalProperties": true, "type": "object" } }, "additionalProperties": false, "type": "object" }, "FileDef": { "properties": { "content": { "type": "string" }, "meta": { "type": "object" } }, "additionalProperties": false, "type": "object" }, "WidgetConfigType": { "properties": { "display:order": { "type": "number" }, "display:hidden": { "type": "boolean" }, "icon": { "type": "string" }, "color": { "type": "string" }, "label": { "type": "string" }, "description": { "type": "string" }, "workspaces": { "items": { "type": "string" }, "type": "array" }, "magnified": { "type": "boolean" }, "blockdef": { "$ref": "#/$defs/BlockDef" } }, "additionalProperties": false, "type": "object", "required": [ "blockdef" ] } }, "additionalProperties": { "$ref": "#/$defs/WidgetConfigType" }, "type": "object" } ================================================ FILE: staticcheck.conf ================================================ checks = ["all", "-ST1005", "-QF1003", "-ST1000", "-ST1003", "-ST1020", "-ST1021", "-ST1022"] ================================================ FILE: testdriver/onboarding.yml ================================================ version: 4.0.65 steps: - prompt: complete the onboarding of wave terminal commands: - command: hover-text text: Continue description: button to complete onboarding action: click - command: hover-text text: Get Started description: button to complete onboarding action: click - command: assert expect: the cpu usage graph is being displayed ================================================ FILE: tests/copytests/cases/test000.sh ================================================ # copy a file to one with a different name # ensure that the original exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt bar.txt if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test001.sh ================================================ # copy a file to one with a different name # ensure that the destination file exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt bar.txt if [ ! -f bar.txt ]; then echo "bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test002.sh ================================================ # copy a file with contents # ensure the contents are the same set -e cd "$HOME/testcp" touch foo.txt echo "The quick brown fox jumps over the lazy dog" > foo.txt wsh file copy foo.txt bar.txt FOO_MD5=$(md5sum foo.txt | cut -d " " -f1) BAR_MD5=$(md5sum bar.txt | cut -d " " -f1) if [ $FOO_MD5 != $BAR_MD5 ]; then echo "files are not the same" echo "FOO_MD5 is $FOO_MD5" echo "BAR_MD5 is $BAR_MD5" exit 1 fi ================================================ FILE: tests/copytests/cases/test003.sh ================================================ # copy a file where source starts with ./ # ensure the source file exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy ./foo.txt bar.txt if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test004.sh ================================================ # copy a file where source starts with ./ # ensure the destination file exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy ./foo.txt bar.txt if [ ! -f bar.txt ]; then echo "bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test005.sh ================================================ # copy a file where destination starts with ./ # ensure the source file exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt ./bar.txt if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test006.sh ================================================ # copy a file where destination starts with ./ # ensure the destination file exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt ./bar.txt if [ ! -f bar.txt ]; then echo "bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test007.sh ================================================ # copy a file where source and destination start with ./ # ensure the source file exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy ./foo.txt ./bar.txt if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test008.sh ================================================ # copy a file where source and destination start with ./ # ensure the destination file exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy ./foo.txt ./bar.txt if [ ! -f bar.txt ]; then echo "bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test009.sh ================================================ # copy a file to itself with the same literal name # ensure the operation fails and the file still exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt foo.txt >/dev/null 2>&1 && echo "copy should have failed" && exit 1 if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test010.sh ================================================ # copy a file to itself with a different literal name # ensure the copy fails and the file still exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt ./foo.txt >/dev/null 2>&1 && echo "copy should have failed" && exit 1 if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test011.sh ================================================ # copy a file with ~ used to resolve the source # ensure the source still exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy ~/testcp/foo.txt bar.txt if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test012.sh ================================================ # copy a file with ~ used to resolve the source # ensure the destination exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy ~/testcp/foo.txt bar.txt if [ ! -f bar.txt ]; then echo "bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test013.sh ================================================ # copy a file with ~ used to resolve the destination # ensure the source exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt ~/testcp/bar.txt if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test014.sh ================================================ # copy a file with ~ used to resolve the destination # ensure the destination exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt ~/testcp/bar.txt if [ ! -f bar.txt ]; then echo "bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test015.sh ================================================ # copy a file where source and destination are resolved with ~ # ensure the source file exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy ~/testcp/foo.txt ~/testcp/bar.txt if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test016.sh ================================================ # copy a file where source and destination are resolved with ~ # ensure the destination file exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy ~/testcp/foo.txt ~/testcp/bar.txt if [ ! -f bar.txt ]; then echo "bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test017.sh ================================================ # copy a file to itself with ~ for destination resolution # ensure that the operation fails and the file still exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt ~/testcp/foo.txt >/dev/null 2>&1 && echo "copy should have failed" && exit 1 if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test018.sh ================================================ # copy a file to itself with ~ for source resolution # ensure that the operation fails and the file still exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy ~/testcp/foo.txt foo.txt >/dev/null 2>&1 && echo "copy should have failed" && exit 1 if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test019.sh ================================================ # copy a file to itself with env var expansion in destination # ensure the operation fails and the file still exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt "${HOME}"/testcp/foo.txt >/dev/null 2>&1 && echo "copy should have failed" && exit 1 if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test020.sh ================================================ # copy a file to itself with env var expansion in source # ensure the operation fails and the file still exists set -e cd "$HOME/testcp" touch foo.txt wsh file copy "${HOME}"/testcp/foo.txt foo.txt >/dev/null 2>&1 && echo "copy should have failed" && exit 1 if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test021.sh ================================================ # copy to a deeper directory and rename # ensure the destination file exists set -e cd "$HOME/testcp" touch foo.txt mkdir baz wsh file copy foo.txt baz/bar.txt if [ ! -f baz/bar.txt ]; then echo "baz/bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test022.sh ================================================ # copy a file to a deeper directory with the same base name # ensure the destination file exists set -e cd "$HOME/testcp" touch foo.txt mkdir baz wsh file copy foo.txt baz/foo.txt if [ ! -f baz/foo.txt ]; then echo "baz/foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test023.sh ================================================ # copy into an existing directory ending in / # ensure the file is inserted in the directory set -e cd "$HOME/testcp" touch foo.txt mkdir baz wsh file copy foo.txt baz/ if [ ! -f baz/foo.txt ]; then echo "baz/foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test024.sh ================================================ # copy into an existing directory not ending in / # ensure the file is inserted in the directory set -e cd "$HOME/testcp" touch foo.txt mkdir baz wsh file copy foo.txt baz if [ ! -f baz/foo.txt ]; then echo "baz/foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test025.sh ================================================ # copy into an non-existing directory where file has the same base name # ensure the file is copied to a file inside the directory # note that this is not regular cp behavior set -e cd "$HOME/testcp" touch foo.txt # this is different from cp behavior wsh file copy foo.txt baz/foo.txt if [ ! -f baz/foo.txt ]; then echo "baz/foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test026.sh ================================================ # copy into an non-existing directory ending with a / # ensure the file is copied to a file inside the directory # note that this is not regular cp behavior set -e cd "$HOME/testcp" touch foo.txt # this is different from cp behavior wsh file copy foo.txt baz/ if [ ! -f baz/foo.txt ]; then echo "baz/foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test027.sh ================================================ # copy into an non-existing file name not-ending with a / # ensure the file is copied to a file instead of a directory set -e cd "$HOME/testcp" touch foo.txt wsh file copy foo.txt baz if [ ! -f baz ]; then echo "baz does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test028.sh ================================================ # copy from relative .. source to current directory . # ensure the file is copied correctly set -e cd "$HOME/testcp" touch foo.txt mkdir baz cd baz wsh file copy ../foo.txt . cd .. if [ ! -f baz/foo.txt ]; then echo "baz/foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test029.sh ================================================ # copy from the current directory to a relative directory .. # ensure the file is copied correctly set -e cd "$HOME/testcp" mkdir baz cd baz touch foo.txt wsh file copy foo.txt .. cd .. if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test030.sh ================================================ # copy from a deeper directory to the current directory . # ensure the file is copied correctly set -e cd "$HOME/testcp" mkdir baz touch baz/foo.txt wsh file copy baz/foo.txt . if [ ! -f foo.txt ]; then echo "foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test032.sh ================================================ # copy an empty directory to a non-existing directory # ensure the empty directory is copied to one with the new name set -e cd "$HOME/testcp" mkdir foo wsh file copy foo bar if [ ! -d bar ]; then echo "bar does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test034.sh ================================================ # copy an empty directory ending with / to a non-existing directory # ensure the copy succeeds and the new directory exists set -e cd "$HOME/testcp" mkdir bar wsh file copy bar/ baz if [ ! -d baz ]; then echo "baz does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test036.sh ================================================ # copy an empty directory to a non-existing directory ending with / # ensure the copy succeeds and the new directory exists set -e cd "$HOME/testcp" mkdir bar wsh file copy bar baz/ if [ ! -d baz ]; then echo "baz does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test037.sh ================================================ # copy an empty directory ending with // to a non-existing directory # ensure the copy succeeds and the new directory exists set -e cd "$HOME/testcp" mkdir bar wsh file copy bar// baz if [ ! -d baz ]; then echo "baz does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test038.sh ================================================ # copy an empty directory to a non-existing directory ending with // # ensure the copy succeeds and the new directory exists set -e cd "$HOME/testcp" mkdir bar wsh file copy bar baz// if [ ! -d baz ]; then echo "baz does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test040.sh ================================================ # copy a directory containing a file to a new directory # ensure this succeeds and the new files exist set -e cd "$HOME/testcp" mkdir bar touch bar/foo.txt wsh file copy bar baz if [ ! -f baz/foo.txt ]; then echo "baz/foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test041.sh ================================================ # copy a directory containing a file to an existing directory # ensure this succeeds and the new files are nested in the existing directory set -e cd "$HOME/testcp" mkdir bar touch bar/foo.txt mkdir baz wsh file copy bar baz if [ ! -f baz/bar/foo.txt ]; then echo "baz/bar/foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test042.sh ================================================ # copy a directory containing a file to an existing directory ending with / # ensure this succeeds and the new files are nested in the existing directory set -e cd "$HOME/testcp" mkdir bar touch bar/foo.txt mkdir baz wsh file copy bar baz/ if [ ! -f baz/bar/foo.txt ]; then echo "baz/bar/foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test043.sh ================================================ # copy a directory containing a file to an existing directory ending with /. # ensure this succeeds and the new files are nested in the existing directory set -e cd "$HOME/testcp" mkdir bar touch bar/foo.txt mkdir baz wsh file copy bar baz/. if [ ! -f baz/bar/foo.txt ]; then echo "baz/bar/foo.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test044.sh ================================================ # copy a doubly nested directory containing a file to a non-existant directory # ensure this succeeds and the new files exist with the first directory renamed set -e cd "$HOME/testcp" mkdir foo mkdir foo/bar touch foo/bar/baz.txt wsh file copy foo qux if [ ! -f qux/bar/baz.txt ]; then echo "qux/bar/baz.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test045.sh ================================================ # copy a doubly nested directory containing a file to an existing directory # ensure this succeeds and the new files exist and are nested in the existing directory set -e cd "$HOME/testcp" mkdir foo mkdir foo/bar touch foo/bar/baz.txt mkdir qux wsh file copy foo qux if [ ! -f qux/foo/bar/baz.txt ]; then echo "qux/foo/bar/baz.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test046.sh ================================================ # copy a file with /// separating directory and file # ensure the copy succeeds and the file exists set -e cd "$HOME/testcp" mkdir foo touch foo/bar.txt wsh file copy foo///bar.txt . if [ ! -f bar.txt ]; then echo "bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test047.sh ================================================ # copy a file with /// to a file with // # ensure the copy succeeds and the file exists set -e cd "$HOME/testcp" mkdir foo touch foo/bar.txt mkdir baz wsh file copy foo///bar.txt baz//qux.txt if [ ! -f baz/qux.txt ]; then echo "baz/qux.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test048.sh ================================================ # copy the current directory into an existing directory # ensure the copy succeeds and the output exists set -e cd "$HOME/testcp" mkdir foo touch foo/bar.txt mkdir baz cd foo wsh file copy . ../baz cd .. if [ ! -f baz/bar.txt ]; then echo "baz/bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test049.sh ================================================ # copy the current directory into a non-existing directory # ensure the copy succeeds and the output exists set -e cd "$HOME/testcp" mkdir foo touch foo/bar.txt cd foo wsh file copy . ../baz cd .. if [ ! -f baz/bar.txt ]; then echo "baz/bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test051.sh ================================================ # copy the current directory into a non-existing directory # ensure the copy succeeds and the output exists set -e cd "$HOME/testcp" mkdir foo touch foo/bar.txt cd foo wsh file copy . ../baz cd .. if [ ! -f baz/bar.txt ]; then echo "baz/bar.txt does not exist" exit 1 fi ================================================ FILE: tests/copytests/cases/test052.sh ================================================ # copy a directory with contents # ensure the contents are the same set -e cd "$HOME/testcp" mkdir foo mkdir foo/bar touch foo/bar/baz.txt mkdir foo/bar/qux touch foo/bar/qux/quux.txt echo "The quick brown fox jumps over the lazy dog." > foo/bar/baz.txt echo "Sphinx of black quartz, judge my vow." > foo/bar/qux/quux.txt mkdir corge # we need a nested corge/foo so the foo.zip contains the same exact file names # in other words, if one file was named foo and the other was corge, they would # not match. this allows them to be the same. wsh file copy foo corge/foo zip -r foo.zip foo >/dev/null 2>&1 FOO_MD5=$(md5sum foo.zip | cut -d " " -f1) cd corge zip -r foo.zip foo >/dev/null 2>&1 CORGE_MD5=$(md5sum foo.zip | cut -d " " -f1) if [ $FOO_MD5 != $CORGE_MD5 ]; then echo "directories are not the same" echo "FOO_MD5 is $FOO_MD5" echo "CORGE_MD5 is $CORGE_MD5" exit 1 fi ================================================ FILE: tests/copytests/runner.sh ================================================ #!/bin/bash cd "$(dirname "$0")" source testutil.sh TOTAL_COPY_TESTS_RUN=0 TOTAL_COPY_TESTS_PASSED=0 for fname in cases/*.sh; do setup_testcp #"${fname}" | read outerr && printf "\e[32mPASS $fname\n\n\e[0m" || printf "\e[31mFAIL $fname: $outerr \n\n\e[0m" if ! outerr=$("${fname}" 2>&1); then printf "\e[31mFAIL $fname:\n$outerr \n\e[0m" cat "${fname}" printf "\n" else printf "\e[32mPASS $fname\n\n\e[0m" ((TOTAL_COPY_TESTS_PASSED++)) fi cleanup_testcp ((TOTAL_COPY_TESTS_RUN++)) done printf "\n\e[32m${TOTAL_COPY_TESTS_PASSED} of ${TOTAL_COPY_TESTS_RUN} Tests Passed \e[0m\n\n" ================================================ FILE: tests/copytests/testutil.sh ================================================ setup_testcp () { if [ -d "$HOME/testcp" ]; then echo "Test cannot run if testcp already exists" exit 1 fi mkdir ~/testcp } cleanup_testcp () { rm -rf "$HOME/testcp" || rmdir "$HOME/testcp" } ================================================ FILE: tsconfig.json ================================================ { "include": ["frontend/**/*", "emain/**/*"], "compilerOptions": { "target": "es6", "module": "es2020", "jsx": "preserve", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "isolatedModules": true, "experimentalDecorators": true, "downlevelIteration": true, "baseUrl": "./", "paths": { "@/app/*": ["frontend/app/*"], "@/builder/*": ["frontend/builder/*"], "@/util/*": ["frontend/util/*"], "@/layout/*": ["frontend/layout/*"], "@/store/*": ["frontend/app/store/*"], "@/view/*": ["frontend/app/view/*"], "@/element/*": ["frontend/app/element/*"], "@/shadcn/*": ["frontend/app/shadcn/*"], "@/preview/*": ["frontend/preview/*"] }, "lib": ["dom", "dom.iterable", "es6"], "allowJs": true, "strict": false, "noEmit": true } } ================================================ FILE: tsunami/.gitignore ================================================ bin/ *.tsapp ================================================ FILE: tsunami/app/atom.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package app import ( "log" "reflect" "runtime" "github.com/wavetermdev/waveterm/tsunami/engine" "github.com/wavetermdev/waveterm/tsunami/util" ) // AtomMeta provides metadata about an atom for validation and documentation type AtomMeta struct { Desc string // short, user-facing Units string // "ms", "GiB", etc. Min *float64 // optional minimum (numeric types) Max *float64 // optional maximum (numeric types) Enum []string // allowed values if finite set Pattern string // regex constraint for strings } // SecretMeta provides metadata about a secret for documentation and validation type SecretMeta struct { Desc string Optional bool } // Atom[T] represents a typed atom implementation type Atom[T any] struct { name string client *engine.ClientImpl } // logInvalidAtomSet logs an error when an atom is being set during component render func logInvalidAtomSet(atomName string) { _, file, line, ok := runtime.Caller(2) if ok { log.Printf("invalid Set of atom '%s' in component render function at %s:%d", atomName, file, line) } else { log.Printf("invalid Set of atom '%s' in component render function", atomName) } } // sameRef returns true if oldVal and newVal share the same underlying reference // (pointer, map, or slice). Nil values return false. func sameRef[T any](oldVal, newVal T) bool { vOld := reflect.ValueOf(oldVal) vNew := reflect.ValueOf(newVal) if !vOld.IsValid() || !vNew.IsValid() { return false } switch vNew.Kind() { case reflect.Ptr: // direct comparison works for *T return any(oldVal) == any(newVal) case reflect.Map, reflect.Slice: if vOld.Kind() != vNew.Kind() || vOld.IsZero() || vNew.IsZero() { return false } return vOld.Pointer() == vNew.Pointer() } // primitives, structs, etc. → not a reference type return false } // logMutationWarning logs a warning when mutation is detected func logMutationWarning(atomName string) { _, file, line, ok := runtime.Caller(2) if ok { log.Printf("WARNING: atom '%s' appears to be mutated instead of copied at %s:%d - use app.DeepCopy to create a copy before mutating", atomName, file, line) } else { log.Printf("WARNING: atom '%s' appears to be mutated instead of copied - use app.DeepCopy to create a copy before mutating", atomName) } } // AtomName implements the vdom.Atom interface func (a Atom[T]) AtomName() string { return a.name } // Get returns the current value of the atom. When called during component render, // it automatically registers the component as a dependency for this atom, ensuring // the component re-renders when the atom value changes. func (a Atom[T]) Get() T { vc := engine.GetGlobalRenderContext() if vc != nil { vc.UsedAtoms[a.name] = true } val := a.client.Root.GetAtomVal(a.name) typedVal := util.GetTypedAtomValue[T](val, a.name) return typedVal } // Set updates the atom's value to the provided new value and triggers re-rendering // of any components that depend on this atom. This method cannot be called during // render cycles - use effects or event handlers instead. func (a Atom[T]) Set(newVal T) { vc := engine.GetGlobalRenderContext() if vc != nil { logInvalidAtomSet(a.name) return } // Check for potential mutation bugs with reference types currentVal := a.client.Root.GetAtomVal(a.name) currentTyped := util.GetTypedAtomValue[T](currentVal, a.name) if sameRef(currentTyped, newVal) { logMutationWarning(a.name) } if err := a.client.Root.SetAtomVal(a.name, newVal); err != nil { log.Printf("Failed to set atom value for %s: %v", a.name, err) return } a.client.Root.AtomAddRenderWork(a.name) } // SetFn updates the atom's value by applying the provided function to the current value. // The function receives a copy of the current atom value, which can be safely mutated // without affecting the original data. The return value from the function becomes the // new atom value. This method cannot be called during render cycles. func (a Atom[T]) SetFn(fn func(T) T) { vc := engine.GetGlobalRenderContext() if vc != nil { logInvalidAtomSet(a.name) return } currentVal := a.Get() copiedVal := DeepCopy(currentVal) newVal := fn(copiedVal) a.Set(newVal) } ================================================ FILE: tsunami/app/defaultclient.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package app import ( "encoding/json" "errors" "fmt" "io" "io/fs" "log" "net/http" "os" "strings" "time" "github.com/wavetermdev/waveterm/tsunami/engine" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) const TsunamiCloseOnStdinEnvVar = "TSUNAMI_CLOSEONSTDIN" const MaxShortDescLen = 120 type AppMeta engine.AppMeta type staticFileInfo struct { fullPath string info fs.FileInfo } func (sfi *staticFileInfo) Name() string { return sfi.fullPath } func (sfi *staticFileInfo) Size() int64 { return sfi.info.Size() } func (sfi *staticFileInfo) Mode() fs.FileMode { return sfi.info.Mode() } func (sfi *staticFileInfo) ModTime() time.Time { return sfi.info.ModTime() } func (sfi *staticFileInfo) IsDir() bool { return sfi.info.IsDir() } func (sfi *staticFileInfo) Sys() any { return sfi.info.Sys() } func DefineComponent[P any](name string, renderFn func(props P) any) vdom.Component[P] { return engine.DefineComponentEx(engine.GetDefaultClient(), name, renderFn) } func Ptr[T any](v T) *T { return &v } func SetGlobalEventHandler(handler func(event vdom.VDomEvent)) { engine.GetDefaultClient().SetGlobalEventHandler(handler) } // RegisterAppInitFn registers a single setup function that is called before the app starts running. // Only one setup function is allowed, so calling this will replace any previously registered // setup function. func RegisterAppInitFn(fn func() error) { engine.GetDefaultClient().RegisterAppInitFn(fn) } // SendAsyncInitiation notifies the frontend that the backend has updated state // and requires a re-render. Normally the frontend calls the backend in response // to events, but when the backend changes state independently (e.g., from a // background process), this function gives the frontend a "nudge" to update. func SendAsyncInitiation() error { return engine.GetDefaultClient().SendAsyncInitiation() } func TermWrite(ref *vdom.VDomRef, data string) error { if ref == nil || !ref.HasCurrent.Load() { return nil } return engine.GetDefaultClient().SendTermWrite(ref.RefId, data) } func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { fullName := "$config." + name client := engine.GetDefaultClient() engineMeta := convertAppMetaToEngineMeta(meta) atom := engine.MakeAtomImpl(defaultValue, engineMeta) client.Root.RegisterAtom(fullName, atom) return Atom[T]{name: fullName, client: client} } func DataAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { fullName := "$data." + name client := engine.GetDefaultClient() engineMeta := convertAppMetaToEngineMeta(meta) atom := engine.MakeAtomImpl(defaultValue, engineMeta) client.Root.RegisterAtom(fullName, atom) return Atom[T]{name: fullName, client: client} } func SharedAtom[T any](name string, defaultValue T) Atom[T] { fullName := "$shared." + name client := engine.GetDefaultClient() atom := engine.MakeAtomImpl(defaultValue, nil) client.Root.RegisterAtom(fullName, atom) return Atom[T]{name: fullName, client: client} } func convertAppMetaToEngineMeta(appMeta *AtomMeta) *engine.AtomMeta { if appMeta == nil { return nil } return &engine.AtomMeta{ Description: appMeta.Desc, Units: appMeta.Units, Min: appMeta.Min, Max: appMeta.Max, Enum: appMeta.Enum, Pattern: appMeta.Pattern, } } // HandleDynFunc registers a dynamic HTTP handler function with the internal http.ServeMux. // The pattern MUST start with "/dyn/" to be valid. This allows registration of dynamic // routes that can be handled at runtime. func HandleDynFunc(pattern string, fn func(http.ResponseWriter, *http.Request)) { engine.GetDefaultClient().HandleDynFunc(pattern, fn) } // RunMain is used internally by generated code and should not be called directly. func RunMain() { closeOnStdin := os.Getenv(TsunamiCloseOnStdinEnvVar) != "" if closeOnStdin { go func() { // Read stdin until EOF/close, then exit the process io.Copy(io.Discard, os.Stdin) log.Printf("[tsunami] shutting down due to close of stdin\n") os.Exit(0) }() } engine.GetDefaultClient().RunMain() } // RegisterEmbeds is used internally by generated code and should not be called directly. func RegisterEmbeds(assetsFilesystem fs.FS, staticFilesystem fs.FS, manifest []byte) { client := engine.GetDefaultClient() client.AssetsFS = assetsFilesystem client.StaticFS = staticFilesystem client.ManifestFileBytes = manifest } // DeepCopy creates a deep copy of the input value using JSON marshal/unmarshal. // Panics on JSON errors. func DeepCopy[T any](v T) T { data, err := json.Marshal(v) if err != nil { panic(err) } var result T err = json.Unmarshal(data, &result) if err != nil { panic(err) } return result } // QueueRefOp queues a reference operation to be executed on the DOM element. // Operations include actions like "focus", "scrollIntoView", etc. // If the ref is nil or not current, the operation is ignored. // This function must be called within a component context. func QueueRefOp(ref *vdom.VDomRef, op vdom.VDomRefOperation) { if ref == nil || !ref.HasCurrent.Load() { return } if op.RefId == "" { op.RefId = ref.RefId } client := engine.GetDefaultClient() client.Root.QueueRefOp(op) } func SetAppMeta(meta AppMeta) { meta.ShortDesc = util.TruncateString(meta.ShortDesc, MaxShortDescLen) client := engine.GetDefaultClient() client.SetAppMeta(engine.AppMeta(meta)) } func SetTitle(title string) { client := engine.GetDefaultClient() m := client.GetAppMeta() m.Title = title client.SetAppMeta(m) } func SetShortDesc(shortDesc string) { shortDesc = util.TruncateString(shortDesc, MaxShortDescLen) client := engine.GetDefaultClient() m := client.GetAppMeta() m.ShortDesc = shortDesc client.SetAppMeta(m) } func DeclareSecret(secretName string, meta *SecretMeta) string { client := engine.GetDefaultClient() var secretDesc string var secretOptional bool if meta != nil { secretDesc = meta.Desc secretOptional = meta.Optional } client.DeclareSecret(secretName, secretDesc, secretOptional) return os.Getenv(secretName) } func PrintAppManifest() { client := engine.GetDefaultClient() client.PrintAppManifest() } // ReadStaticFile reads a file from the embedded static filesystem. // The path MUST start with "static/" (e.g., "static/config.json"). // Returns the file contents or an error if the file doesn't exist or can't be read. func ReadStaticFile(path string) ([]byte, error) { client := engine.GetDefaultClient() if client.StaticFS == nil { return nil, errors.New("static files not available before app initialization; use AppInit to access files during initialization") } if !strings.HasPrefix(path, "static/") { return nil, fmt.Errorf("ReadStaticFile path must start with 'static/': %w", fs.ErrNotExist) } // Strip "static/" prefix since the FS is already sub'd to the static directory relativePath := strings.TrimPrefix(path, "static/") return fs.ReadFile(client.StaticFS, relativePath) } // OpenStaticFile opens a file from the embedded static filesystem. // The path MUST start with "static/" (e.g., "static/config.json"). // Returns an fs.File or an error if the file doesn't exist or can't be opened. func OpenStaticFile(path string) (fs.File, error) { client := engine.GetDefaultClient() if client.StaticFS == nil { return nil, errors.New("static files not available before app initialization; use AppInit to access files during initialization") } if !strings.HasPrefix(path, "static/") { return nil, fmt.Errorf("OpenStaticFile path must start with 'static/': %w", fs.ErrNotExist) } // Strip "static/" prefix since the FS is already sub'd to the static directory relativePath := strings.TrimPrefix(path, "static/") return client.StaticFS.Open(relativePath) } // ListStaticFiles returns FileInfo for all files in the embedded static filesystem. // The Name() of each FileInfo will be the full path prefixed with "static/" (e.g., "static/config.json"), // which can be passed directly to ReadStaticFile or OpenStaticFile. func ListStaticFiles() ([]fs.FileInfo, error) { client := engine.GetDefaultClient() if client.StaticFS == nil { return nil, errors.New("static files not available before app initialization; use AppInit to access files during initialization") } var fileInfos []fs.FileInfo err := fs.WalkDir(client.StaticFS, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { info, err := d.Info() if err != nil { return err } fullPath := "static/" + path fileInfos = append(fileInfos, &staticFileInfo{ fullPath: fullPath, info: info, }) } return nil }) if err != nil { return nil, err } return fileInfos, nil } ================================================ FILE: tsunami/app/hooks.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package app import ( "context" "fmt" "log" "time" "github.com/google/uuid" "github.com/wavetermdev/waveterm/tsunami/engine" "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) // UseVDomRef provides a reference to a DOM element in the VDOM tree. // It returns a VDomRef that can be attached to elements for direct DOM access. // The ref will not be current on the first render - refs are set and become // current after client-side mounting. // This hook must be called within a component context. func UseVDomRef() *vdom.VDomRef { rc := engine.GetGlobalRenderContext() val := engine.UseVDomRef(rc) refVal, ok := val.(*vdom.VDomRef) if !ok { panic("UseVDomRef hook value is not a ref (possible out of order or conditional hooks)") } return refVal } // TermRef wraps a VDomRef and implements io.Writer by forwarding writes to the terminal. type TermRef struct { *vdom.VDomRef } // Write implements io.Writer by sending data to the terminal via TermWrite. func (tr *TermRef) Write(p []byte) (n int, err error) { if tr.VDomRef == nil || !tr.VDomRef.HasCurrent.Load() { return 0, fmt.Errorf("TermRef not current") } err = TermWrite(tr.VDomRef, string(p)) if err != nil { return 0, err } return len(p), nil } // TermSize returns the current terminal size, or nil if not yet set. func (tr *TermRef) TermSize() *vdom.VDomTermSize { if tr.VDomRef == nil { return nil } return tr.VDomRef.TermSize } // UseTermRef returns a TermRef that can be passed as a ref to "wave:term" elements // and also implements io.Writer for writing directly to the terminal. func UseTermRef() *TermRef { ref := UseVDomRef() return &TermRef{VDomRef: ref} } // UseRef is the tsunami analog to React's useRef hook. // It provides a mutable ref object that persists across re-renders. // Unlike UseVDomRef, this is not tied to DOM elements but holds arbitrary values. // This hook must be called within a component context. func UseRef[T any](val T) *vdom.VDomSimpleRef[T] { rc := engine.GetGlobalRenderContext() refVal := engine.UseRef(rc, &vdom.VDomSimpleRef[T]{Current: val}) typedRef, ok := refVal.(*vdom.VDomSimpleRef[T]) if !ok { panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") } return typedRef } // UseId returns the underlying component's unique identifier (UUID). // The ID persists across re-renders but is recreated when the component // is recreated, following React component lifecycle. // This hook must be called within a component context. func UseId() string { rc := engine.GetGlobalRenderContext() if rc == nil { panic("UseId must be called within a component (no context)") } return engine.UseId(rc) } // UseRenderTs returns the timestamp of the current render. // This hook must be called within a component context. func UseRenderTs() int64 { rc := engine.GetGlobalRenderContext() if rc == nil { panic("UseRenderTs must be called within a component (no context)") } return engine.UseRenderTs(rc) } // UseResync returns whether the current render is a resync operation. // Resyncs happen on initial app loads or full refreshes, as opposed to // incremental renders which happen otherwise. // This hook must be called within a component context. func UseResync() bool { rc := engine.GetGlobalRenderContext() if rc == nil { panic("UseResync must be called within a component (no context)") } return engine.UseResync(rc) } // UseEffect is the tsunami analog to React's useEffect hook. // It queues effects to run after the render cycle completes. // The function can return a cleanup function that runs before the next effect // or when the component unmounts. Dependencies use shallow comparison, just like React. // This hook must be called within a component context. func UseEffect(fn func() func(), deps []any) { // note UseEffect never actually runs anything, it just queues the effect to run later rc := engine.GetGlobalRenderContext() if rc == nil { panic("UseEffect must be called within a component (no context)") } engine.UseEffect(rc, fn, deps) } // UseLocal creates a component-local atom that is automatically cleaned up when the component unmounts. // The atom is created with a unique name based on the component's wave ID and hook index. // This hook must be called within a component context. func UseLocal[T any](initialVal T) Atom[T] { rc := engine.GetGlobalRenderContext() if rc == nil { panic("UseLocal must be called within a component (no context)") } atomName := engine.UseLocal(rc, initialVal) return Atom[T]{ name: atomName, client: engine.GetDefaultClient(), } } // UseGoRoutine manages a goroutine lifecycle within a component. // It spawns a new goroutine with the provided function when dependencies change, // and automatically cancels the context on dependency changes or component unmount. // This hook must be called within a component context. func UseGoRoutine(fn func(ctx context.Context), deps []any) { rc := engine.GetGlobalRenderContext() if rc == nil { panic("UseGoRoutine must be called within a component (no context)") } // Use UseRef to store the cancel function cancelRef := UseRef[context.CancelFunc](nil) UseEffect(func() func() { // Cancel any existing goroutine if cancelRef.Current != nil { cancelRef.Current() } // Create new context and start goroutine ctx, cancel := context.WithCancel(context.Background()) cancelRef.Current = cancel componentName := "unknown" if rc.Comp != nil && rc.Comp.Elem != nil { componentName = rc.Comp.Elem.Tag } go func() { defer func() { util.PanicHandler(fmt.Sprintf("UseGoRoutine in component '%s'", componentName), recover()) }() fn(ctx) }() // Return cleanup function that cancels the context return func() { if cancel != nil { cancel() } } }, deps) } // UseTicker manages a ticker lifecycle within a component. // It creates a ticker that calls the provided function at regular intervals. // The ticker is automatically stopped on dependency changes or component unmount. // This hook must be called within a component context. func UseTicker(interval time.Duration, tickFn func(), deps []any) { UseGoRoutine(func(ctx context.Context) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: tickFn() } } }, deps) } // UseAfter manages a timeout lifecycle within a component. // It creates a timer that calls the provided function after the specified duration. // The timer is automatically canceled on dependency changes or component unmount. // This hook must be called within a component context. func UseAfter(duration time.Duration, timeoutFn func(), deps []any) { UseGoRoutine(func(ctx context.Context) { timer := time.NewTimer(duration) defer timer.Stop() select { case <-ctx.Done(): return case <-timer.C: timeoutFn() } }, deps) } // ModalConfig contains all configuration options for modals type ModalConfig struct { Icon string `json:"icon,omitempty"` // Optional icon to display (emoji or icon name) Title string `json:"title"` // Modal title Text string `json:"text,omitempty"` // Optional body text OkText string `json:"oktext,omitempty"` // Optional OK button text (defaults to "OK") CancelText string `json:"canceltext,omitempty"` // Optional Cancel button text for confirm modals (defaults to "Cancel") OnClose func() `json:"-"` // Optional callback for alert modals when dismissed OnResult func(bool) `json:"-"` // Optional callback for confirm modals with the result (true = confirmed, false = cancelled) } // UseAlertModal returns a boolean indicating if the modal is open and a function to trigger it func UseAlertModal() (modalOpen bool, triggerAlert func(config ModalConfig)) { isOpen := UseLocal(false) trigger := func(config ModalConfig) { if isOpen.Get() { log.Printf("warning: UseAlertModal trigger called while modal is already open") if config.OnClose != nil { go func() { defer func() { util.PanicHandler("UseAlertModal callback goroutine", recover()) }() time.Sleep(10 * time.Millisecond) config.OnClose() }() } return } isOpen.Set(true) // Create modal config for backend modalId := uuid.New().String() backendConfig := rpctypes.ModalConfig{ ModalId: modalId, ModalType: "alert", Icon: config.Icon, Title: config.Title, Text: config.Text, OkText: config.OkText, CancelText: config.CancelText, } // Show modal and wait for result in a goroutine go func() { defer func() { util.PanicHandler("UseAlertModal goroutine", recover()) }() resultChan := engine.GetDefaultClient().ShowModal(backendConfig) <-resultChan // Wait for result (always dismissed for alerts) isOpen.Set(false) if config.OnClose != nil { config.OnClose() } }() } return isOpen.Get(), trigger } // UseConfirmModal returns a boolean indicating if the modal is open and a function to trigger it func UseConfirmModal() (modalOpen bool, triggerConfirm func(config ModalConfig)) { isOpen := UseLocal(false) trigger := func(config ModalConfig) { if isOpen.Get() { log.Printf("warning: UseConfirmModal trigger called while modal is already open") if config.OnResult != nil { go func() { defer func() { util.PanicHandler("UseConfirmModal callback goroutine", recover()) }() time.Sleep(10 * time.Millisecond) config.OnResult(false) }() } return } isOpen.Set(true) // Create modal config for backend modalId := uuid.New().String() backendConfig := rpctypes.ModalConfig{ ModalId: modalId, ModalType: "confirm", Icon: config.Icon, Title: config.Title, Text: config.Text, OkText: config.OkText, CancelText: config.CancelText, } // Show modal and wait for result in a goroutine go func() { defer func() { util.PanicHandler("UseConfirmModal goroutine", recover()) }() resultChan := engine.GetDefaultClient().ShowModal(backendConfig) result := <-resultChan isOpen.Set(false) if config.OnResult != nil { config.OnResult(result) } }() } return isOpen.Get(), trigger } ================================================ FILE: tsunami/build/build-ast.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package build import ( "fmt" "go/ast" "go/parser" "go/token" "io/fs" "path/filepath" "strings" ) const AppInitFnName = "AppInit" func buildImportsMap(dir string) (map[string]bool, error) { imports := make(map[string]bool) files, err := filepath.Glob(filepath.Join(dir, "*.go")) if err != nil { return nil, fmt.Errorf("failed to list go files: %w", err) } fset := token.NewFileSet() for _, file := range files { node, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly) if err != nil { continue // Skip files that can't be parsed } for _, imp := range node.Imports { // Remove quotes from import path importPath := strings.Trim(imp.Path.Value, `"`) imports[importPath] = true } } return imports, nil } type parsedAppInfo struct { HasAppInit bool } func parseAndValidateAppFile(appFS fs.FS) (*parsedAppInfo, error) { appGoFile, err := fs.ReadFile(appFS, MainAppFileName) if err != nil { return &parsedAppInfo{HasAppInit: false}, nil } fset := token.NewFileSet() node, err := parser.ParseFile(fset, MainAppFileName, appGoFile, 0) if err != nil { return &parsedAppInfo{HasAppInit: false}, nil } hasAppInit := false for _, decl := range node.Decls { funcDecl, ok := decl.(*ast.FuncDecl) if !ok { continue } if funcDecl.Name.Name == "init" { hasNoParams := funcDecl.Type.Params == nil || len(funcDecl.Type.Params.List) == 0 hasNoResults := funcDecl.Type.Results == nil || len(funcDecl.Type.Results.List) == 0 if hasNoParams && hasNoResults { return nil, fmt.Errorf("tsunami apps may not define an init() function, use %s for initialization", AppInitFnName) } } if funcDecl.Name.Name == AppInitFnName { if funcDecl.Type.Params != nil && len(funcDecl.Type.Params.List) > 0 { return nil, fmt.Errorf("%s function must take no parameters, but has %d parameter(s)", AppInitFnName, len(funcDecl.Type.Params.List)) } if funcDecl.Type.Results == nil || len(funcDecl.Type.Results.List) != 1 { return nil, fmt.Errorf("%s function must return exactly one value of type error", AppInitFnName) } returnType := funcDecl.Type.Results.List[0] ident, ok := returnType.Type.(*ast.Ident) if !ok || ident.Name != "error" { return nil, fmt.Errorf("%s function must return error, not %v", AppInitFnName, returnType.Type) } hasAppInit = true } } return &parsedAppInfo{HasAppInit: hasAppInit}, nil } ================================================ FILE: tsunami/build/build.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package build import ( "archive/zip" "bufio" "fmt" "io" "io/fs" "log" "net/url" "os" "os/exec" "os/signal" "path/filepath" "regexp" "runtime" "slices" "strconv" "strings" "sync" "syscall" "time" "github.com/wavetermdev/waveterm/tsunami/util" "golang.org/x/mod/modfile" ) const MinSupportedGoMinorVersion = 22 const TsunamiUIImportPath = "github.com/wavetermdev/waveterm/tsunami/ui" const MainAppFileName = "app.go" type OutputCapture struct { lock sync.Mutex lines []string lineWriter *util.LineWriter } func MakeOutputCapture() *OutputCapture { oc := &OutputCapture{ lines: make([]string, 0), } oc.lineWriter = util.NewLineWriter(func(line []byte) { // synchronized via the Write/Flush functions oc.lines = append(oc.lines, string(line)) }) return oc } func (oc *OutputCapture) Write(p []byte) (n int, err error) { if oc == nil { return os.Stdout.Write(p) } oc.lock.Lock() defer oc.lock.Unlock() return oc.lineWriter.Write(p) } func (oc *OutputCapture) Flush() { if oc == nil || oc.lineWriter == nil { return } oc.lock.Lock() defer oc.lock.Unlock() oc.lineWriter.Flush() } func (oc *OutputCapture) Printf(format string, args ...interface{}) { if oc == nil || oc.lineWriter == nil { log.Printf(format, args...) return } line := fmt.Sprintf(format, args...) oc.lock.Lock() defer oc.lock.Unlock() oc.lines = append(oc.lines, line) } func (oc *OutputCapture) GetLines() []string { if oc == nil { return nil } oc.lock.Lock() defer oc.lock.Unlock() result := make([]string, len(oc.lines)) copy(result, oc.lines) return result } type BuildOpts struct { AppPath string AppNS string Verbose bool Open bool KeepTemp bool OutputFile string ScaffoldPath string SdkReplacePath string SdkVersion string NodePath string GoPath string MoveFileBack bool OutputCapture *OutputCapture } func GetAppName(appPath string) string { baseName := filepath.Base(appPath) return strings.TrimSuffix(baseName, ".tsapp") } type BuildEnv struct { GoVersion string GoPath string TempDir string cleanupOnce *sync.Once } func (opts BuildOpts) getNodePath() string { if opts.NodePath != "" { return opts.NodePath } return "node" } type GoVersionCheckResult struct { GoStatus string GoPath string GoVersion string ErrorString string } func FindGoExecutable() (string, error) { // First try the standard PATH lookup if goPath, err := exec.LookPath("go"); err == nil { return goPath, nil } // Define platform-specific paths to check var pathsToCheck []string if runtime.GOOS == "windows" { pathsToCheck = []string{ `c:\go\bin\go.exe`, `c:\program files\go\bin\go.exe`, } } else { // Unix-like systems (macOS, Linux, etc.) pathsToCheck = []string{ "/opt/homebrew/bin/go", // Homebrew on Apple Silicon "/usr/local/bin/go", // Traditional Homebrew or manual install "/usr/local/go/bin/go", // Official Go installation "/usr/bin/go", // System package manager } } // Check each path for _, path := range pathsToCheck { if _, err := os.Stat(path); err == nil { // File exists, check if it's executable if info, err := os.Stat(path); err == nil && !info.IsDir() { return path, nil } } } return "", fmt.Errorf("go command not found in PATH or common installation locations") } func CheckGoVersion(customGoPath string) GoVersionCheckResult { var goPath string var err error if customGoPath != "" { goPath = customGoPath } else { goPath, err = FindGoExecutable() if err != nil { return GoVersionCheckResult{ GoStatus: "notfound", GoPath: "", GoVersion: "", ErrorString: "", } } } cmd := exec.Command(goPath, "version") output, err := cmd.Output() if err != nil { return GoVersionCheckResult{ GoStatus: "error", GoPath: goPath, GoVersion: "", ErrorString: fmt.Sprintf("failed to run 'go version': %v", err), } } versionStr := strings.TrimSpace(string(output)) versionRegex := regexp.MustCompile(`go(1\.\d+)`) matches := versionRegex.FindStringSubmatch(versionStr) if len(matches) < 2 { return GoVersionCheckResult{ GoStatus: "error", GoPath: goPath, GoVersion: versionStr, ErrorString: fmt.Sprintf("unable to parse go version from: %s", versionStr), } } goVersion := matches[1] minorRegex := regexp.MustCompile(`1\.(\d+)`) minorMatches := minorRegex.FindStringSubmatch(goVersion) if len(minorMatches) < 2 { return GoVersionCheckResult{ GoStatus: "error", GoPath: goPath, GoVersion: versionStr, ErrorString: fmt.Sprintf("unable to parse minor version from: %s", goVersion), } } minor, err := strconv.Atoi(minorMatches[1]) if err != nil { return GoVersionCheckResult{ GoStatus: "error", GoPath: goPath, GoVersion: versionStr, ErrorString: fmt.Sprintf("failed to parse minor version: %v", err), } } if minor < MinSupportedGoMinorVersion { return GoVersionCheckResult{ GoStatus: "badversion", GoPath: goPath, GoVersion: versionStr, ErrorString: "", } } return GoVersionCheckResult{ GoStatus: "ok", GoPath: goPath, GoVersion: versionStr, ErrorString: "", } } func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { oc := opts.OutputCapture if opts.SdkVersion == "" && opts.SdkReplacePath == "" { return nil, fmt.Errorf("either SdkVersion or SdkReplacePath must be set") } if opts.SdkVersion != "" { versionRegex := regexp.MustCompile(`^v\d+\.\d+\.\d+`) if !versionRegex.MatchString(opts.SdkVersion) { return nil, fmt.Errorf("SdkVersion must be in semantic version format (e.g., v0.0.0), got: %s", opts.SdkVersion) } } result := CheckGoVersion(opts.GoPath) switch result.GoStatus { case "notfound": return nil, fmt.Errorf("go command not found") case "badversion": return nil, fmt.Errorf("go version 1.%d or higher required, found: %s", MinSupportedGoMinorVersion, result.GoVersion) case "error": return nil, fmt.Errorf("%s", result.ErrorString) case "ok": if verbose { if opts.GoPath != "" { oc.Printf("[debug] Using custom go path: %s", result.GoPath) } else { oc.Printf("[debug] Using go path: %s", result.GoPath) } oc.Printf("[debug] Found %s", result.GoVersion) } default: return nil, fmt.Errorf("unexpected go status: %s", result.GoStatus) } versionRegex := regexp.MustCompile(`go(1\.\d+)`) matches := versionRegex.FindStringSubmatch(result.GoVersion) if len(matches) < 2 { return nil, fmt.Errorf("unable to parse go version from: %s", result.GoVersion) } goVersion := matches[1] var err error // Check if node is available if opts.NodePath != "" { // Custom node path specified - verify it's absolute and executable if !filepath.IsAbs(opts.NodePath) { return nil, fmt.Errorf("NodePath must be an absolute path, got: %s", opts.NodePath) } info, err := os.Stat(opts.NodePath) if err != nil { return nil, fmt.Errorf("NodePath does not exist: %s: %w", opts.NodePath, err) } if info.IsDir() { return nil, fmt.Errorf("NodePath is a directory, not an executable: %s", opts.NodePath) } // Check if file is executable (Unix-like systems) if runtime.GOOS != "windows" && info.Mode()&0111 == 0 { return nil, fmt.Errorf("NodePath is not executable: %s", opts.NodePath) } if verbose { oc.Printf("[debug] Using custom node path: %s", opts.NodePath) } } else { // Use standard PATH lookup _, err = exec.LookPath("node") if err != nil { return nil, fmt.Errorf("node command not found in PATH: %w", err) } if verbose { oc.Printf("[debug] Found node in PATH") } } return &BuildEnv{ GoVersion: goVersion, GoPath: result.GoPath, cleanupOnce: &sync.Once{}, }, nil } func createGoMod(tempDir, appNS, appName string, buildEnv *BuildEnv, opts BuildOpts, verbose bool) error { oc := opts.OutputCapture if appNS == "" { appNS = "app" } modulePath := fmt.Sprintf("tsunami/%s/%s", appNS, appName) // Check if go.mod already exists in temp directory (copied from app path) tempGoModPath := filepath.Join(tempDir, "go.mod") var modFile *modfile.File var err error if _, err := os.Stat(tempGoModPath); err == nil { // go.mod exists in temp dir, parse it if verbose { oc.Printf("[debug] Found existing go.mod in temp directory, parsing it") } // Parse the existing go.mod goModContent, err := os.ReadFile(tempGoModPath) if err != nil { return fmt.Errorf("failed to read go.mod: %w", err) } modFile, err = modfile.Parse("go.mod", goModContent, nil) if err != nil { return fmt.Errorf("failed to parse existing go.mod: %w", err) } } else if os.IsNotExist(err) { // go.mod doesn't exist, create new one if verbose { oc.Printf("[debug] No existing go.mod found, creating new one") } modFile = &modfile.File{} if err := modFile.AddModuleStmt(modulePath); err != nil { return fmt.Errorf("failed to add module statement: %w", err) } if err := modFile.AddGoStmt(buildEnv.GoVersion); err != nil { return fmt.Errorf("failed to add go version: %w", err) } // Add requirement for tsunami SDK if err := modFile.AddRequire("github.com/wavetermdev/waveterm/tsunami", opts.SdkVersion); err != nil { return fmt.Errorf("failed to add require directive: %w", err) } } else { return fmt.Errorf("error checking for go.mod in temp directory: %w", err) } // Add replace directive for tsunami SDK if path is provided if opts.SdkReplacePath != "" { if err := modFile.AddReplace("github.com/wavetermdev/waveterm/tsunami", "", opts.SdkReplacePath, ""); err != nil { return fmt.Errorf("failed to add replace directive: %w", err) } } // Format and write the file modFile.Cleanup() goModContent, err := modFile.Format() if err != nil { return fmt.Errorf("failed to format go.mod: %w", err) } goModPath := filepath.Join(tempDir, "go.mod") if err := os.WriteFile(goModPath, goModContent, 0644); err != nil { return fmt.Errorf("failed to write go.mod file: %w", err) } if verbose { oc.Printf("[debug] Created go.mod with module path: %s", modulePath) oc.Printf("[debug] Added require: github.com/wavetermdev/waveterm/tsunami %s", opts.SdkVersion) if opts.SdkReplacePath != "" { oc.Printf("[debug] Added replace directive: github.com/wavetermdev/waveterm/tsunami => %s", opts.SdkReplacePath) } } // Run go mod tidy to clean up dependencies tidyCmd := exec.Command(buildEnv.GoPath, "mod", "tidy") tidyCmd.Dir = tempDir if verbose { oc.Printf("[debug] Running go mod tidy") } if oc != nil { tidyCmd.Stdout = oc tidyCmd.Stderr = oc } else { tidyCmd.Stdout = os.Stdout tidyCmd.Stderr = os.Stderr } if err := tidyCmd.Run(); err != nil { oc.Flush() return fmt.Errorf("go mod tidy failed (see output for errors)") } if oc != nil { oc.Flush() } if verbose { oc.Printf("[debug] Successfully ran go mod tidy") } return nil } func verifyAppPathFs(fsys fs.FS) error { if err := checkFileExistsFS(fsys, MainAppFileName); err != nil { return fmt.Errorf("%s check failed: %w", MainAppFileName, err) } // Check static directory if it exists if err := isDirOrNotFoundFS(fsys, "static"); err != nil { return fmt.Errorf("static directory check failed: %w", err) } return nil } func GetAppModTime(appPath string) (time.Time, error) { if strings.HasSuffix(appPath, ".tsapp") { info, err := os.Stat(appPath) if err != nil { return time.Time{}, fmt.Errorf("failed to get tsapp mod time: %w", err) } return info.ModTime(), nil } appGoPath := filepath.Join(appPath, MainAppFileName) info, err := os.Stat(appGoPath) if err != nil { return time.Time{}, fmt.Errorf("failed to get %s mod time: %w", MainAppFileName, err) } return info.ModTime(), nil } func verifyScaffoldFs(fsys fs.FS) error { // Check for dist directory if err := isDirOrNotFoundFS(fsys, "dist"); err != nil { return fmt.Errorf("dist directory check failed: %w", err) } info, err := fs.Stat(fsys, "dist") if err != nil || !info.IsDir() { return fmt.Errorf("dist directory must exist in scaffold") } // Check for app-main.go.tmpl file if err := checkFileExistsFS(fsys, "app-main.go.tmpl"); err != nil { return fmt.Errorf("app-main.go check failed: %w", err) } // Check for app-init.go.tmpl file if err := checkFileExistsFS(fsys, "app-init.go.tmpl"); err != nil { return fmt.Errorf("app-init.go check failed: %w", err) } // Check for tailwind.css file if err := checkFileExistsFS(fsys, "tailwind.css"); err != nil { return fmt.Errorf("tailwind.css check failed: %w", err) } // Check for package.json file if err := checkFileExistsFS(fsys, "package.json"); err != nil { return fmt.Errorf("package.json check failed: %w", err) } // Check for nm directory if err := isDirOrNotFoundFS(fsys, "nm"); err != nil { return fmt.Errorf("nm (node_modules) directory check failed: %w", err) } info, err = fs.Stat(fsys, "nm") if err != nil || !info.IsDir() { return fmt.Errorf("nm (node_modules) directory must exist in scaffold") } return nil } func (be *BuildEnv) cleanupTempDir(keepTemp bool, verbose bool) { if be == nil || be.cleanupOnce == nil { return } be.cleanupOnce.Do(func() { if keepTemp || be.TempDir == "" { log.Printf("NOT cleaning tempdir\n") return } if err := os.RemoveAll(be.TempDir); err != nil { log.Printf("Failed to remove temp directory %s: %v", be.TempDir, err) } else if verbose { log.Printf("Removed temp directory: %s", be.TempDir) } }) } func setupSignalCleanup(buildEnv *BuildEnv, keepTemp, verbose bool) { if keepTemp { return } sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) go func() { defer signal.Stop(sigChan) sig := <-sigChan if verbose { log.Printf("Received signal %v, cleaning up temp directory", sig) } buildEnv.cleanupTempDir(keepTemp, verbose) os.Exit(1) }() } func TsunamiBuild(opts BuildOpts) error { buildEnv, err := TsunamiBuildInternal(opts) defer buildEnv.cleanupTempDir(opts.KeepTemp, opts.Verbose) if err != nil { return err } setupSignalCleanup(buildEnv, opts.KeepTemp, opts.Verbose) return nil } func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { oc := opts.OutputCapture buildEnv, err := verifyEnvironment(opts.Verbose, opts) if err != nil { return nil, err } appFS, canWrite, appCloser, err := pathToFS(opts.AppPath) if err != nil { return nil, fmt.Errorf("bad app path: %w", err) } if appCloser != nil { defer appCloser() } if err := verifyAppPathFs(appFS); err != nil { return nil, fmt.Errorf("bad app path: %w", err) } scaffoldFS, _, scaffoldCloser, err := pathToFS(opts.ScaffoldPath) if err != nil { return nil, fmt.Errorf("bad scaffold path: %w", err) } if scaffoldCloser != nil { defer scaffoldCloser() } if err := verifyScaffoldFs(scaffoldFS); err != nil { return nil, err } appInfo, err := parseAndValidateAppFile(appFS) if err != nil { return nil, err } // Create temporary directory tempDir, err := os.MkdirTemp("", "tsunami-build-*") if err != nil { return nil, fmt.Errorf("failed to create temp directory: %w", err) } buildEnv.TempDir = tempDir oc.Printf("Building tsunami app from %s", opts.AppPath) oc.Printf("[debug] using scaffold path %s", opts.ScaffoldPath) if opts.Verbose || opts.KeepTemp { oc.Printf("[debug] Temp dir: %s", tempDir) } // Copy files from app path (go.mod, go.sum, static/, *.go) copyStats, err := copyFilesFromAppFS(appFS, opts.AppPath, tempDir, opts.Verbose, oc) if err != nil { return buildEnv, fmt.Errorf("failed to copy files from app path: %w", err) } // Copy scaffold directory contents selectively scaffoldCount, err := copyScaffoldFS(scaffoldFS, tempDir, appInfo.HasAppInit, opts.Verbose, oc) if err != nil { return buildEnv, fmt.Errorf("failed to copy scaffold directory: %w", err) } if opts.Verbose { oc.Printf("[debug] Copied %d go files, %d static files, %d scaffold files (go.mod: %t, go.sum: %t)", copyStats.GoFiles, copyStats.StaticFiles, scaffoldCount, copyStats.GoMod, copyStats.GoSum) } // Create go.mod file appName := GetAppName(opts.AppPath) if err := createGoMod(tempDir, opts.AppNS, appName, buildEnv, opts, opts.Verbose); err != nil { return buildEnv, err } // Generate Tailwind CSS if err := generateAppTailwindCss(tempDir, opts.Verbose, opts); err != nil { return buildEnv, err } // Build the Go application outputPath, err := runGoBuild(tempDir, buildEnv, opts) if err != nil { return buildEnv, err } // Generate manifest if err := generateManifest(tempDir, outputPath, opts); err != nil { return buildEnv, err } // Move generated files back to original directory if opts.MoveFileBack && canWrite { if err := moveFilesBack(tempDir, opts.AppPath, opts.Verbose, oc); err != nil { return buildEnv, fmt.Errorf("failed to move files back: %w", err) } } else if opts.MoveFileBack && !canWrite { if opts.Verbose { oc.Printf("Skipping move files back - app path is not writable: %s", opts.AppPath) } } return buildEnv, nil } func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture) error { // Move go.mod back to original directory goModSrc := filepath.Join(tempDir, "go.mod") goModDest := filepath.Join(originalDir, "go.mod") if err := copyFile(goModSrc, goModDest); err != nil { return fmt.Errorf("failed to copy go.mod back: %w", err) } if verbose { oc.Printf("[debug] Moved go.mod back to %s", goModDest) } // Move go.sum back to original directory (only if it exists) goSumSrc := filepath.Join(tempDir, "go.sum") if _, err := os.Stat(goSumSrc); err == nil { goSumDest := filepath.Join(originalDir, "go.sum") if err := copyFile(goSumSrc, goSumDest); err != nil { return fmt.Errorf("failed to copy go.sum back: %w", err) } if verbose { oc.Printf("[debug] Moved go.sum back to %s", goSumDest) } } // Ensure static directory exists in original directory staticDir := filepath.Join(originalDir, "static") if err := os.MkdirAll(staticDir, 0755); err != nil { return fmt.Errorf("failed to create static directory: %w", err) } if verbose { oc.Printf("[debug] Ensured static directory exists at %s", staticDir) } // Move tw.css back to original directory twCssSrc := filepath.Join(tempDir, "static", "tw.css") twCssDest := filepath.Join(originalDir, "static", "tw.css") if err := copyFile(twCssSrc, twCssDest); err != nil { return fmt.Errorf("failed to copy tw.css back: %w", err) } if verbose { oc.Printf("[debug] Moved tw.css back to %s", twCssDest) } // Move manifest.json back to original directory (only if it exists) manifestSrc := filepath.Join(tempDir, "manifest.json") if _, err := os.Stat(manifestSrc); err == nil { manifestDest := filepath.Join(originalDir, "manifest.json") if err := copyFile(manifestSrc, manifestDest); err != nil { return fmt.Errorf("failed to copy manifest.json back: %w", err) } if verbose { oc.Printf("[debug] Moved manifest.json back to %s", manifestDest) } } return nil } func runGoBuild(tempDir string, buildEnv *BuildEnv, opts BuildOpts) (string, error) { oc := opts.OutputCapture var outputPath string var absOutputPath string if opts.OutputFile != "" { // Convert to absolute path resolved against current working directory var err error absOutputPath, err = filepath.Abs(opts.OutputFile) if err != nil { return "", fmt.Errorf("failed to resolve output path: %w", err) } outputPath = absOutputPath } else { binDir := filepath.Join(tempDir, "bin") if err := os.MkdirAll(binDir, 0755); err != nil { return "", fmt.Errorf("failed to create bin directory: %w", err) } outputPath = "bin/app" absOutputPath = filepath.Join(tempDir, "bin", "app") } goFiles, err := listGoFilesInDir(tempDir) if err != nil { return "", fmt.Errorf("failed to list go files: %w", err) } if len(goFiles) == 0 { return "", fmt.Errorf("no .go files found in %s", tempDir) } // Build command with explicit go files args := append([]string{"build", "-o", outputPath}, ".") buildCmd := exec.Command(buildEnv.GoPath, args...) buildCmd.Dir = tempDir if oc != nil || opts.Verbose { oc.Printf("[debug] Running: %s", strings.Join(buildCmd.Args, " ")) oc.Printf("Building application...") } if oc != nil { buildCmd.Stdout = oc buildCmd.Stderr = oc } else { buildCmd.Stdout = os.Stdout buildCmd.Stderr = os.Stderr } if err := buildCmd.Run(); err != nil { return "", fmt.Errorf("compilation failed (see output for errors)") } if oc != nil { oc.Flush() } if opts.Verbose { oc.Printf("Application built successfully") oc.Printf("[debug] Output path: %s", absOutputPath) } return absOutputPath, nil } func generateManifest(tempDir, exePath string, opts BuildOpts) error { oc := opts.OutputCapture manifestCmd := exec.Command(exePath, "--manifest") manifestCmd.Dir = tempDir if opts.Verbose { oc.Printf("[debug] Running: %s --manifest", exePath) oc.Printf("Generating manifest...") } manifestOutput, err := manifestCmd.Output() if err != nil { return fmt.Errorf("manifest generation failed: %w", err) } // Extract manifest between delimiters manifestStr := string(manifestOutput) startTag := "<AppManifest>" endTag := "</AppManifest>" startIdx := strings.Index(manifestStr, startTag) endIdx := strings.Index(manifestStr, endTag) if startIdx == -1 || endIdx == -1 || endIdx <= startIdx { return fmt.Errorf("manifest delimiters not found in output") } manifestJSON := manifestStr[startIdx+len(startTag) : endIdx] manifestJSON = strings.TrimSpace(manifestJSON) manifestPath := filepath.Join(tempDir, "manifest.json") if err := os.WriteFile(manifestPath, []byte(manifestJSON), 0644); err != nil { return fmt.Errorf("failed to write manifest.json: %w", err) } if opts.Verbose { oc.Printf("Manifest generated successfully") oc.Printf("[debug] Manifest path: %s", manifestPath) } return nil } func generateAppTailwindCss(tempDir string, verbose bool, opts BuildOpts) error { oc := opts.OutputCapture // tailwind.css is already in tempDir from scaffold copy tailwindOutput := filepath.Join(tempDir, "static", "tw.css") tailwindCmd := exec.Command(opts.getNodePath(), "--preserve-symlinks-main", "--preserve-symlinks", "node_modules/@tailwindcss/cli/dist/index.mjs", "-i", "./tailwind.css", "-o", tailwindOutput) tailwindCmd.Dir = tempDir tailwindCmd.Env = append(os.Environ(), "ELECTRON_RUN_AS_NODE=1") if verbose { oc.Printf("[debug] Running: %s", strings.Join(tailwindCmd.Args, " ")) } output, err := tailwindCmd.CombinedOutput() if err != nil { return fmt.Errorf("tailwind CSS generation failed (see output for errors)") } // Process and filter tailwind output lines := strings.Split(string(output), "\n") for _, line := range lines { // Skip empty lines if strings.TrimSpace(line) == "" { continue } // Skip version line (contains ≈ and tailwindcss) if strings.Contains(line, "≈") && strings.Contains(line, "tailwindcss") { continue } // Skip "Done in" timing line if strings.HasPrefix(strings.TrimSpace(line), "Done in") { continue } // Write remaining lines to output oc.Printf("%s", line) } if verbose { oc.Printf("Tailwind CSS generated successfully") } return nil } type CopyStats struct { GoFiles int StaticFiles int GoMod bool GoSum bool } func copyGoFilesFromFS(fsys fs.FS, destDir string) (int, error) { entries, err := fs.ReadDir(fsys, ".") if err != nil { return 0, err } fileCount := 0 for _, entry := range entries { if entry.IsDir() { continue } if strings.HasSuffix(entry.Name(), ".go") { destPath := filepath.Join(destDir, entry.Name()) if err := CopyFileFromFS(fsys, entry.Name(), destPath); err != nil { return 0, fmt.Errorf("failed to copy %s: %w", entry.Name(), err) } fileCount++ } } return fileCount, nil } // appPath is just used for logging (we do the copies from appFS) func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool, oc *OutputCapture) (*CopyStats, error) { stats := &CopyStats{} // Copy go.mod if it exists goModDest := filepath.Join(tempDir, "go.mod") copied, err := CopyFileIfExists(appFS, "go.mod", goModDest) if err != nil { return nil, err } stats.GoMod = copied if copied && verbose { oc.Printf("Copied go.mod from %s", filepath.Join(appPath, "go.mod")) } // Copy go.sum if it exists goSumDest := filepath.Join(tempDir, "go.sum") copied, err = CopyFileIfExists(appFS, "go.sum", goSumDest) if err != nil { return nil, err } stats.GoSum = copied if copied && verbose { oc.Printf("Copied go.sum from %s", filepath.Join(appPath, "go.sum")) } // Copy manifest.json if it exists manifestDest := filepath.Join(tempDir, "manifest.json") copied, err = CopyFileIfExists(appFS, "manifest.json", manifestDest) if err != nil { return nil, err } if copied && verbose { oc.Printf("Copied manifest.json from %s", filepath.Join(appPath, "manifest.json")) } // Copy static directory staticDestDir := filepath.Join(tempDir, "static") staticCount, err := copyDirFromFS(appFS, "static", staticDestDir, true) if err != nil { return nil, fmt.Errorf("failed to copy static directory: %w", err) } stats.StaticFiles = staticCount // Copy all *.go files from the root directory goCount, err := copyGoFilesFromFS(appFS, tempDir) if err != nil { return nil, fmt.Errorf("failed to copy go files: %w", err) } stats.GoFiles = goCount return stats, nil } func TsunamiRun(opts BuildOpts) error { oc := opts.OutputCapture buildEnv, err := TsunamiBuildInternal(opts) defer buildEnv.cleanupTempDir(opts.KeepTemp, opts.Verbose) if err != nil { return err } setupSignalCleanup(buildEnv, opts.KeepTemp, opts.Verbose) // Run the built application appBinPath := filepath.Join(buildEnv.TempDir, "bin", "app") runCmd := exec.Command(appBinPath) runCmd.Dir = buildEnv.TempDir oc.Printf("Running tsunami app from %s", opts.AppPath) runCmd.Stdin = os.Stdin if opts.Open { // If --open flag is set, we need to capture stderr to parse the listening message stderr, err := runCmd.StderrPipe() if err != nil { return fmt.Errorf("failed to create stderr pipe: %w", err) } runCmd.Stdout = os.Stdout if err := runCmd.Start(); err != nil { return fmt.Errorf("failed to start application: %w", err) } // Monitor stderr for the listening message go monitorAndOpenBrowser(stderr, opts.Verbose) if err := runCmd.Wait(); err != nil { return fmt.Errorf("application exited with error: %w", err) } } else { // Normal execution without browser opening if opts.Verbose { log.Printf("Executing: %s", appBinPath) runCmd.Stdout = os.Stdout runCmd.Stderr = os.Stderr } if err := runCmd.Start(); err != nil { return fmt.Errorf("failed to start application: %w", err) } if err := runCmd.Wait(); err != nil { return fmt.Errorf("application exited with error: %w", err) } } return nil } func monitorAndOpenBrowser(r io.ReadCloser, verbose bool) { defer r.Close() scanner := bufio.NewScanner(r) browserOpened := false if verbose { log.Printf("monitoring for browser open\n") } for scanner.Scan() { line := scanner.Text() fmt.Println(line) if !browserOpened { port := ParseTsunamiPort(line) if port > 0 { url := fmt.Sprintf("http://localhost:%d", port) if verbose { log.Printf("Opening browser to %s", url) } go util.OpenBrowser(url, 100*time.Millisecond) browserOpened = true } } } } func ParseTsunamiPort(line string) int { urlRegex := regexp.MustCompile(`\[tsunami\] listening at (http://[^\s]+)`) matches := urlRegex.FindStringSubmatch(line) if len(matches) < 2 { return 0 } u, err := url.Parse(matches[1]) if err != nil { return 0 } portStr := u.Port() if portStr == "" { return 0 } port, err := strconv.Atoi(portStr) if err != nil { return 0 } return port } func copyScaffoldFS(scaffoldFS fs.FS, destDir string, hasAppInit bool, verbose bool, oc *OutputCapture) (int, error) { fileCount := 0 // Handle nm (node_modules) directory - prefer symlink if possible, otherwise copy if _, err := fs.Stat(scaffoldFS, "nm"); err == nil { destPath := filepath.Join(destDir, "node_modules") // Try to create symlink if we have DirFS symlinked := false if dirFS, ok := scaffoldFS.(DirFS); ok { srcPath := dirFS.JoinOS("nm") if err := os.Symlink(srcPath, destPath); err == nil { if verbose { oc.Printf("[debug] Symlinked nm to node_modules directory") } fileCount++ symlinked = true } } // Fallback to recursive copy if symlink failed or not attempted if !symlinked { dirCount, err := copyDirFromFS(scaffoldFS, "nm", destPath, false) if err != nil { return 0, fmt.Errorf("failed to copy nm (node_modules) directory: %w", err) } if verbose { oc.Printf("Copied nm to node_modules directory (%d files)", dirCount) } fileCount += dirCount } } else if !os.IsNotExist(err) { return 0, fmt.Errorf("error checking nm (node_modules): %w", err) } // Copy package files instead of symlinking packageFiles := []string{"package.json", "package-lock.json"} for _, fileName := range packageFiles { destPath := filepath.Join(destDir, fileName) // Check if source exists in FS if _, err := fs.Stat(scaffoldFS, fileName); err != nil { if os.IsNotExist(err) { continue // Skip if doesn't exist } return 0, fmt.Errorf("error checking %s: %w", fileName, err) } // Copy file from FS if err := CopyFileFromFS(scaffoldFS, fileName, destPath); err != nil { return 0, fmt.Errorf("failed to copy %s: %w", fileName, err) } fileCount++ } // Copy dist directory using FS distDestPath := filepath.Join(destDir, "dist") dirCount, err := copyDirFromFS(scaffoldFS, "dist", distDestPath, false) if err != nil { return 0, fmt.Errorf("failed to copy dist directory: %w", err) } fileCount += dirCount // Always copy app-main.go.tmpl => app-main.go destPath := filepath.Join(destDir, "app-main.go") if err := CopyFileFromFS(scaffoldFS, "app-main.go.tmpl", destPath); err != nil { return 0, fmt.Errorf("failed to copy app-main.go.tmpl: %w", err) } fileCount++ // Conditionally copy app-init.go.tmpl => app-init.go if hasAppInit { destPath := filepath.Join(destDir, "app-init.go") if err := CopyFileFromFS(scaffoldFS, "app-init.go.tmpl", destPath); err != nil { return 0, fmt.Errorf("failed to copy app-init.go.tmpl: %w", err) } fileCount++ } // Copy files by pattern (*.md, *.json, tailwind.css) patterns := []string{"*.md", "*.json", "tailwind.css"} for _, pattern := range patterns { matches, err := fs.Glob(scaffoldFS, pattern) if err != nil { return 0, fmt.Errorf("failed to glob pattern %s: %w", pattern, err) } for _, match := range matches { if slices.Contains(packageFiles, match) { continue } destPath := filepath.Join(destDir, match) if err := CopyFileFromFS(scaffoldFS, match, destPath); err != nil { return 0, fmt.Errorf("failed to copy %s: %w", match, err) } fileCount++ } } return fileCount, nil } func MakeAppPackage(appFS fs.FS, appPath string, verbose bool, outputFile string) error { if verbose { log.Printf("Creating app package from %s to %s", appPath, outputFile) } // Create output directory if it doesn't exist outputDir := filepath.Dir(outputFile) if err := os.MkdirAll(outputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } // Create zip file zipFile, err := os.Create(outputFile) if err != nil { return fmt.Errorf("failed to create zip file: %w", err) } defer zipFile.Close() zipWriter := zip.NewWriter(zipFile) defer zipWriter.Close() fileCount := 0 // Add go.mod if it exists if err := addFileToZipIfExists(zipWriter, appFS, "go.mod", &fileCount, verbose); err != nil { return fmt.Errorf("failed to add go.mod: %w", err) } // Add go.sum if it exists if err := addFileToZipIfExists(zipWriter, appFS, "go.sum", &fileCount, verbose); err != nil { return fmt.Errorf("failed to add go.sum: %w", err) } // Add manifest.json if it exists if err := addFileToZipIfExists(zipWriter, appFS, "manifest.json", &fileCount, verbose); err != nil { return fmt.Errorf("failed to add manifest.json: %w", err) } // Add all *.go files if err := addGoFilesToZip(zipWriter, appFS, &fileCount, verbose); err != nil { return fmt.Errorf("failed to add go files: %w", err) } // Add static directory if it exists if err := addDirToZipIfExists(zipWriter, appFS, "static", &fileCount, verbose); err != nil { return fmt.Errorf("failed to add static directory: %w", err) } if verbose { log.Printf("Package created successfully with %d files", fileCount) } return nil } ================================================ FILE: tsunami/build/buildutil.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package build import ( "archive/zip" "fmt" "io" "io/fs" "log" "os" "path/filepath" "strings" ) type DirFS struct { Root string fs.FS } func NewDirFS(root string) DirFS { return DirFS{Root: root, FS: os.DirFS(root)} } func (d DirFS) JoinOS(name string) string { return filepath.Join(d.Root, filepath.FromSlash(name)) } func (d DirFS) Stat(name string) (fs.FileInfo, error) { return fs.Stat(d.FS, name) } func (d DirFS) ReadFile(name string) ([]byte, error) { return fs.ReadFile(d.FS, name) } func (d DirFS) ReadDir(name string) ([]fs.DirEntry, error) { return fs.ReadDir(d.FS, name) } func (d DirFS) Glob(p string) ([]string, error) { return fs.Glob(d.FS, p) } func pathToFS(path string) (fs.FS, bool, func() error, error) { if path == "" { return nil, false, nil, fmt.Errorf("directory path cannot be empty") } // Check if path exists info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return nil, false, nil, fmt.Errorf("path %q does not exist", path) } return nil, false, nil, fmt.Errorf("error accessing path %q: %w", path, err) } // Check if it's a .tsapp file (zip archive) if strings.HasSuffix(path, ".tsapp") { if info.IsDir() { return nil, false, nil, fmt.Errorf("%q is a directory, but .tsapp files must be zip archives", path) } // Open as zip file zipReader, err := zip.OpenReader(path) if err != nil { return nil, false, nil, fmt.Errorf("failed to open .tsapp file %q as zip archive: %w", path, err) } // Return zip filesystem (not writable) with closer function return zipReader, false, zipReader.Close, nil } // Handle regular directories if !info.IsDir() { return nil, false, nil, fmt.Errorf("%q is not a directory", path) } // Check if directory is writable by checking permissions canWrite := info.Mode().Perm()&0200 != 0 // Check if owner has write permission return NewDirFS(path), canWrite, nil, nil } func IsDirOrNotFound(path string) error { info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return nil // Not found is OK } return err // Other errors are not OK } if !info.IsDir() { return fmt.Errorf("%q exists but is not a directory", path) } return nil // It's a directory, which is OK } func CheckFileExists(path string) error { info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("file %q not found", path) } return fmt.Errorf("error accessing file %q: %w", path, err) } if info.IsDir() { return fmt.Errorf("%q is a directory, not a file", path) } return nil } func FileMustNotExist(path string) error { if _, err := os.Stat(path); err == nil { return fmt.Errorf("%q must not exist", path) } else if !os.IsNotExist(err) { return err // Other errors are not OK } return nil // Not found is OK } func copyFile(srcPath, destPath string) error { return CopyFileFromFS(os.DirFS("/"), strings.TrimPrefix(srcPath, "/"), destPath) } func listGoFilesInDir(dirPath string) ([]string, error) { entries, err := os.ReadDir(dirPath) if err != nil { return nil, fmt.Errorf("failed to read directory %s: %w", dirPath, err) } var goFiles []string for _, entry := range entries { if !entry.IsDir() && filepath.Ext(entry.Name()) == ".go" { goFiles = append(goFiles, entry.Name()) } } return goFiles, nil } func CopyFileIfExists(fsys fs.FS, srcPath, destPath string) (bool, error) { if fileInfo, err := fs.Stat(fsys, srcPath); err == nil { if fileInfo.IsDir() { return false, fmt.Errorf("source path %s is a directory", srcPath) } if err := CopyFileFromFS(fsys, srcPath, destPath); err != nil { return false, fmt.Errorf("failed to copy %s: %w", srcPath, err) } return true, nil } else if os.IsNotExist(err) { return false, nil } else { return false, fmt.Errorf("error checking %s: %w", srcPath, err) } } func CopyFileFromFS(fsys fs.FS, srcPath, destPath string) error { // Open source file from filesystem srcFile, err := fsys.Open(srcPath) if err != nil { return err } defer srcFile.Close() // Get source file info srcInfo, err := fs.Stat(fsys, srcPath) if err != nil { return err } // Create destination directory if it doesn't exist destDir := filepath.Dir(destPath) if err := os.MkdirAll(destDir, 0755); err != nil { return err } // Create destination file destFile, err := os.Create(destPath) if err != nil { return err } defer destFile.Close() // Copy content _, err = io.Copy(destFile, srcFile) if err != nil { return err } // Set the same mode as source file return os.Chmod(destPath, srcInfo.Mode()) } func checkFileExistsFS(fsys fs.FS, path string) error { info, err := fs.Stat(fsys, path) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("file %q not found", path) } return fmt.Errorf("error accessing file %q: %w", path, err) } if info.IsDir() { return fmt.Errorf("%q is a directory, not a file", path) } return nil } func isDirOrNotFoundFS(fsys fs.FS, path string) error { info, err := fs.Stat(fsys, path) if err != nil { if os.IsNotExist(err) { return nil // Not found is OK } return err // Other errors are not OK } if !info.IsDir() { return fmt.Errorf("%q exists but is not a directory", path) } return nil // It's a directory, which is OK } func copyDirFromFS(fsys fs.FS, srcDir, destDir string, forceCreateDestDir bool) (int, error) { fileCount := 0 // Check if source directory exists srcInfo, err := fs.Stat(fsys, srcDir) if err != nil { if os.IsNotExist(err) { if forceCreateDestDir { // Create destination directory even if source doesn't exist if err := os.MkdirAll(destDir, 0755); err != nil { return 0, fmt.Errorf("failed to create destination directory %s: %w", destDir, err) } } return 0, nil // Source doesn't exist, return 0 files copied } return 0, fmt.Errorf("error accessing source directory %s: %w", srcDir, err) } // Check if source is actually a directory if !srcInfo.IsDir() { return 0, fmt.Errorf("source %s is not a directory", srcDir) } err = fs.WalkDir(fsys, srcDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } // Calculate destination path relPath, err := filepath.Rel(srcDir, path) if err != nil { return err } destPath := filepath.Join(destDir, relPath) if d.IsDir() { // Create directory with standard permissions (0755) regardless of source permissions // This is important when extracting from zip files which may have read-only dirs if err := os.MkdirAll(destPath, 0755); err != nil { return err } } else { // Copy file if err := CopyFileFromFS(fsys, path, destPath); err != nil { return err } fileCount++ } return nil }) return fileCount, err } func addFileToZipIfExists(zipWriter *zip.Writer, fsys fs.FS, fileName string, fileCount *int, verbose bool) error { if _, err := fs.Stat(fsys, fileName); err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("error checking %s: %w", fileName, err) } if err := addFileToZip(zipWriter, fsys, fileName, fileName); err != nil { return err } *fileCount++ if verbose { log.Printf("Added %s to package", fileName) } return nil } func addGoFilesToZip(zipWriter *zip.Writer, fsys fs.FS, fileCount *int, verbose bool) error { entries, err := fs.ReadDir(fsys, ".") if err != nil { return fmt.Errorf("failed to read directory: %w", err) } for _, entry := range entries { if entry.IsDir() { continue } if strings.HasSuffix(entry.Name(), ".go") { if err := addFileToZip(zipWriter, fsys, entry.Name(), entry.Name()); err != nil { return fmt.Errorf("failed to add %s: %w", entry.Name(), err) } *fileCount++ if verbose { log.Printf("Added %s to package", entry.Name()) } } } return nil } func addDirToZipIfExists(zipWriter *zip.Writer, fsys fs.FS, dirName string, fileCount *int, verbose bool) error { info, err := fs.Stat(fsys, dirName) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("error checking %s: %w", dirName, err) } if !info.IsDir() { return fmt.Errorf("%s exists but is not a directory", dirName) } return fs.WalkDir(fsys, dirName, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { if err := addFileToZip(zipWriter, fsys, path, path); err != nil { return fmt.Errorf("failed to add file %s: %w", path, err) } *fileCount++ if verbose { log.Printf("Added %s to package", path) } } return nil }) } func addFileToZip(zipWriter *zip.Writer, fsys fs.FS, srcPath, destPath string) error { srcFile, err := fsys.Open(srcPath) if err != nil { return fmt.Errorf("failed to open source file %s: %w", srcPath, err) } defer srcFile.Close() info, err := fs.Stat(fsys, srcPath) if err != nil { return fmt.Errorf("failed to get file info for %s: %w", srcPath, err) } header, err := zip.FileInfoHeader(info) if err != nil { return fmt.Errorf("failed to create zip header for %s: %w", srcPath, err) } header.Name = destPath destFile, err := zipWriter.CreateHeader(header) if err != nil { return fmt.Errorf("failed to create zip entry for %s: %w", destPath, err) } _, err = io.Copy(destFile, srcFile) if err != nil { return fmt.Errorf("failed to copy content for %s: %w", srcPath, err) } return nil } ================================================ FILE: tsunami/cmd/main-tsunami.go ================================================ package main import ( "fmt" "os" "path/filepath" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/tsunami/build" "github.com/wavetermdev/waveterm/tsunami/tsunamibase" ) const ( EnvTsunamiScaffoldPath = "TSUNAMI_SCAFFOLDPATH" EnvTsunamiSdkReplacePath = "TSUNAMI_SDKREPLACEPATH" EnvTsunamiNodePath = "TSUNAMI_NODEPATH" TsunamiSdkVersion = "v0.12.4" ) // these are set at build time var TsunamiVersion = "0.0.0" var BuildTime = "0" var rootCmd = &cobra.Command{ Use: "tsunami", Short: "Tsunami - A VDOM-based UI framework", Long: `Tsunami is a VDOM-based UI framework for building modern applications.`, } var versionCmd = &cobra.Command{ Use: "version", Short: "Print Tsunami version", Long: `Print Tsunami version`, Run: func(cmd *cobra.Command, args []string) { fmt.Println("v" + tsunamibase.TsunamiVersion) }, } func validateEnvironmentVars(opts *build.BuildOpts) error { scaffoldPath := os.Getenv(EnvTsunamiScaffoldPath) if scaffoldPath == "" { return fmt.Errorf("%s environment variable must be set", EnvTsunamiScaffoldPath) } absScaffoldPath, err := filepath.Abs(scaffoldPath) if err != nil { return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiScaffoldPath, err) } sdkReplacePath := os.Getenv(EnvTsunamiSdkReplacePath) if sdkReplacePath == "" { return fmt.Errorf("%s environment variable must be set", EnvTsunamiSdkReplacePath) } absSdkReplacePath, err := filepath.Abs(sdkReplacePath) if err != nil { return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiSdkReplacePath, err) } opts.ScaffoldPath = absScaffoldPath opts.SdkReplacePath = absSdkReplacePath // NodePath is optional if nodePath := os.Getenv(EnvTsunamiNodePath); nodePath != "" { opts.NodePath = nodePath } return nil } var buildCmd = &cobra.Command{ Use: "build [apppath]", Short: "Build a Tsunami application", Long: `Build a Tsunami application.`, Args: cobra.ExactArgs(1), SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { verbose, _ := cmd.Flags().GetBool("verbose") keepTemp, _ := cmd.Flags().GetBool("keeptemp") output, _ := cmd.Flags().GetString("output") opts := build.BuildOpts{ AppPath: args[0], Verbose: verbose, KeepTemp: keepTemp, OutputFile: output, MoveFileBack: true, SdkVersion: TsunamiSdkVersion, } if err := validateEnvironmentVars(&opts); err != nil { fmt.Println(err) os.Exit(1) } if err := build.TsunamiBuild(opts); err != nil { fmt.Println(err) os.Exit(1) } }, } var runCmd = &cobra.Command{ Use: "run [apppath]", Short: "Build and run a Tsunami application", Long: `Build and run a Tsunami application.`, Args: cobra.ExactArgs(1), SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { verbose, _ := cmd.Flags().GetBool("verbose") open, _ := cmd.Flags().GetBool("open") keepTemp, _ := cmd.Flags().GetBool("keeptemp") opts := build.BuildOpts{ AppPath: args[0], Verbose: verbose, Open: open, KeepTemp: keepTemp, MoveFileBack: true, SdkVersion: TsunamiSdkVersion, } if err := validateEnvironmentVars(&opts); err != nil { fmt.Println(err) os.Exit(1) } if err := build.TsunamiRun(opts); err != nil { fmt.Println(err) os.Exit(1) } }, } var packageCmd = &cobra.Command{ Use: "package [apppath]", Short: "Package a Tsunami application into a .tsapp file", Long: `Package a Tsunami application into a .tsapp file.`, Args: cobra.ExactArgs(1), SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { verbose, _ := cmd.Flags().GetBool("verbose") output, _ := cmd.Flags().GetString("output") appPath := args[0] if output == "" { appName := build.GetAppName(appPath) output = filepath.Join(appPath, appName+".tsapp") } appFS := build.NewDirFS(appPath) if err := build.MakeAppPackage(appFS, appPath, verbose, output); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } if verbose { fmt.Printf("Successfully created package: %s\n", output) } }, } func init() { rootCmd.AddCommand(versionCmd) buildCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output") buildCmd.Flags().Bool("keeptemp", false, "Keep temporary build directory") buildCmd.Flags().StringP("output", "o", "", "Output file path for the built application") rootCmd.AddCommand(buildCmd) runCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output") runCmd.Flags().Bool("open", false, "Open the application in the browser after starting") runCmd.Flags().Bool("keeptemp", false, "Keep temporary build directory") rootCmd.AddCommand(runCmd) packageCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output") packageCmd.Flags().StringP("output", "o", "", "Output file path for the package (default: [appname].tsapp in apppath)") rootCmd.AddCommand(packageCmd) } func main() { tsunamibase.TsunamiVersion = TsunamiVersion if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } ================================================ FILE: tsunami/demo/.gitignore ================================================ test/ ================================================ FILE: tsunami/demo/cpuchart/app.go ================================================ package main import ( "log" "time" "github.com/shirou/gopsutil/v4/cpu" "github.com/wavetermdev/waveterm/tsunami/app" "github.com/wavetermdev/waveterm/tsunami/vdom" ) var AppMeta = app.AppMeta{ Title: "CPU Usage Monitor", ShortDesc: "Real-time CPU usage monitoring and charting", } // Global atoms for config and data var ( dataPointCountAtom = app.ConfigAtom("dataPointCount", 60, &app.AtomMeta{ Desc: "Number of CPU data points to display in the chart", Min: app.Ptr(10.0), Max: app.Ptr(300.0), }) cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint { // Initialize with empty data points to maintain consistent chart size dataPointCount := 60 // Default value for initialization initialData := make([]CPUDataPoint, dataPointCount) for i := range initialData { initialData[i] = CPUDataPoint{ Time: 0, CPUUsage: nil, // Use nil to represent empty slots Timestamp: "", } } return initialData }(), &app.AtomMeta{ Desc: "Historical CPU usage data points for charting", }) currentCpuUsageAtom = app.DataAtom("currentCpuUsage", 0.0, &app.AtomMeta{ Desc: "Current CPU usage percentage", Units: "%", Min: app.Ptr(0.0), Max: app.Ptr(100.0), }) ) type CPUDataPoint struct { Time int64 `json:"time" desc:"Unix timestamp (seconds since epoch)" units:"s"` CPUUsage *float64 `json:"cpuUsage" desc:"CPU usage percentage" units:"%" min:"0" max:"100"` Timestamp string `json:"timestamp" desc:"Human-readable HH:MM:SS"` } type StatsPanelProps struct { Data []CPUDataPoint `json:"data"` } func collectCPUUsage() (float64, error) { percentages, err := cpu.Percent(time.Second, false) if err != nil { return 0, err } if len(percentages) == 0 { return 0, nil } return percentages[0], nil } func generateCPUDataPoint() CPUDataPoint { now := time.Now() cpuUsage, err := collectCPUUsage() if err != nil { log.Printf("Error collecting CPU usage: %v", err) cpuUsage = 0 } dataPoint := CPUDataPoint{ Time: now.Unix(), CPUUsage: &cpuUsage, // Convert to pointer Timestamp: now.Format("15:04:05"), } return dataPoint } var StatsPanel = app.DefineComponent("StatsPanel", func(props StatsPanelProps) any { var currentUsage float64 var avgUsage float64 var maxUsage float64 var validCount int if len(props.Data) > 0 { lastPoint := props.Data[len(props.Data)-1] if lastPoint.CPUUsage != nil { currentUsage = *lastPoint.CPUUsage } // Calculate average and max from non-nil values total := 0.0 for _, point := range props.Data { if point.CPUUsage != nil { total += *point.CPUUsage validCount++ if *point.CPUUsage > maxUsage { maxUsage = *point.CPUUsage } } } if validCount > 0 { avgUsage = total / float64(validCount) } } return vdom.H("div", map[string]any{ "className": "bg-gray-800 rounded-lg p-4 mb-6", }, vdom.H("h3", map[string]any{ "className": "text-lg font-semibold text-white mb-3", }, "CPU Statistics"), vdom.H("div", map[string]any{ "className": "grid grid-cols-3 gap-4", }, // Current Usage vdom.H("div", map[string]any{ "className": "bg-gray-700 rounded p-3", }, vdom.H("div", map[string]any{ "className": "text-sm text-gray-400 mb-1", }, "Current"), vdom.H("div", map[string]any{ "className": "text-2xl font-bold text-blue-400", }, vdom.H("span", nil, int(currentUsage+0.5), "%")), ), // Average Usage vdom.H("div", map[string]any{ "className": "bg-gray-700 rounded p-3", }, vdom.H("div", map[string]any{ "className": "text-sm text-gray-400 mb-1", }, "Average"), vdom.H("div", map[string]any{ "className": "text-2xl font-bold text-green-400", }, vdom.H("span", nil, int(avgUsage+0.5), "%")), ), // Max Usage vdom.H("div", map[string]any{ "className": "bg-gray-700 rounded p-3", }, vdom.H("div", map[string]any{ "className": "text-sm text-gray-400 mb-1", }, "Peak"), vdom.H("div", map[string]any{ "className": "text-2xl font-bold text-red-400", }, vdom.H("span", nil, int(maxUsage+0.5), "%")), ), ), ) }, ) var App = app.DefineComponent("App", func(_ struct{}) any { // Use UseTicker for continuous CPU data collection - automatically cleaned up on unmount app.UseTicker(time.Second, func() { // Collect new CPU data point and shift the data window newPoint := generateCPUDataPoint() // Update current CPU usage atom for easy AI access if newPoint.CPUUsage != nil { currentCpuUsageAtom.Set(*newPoint.CPUUsage) } cpuDataAtom.SetFn(func(data []CPUDataPoint) []CPUDataPoint { currentDataPointCount := dataPointCountAtom.Get() // Ensure we have the right size array if len(data) != currentDataPointCount { // Resize array if config changed resized := make([]CPUDataPoint, currentDataPointCount) copyCount := currentDataPointCount if len(data) < copyCount { copyCount = len(data) } if copyCount > 0 { copy(resized[currentDataPointCount-copyCount:], data[len(data)-copyCount:]) } data = resized } // Append new point and keep only the last currentDataPointCount elements data = append(data, newPoint) if len(data) > currentDataPointCount { data = data[len(data)-currentDataPointCount:] } return data }) }, []any{}) handleClear := func() { // Reset with empty data points based on current config currentDataPointCount := dataPointCountAtom.Get() initialData := make([]CPUDataPoint, currentDataPointCount) for i := range initialData { initialData[i] = CPUDataPoint{ Time: 0, CPUUsage: nil, Timestamp: "", } } cpuDataAtom.Set(initialData) currentCpuUsageAtom.Set(0.0) } // Read atom values once for rendering cpuData := cpuDataAtom.Get() dataPointCount := dataPointCountAtom.Get() return vdom.H("div", map[string]any{ "className": "min-h-screen bg-gray-900 text-white p-6", }, vdom.H("div", map[string]any{ "className": "max-w-6xl mx-auto", }, // Header vdom.H("div", map[string]any{ "className": "mb-8", }, vdom.H("h1", map[string]any{ "className": "text-3xl font-bold text-white mb-2", }, "Real-Time CPU Usage Monitor"), vdom.H("p", map[string]any{ "className": "text-gray-400", }, "Live CPU usage data collected using gopsutil, displaying ", dataPointCount, " seconds of history"), ), // Controls vdom.H("div", map[string]any{ "className": "bg-gray-800 rounded-lg p-4 mb-6", }, vdom.H("div", map[string]any{ "className": "flex items-center gap-4 flex-wrap", }, // Clear button vdom.H("button", map[string]any{ "className": "px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md text-sm font-medium transition-colors cursor-pointer", "onClick": handleClear, }, "Clear Data"), // Status indicator vdom.H("div", map[string]any{ "className": "flex items-center gap-2", }, vdom.H("div", map[string]any{ "className": "w-2 h-2 rounded-full bg-green-500", }), vdom.H("span", map[string]any{ "className": "text-sm text-gray-400", }, "Live Monitoring"), vdom.H("span", map[string]any{ "className": "text-sm text-gray-500 ml-2", }, "(", len(cpuData), "/", dataPointCount, " data points)"), ), ), ), // Statistics Panel StatsPanel(StatsPanelProps{ Data: cpuData, }), // Main chart vdom.H("div", map[string]any{ "className": "bg-gray-800 rounded-lg p-6 mb-6", }, vdom.H("h2", map[string]any{ "className": "text-xl font-semibold text-white mb-4", }, "CPU Usage Over Time"), vdom.H("div", map[string]any{ "className": "w-full h-96", }, vdom.H("recharts:ResponsiveContainer", map[string]any{ "width": "100%", "height": "100%", }, vdom.H("recharts:LineChart", map[string]any{ "data": cpuData, "isAnimationActive": false, }, vdom.H("recharts:CartesianGrid", map[string]any{ "strokeDasharray": "3 3", "stroke": "#374151", }), vdom.H("recharts:XAxis", map[string]any{ "dataKey": "timestamp", "stroke": "#9CA3AF", "fontSize": 12, }), vdom.H("recharts:YAxis", map[string]any{ "domain": []int{0, 100}, "stroke": "#9CA3AF", "fontSize": 12, }), vdom.H("recharts:Tooltip", map[string]any{ "labelStyle": map[string]any{ "color": "#374151", }, "contentStyle": map[string]any{ "backgroundColor": "#1F2937", "border": "1px solid #374151", "borderRadius": "6px", "color": "#F3F4F6", }, }), vdom.H("recharts:Line", map[string]any{ "type": "monotone", "dataKey": "cpuUsage", "stroke": "#3B82F6", "strokeWidth": 2, "dot": false, "name": "CPU Usage (%)", "isAnimationActive": false, }), ), ), ), ), // Info section vdom.H("div", map[string]any{ "className": "bg-blue-900 bg-opacity-50 border border-blue-700 rounded-lg p-4", }, vdom.H("h3", map[string]any{ "className": "text-lg font-semibold text-blue-200 mb-2", }, "Real-Time CPU Monitoring Features"), vdom.H("ul", map[string]any{ "className": "space-y-2 text-blue-100", }, vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), "Live CPU usage data collected using github.com/shirou/gopsutil/v4", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), "Continuous monitoring with 1-second update intervals", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), "Rolling window of ", dataPointCount, " seconds of historical data", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), "Real-time statistics: current, average, and peak usage", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), "Dark theme optimized for Wave Terminal", ), ), ), ), ) }, ) ================================================ FILE: tsunami/demo/cpuchart/go.mod ================================================ module tsunami/app/cpuchart go 1.25.6 require ( github.com/shirou/gopsutil/v4 v4.25.8 github.com/wavetermdev/waveterm/tsunami v0.0.0 ) require ( github.com/ebitengine/purego v0.8.4 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/outrigdev/goid v0.3.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/sys v0.35.0 // indirect ) replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami ================================================ FILE: tsunami/demo/cpuchart/go.sum ================================================ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: tsunami/demo/cpuchart/static/tw.css ================================================ /*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { :root, :host { --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; --color-red-100: oklch(93.6% 0.032 17.717); --color-red-400: oklch(70.4% 0.191 22.216); --color-red-500: oklch(63.7% 0.237 25.331); --color-red-800: oklch(44.4% 0.177 26.899); --color-green-400: oklch(79.2% 0.209 151.711); --color-green-500: oklch(72.3% 0.219 149.579); --color-blue-100: oklch(93.2% 0.032 255.585); --color-blue-200: oklch(88.2% 0.059 254.128); --color-blue-400: oklch(70.7% 0.165 254.624); --color-blue-700: oklch(48.8% 0.243 264.376); --color-blue-900: oklch(37.9% 0.146 265.522); --color-gray-400: oklch(70.7% 0.022 261.325); --color-gray-500: oklch(55.1% 0.027 264.364); --color-gray-600: oklch(44.6% 0.03 256.802); --color-gray-700: oklch(37.3% 0.034 259.733); --color-gray-800: oklch(27.8% 0.033 256.848); --color-gray-900: oklch(21% 0.034 264.665); --color-white: #fff; --spacing: 0.25rem; --container-6xl: 72rem; --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); --text-base: 1rem; --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; --leading-relaxed: 1.625; --radius-md: 0.375rem; --radius-lg: 0.5rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); --radius: 8px; --color-background: rgb(34, 34, 34); --color-primary: rgb(247, 247, 247); --color-secondary: rgba(215, 218, 224, 0.7); --color-muted: rgba(215, 218, 224, 0.5); --color-accent-300: rgb(110, 231, 133); --color-panel: rgba(255, 255, 255, 0.12); --color-border: rgba(255, 255, 255, 0.16); --color-accent: rgb(88, 193, 66); } } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); font-feature-settings: var(--default-mono-font-feature-settings, normal); font-variation-settings: var(--default-mono-font-variation-settings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: currentcolor; @supports (color: color-mix(in lab, red, red)) { color: color-mix(in oklab, currentcolor 50%, transparent); } } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden="until-found"])) { display: none !important; } } @layer utilities { .collapse { visibility: collapse; } .invisible { visibility: hidden; } .visible { visibility: visible; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } .not-sr-only { position: static; width: auto; height: auto; padding: 0; margin: 0; overflow: visible; clip-path: none; white-space: normal; } .absolute { position: absolute; } .fixed { position: fixed; } .relative { position: relative; } .static { position: static; } .sticky { position: sticky; } .isolate { isolation: isolate; } .isolation-auto { isolation: auto; } .container { width: 100%; @media (width >= 40rem) { max-width: 40rem; } @media (width >= 48rem) { max-width: 48rem; } @media (width >= 64rem) { max-width: 64rem; } @media (width >= 80rem) { max-width: 80rem; } @media (width >= 96rem) { max-width: 96rem; } } .mx-auto { margin-inline: auto; } .my-6 { margin-block: calc(var(--spacing) * 6); } .mt-1 { margin-top: calc(var(--spacing) * 1); } .mt-3 { margin-top: calc(var(--spacing) * 3); } .mt-4 { margin-top: calc(var(--spacing) * 4); } .mt-5 { margin-top: calc(var(--spacing) * 5); } .mt-6 { margin-top: calc(var(--spacing) * 6); } .mb-1 { margin-bottom: calc(var(--spacing) * 1); } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } .mb-6 { margin-bottom: calc(var(--spacing) * 6); } .mb-8 { margin-bottom: calc(var(--spacing) * 8); } .ml-2 { margin-left: calc(var(--spacing) * 2); } .ml-4 { margin-left: calc(var(--spacing) * 4); } .block { display: block; } .contents { display: contents; } .flex { display: flex; } .flow-root { display: flow-root; } .grid { display: grid; } .hidden { display: none; } .inline { display: inline; } .inline-block { display: inline-block; } .inline-flex { display: inline-flex; } .inline-grid { display: inline-grid; } .inline-table { display: inline-table; } .list-item { display: list-item; } .table { display: table; } .table-caption { display: table-caption; } .table-cell { display: table-cell; } .table-column { display: table-column; } .table-column-group { display: table-column-group; } .table-footer-group { display: table-footer-group; } .table-header-group { display: table-header-group; } .table-row { display: table-row; } .table-row-group { display: table-row-group; } .h-2 { height: calc(var(--spacing) * 2); } .h-96 { height: calc(var(--spacing) * 96); } .min-h-full { min-height: 100%; } .min-h-screen { min-height: 100vh; } .w-2 { width: calc(var(--spacing) * 2); } .w-full { width: 100%; } .max-w-6xl { max-width: var(--container-6xl); } .max-w-none { max-width: none; } .min-w-full { min-width: 100%; } .shrink { flex-shrink: 1; } .grow { flex-grow: 1; } .border-collapse { border-collapse: collapse; } .translate-none { translate: none; } .scale-3d { scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z); } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } .cursor-pointer { cursor: pointer; } .touch-pinch-zoom { --tw-pinch-zoom: pinch-zoom; touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,); } .resize { resize: both; } .list-inside { list-style-position: inside; } .list-decimal { list-style-type: decimal; } .list-disc { list-style-type: disc; } .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } .flex-wrap { flex-wrap: wrap; } .items-center { align-items: center; } .items-start { align-items: flex-start; } .gap-2 { gap: calc(var(--spacing) * 2); } .gap-4 { gap: calc(var(--spacing) * 4); } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-2 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-reverse { :where(& > :not(:last-child)) { --tw-space-y-reverse: 1; } } .space-x-reverse { :where(& > :not(:last-child)) { --tw-space-x-reverse: 1; } } .divide-x { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 0; border-inline-style: var(--tw-border-style); border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); } } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; border-bottom-style: var(--tw-border-style); border-top-style: var(--tw-border-style); border-top-width: calc(1px * var(--tw-divide-y-reverse)); border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); } } .divide-y-reverse { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 1; } } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .overflow-auto { overflow: auto; } .overflow-x-auto { overflow-x: auto; } .rounded { border-radius: var(--radius); } .rounded-full { border-radius: calc(infinity * 1px); } .rounded-lg { border-radius: var(--radius-lg); } .rounded-md { border-radius: var(--radius-md); } .rounded-s { border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); } .rounded-ss { border-start-start-radius: var(--radius); } .rounded-e { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); } .rounded-se { border-start-end-radius: var(--radius); } .rounded-ee { border-end-end-radius: var(--radius); } .rounded-es { border-end-start-radius: var(--radius); } .rounded-t { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .rounded-l { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-tl { border-top-left-radius: var(--radius); } .rounded-r { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); } .rounded-tr { border-top-right-radius: var(--radius); } .rounded-b { border-bottom-right-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-br { border-bottom-right-radius: var(--radius); } .rounded-bl { border-bottom-left-radius: var(--radius); } .border { border-style: var(--tw-border-style); border-width: 1px; } .border-x { border-inline-style: var(--tw-border-style); border-inline-width: 1px; } .border-y { border-block-style: var(--tw-border-style); border-block-width: 1px; } .border-s { border-inline-start-style: var(--tw-border-style); border-inline-start-width: 1px; } .border-e { border-inline-end-style: var(--tw-border-style); border-inline-end-width: 1px; } .border-t { border-top-style: var(--tw-border-style); border-top-width: 1px; } .border-r { border-right-style: var(--tw-border-style); border-right-width: 1px; } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } .border-l { border-left-style: var(--tw-border-style); border-left-width: 1px; } .border-l-4 { border-left-style: var(--tw-border-style); border-left-width: 4px; } .border-blue-700 { border-color: var(--color-blue-700); } .border-border { border-color: var(--color-border); } .border-red-500 { border-color: var(--color-red-500); } .bg-background { background-color: var(--color-background); } .bg-blue-900 { background-color: var(--color-blue-900); } .bg-gray-600 { background-color: var(--color-gray-600); } .bg-gray-700 { background-color: var(--color-gray-700); } .bg-gray-800 { background-color: var(--color-gray-800); } .bg-gray-900 { background-color: var(--color-gray-900); } .bg-green-500 { background-color: var(--color-green-500); } .bg-panel { background-color: var(--color-panel); } .bg-red-100 { background-color: var(--color-red-100); } .bg-repeat { background-repeat: repeat; } .mask-no-clip { mask-clip: no-clip; } .mask-repeat { mask-repeat: repeat; } .p-3 { padding: calc(var(--spacing) * 3); } .p-4 { padding: calc(var(--spacing) * 4); } .p-6 { padding: calc(var(--spacing) * 6); } .px-1 { padding-inline: calc(var(--spacing) * 1); } .px-4 { padding-inline: calc(var(--spacing) * 4); } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } .py-2 { padding-block: calc(var(--spacing) * 2); } .pl-4 { padding-left: calc(var(--spacing) * 4); } .text-left { text-align: left; } .font-mono { font-family: var(--font-mono); } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); } .text-3xl { font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); } .text-base { font-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height)); } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); } .text-xl { font-size: var(--text-xl); line-height: var(--tw-leading, var(--text-xl--line-height)); } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } .font-medium { --tw-font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium); } .font-semibold { --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); } .text-wrap { text-wrap: wrap; } .text-clip { text-overflow: clip; } .text-ellipsis { text-overflow: ellipsis; } .text-accent { color: var(--color-accent); } .text-blue-100 { color: var(--color-blue-100); } .text-blue-200 { color: var(--color-blue-200); } .text-blue-400 { color: var(--color-blue-400); } .text-gray-400 { color: var(--color-gray-400); } .text-gray-500 { color: var(--color-gray-500); } .text-green-400 { color: var(--color-green-400); } .text-muted { color: var(--color-muted); } .text-primary { color: var(--color-primary); } .text-red-400 { color: var(--color-red-400); } .text-red-800 { color: var(--color-red-800); } .text-secondary { color: var(--color-secondary); } .text-white { color: var(--color-white); } .capitalize { text-transform: capitalize; } .lowercase { text-transform: lowercase; } .normal-case { text-transform: none; } .uppercase { text-transform: uppercase; } .italic { font-style: italic; } .not-italic { font-style: normal; } .diagonal-fractions { --tw-numeric-fraction: diagonal-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .lining-nums { --tw-numeric-figure: lining-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .oldstyle-nums { --tw-numeric-figure: oldstyle-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .ordinal { --tw-ordinal: ordinal; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .proportional-nums { --tw-numeric-spacing: proportional-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .slashed-zero { --tw-slashed-zero: slashed-zero; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .stacked-fractions { --tw-numeric-fraction: stacked-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .normal-nums { font-variant-numeric: normal; } .line-through { text-decoration-line: line-through; } .no-underline { text-decoration-line: none; } .overline { text-decoration-line: overline; } .underline { text-decoration-line: underline; } .antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .subpixel-antialiased { -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .inset-ring { --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .drop-shadow { --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06))); --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06)); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .backdrop-blur { --tw-backdrop-blur: blur(8px); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-grayscale { --tw-backdrop-grayscale: grayscale(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-invert { --tw-backdrop-invert: invert(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-sepia { --tw-backdrop-sepia: sepia(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-filter { -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } .ease-in { --tw-ease: var(--ease-in); transition-timing-function: var(--ease-in); } .ease-in-out { --tw-ease: var(--ease-in-out); transition-timing-function: var(--ease-in-out); } .ease-out { --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } .divide-x-reverse { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 1; } } .ring-inset { --tw-ring-inset: inset; } .hover\:bg-gray-700 { &:hover { @media (hover: hover) { background-color: var(--color-gray-700); } } } .hover\:text-accent-300 { &:hover { @media (hover: hover) { color: var(--color-accent-300); } } } } @property --tw-scale-x { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-y { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-z { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-rotate-x { syntax: "*"; inherits: false; } @property --tw-rotate-y { syntax: "*"; inherits: false; } @property --tw-rotate-z { syntax: "*"; inherits: false; } @property --tw-skew-x { syntax: "*"; inherits: false; } @property --tw-skew-y { syntax: "*"; inherits: false; } @property --tw-pan-x { syntax: "*"; inherits: false; } @property --tw-pan-y { syntax: "*"; inherits: false; } @property --tw-pinch-zoom { syntax: "*"; inherits: false; } @property --tw-space-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-space-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-divide-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } @property --tw-divide-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-leading { syntax: "*"; inherits: false; } @property --tw-font-weight { syntax: "*"; inherits: false; } @property --tw-ordinal { syntax: "*"; inherits: false; } @property --tw-slashed-zero { syntax: "*"; inherits: false; } @property --tw-numeric-figure { syntax: "*"; inherits: false; } @property --tw-numeric-spacing { syntax: "*"; inherits: false; } @property --tw-numeric-fraction { syntax: "*"; inherits: false; } @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-shadow-color { syntax: "*"; inherits: false; } @property --tw-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-inset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-shadow-color { syntax: "*"; inherits: false; } @property --tw-inset-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-ring-color { syntax: "*"; inherits: false; } @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-ring-color { syntax: "*"; inherits: false; } @property --tw-inset-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-ring-inset { syntax: "*"; inherits: false; } @property --tw-ring-offset-width { syntax: "<length>"; inherits: false; initial-value: 0px; } @property --tw-ring-offset-color { syntax: "*"; inherits: false; initial-value: #fff; } @property --tw-ring-offset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-blur { syntax: "*"; inherits: false; } @property --tw-brightness { syntax: "*"; inherits: false; } @property --tw-contrast { syntax: "*"; inherits: false; } @property --tw-grayscale { syntax: "*"; inherits: false; } @property --tw-hue-rotate { syntax: "*"; inherits: false; } @property --tw-invert { syntax: "*"; inherits: false; } @property --tw-opacity { syntax: "*"; inherits: false; } @property --tw-saturate { syntax: "*"; inherits: false; } @property --tw-sepia { syntax: "*"; inherits: false; } @property --tw-drop-shadow { syntax: "*"; inherits: false; } @property --tw-drop-shadow-color { syntax: "*"; inherits: false; } @property --tw-drop-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-drop-shadow-size { syntax: "*"; inherits: false; } @property --tw-backdrop-blur { syntax: "*"; inherits: false; } @property --tw-backdrop-brightness { syntax: "*"; inherits: false; } @property --tw-backdrop-contrast { syntax: "*"; inherits: false; } @property --tw-backdrop-grayscale { syntax: "*"; inherits: false; } @property --tw-backdrop-hue-rotate { syntax: "*"; inherits: false; } @property --tw-backdrop-invert { syntax: "*"; inherits: false; } @property --tw-backdrop-opacity { syntax: "*"; inherits: false; } @property --tw-backdrop-saturate { syntax: "*"; inherits: false; } @property --tw-backdrop-sepia { syntax: "*"; inherits: false; } @property --tw-ease { syntax: "*"; inherits: false; } @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; --tw-pan-x: initial; --tw-pan-y: initial; --tw-pinch-zoom: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; --tw-divide-x-reverse: 0; --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-ordinal: initial; --tw-slashed-zero: initial; --tw-numeric-figure: initial; --tw-numeric-spacing: initial; --tw-numeric-fraction: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; --tw-shadow-alpha: 100%; --tw-inset-shadow: 0 0 #0000; --tw-inset-shadow-color: initial; --tw-inset-shadow-alpha: 100%; --tw-ring-color: initial; --tw-ring-shadow: 0 0 #0000; --tw-inset-ring-color: initial; --tw-inset-ring-shadow: 0 0 #0000; --tw-ring-inset: initial; --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; --tw-grayscale: initial; --tw-hue-rotate: initial; --tw-invert: initial; --tw-opacity: initial; --tw-saturate: initial; --tw-sepia: initial; --tw-drop-shadow: initial; --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; --tw-backdrop-blur: initial; --tw-backdrop-brightness: initial; --tw-backdrop-contrast: initial; --tw-backdrop-grayscale: initial; --tw-backdrop-hue-rotate: initial; --tw-backdrop-invert: initial; --tw-backdrop-opacity: initial; --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; --tw-ease: initial; } } } ================================================ FILE: tsunami/demo/githubaction/app.go ================================================ package main import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "sort" "strconv" "time" "github.com/wavetermdev/waveterm/tsunami/app" "github.com/wavetermdev/waveterm/tsunami/vdom" ) var AppMeta = app.AppMeta{ Title: "GitHub Actions Monitor", ShortDesc: "Real-time GitHub Actions workflow monitoring and status tracking", } // Global atoms for config and data var ( pollIntervalAtom = app.ConfigAtom("pollInterval", 5, &app.AtomMeta{ Desc: "Polling interval for GitHub API requests", Units: "s", Min: app.Ptr(1.0), Max: app.Ptr(300.0), }) repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm", &app.AtomMeta{ Desc: "GitHub repository in owner/repo format", Pattern: `^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$`, }) workflowAtom = app.ConfigAtom("workflow", "build-helper.yml", &app.AtomMeta{ Desc: "GitHub Actions workflow file name", Pattern: `^.+\.(yml|yaml)$`, }) maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10, &app.AtomMeta{ Desc: "Maximum number of workflow runs to fetch", Min: app.Ptr(1.0), Max: app.Ptr(100.0), }) workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{}, &app.AtomMeta{ Desc: "List of GitHub Actions workflow runs", }) lastErrorAtom = app.DataAtom("lastError", "", &app.AtomMeta{ Desc: "Last error message from GitHub API", }) isLoadingAtom = app.DataAtom("isLoading", true, &app.AtomMeta{ Desc: "Loading state for workflow data fetch", }) lastRefreshTimeAtom = app.DataAtom("lastRefreshTime", time.Time{}, &app.AtomMeta{ Desc: "Timestamp of last successful data refresh", }) ) type WorkflowRun struct { ID int64 `json:"id"` Name string `json:"name"` Status string `json:"status"` Conclusion string `json:"conclusion"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` HTMLURL string `json:"html_url"` RunNumber int `json:"run_number"` } type GitHubResponse struct { TotalCount int `json:"total_count"` WorkflowRuns []WorkflowRun `json:"workflow_runs"` } func fetchWorkflowRuns(repository, workflow string, maxRuns int) ([]WorkflowRun, error) { apiKey := os.Getenv("GITHUB_APIKEY") if apiKey == "" { return nil, fmt.Errorf("GITHUB_APIKEY environment variable not set") } url := fmt.Sprintf("https://api.github.com/repos/%s/actions/workflows/%s/runs?per_page=%d", repository, workflow, maxRuns) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("User-Agent", "WaveTerminal-GitHubMonitor") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } var response GitHubResponse if err := json.Unmarshal(body, &response); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return response.WorkflowRuns, nil } func getStatusIcon(status, conclusion string) string { switch status { case "in_progress", "queued", "pending": return "🔄" case "completed": switch conclusion { case "success": return "✅" case "failure": return "❌" case "cancelled": return "🚫" case "skipped": return "⏭️" default: return "❓" } default: return "❓" } } func getStatusColor(status, conclusion string) string { switch status { case "in_progress", "queued", "pending": return "text-yellow-400" case "completed": switch conclusion { case "success": return "text-green-400" case "failure": return "text-red-400" case "cancelled": return "text-gray-400" case "skipped": return "text-blue-400" default: return "text-gray-400" } default: return "text-gray-400" } } func formatDuration(start, end time.Time, isRunning bool) string { if isRunning { duration := time.Since(start) return fmt.Sprintf("%v (running)", duration.Round(time.Second)) } if end.IsZero() { return "Unknown" } duration := end.Sub(start) return duration.Round(time.Second).String() } func getDisplayStatus(status, conclusion string) string { switch status { case "in_progress": return "Running" case "queued": return "Queued" case "pending": return "Pending" case "completed": switch conclusion { case "success": return "Success" case "failure": return "Failed" case "cancelled": return "Cancelled" case "skipped": return "Skipped" default: return "Completed" } default: return status } } type WorkflowRunItemProps struct { Run WorkflowRun `json:"run"` } var WorkflowRunItem = app.DefineComponent("WorkflowRunItem", func(props WorkflowRunItemProps) any { run := props.Run isRunning := run.Status == "in_progress" || run.Status == "queued" || run.Status == "pending" return vdom.H("div", map[string]any{ "className": "bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors", }, vdom.H("div", map[string]any{ "className": "flex items-start justify-between", }, vdom.H("div", map[string]any{ "className": "flex-1 min-w-0", }, vdom.H("div", map[string]any{ "className": "flex items-center gap-3 mb-2", }, vdom.H("span", map[string]any{ "className": "text-2xl", }, getStatusIcon(run.Status, run.Conclusion)), vdom.H("a", map[string]any{ "href": run.HTMLURL, "target": "_blank", "className": "font-semibold text-blue-400 hover:text-blue-300 cursor-pointer", }, run.Name), vdom.H("span", map[string]any{ "className": "text-sm text-gray-300", }, "#", run.RunNumber), ), vdom.H("div", map[string]any{ "className": "flex items-center gap-4 text-sm", }, vdom.H("span", map[string]any{ "className": vdom.Classes("font-medium", getStatusColor(run.Status, run.Conclusion)), }, getDisplayStatus(run.Status, run.Conclusion)), vdom.H("span", map[string]any{ "className": "text-gray-400", }, "Duration: ", formatDuration(run.CreatedAt, run.UpdatedAt, isRunning)), vdom.H("span", map[string]any{ "className": "text-gray-300", }, "Started: ", run.CreatedAt.Format("15:04:05")), ), ), ), ) }, ) var App = app.DefineComponent("App", func(_ struct{}) any { fetchData := func() { currentMaxRuns := maxWorkflowRunsAtom.Get() runs, err := fetchWorkflowRuns(repositoryAtom.Get(), workflowAtom.Get(), currentMaxRuns) if err != nil { log.Printf("Error fetching workflow runs: %v", err) lastErrorAtom.Set(err.Error()) } else { sort.Slice(runs, func(i, j int) bool { return runs[i].CreatedAt.After(runs[j].CreatedAt) }) workflowRunsAtom.Set(runs) lastErrorAtom.Set("") } lastRefreshTimeAtom.Set(time.Now()) isLoadingAtom.Set(false) } // Initial fetch on mount app.UseEffect(func() func() { fetchData() return nil }, []any{}) // Automatic polling with UseTicker - automatically cleaned up on unmount app.UseTicker(time.Duration(pollIntervalAtom.Get())*time.Second, func() { fetchData() }, []any{pollIntervalAtom.Get()}) handleRefresh := func() { isLoadingAtom.Set(true) go func() { fetchData() }() } workflowRuns := workflowRunsAtom.Get() lastError := lastErrorAtom.Get() isLoading := isLoadingAtom.Get() lastRefreshTime := lastRefreshTimeAtom.Get() pollInterval := pollIntervalAtom.Get() repository := repositoryAtom.Get() workflow := workflowAtom.Get() maxWorkflowRuns := maxWorkflowRunsAtom.Get() return vdom.H("div", map[string]any{ "className": "min-h-screen bg-gray-900 text-white p-6", }, vdom.H("div", map[string]any{ "className": "max-w-6xl mx-auto", }, vdom.H("div", map[string]any{ "className": "mb-8", }, vdom.H("h1", map[string]any{ "className": "text-3xl font-bold text-white mb-2", }, "GitHub Actions Monitor"), vdom.H("p", map[string]any{ "className": "text-gray-400", }, "Monitoring ", repositoryAtom.Get(), " ", workflowAtom.Get(), " workflow"), ), vdom.H("div", map[string]any{ "className": "bg-gray-800 rounded-lg p-4 mb-6", }, vdom.H("div", map[string]any{ "className": "flex items-center justify-between", }, vdom.H("div", map[string]any{ "className": "flex items-center gap-4", }, vdom.H("button", map[string]any{ "className": vdom.Classes( "px-4 py-2 rounded-md text-sm font-medium transition-colors cursor-pointer", vdom.IfElse(isLoadingAtom.Get(), "bg-gray-600 text-gray-400", "bg-blue-600 hover:bg-blue-700 text-white"), ), "onClick": vdom.If(!isLoadingAtom.Get(), handleRefresh), "disabled": isLoadingAtom.Get(), }, vdom.IfElse(isLoadingAtom.Get(), "Refreshing...", "Refresh")), vdom.H("div", map[string]any{ "className": "flex items-center gap-2", }, vdom.H("div", map[string]any{ "className": vdom.Classes("w-2 h-2 rounded-full", vdom.IfElse(lastError == "", "bg-green-500", "bg-red-500")), }), vdom.H("span", map[string]any{ "className": "text-sm text-gray-400", }, vdom.IfElse(lastError == "", "Connected", "Error")), vdom.H("span", map[string]any{ "className": "text-sm text-gray-300 ml-2", }, "Poll interval: ", pollInterval, "s"), vdom.If(!lastRefreshTime.IsZero(), vdom.H("span", map[string]any{ "className": "text-sm text-gray-300 ml-4", }, "Last refresh: ", lastRefreshTime.Format("15:04:05")), ), ), ), vdom.H("div", map[string]any{ "className": "text-sm text-gray-300", }, "Last ", maxWorkflowRuns, " workflow runs"), ), ), vdom.If(lastError != "", vdom.H("div", map[string]any{ "className": "bg-red-900 bg-opacity-50 border border-red-700 rounded-lg p-4 mb-6", }, vdom.H("div", map[string]any{ "className": "flex items-center gap-2 text-red-200", }, vdom.H("span", nil, "❌"), vdom.H("strong", nil, "Error:"), ), vdom.H("p", map[string]any{ "className": "text-red-100 mt-1", }, lastError), ), ), vdom.H("div", map[string]any{ "className": "space-y-4", }, vdom.If(isLoading && len(workflowRuns) == 0, vdom.H("div", map[string]any{ "className": "text-center py-8 text-gray-400", }, "Loading workflow runs..."), ), vdom.If(len(workflowRuns) > 0, vdom.ForEach(workflowRuns, func(run WorkflowRun, idx int) any { return WorkflowRunItem(WorkflowRunItemProps{ Run: run, }).WithKey(strconv.FormatInt(run.ID, 10)) }), ), vdom.If(!isLoading && len(workflowRuns) == 0 && lastError == "", vdom.H("div", map[string]any{ "className": "text-center py-8 text-gray-400", }, "No workflow runs found"), ), ), vdom.H("div", map[string]any{ "className": "mt-8 bg-blue-900 bg-opacity-50 border border-blue-700 rounded-lg p-4", }, vdom.H("h3", map[string]any{ "className": "text-lg font-semibold text-blue-200 mb-2", }, "GitHub Actions Monitor Features"), vdom.H("ul", map[string]any{ "className": "space-y-2 text-blue-100", }, vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), "Monitors ", repository, " ", workflow, " workflow", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), "Polls GitHub API every ", pollInterval, " seconds for real-time updates", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), "Shows status icons: ✅ Success, ❌ Failure, 🔄 Running", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), "Clickable workflow names open in GitHub (new tab)", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), "Live duration tracking for running jobs", ), ), ), ), ) }, ) ================================================ FILE: tsunami/demo/githubaction/go.mod ================================================ module tsunami/app/githubaction go 1.25.6 require github.com/wavetermdev/waveterm/tsunami v0.0.0 require ( github.com/google/uuid v1.6.0 // indirect github.com/outrigdev/goid v0.3.0 // indirect ) replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami ================================================ FILE: tsunami/demo/githubaction/go.sum ================================================ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= ================================================ FILE: tsunami/demo/githubaction/static/tw.css ================================================ /*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { :root, :host { --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; --color-red-100: oklch(93.6% 0.032 17.717); --color-red-200: oklch(88.5% 0.062 18.334); --color-red-400: oklch(70.4% 0.191 22.216); --color-red-500: oklch(63.7% 0.237 25.331); --color-red-700: oklch(50.5% 0.213 27.518); --color-red-800: oklch(44.4% 0.177 26.899); --color-red-900: oklch(39.6% 0.141 25.723); --color-yellow-400: oklch(85.2% 0.199 91.936); --color-green-400: oklch(79.2% 0.209 151.711); --color-green-500: oklch(72.3% 0.219 149.579); --color-blue-100: oklch(93.2% 0.032 255.585); --color-blue-200: oklch(88.2% 0.059 254.128); --color-blue-300: oklch(80.9% 0.105 251.813); --color-blue-400: oklch(70.7% 0.165 254.624); --color-blue-600: oklch(54.6% 0.245 262.881); --color-blue-700: oklch(48.8% 0.243 264.376); --color-blue-900: oklch(37.9% 0.146 265.522); --color-gray-300: oklch(87.2% 0.01 258.338); --color-gray-400: oklch(70.7% 0.022 261.325); --color-gray-600: oklch(44.6% 0.03 256.802); --color-gray-700: oklch(37.3% 0.034 259.733); --color-gray-800: oklch(27.8% 0.033 256.848); --color-gray-900: oklch(21% 0.034 264.665); --color-white: #fff; --spacing: 0.25rem; --container-6xl: 72rem; --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); --text-base: 1rem; --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; --leading-relaxed: 1.625; --radius-md: 0.375rem; --radius-lg: 0.5rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); --radius: 8px; --color-background: rgb(34, 34, 34); --color-primary: rgb(247, 247, 247); --color-secondary: rgba(215, 218, 224, 0.7); --color-muted: rgba(215, 218, 224, 0.5); --color-accent-300: rgb(110, 231, 133); --color-panel: rgba(255, 255, 255, 0.12); --color-border: rgba(255, 255, 255, 0.16); --color-accent: rgb(88, 193, 66); } } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); font-feature-settings: var(--default-mono-font-feature-settings, normal); font-variation-settings: var(--default-mono-font-variation-settings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: currentcolor; @supports (color: color-mix(in lab, red, red)) { color: color-mix(in oklab, currentcolor 50%, transparent); } } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden="until-found"])) { display: none !important; } } @layer utilities { .collapse { visibility: collapse; } .invisible { visibility: hidden; } .visible { visibility: visible; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } .not-sr-only { position: static; width: auto; height: auto; padding: 0; margin: 0; overflow: visible; clip-path: none; white-space: normal; } .absolute { position: absolute; } .fixed { position: fixed; } .relative { position: relative; } .static { position: static; } .sticky { position: sticky; } .isolate { isolation: isolate; } .isolation-auto { isolation: auto; } .container { width: 100%; @media (width >= 40rem) { max-width: 40rem; } @media (width >= 48rem) { max-width: 48rem; } @media (width >= 64rem) { max-width: 64rem; } @media (width >= 80rem) { max-width: 80rem; } @media (width >= 96rem) { max-width: 96rem; } } .mx-auto { margin-inline: auto; } .my-6 { margin-block: calc(var(--spacing) * 6); } .mt-1 { margin-top: calc(var(--spacing) * 1); } .mt-3 { margin-top: calc(var(--spacing) * 3); } .mt-4 { margin-top: calc(var(--spacing) * 4); } .mt-5 { margin-top: calc(var(--spacing) * 5); } .mt-6 { margin-top: calc(var(--spacing) * 6); } .mt-8 { margin-top: calc(var(--spacing) * 8); } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } .mb-6 { margin-bottom: calc(var(--spacing) * 6); } .mb-8 { margin-bottom: calc(var(--spacing) * 8); } .ml-2 { margin-left: calc(var(--spacing) * 2); } .ml-4 { margin-left: calc(var(--spacing) * 4); } .block { display: block; } .contents { display: contents; } .flex { display: flex; } .flow-root { display: flow-root; } .grid { display: grid; } .hidden { display: none; } .inline { display: inline; } .inline-block { display: inline-block; } .inline-flex { display: inline-flex; } .inline-grid { display: inline-grid; } .inline-table { display: inline-table; } .list-item { display: list-item; } .table { display: table; } .table-caption { display: table-caption; } .table-cell { display: table-cell; } .table-column { display: table-column; } .table-column-group { display: table-column-group; } .table-footer-group { display: table-footer-group; } .table-header-group { display: table-header-group; } .table-row { display: table-row; } .table-row-group { display: table-row-group; } .h-2 { height: calc(var(--spacing) * 2); } .min-h-full { min-height: 100%; } .min-h-screen { min-height: 100vh; } .w-2 { width: calc(var(--spacing) * 2); } .w-full { width: 100%; } .max-w-6xl { max-width: var(--container-6xl); } .max-w-none { max-width: none; } .min-w-0 { min-width: calc(var(--spacing) * 0); } .min-w-full { min-width: 100%; } .flex-1 { flex: 1; } .shrink { flex-shrink: 1; } .grow { flex-grow: 1; } .border-collapse { border-collapse: collapse; } .translate-none { translate: none; } .scale-3d { scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z); } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } .cursor-pointer { cursor: pointer; } .touch-pinch-zoom { --tw-pinch-zoom: pinch-zoom; touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,); } .resize { resize: both; } .list-inside { list-style-position: inside; } .list-decimal { list-style-type: decimal; } .list-disc { list-style-type: disc; } .flex-wrap { flex-wrap: wrap; } .items-center { align-items: center; } .items-start { align-items: flex-start; } .justify-between { justify-content: space-between; } .gap-2 { gap: calc(var(--spacing) * 2); } .gap-3 { gap: calc(var(--spacing) * 3); } .gap-4 { gap: calc(var(--spacing) * 4); } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-2 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-4 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-reverse { :where(& > :not(:last-child)) { --tw-space-y-reverse: 1; } } .space-x-reverse { :where(& > :not(:last-child)) { --tw-space-x-reverse: 1; } } .divide-x { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 0; border-inline-style: var(--tw-border-style); border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); } } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; border-bottom-style: var(--tw-border-style); border-top-style: var(--tw-border-style); border-top-width: calc(1px * var(--tw-divide-y-reverse)); border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); } } .divide-y-reverse { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 1; } } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .overflow-auto { overflow: auto; } .overflow-x-auto { overflow-x: auto; } .rounded { border-radius: var(--radius); } .rounded-full { border-radius: calc(infinity * 1px); } .rounded-lg { border-radius: var(--radius-lg); } .rounded-md { border-radius: var(--radius-md); } .rounded-s { border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); } .rounded-ss { border-start-start-radius: var(--radius); } .rounded-e { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); } .rounded-se { border-start-end-radius: var(--radius); } .rounded-ee { border-end-end-radius: var(--radius); } .rounded-es { border-end-start-radius: var(--radius); } .rounded-t { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .rounded-l { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-tl { border-top-left-radius: var(--radius); } .rounded-r { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); } .rounded-tr { border-top-right-radius: var(--radius); } .rounded-b { border-bottom-right-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-br { border-bottom-right-radius: var(--radius); } .rounded-bl { border-bottom-left-radius: var(--radius); } .border { border-style: var(--tw-border-style); border-width: 1px; } .border-x { border-inline-style: var(--tw-border-style); border-inline-width: 1px; } .border-y { border-block-style: var(--tw-border-style); border-block-width: 1px; } .border-s { border-inline-start-style: var(--tw-border-style); border-inline-start-width: 1px; } .border-e { border-inline-end-style: var(--tw-border-style); border-inline-end-width: 1px; } .border-t { border-top-style: var(--tw-border-style); border-top-width: 1px; } .border-r { border-right-style: var(--tw-border-style); border-right-width: 1px; } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } .border-l { border-left-style: var(--tw-border-style); border-left-width: 1px; } .border-l-4 { border-left-style: var(--tw-border-style); border-left-width: 4px; } .border-blue-700 { border-color: var(--color-blue-700); } .border-border { border-color: var(--color-border); } .border-gray-700 { border-color: var(--color-gray-700); } .border-red-500 { border-color: var(--color-red-500); } .border-red-700 { border-color: var(--color-red-700); } .bg-background { background-color: var(--color-background); } .bg-blue-600 { background-color: var(--color-blue-600); } .bg-blue-900 { background-color: var(--color-blue-900); } .bg-gray-600 { background-color: var(--color-gray-600); } .bg-gray-800 { background-color: var(--color-gray-800); } .bg-gray-900 { background-color: var(--color-gray-900); } .bg-green-500 { background-color: var(--color-green-500); } .bg-panel { background-color: var(--color-panel); } .bg-red-100 { background-color: var(--color-red-100); } .bg-red-500 { background-color: var(--color-red-500); } .bg-red-900 { background-color: var(--color-red-900); } .bg-repeat { background-repeat: repeat; } .mask-no-clip { mask-clip: no-clip; } .mask-repeat { mask-repeat: repeat; } .p-4 { padding: calc(var(--spacing) * 4); } .p-6 { padding: calc(var(--spacing) * 6); } .px-1 { padding-inline: calc(var(--spacing) * 1); } .px-4 { padding-inline: calc(var(--spacing) * 4); } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } .py-2 { padding-block: calc(var(--spacing) * 2); } .py-8 { padding-block: calc(var(--spacing) * 8); } .pl-4 { padding-left: calc(var(--spacing) * 4); } .text-center { text-align: center; } .text-left { text-align: left; } .font-mono { font-family: var(--font-mono); } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); } .text-3xl { font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); } .text-base { font-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height)); } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); } .text-xl { font-size: var(--text-xl); line-height: var(--tw-leading, var(--text-xl--line-height)); } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } .font-medium { --tw-font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium); } .font-semibold { --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); } .text-wrap { text-wrap: wrap; } .text-clip { text-overflow: clip; } .text-ellipsis { text-overflow: ellipsis; } .text-accent { color: var(--color-accent); } .text-blue-100 { color: var(--color-blue-100); } .text-blue-200 { color: var(--color-blue-200); } .text-blue-400 { color: var(--color-blue-400); } .text-gray-300 { color: var(--color-gray-300); } .text-gray-400 { color: var(--color-gray-400); } .text-green-400 { color: var(--color-green-400); } .text-muted { color: var(--color-muted); } .text-primary { color: var(--color-primary); } .text-red-100 { color: var(--color-red-100); } .text-red-200 { color: var(--color-red-200); } .text-red-400 { color: var(--color-red-400); } .text-red-800 { color: var(--color-red-800); } .text-secondary { color: var(--color-secondary); } .text-white { color: var(--color-white); } .text-yellow-400 { color: var(--color-yellow-400); } .capitalize { text-transform: capitalize; } .lowercase { text-transform: lowercase; } .normal-case { text-transform: none; } .uppercase { text-transform: uppercase; } .italic { font-style: italic; } .not-italic { font-style: normal; } .diagonal-fractions { --tw-numeric-fraction: diagonal-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .lining-nums { --tw-numeric-figure: lining-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .oldstyle-nums { --tw-numeric-figure: oldstyle-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .ordinal { --tw-ordinal: ordinal; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .proportional-nums { --tw-numeric-spacing: proportional-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .slashed-zero { --tw-slashed-zero: slashed-zero; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .stacked-fractions { --tw-numeric-fraction: stacked-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .normal-nums { font-variant-numeric: normal; } .line-through { text-decoration-line: line-through; } .no-underline { text-decoration-line: none; } .overline { text-decoration-line: overline; } .underline { text-decoration-line: underline; } .antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .subpixel-antialiased { -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .inset-ring { --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .drop-shadow { --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06))); --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06)); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .backdrop-blur { --tw-backdrop-blur: blur(8px); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-grayscale { --tw-backdrop-grayscale: grayscale(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-invert { --tw-backdrop-invert: invert(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-sepia { --tw-backdrop-sepia: sepia(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-filter { -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } .ease-in { --tw-ease: var(--ease-in); transition-timing-function: var(--ease-in); } .ease-in-out { --tw-ease: var(--ease-in-out); transition-timing-function: var(--ease-in-out); } .ease-out { --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } .divide-x-reverse { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 1; } } .ring-inset { --tw-ring-inset: inset; } .hover\:border-gray-600 { &:hover { @media (hover: hover) { border-color: var(--color-gray-600); } } } .hover\:bg-blue-700 { &:hover { @media (hover: hover) { background-color: var(--color-blue-700); } } } .hover\:text-accent-300 { &:hover { @media (hover: hover) { color: var(--color-accent-300); } } } .hover\:text-blue-300 { &:hover { @media (hover: hover) { color: var(--color-blue-300); } } } } @property --tw-scale-x { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-y { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-z { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-rotate-x { syntax: "*"; inherits: false; } @property --tw-rotate-y { syntax: "*"; inherits: false; } @property --tw-rotate-z { syntax: "*"; inherits: false; } @property --tw-skew-x { syntax: "*"; inherits: false; } @property --tw-skew-y { syntax: "*"; inherits: false; } @property --tw-pan-x { syntax: "*"; inherits: false; } @property --tw-pan-y { syntax: "*"; inherits: false; } @property --tw-pinch-zoom { syntax: "*"; inherits: false; } @property --tw-space-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-space-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-divide-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } @property --tw-divide-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-leading { syntax: "*"; inherits: false; } @property --tw-font-weight { syntax: "*"; inherits: false; } @property --tw-ordinal { syntax: "*"; inherits: false; } @property --tw-slashed-zero { syntax: "*"; inherits: false; } @property --tw-numeric-figure { syntax: "*"; inherits: false; } @property --tw-numeric-spacing { syntax: "*"; inherits: false; } @property --tw-numeric-fraction { syntax: "*"; inherits: false; } @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-shadow-color { syntax: "*"; inherits: false; } @property --tw-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-inset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-shadow-color { syntax: "*"; inherits: false; } @property --tw-inset-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-ring-color { syntax: "*"; inherits: false; } @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-ring-color { syntax: "*"; inherits: false; } @property --tw-inset-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-ring-inset { syntax: "*"; inherits: false; } @property --tw-ring-offset-width { syntax: "<length>"; inherits: false; initial-value: 0px; } @property --tw-ring-offset-color { syntax: "*"; inherits: false; initial-value: #fff; } @property --tw-ring-offset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-blur { syntax: "*"; inherits: false; } @property --tw-brightness { syntax: "*"; inherits: false; } @property --tw-contrast { syntax: "*"; inherits: false; } @property --tw-grayscale { syntax: "*"; inherits: false; } @property --tw-hue-rotate { syntax: "*"; inherits: false; } @property --tw-invert { syntax: "*"; inherits: false; } @property --tw-opacity { syntax: "*"; inherits: false; } @property --tw-saturate { syntax: "*"; inherits: false; } @property --tw-sepia { syntax: "*"; inherits: false; } @property --tw-drop-shadow { syntax: "*"; inherits: false; } @property --tw-drop-shadow-color { syntax: "*"; inherits: false; } @property --tw-drop-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-drop-shadow-size { syntax: "*"; inherits: false; } @property --tw-backdrop-blur { syntax: "*"; inherits: false; } @property --tw-backdrop-brightness { syntax: "*"; inherits: false; } @property --tw-backdrop-contrast { syntax: "*"; inherits: false; } @property --tw-backdrop-grayscale { syntax: "*"; inherits: false; } @property --tw-backdrop-hue-rotate { syntax: "*"; inherits: false; } @property --tw-backdrop-invert { syntax: "*"; inherits: false; } @property --tw-backdrop-opacity { syntax: "*"; inherits: false; } @property --tw-backdrop-saturate { syntax: "*"; inherits: false; } @property --tw-backdrop-sepia { syntax: "*"; inherits: false; } @property --tw-ease { syntax: "*"; inherits: false; } @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; --tw-pan-x: initial; --tw-pan-y: initial; --tw-pinch-zoom: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; --tw-divide-x-reverse: 0; --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-ordinal: initial; --tw-slashed-zero: initial; --tw-numeric-figure: initial; --tw-numeric-spacing: initial; --tw-numeric-fraction: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; --tw-shadow-alpha: 100%; --tw-inset-shadow: 0 0 #0000; --tw-inset-shadow-color: initial; --tw-inset-shadow-alpha: 100%; --tw-ring-color: initial; --tw-ring-shadow: 0 0 #0000; --tw-inset-ring-color: initial; --tw-inset-ring-shadow: 0 0 #0000; --tw-ring-inset: initial; --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; --tw-grayscale: initial; --tw-hue-rotate: initial; --tw-invert: initial; --tw-opacity: initial; --tw-saturate: initial; --tw-sepia: initial; --tw-drop-shadow: initial; --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; --tw-backdrop-blur: initial; --tw-backdrop-brightness: initial; --tw-backdrop-contrast: initial; --tw-backdrop-grayscale: initial; --tw-backdrop-hue-rotate: initial; --tw-backdrop-invert: initial; --tw-backdrop-opacity: initial; --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; --tw-ease: initial; } } } ================================================ FILE: tsunami/demo/modaltest/app.go ================================================ package main import ( "github.com/wavetermdev/waveterm/tsunami/app" "github.com/wavetermdev/waveterm/tsunami/vdom" ) var AppMeta = app.AppMeta{ Title: "Modal Test (Tsunami Demo)", ShortDesc: "Test alert and confirm modals in Tsunami", } var App = app.DefineComponent("App", func(_ struct{}) any { // State to track modal results alertResult := app.UseLocal("") confirmResult := app.UseLocal("") // Hook for alert modal alertOpen, triggerAlert := app.UseAlertModal() // Hook for confirm modal confirmOpen, triggerConfirm := app.UseConfirmModal() // Event handlers for alert handleShowAlert := func() { triggerAlert(app.ModalConfig{ Icon: "⚠️", Title: "Alert Message", Text: "This is an alert modal. Click OK to dismiss.", OnClose: func() { alertResult.Set("Alert dismissed") }, }) } handleShowAlertSimple := func() { triggerAlert(app.ModalConfig{ Title: "Simple Alert", Text: "This alert has no icon and custom OK text.", OkText: "Got it!", OnClose: func() { alertResult.Set("Simple alert dismissed") }, }) } // Event handlers for confirm handleShowConfirm := func() { triggerConfirm(app.ModalConfig{ Icon: "❓", Title: "Confirm Action", Text: "Do you want to proceed with this action?", OnResult: func(confirmed bool) { if confirmed { confirmResult.Set("User confirmed the action") } else { confirmResult.Set("User cancelled the action") } }, }) } handleShowConfirmCustom := func() { triggerConfirm(app.ModalConfig{ Icon: "🗑️", Title: "Delete Item", Text: "Are you sure you want to delete this item? This action cannot be undone.", OkText: "Delete", CancelText: "Keep", OnResult: func(confirmed bool) { if confirmed { confirmResult.Set("Item deleted") } else { confirmResult.Set("Item kept") } }, }) } // Read state values currentAlertResult := alertResult.Get() currentConfirmResult := confirmResult.Get() return vdom.H("div", map[string]any{ "className": "max-w-4xl mx-auto p-8", }, vdom.H("h1", map[string]any{ "className": "text-3xl font-bold mb-6 text-white", }, "Tsunami Modal Test"), // Alert Modal Section vdom.H("div", map[string]any{ "className": "mb-8 p-6 bg-gray-800 rounded-lg border border-gray-700", }, vdom.H("h2", map[string]any{ "className": "text-2xl font-semibold mb-4 text-white", }, "Alert Modals"), vdom.H("div", map[string]any{ "className": "flex gap-4 mb-4", }, vdom.H("button", map[string]any{ "className": "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed", "onClick": handleShowAlert, "disabled": alertOpen, }, "Show Alert with Icon"), vdom.H("button", map[string]any{ "className": "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed", "onClick": handleShowAlertSimple, "disabled": alertOpen, }, "Show Simple Alert"), ), vdom.If(currentAlertResult != "", vdom.H("div", map[string]any{ "className": "mt-4 p-3 bg-gray-700 rounded text-gray-200", }, "Result: ", currentAlertResult)), ), // Confirm Modal Section vdom.H("div", map[string]any{ "className": "mb-8 p-6 bg-gray-800 rounded-lg border border-gray-700", }, vdom.H("h2", map[string]any{ "className": "text-2xl font-semibold mb-4 text-white", }, "Confirm Modals"), vdom.H("div", map[string]any{ "className": "flex gap-4 mb-4", }, vdom.H("button", map[string]any{ "className": "px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed", "onClick": handleShowConfirm, "disabled": confirmOpen, }, "Show Confirm Modal"), vdom.H("button", map[string]any{ "className": "px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed", "onClick": handleShowConfirmCustom, "disabled": confirmOpen, }, "Show Delete Confirm"), ), vdom.If(currentConfirmResult != "", vdom.H("div", map[string]any{ "className": "mt-4 p-3 bg-gray-700 rounded text-gray-200", }, "Result: ", currentConfirmResult)), ), // Status info vdom.H("div", map[string]any{ "className": "p-6 bg-gray-800 rounded-lg border border-gray-700", }, vdom.H("h2", map[string]any{ "className": "text-2xl font-semibold mb-4 text-white", }, "Modal Status"), vdom.H("div", map[string]any{ "className": "text-gray-300", }, vdom.H("div", nil, "Alert Modal Open: ", vdom.IfElse(alertOpen, "Yes", "No")), vdom.H("div", nil, "Confirm Modal Open: ", vdom.IfElse(confirmOpen, "Yes", "No")), ), ), ) }) ================================================ FILE: tsunami/demo/modaltest/go.mod ================================================ module github.com/wavetermdev/waveterm/tsunami/demo/modaltest go 1.25.6 require github.com/wavetermdev/waveterm/tsunami v0.0.0-00010101000000-000000000000 require ( github.com/google/uuid v1.6.0 // indirect github.com/outrigdev/goid v0.3.0 // indirect ) replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami ================================================ FILE: tsunami/demo/modaltest/go.sum ================================================ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= ================================================ FILE: tsunami/demo/modaltest/static/tw.css ================================================ /*! tailwindcss v4.1.16 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { :root, :host { --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; --color-red-100: oklch(93.6% 0.032 17.717); --color-red-500: oklch(63.7% 0.237 25.331); --color-red-600: oklch(57.7% 0.245 27.325); --color-red-700: oklch(50.5% 0.213 27.518); --color-red-800: oklch(44.4% 0.177 26.899); --color-green-600: oklch(62.7% 0.194 149.214); --color-green-700: oklch(52.7% 0.154 150.069); --color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-600: oklch(54.6% 0.245 262.881); --color-blue-700: oklch(48.8% 0.243 264.376); --color-gray-200: oklch(92.8% 0.006 264.531); --color-gray-300: oklch(87.2% 0.01 258.338); --color-gray-500: oklch(55.1% 0.027 264.364); --color-gray-600: oklch(44.6% 0.03 256.802); --color-gray-700: oklch(37.3% 0.034 259.733); --color-gray-800: oklch(27.8% 0.033 256.848); --color-black: #000; --color-white: #fff; --spacing: 0.25rem; --container-md: 28rem; --container-4xl: 56rem; --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); --text-base: 1rem; --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); --text-4xl: 2.25rem; --text-4xl--line-height: calc(2.5 / 2.25); --font-weight-semibold: 600; --font-weight-bold: 700; --leading-relaxed: 1.625; --radius-lg: 0.5rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); --radius: 8px; --color-background: rgb(34, 34, 34); --color-primary: rgb(247, 247, 247); --color-secondary: rgba(215, 218, 224, 0.7); --color-muted: rgba(215, 218, 224, 0.5); --color-accent-300: rgb(110, 231, 133); --color-panel: rgba(255, 255, 255, 0.12); --color-border: rgba(255, 255, 255, 0.16); --color-accent: rgb(88, 193, 66); } } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); font-feature-settings: var(--default-mono-font-feature-settings, normal); font-variation-settings: var(--default-mono-font-variation-settings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: currentcolor; @supports (color: color-mix(in lab, red, red)) { color: color-mix(in oklab, currentcolor 50%, transparent); } } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden="until-found"])) { display: none !important; } } @layer utilities { .collapse { visibility: collapse; } .invisible { visibility: hidden; } .visible { visibility: visible; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } .not-sr-only { position: static; width: auto; height: auto; padding: 0; margin: 0; overflow: visible; clip-path: none; white-space: normal; } .absolute { position: absolute; } .fixed { position: fixed; } .relative { position: relative; } .static { position: static; } .sticky { position: sticky; } .inset-0 { inset: calc(var(--spacing) * 0); } .isolate { isolation: isolate; } .isolation-auto { isolation: auto; } .z-50 { z-index: 50; } .container { width: 100%; @media (width >= 40rem) { max-width: 40rem; } @media (width >= 48rem) { max-width: 48rem; } @media (width >= 64rem) { max-width: 64rem; } @media (width >= 80rem) { max-width: 80rem; } @media (width >= 96rem) { max-width: 96rem; } } .mx-4 { margin-inline: calc(var(--spacing) * 4); } .mx-auto { margin-inline: auto; } .my-6 { margin-block: calc(var(--spacing) * 6); } .mt-2 { margin-top: calc(var(--spacing) * 2); } .mt-3 { margin-top: calc(var(--spacing) * 3); } .mt-4 { margin-top: calc(var(--spacing) * 4); } .mt-5 { margin-top: calc(var(--spacing) * 5); } .mt-6 { margin-top: calc(var(--spacing) * 6); } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } .mb-6 { margin-bottom: calc(var(--spacing) * 6); } .mb-8 { margin-bottom: calc(var(--spacing) * 8); } .ml-4 { margin-left: calc(var(--spacing) * 4); } .block { display: block; } .contents { display: contents; } .flex { display: flex; } .flow-root { display: flow-root; } .grid { display: grid; } .hidden { display: none; } .inline { display: inline; } .inline-block { display: inline-block; } .inline-flex { display: inline-flex; } .inline-grid { display: inline-grid; } .inline-table { display: inline-table; } .list-item { display: list-item; } .table { display: table; } .table-caption { display: table-caption; } .table-cell { display: table-cell; } .table-column { display: table-column; } .table-column-group { display: table-column-group; } .table-footer-group { display: table-footer-group; } .table-header-group { display: table-header-group; } .table-row { display: table-row; } .table-row-group { display: table-row-group; } .min-h-full { min-height: 100%; } .min-h-screen { min-height: 100vh; } .w-full { width: 100%; } .max-w-4xl { max-width: var(--container-4xl); } .max-w-md { max-width: var(--container-md); } .max-w-none { max-width: none; } .min-w-full { min-width: 100%; } .shrink { flex-shrink: 1; } .grow { flex-grow: 1; } .border-collapse { border-collapse: collapse; } .translate-none { translate: none; } .scale-3d { scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z); } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } .touch-pinch-zoom { --tw-pinch-zoom: pinch-zoom; touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,); } .resize { resize: both; } .list-inside { list-style-position: inside; } .list-decimal { list-style-type: decimal; } .list-disc { list-style-type: disc; } .flex-col { flex-direction: column; } .flex-wrap { flex-wrap: wrap; } .items-center { align-items: center; } .justify-center { justify-content: center; } .justify-end { justify-content: flex-end; } .gap-3 { gap: calc(var(--spacing) * 3); } .gap-4 { gap: calc(var(--spacing) * 4); } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-reverse { :where(& > :not(:last-child)) { --tw-space-y-reverse: 1; } } .space-x-reverse { :where(& > :not(:last-child)) { --tw-space-x-reverse: 1; } } .divide-x { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 0; border-inline-style: var(--tw-border-style); border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); } } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; border-bottom-style: var(--tw-border-style); border-top-style: var(--tw-border-style); border-top-width: calc(1px * var(--tw-divide-y-reverse)); border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); } } .divide-y-reverse { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 1; } } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .overflow-auto { overflow: auto; } .overflow-x-auto { overflow-x: auto; } .rounded { border-radius: var(--radius); } .rounded-lg { border-radius: var(--radius-lg); } .rounded-s { border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); } .rounded-ss { border-start-start-radius: var(--radius); } .rounded-e { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); } .rounded-se { border-start-end-radius: var(--radius); } .rounded-ee { border-end-end-radius: var(--radius); } .rounded-es { border-end-start-radius: var(--radius); } .rounded-t { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .rounded-l { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-tl { border-top-left-radius: var(--radius); } .rounded-r { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); } .rounded-tr { border-top-right-radius: var(--radius); } .rounded-b { border-bottom-right-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-br { border-bottom-right-radius: var(--radius); } .rounded-bl { border-bottom-left-radius: var(--radius); } .border { border-style: var(--tw-border-style); border-width: 1px; } .border-x { border-inline-style: var(--tw-border-style); border-inline-width: 1px; } .border-y { border-block-style: var(--tw-border-style); border-block-width: 1px; } .border-s { border-inline-start-style: var(--tw-border-style); border-inline-start-width: 1px; } .border-e { border-inline-end-style: var(--tw-border-style); border-inline-end-width: 1px; } .border-t { border-top-style: var(--tw-border-style); border-top-width: 1px; } .border-r { border-right-style: var(--tw-border-style); border-right-width: 1px; } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } .border-l { border-left-style: var(--tw-border-style); border-left-width: 1px; } .border-l-4 { border-left-style: var(--tw-border-style); border-left-width: 4px; } .border-border { border-color: var(--color-border); } .border-gray-700 { border-color: var(--color-gray-700); } .border-red-500 { border-color: var(--color-red-500); } .bg-background { background-color: var(--color-background); } .bg-black { background-color: var(--color-black); } .bg-black\/50 { background-color: color-mix(in srgb, #000 50%, transparent); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, var(--color-black) 50%, transparent); } } .bg-blue-600 { background-color: var(--color-blue-600); } .bg-gray-600 { background-color: var(--color-gray-600); } .bg-gray-700 { background-color: var(--color-gray-700); } .bg-gray-800 { background-color: var(--color-gray-800); } .bg-green-600 { background-color: var(--color-green-600); } .bg-panel { background-color: var(--color-panel); } .bg-red-100 { background-color: var(--color-red-100); } .bg-red-600 { background-color: var(--color-red-600); } .bg-repeat { background-repeat: repeat; } .mask-no-clip { mask-clip: no-clip; } .mask-repeat { mask-repeat: repeat; } .p-3 { padding: calc(var(--spacing) * 3); } .p-4 { padding: calc(var(--spacing) * 4); } .p-6 { padding: calc(var(--spacing) * 6); } .p-8 { padding: calc(var(--spacing) * 8); } .px-1 { padding-inline: calc(var(--spacing) * 1); } .px-4 { padding-inline: calc(var(--spacing) * 4); } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } .py-2 { padding-block: calc(var(--spacing) * 2); } .pl-4 { padding-left: calc(var(--spacing) * 4); } .text-left { text-align: left; } .font-mono { font-family: var(--font-mono); } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); } .text-3xl { font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); } .text-4xl { font-size: var(--text-4xl); line-height: var(--tw-leading, var(--text-4xl--line-height)); } .text-base { font-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height)); } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); } .text-xl { font-size: var(--text-xl); line-height: var(--tw-leading, var(--text-xl--line-height)); } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } .font-semibold { --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); } .text-wrap { text-wrap: wrap; } .text-clip { text-overflow: clip; } .text-ellipsis { text-overflow: ellipsis; } .text-accent { color: var(--color-accent); } .text-gray-200 { color: var(--color-gray-200); } .text-gray-300 { color: var(--color-gray-300); } .text-muted { color: var(--color-muted); } .text-primary { color: var(--color-primary); } .text-red-800 { color: var(--color-red-800); } .text-secondary { color: var(--color-secondary); } .text-white { color: var(--color-white); } .capitalize { text-transform: capitalize; } .lowercase { text-transform: lowercase; } .normal-case { text-transform: none; } .uppercase { text-transform: uppercase; } .italic { font-style: italic; } .not-italic { font-style: normal; } .diagonal-fractions { --tw-numeric-fraction: diagonal-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .lining-nums { --tw-numeric-figure: lining-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .oldstyle-nums { --tw-numeric-figure: oldstyle-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .ordinal { --tw-ordinal: ordinal; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .proportional-nums { --tw-numeric-spacing: proportional-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .slashed-zero { --tw-slashed-zero: slashed-zero; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .stacked-fractions { --tw-numeric-fraction: stacked-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .normal-nums { font-variant-numeric: normal; } .line-through { text-decoration-line: line-through; } .no-underline { text-decoration-line: none; } .overline { text-decoration-line: overline; } .underline { text-decoration-line: underline; } .antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .subpixel-antialiased { -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .shadow-xl { --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .inset-ring { --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .drop-shadow { --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06))); --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06)); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .backdrop-blur { --tw-backdrop-blur: blur(8px); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-grayscale { --tw-backdrop-grayscale: grayscale(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-invert { --tw-backdrop-invert: invert(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-sepia { --tw-backdrop-sepia: sepia(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-filter { -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .ease-in { --tw-ease: var(--ease-in); transition-timing-function: var(--ease-in); } .ease-in-out { --tw-ease: var(--ease-in-out); transition-timing-function: var(--ease-in-out); } .ease-out { --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } .divide-x-reverse { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 1; } } .ring-inset { --tw-ring-inset: inset; } .hover\:bg-blue-700 { &:hover { @media (hover: hover) { background-color: var(--color-blue-700); } } } .hover\:bg-gray-700 { &:hover { @media (hover: hover) { background-color: var(--color-gray-700); } } } .hover\:bg-green-700 { &:hover { @media (hover: hover) { background-color: var(--color-green-700); } } } .hover\:bg-red-700 { &:hover { @media (hover: hover) { background-color: var(--color-red-700); } } } .hover\:text-accent-300 { &:hover { @media (hover: hover) { color: var(--color-accent-300); } } } .focus\:ring-2 { &:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } } .focus\:ring-blue-500 { &:focus { --tw-ring-color: var(--color-blue-500); } } .focus\:ring-gray-500 { &:focus { --tw-ring-color: var(--color-gray-500); } } .focus\:outline-none { &:focus { --tw-outline-style: none; outline-style: none; } } .disabled\:cursor-not-allowed { &:disabled { cursor: not-allowed; } } .disabled\:opacity-50 { &:disabled { opacity: 50%; } } } @property --tw-scale-x { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-y { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-z { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-rotate-x { syntax: "*"; inherits: false; } @property --tw-rotate-y { syntax: "*"; inherits: false; } @property --tw-rotate-z { syntax: "*"; inherits: false; } @property --tw-skew-x { syntax: "*"; inherits: false; } @property --tw-skew-y { syntax: "*"; inherits: false; } @property --tw-pan-x { syntax: "*"; inherits: false; } @property --tw-pan-y { syntax: "*"; inherits: false; } @property --tw-pinch-zoom { syntax: "*"; inherits: false; } @property --tw-space-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-space-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-divide-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } @property --tw-divide-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-leading { syntax: "*"; inherits: false; } @property --tw-font-weight { syntax: "*"; inherits: false; } @property --tw-ordinal { syntax: "*"; inherits: false; } @property --tw-slashed-zero { syntax: "*"; inherits: false; } @property --tw-numeric-figure { syntax: "*"; inherits: false; } @property --tw-numeric-spacing { syntax: "*"; inherits: false; } @property --tw-numeric-fraction { syntax: "*"; inherits: false; } @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-shadow-color { syntax: "*"; inherits: false; } @property --tw-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-inset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-shadow-color { syntax: "*"; inherits: false; } @property --tw-inset-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-ring-color { syntax: "*"; inherits: false; } @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-ring-color { syntax: "*"; inherits: false; } @property --tw-inset-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-ring-inset { syntax: "*"; inherits: false; } @property --tw-ring-offset-width { syntax: "<length>"; inherits: false; initial-value: 0px; } @property --tw-ring-offset-color { syntax: "*"; inherits: false; initial-value: #fff; } @property --tw-ring-offset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-blur { syntax: "*"; inherits: false; } @property --tw-brightness { syntax: "*"; inherits: false; } @property --tw-contrast { syntax: "*"; inherits: false; } @property --tw-grayscale { syntax: "*"; inherits: false; } @property --tw-hue-rotate { syntax: "*"; inherits: false; } @property --tw-invert { syntax: "*"; inherits: false; } @property --tw-opacity { syntax: "*"; inherits: false; } @property --tw-saturate { syntax: "*"; inherits: false; } @property --tw-sepia { syntax: "*"; inherits: false; } @property --tw-drop-shadow { syntax: "*"; inherits: false; } @property --tw-drop-shadow-color { syntax: "*"; inherits: false; } @property --tw-drop-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-drop-shadow-size { syntax: "*"; inherits: false; } @property --tw-backdrop-blur { syntax: "*"; inherits: false; } @property --tw-backdrop-brightness { syntax: "*"; inherits: false; } @property --tw-backdrop-contrast { syntax: "*"; inherits: false; } @property --tw-backdrop-grayscale { syntax: "*"; inherits: false; } @property --tw-backdrop-hue-rotate { syntax: "*"; inherits: false; } @property --tw-backdrop-invert { syntax: "*"; inherits: false; } @property --tw-backdrop-opacity { syntax: "*"; inherits: false; } @property --tw-backdrop-saturate { syntax: "*"; inherits: false; } @property --tw-backdrop-sepia { syntax: "*"; inherits: false; } @property --tw-ease { syntax: "*"; inherits: false; } @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; --tw-pan-x: initial; --tw-pan-y: initial; --tw-pinch-zoom: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; --tw-divide-x-reverse: 0; --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-ordinal: initial; --tw-slashed-zero: initial; --tw-numeric-figure: initial; --tw-numeric-spacing: initial; --tw-numeric-fraction: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; --tw-shadow-alpha: 100%; --tw-inset-shadow: 0 0 #0000; --tw-inset-shadow-color: initial; --tw-inset-shadow-alpha: 100%; --tw-ring-color: initial; --tw-ring-shadow: 0 0 #0000; --tw-inset-ring-color: initial; --tw-inset-ring-shadow: 0 0 #0000; --tw-ring-inset: initial; --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; --tw-grayscale: initial; --tw-hue-rotate: initial; --tw-invert: initial; --tw-opacity: initial; --tw-saturate: initial; --tw-sepia: initial; --tw-drop-shadow: initial; --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; --tw-backdrop-blur: initial; --tw-backdrop-brightness: initial; --tw-backdrop-contrast: initial; --tw-backdrop-grayscale: initial; --tw-backdrop-hue-rotate: initial; --tw-backdrop-invert: initial; --tw-backdrop-opacity: initial; --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; --tw-ease: initial; } } } ================================================ FILE: tsunami/demo/pomodoro/app.go ================================================ package main import ( "fmt" "time" "github.com/wavetermdev/waveterm/tsunami/app" "github.com/wavetermdev/waveterm/tsunami/vdom" ) var AppMeta = app.AppMeta{ Title: "Pomodoro Timer (Tsunami Demo)", ShortDesc: "Productivity timer with work and break intervals", } type Mode struct { Name string `json:"name"` Duration int `json:"duration"` // in minutes } var ( WorkMode = Mode{Name: "Work", Duration: 25} BreakMode = Mode{Name: "Break", Duration: 5} // Data atom to expose remaining seconds to external systems remainingSecondsAtom = app.DataAtom("remainingSeconds", WorkMode.Duration*60, &app.AtomMeta{ Desc: "Remaining seconds in current pomodoro timer", Units: "s", Min: app.Ptr(0.0), Max: app.Ptr(3600.0), }) ) type TimerDisplayProps struct { RemainingSeconds int `json:"remainingSeconds"` Mode string `json:"mode"` } type ControlButtonsProps struct { IsRunning bool `json:"isRunning"` OnStart func() `json:"onStart"` OnPause func() `json:"onPause"` OnReset func() `json:"onReset"` OnMode func(int) `json:"onMode"` } var TimerDisplay = app.DefineComponent("TimerDisplay", func(props TimerDisplayProps) any { minutes := props.RemainingSeconds / 60 seconds := props.RemainingSeconds % 60 return vdom.H("div", map[string]any{"className": "bg-slate-700 p-8 rounded-lg mb-8 text-center"}, vdom.H("div", map[string]any{"className": "text-xl text-blue-400 mb-2"}, props.Mode, ), vdom.H("div", map[string]any{"className": "text-6xl font-bold font-mono text-slate-100"}, fmt.Sprintf("%02d:%02d", minutes, seconds), ), ) }, ) var ControlButtons = app.DefineComponent("ControlButtons", func(props ControlButtonsProps) any { return vdom.H("div", map[string]any{"className": "flex flex-col gap-4"}, vdom.IfElse(props.IsRunning, vdom.H("button", map[string]any{ "className": "px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200", "onClick": props.OnPause, }, "Pause", ), vdom.H("button", map[string]any{ "className": "px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200", "onClick": props.OnStart, }, "Start", ), ), vdom.H("button", map[string]any{ "className": "px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200", "onClick": props.OnReset, }, "Reset", ), vdom.H("div", map[string]any{"className": "flex gap-4 mt-4"}, vdom.H("button", map[string]any{ "className": "flex-1 px-3 py-3 text-base border-none rounded bg-green-500 text-white cursor-pointer hover:bg-green-600 transition-colors duration-200", "onClick": func() { props.OnMode(WorkMode.Duration) }, }, "Work Mode", ), vdom.H("button", map[string]any{ "className": "flex-1 px-3 py-3 text-base border-none rounded bg-green-500 text-white cursor-pointer hover:bg-green-600 transition-colors duration-200", "onClick": func() { props.OnMode(BreakMode.Duration) }, }, "Break Mode", ), ), ) }, ) var App = app.DefineComponent("App", func(_ struct{}) any { isRunning := app.UseLocal(false) mode := app.UseLocal(WorkMode.Name) isComplete := app.UseLocal(false) startTime := app.UseRef(time.Time{}) totalDuration := app.UseRef(time.Duration(0)) // Timer that updates every second using the new pattern app.UseTicker(time.Second, func() { if !isRunning.Get() { return } elapsed := time.Since(startTime.Current) remaining := totalDuration.Current - elapsed if remaining <= 0 { // Timer completed isRunning.Set(false) remainingSecondsAtom.Set(0) isComplete.Set(true) return } newSeconds := int(remaining.Seconds()) // Only send update if value actually changed if newSeconds != remainingSecondsAtom.Get() { remainingSecondsAtom.Set(newSeconds) } }, []any{isRunning.Get()}) startTimer := func() { if isRunning.Get() { return // Timer already running } isComplete.Set(false) startTime.Current = time.Now() totalDuration.Current = time.Duration(remainingSecondsAtom.Get()) * time.Second isRunning.Set(true) } pauseTimer := func() { if !isRunning.Get() { return } // Calculate remaining time and update remainingSeconds elapsed := time.Since(startTime.Current) remaining := totalDuration.Current - elapsed if remaining > 0 { remainingSecondsAtom.Set(int(remaining.Seconds())) } isRunning.Set(false) } resetTimer := func() { isRunning.Set(false) isComplete.Set(false) if mode.Get() == WorkMode.Name { remainingSecondsAtom.Set(WorkMode.Duration * 60) } else { remainingSecondsAtom.Set(BreakMode.Duration * 60) } } changeMode := func(duration int) { isRunning.Set(false) isComplete.Set(false) remainingSecondsAtom.Set(duration * 60) if duration == WorkMode.Duration { mode.Set(WorkMode.Name) } else { mode.Set(BreakMode.Name) } } return vdom.H("div", map[string]any{"className": "max-w-sm mx-auto my-8 p-8 bg-slate-800 rounded-xl text-slate-100 font-sans"}, vdom.H("h1", map[string]any{"className": "text-center text-slate-100 mb-8 text-3xl"}, "Pomodoro Timer", ), TimerDisplay(TimerDisplayProps{ RemainingSeconds: remainingSecondsAtom.Get(), Mode: mode.Get(), }), ControlButtons(ControlButtonsProps{ IsRunning: isRunning.Get(), OnStart: startTimer, OnPause: pauseTimer, OnReset: resetTimer, OnMode: changeMode, }), ) }, ) ================================================ FILE: tsunami/demo/pomodoro/go.mod ================================================ module tsunami/app/pomodoro go 1.25.6 require github.com/wavetermdev/waveterm/tsunami v0.0.0 require ( github.com/google/uuid v1.6.0 // indirect github.com/outrigdev/goid v0.3.0 // indirect ) replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami ================================================ FILE: tsunami/demo/pomodoro/go.sum ================================================ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= ================================================ FILE: tsunami/demo/pomodoro/static/tw.css ================================================ /*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { :root, :host { --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; --color-red-100: oklch(93.6% 0.032 17.717); --color-red-500: oklch(63.7% 0.237 25.331); --color-red-800: oklch(44.4% 0.177 26.899); --color-green-500: oklch(72.3% 0.219 149.579); --color-green-600: oklch(62.7% 0.194 149.214); --color-blue-400: oklch(70.7% 0.165 254.624); --color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-600: oklch(54.6% 0.245 262.881); --color-slate-100: oklch(96.8% 0.007 247.896); --color-slate-700: oklch(37.2% 0.044 257.287); --color-slate-800: oklch(27.9% 0.041 260.031); --color-white: #fff; --spacing: 0.25rem; --container-sm: 24rem; --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); --text-base: 1rem; --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); --text-6xl: 3.75rem; --text-6xl--line-height: 1; --font-weight-bold: 700; --leading-relaxed: 1.625; --radius-lg: 0.5rem; --radius-xl: 0.75rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); --radius: 8px; --color-background: rgb(34, 34, 34); --color-primary: rgb(247, 247, 247); --color-secondary: rgba(215, 218, 224, 0.7); --color-muted: rgba(215, 218, 224, 0.5); --color-accent-300: rgb(110, 231, 133); --color-panel: rgba(255, 255, 255, 0.12); --color-border: rgba(255, 255, 255, 0.16); --color-accent: rgb(88, 193, 66); } } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); font-feature-settings: var(--default-mono-font-feature-settings, normal); font-variation-settings: var(--default-mono-font-variation-settings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: currentcolor; @supports (color: color-mix(in lab, red, red)) { color: color-mix(in oklab, currentcolor 50%, transparent); } } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden="until-found"])) { display: none !important; } } @layer utilities { .collapse { visibility: collapse; } .invisible { visibility: hidden; } .visible { visibility: visible; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } .not-sr-only { position: static; width: auto; height: auto; padding: 0; margin: 0; overflow: visible; clip-path: none; white-space: normal; } .absolute { position: absolute; } .fixed { position: fixed; } .relative { position: relative; } .static { position: static; } .sticky { position: sticky; } .isolate { isolation: isolate; } .isolation-auto { isolation: auto; } .container { width: 100%; @media (width >= 40rem) { max-width: 40rem; } @media (width >= 48rem) { max-width: 48rem; } @media (width >= 64rem) { max-width: 64rem; } @media (width >= 80rem) { max-width: 80rem; } @media (width >= 96rem) { max-width: 96rem; } } .mx-auto { margin-inline: auto; } .my-6 { margin-block: calc(var(--spacing) * 6); } .my-8 { margin-block: calc(var(--spacing) * 8); } .mt-3 { margin-top: calc(var(--spacing) * 3); } .mt-4 { margin-top: calc(var(--spacing) * 4); } .mt-5 { margin-top: calc(var(--spacing) * 5); } .mt-6 { margin-top: calc(var(--spacing) * 6); } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } .mb-8 { margin-bottom: calc(var(--spacing) * 8); } .ml-4 { margin-left: calc(var(--spacing) * 4); } .block { display: block; } .contents { display: contents; } .flex { display: flex; } .flow-root { display: flow-root; } .grid { display: grid; } .hidden { display: none; } .inline { display: inline; } .inline-block { display: inline-block; } .inline-flex { display: inline-flex; } .inline-grid { display: inline-grid; } .inline-table { display: inline-table; } .list-item { display: list-item; } .table { display: table; } .table-caption { display: table-caption; } .table-cell { display: table-cell; } .table-column { display: table-column; } .table-column-group { display: table-column-group; } .table-footer-group { display: table-footer-group; } .table-header-group { display: table-header-group; } .table-row { display: table-row; } .table-row-group { display: table-row-group; } .min-h-full { min-height: 100%; } .min-h-screen { min-height: 100vh; } .w-full { width: 100%; } .max-w-none { max-width: none; } .max-w-sm { max-width: var(--container-sm); } .min-w-full { min-width: 100%; } .flex-1 { flex: 1; } .shrink { flex-shrink: 1; } .grow { flex-grow: 1; } .border-collapse { border-collapse: collapse; } .translate-none { translate: none; } .scale-3d { scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z); } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } .cursor-pointer { cursor: pointer; } .touch-pinch-zoom { --tw-pinch-zoom: pinch-zoom; touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,); } .resize { resize: both; } .list-inside { list-style-position: inside; } .list-decimal { list-style-type: decimal; } .list-disc { list-style-type: disc; } .flex-col { flex-direction: column; } .flex-wrap { flex-wrap: wrap; } .gap-4 { gap: calc(var(--spacing) * 4); } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-reverse { :where(& > :not(:last-child)) { --tw-space-y-reverse: 1; } } .space-x-reverse { :where(& > :not(:last-child)) { --tw-space-x-reverse: 1; } } .divide-x { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 0; border-inline-style: var(--tw-border-style); border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); } } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; border-bottom-style: var(--tw-border-style); border-top-style: var(--tw-border-style); border-top-width: calc(1px * var(--tw-divide-y-reverse)); border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); } } .divide-y-reverse { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 1; } } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .overflow-auto { overflow: auto; } .overflow-x-auto { overflow-x: auto; } .rounded { border-radius: var(--radius); } .rounded-lg { border-radius: var(--radius-lg); } .rounded-xl { border-radius: var(--radius-xl); } .rounded-s { border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); } .rounded-ss { border-start-start-radius: var(--radius); } .rounded-e { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); } .rounded-se { border-start-end-radius: var(--radius); } .rounded-ee { border-end-end-radius: var(--radius); } .rounded-es { border-end-start-radius: var(--radius); } .rounded-t { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .rounded-l { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-tl { border-top-left-radius: var(--radius); } .rounded-r { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); } .rounded-tr { border-top-right-radius: var(--radius); } .rounded-b { border-bottom-right-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-br { border-bottom-right-radius: var(--radius); } .rounded-bl { border-bottom-left-radius: var(--radius); } .border { border-style: var(--tw-border-style); border-width: 1px; } .border-x { border-inline-style: var(--tw-border-style); border-inline-width: 1px; } .border-y { border-block-style: var(--tw-border-style); border-block-width: 1px; } .border-s { border-inline-start-style: var(--tw-border-style); border-inline-start-width: 1px; } .border-e { border-inline-end-style: var(--tw-border-style); border-inline-end-width: 1px; } .border-t { border-top-style: var(--tw-border-style); border-top-width: 1px; } .border-r { border-right-style: var(--tw-border-style); border-right-width: 1px; } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } .border-l { border-left-style: var(--tw-border-style); border-left-width: 1px; } .border-l-4 { border-left-style: var(--tw-border-style); border-left-width: 4px; } .border-none { --tw-border-style: none; border-style: none; } .border-border { border-color: var(--color-border); } .border-red-500 { border-color: var(--color-red-500); } .bg-background { background-color: var(--color-background); } .bg-blue-500 { background-color: var(--color-blue-500); } .bg-green-500 { background-color: var(--color-green-500); } .bg-panel { background-color: var(--color-panel); } .bg-red-100 { background-color: var(--color-red-100); } .bg-slate-700 { background-color: var(--color-slate-700); } .bg-slate-800 { background-color: var(--color-slate-800); } .bg-repeat { background-repeat: repeat; } .mask-no-clip { mask-clip: no-clip; } .mask-repeat { mask-repeat: repeat; } .p-4 { padding: calc(var(--spacing) * 4); } .p-8 { padding: calc(var(--spacing) * 8); } .px-1 { padding-inline: calc(var(--spacing) * 1); } .px-3 { padding-inline: calc(var(--spacing) * 3); } .px-4 { padding-inline: calc(var(--spacing) * 4); } .px-6 { padding-inline: calc(var(--spacing) * 6); } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } .py-2 { padding-block: calc(var(--spacing) * 2); } .py-3 { padding-block: calc(var(--spacing) * 3); } .pl-4 { padding-left: calc(var(--spacing) * 4); } .text-center { text-align: center; } .text-left { text-align: left; } .font-mono { font-family: var(--font-mono); } .font-sans { font-family: var(--font-sans); } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); } .text-3xl { font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); } .text-6xl { font-size: var(--text-6xl); line-height: var(--tw-leading, var(--text-6xl--line-height)); } .text-base { font-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height)); } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); } .text-xl { font-size: var(--text-xl); line-height: var(--tw-leading, var(--text-xl--line-height)); } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } .text-wrap { text-wrap: wrap; } .text-clip { text-overflow: clip; } .text-ellipsis { text-overflow: ellipsis; } .text-accent { color: var(--color-accent); } .text-blue-400 { color: var(--color-blue-400); } .text-muted { color: var(--color-muted); } .text-primary { color: var(--color-primary); } .text-red-800 { color: var(--color-red-800); } .text-secondary { color: var(--color-secondary); } .text-slate-100 { color: var(--color-slate-100); } .text-white { color: var(--color-white); } .capitalize { text-transform: capitalize; } .lowercase { text-transform: lowercase; } .normal-case { text-transform: none; } .uppercase { text-transform: uppercase; } .italic { font-style: italic; } .not-italic { font-style: normal; } .diagonal-fractions { --tw-numeric-fraction: diagonal-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .lining-nums { --tw-numeric-figure: lining-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .oldstyle-nums { --tw-numeric-figure: oldstyle-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .ordinal { --tw-ordinal: ordinal; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .proportional-nums { --tw-numeric-spacing: proportional-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .slashed-zero { --tw-slashed-zero: slashed-zero; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .stacked-fractions { --tw-numeric-fraction: stacked-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .normal-nums { font-variant-numeric: normal; } .line-through { text-decoration-line: line-through; } .no-underline { text-decoration-line: none; } .overline { text-decoration-line: overline; } .underline { text-decoration-line: underline; } .antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .subpixel-antialiased { -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .inset-ring { --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .drop-shadow { --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06))); --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06)); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .backdrop-blur { --tw-backdrop-blur: blur(8px); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-grayscale { --tw-backdrop-grayscale: grayscale(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-invert { --tw-backdrop-invert: invert(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-sepia { --tw-backdrop-sepia: sepia(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-filter { -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } .duration-200 { --tw-duration: 200ms; transition-duration: 200ms; } .ease-in { --tw-ease: var(--ease-in); transition-timing-function: var(--ease-in); } .ease-in-out { --tw-ease: var(--ease-in-out); transition-timing-function: var(--ease-in-out); } .ease-out { --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } .divide-x-reverse { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 1; } } .ring-inset { --tw-ring-inset: inset; } .hover\:bg-blue-600 { &:hover { @media (hover: hover) { background-color: var(--color-blue-600); } } } .hover\:bg-green-600 { &:hover { @media (hover: hover) { background-color: var(--color-green-600); } } } .hover\:text-accent-300 { &:hover { @media (hover: hover) { color: var(--color-accent-300); } } } } @property --tw-scale-x { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-y { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-z { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-rotate-x { syntax: "*"; inherits: false; } @property --tw-rotate-y { syntax: "*"; inherits: false; } @property --tw-rotate-z { syntax: "*"; inherits: false; } @property --tw-skew-x { syntax: "*"; inherits: false; } @property --tw-skew-y { syntax: "*"; inherits: false; } @property --tw-pan-x { syntax: "*"; inherits: false; } @property --tw-pan-y { syntax: "*"; inherits: false; } @property --tw-pinch-zoom { syntax: "*"; inherits: false; } @property --tw-space-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-space-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-divide-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } @property --tw-divide-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-leading { syntax: "*"; inherits: false; } @property --tw-font-weight { syntax: "*"; inherits: false; } @property --tw-ordinal { syntax: "*"; inherits: false; } @property --tw-slashed-zero { syntax: "*"; inherits: false; } @property --tw-numeric-figure { syntax: "*"; inherits: false; } @property --tw-numeric-spacing { syntax: "*"; inherits: false; } @property --tw-numeric-fraction { syntax: "*"; inherits: false; } @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-shadow-color { syntax: "*"; inherits: false; } @property --tw-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-inset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-shadow-color { syntax: "*"; inherits: false; } @property --tw-inset-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-ring-color { syntax: "*"; inherits: false; } @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-ring-color { syntax: "*"; inherits: false; } @property --tw-inset-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-ring-inset { syntax: "*"; inherits: false; } @property --tw-ring-offset-width { syntax: "<length>"; inherits: false; initial-value: 0px; } @property --tw-ring-offset-color { syntax: "*"; inherits: false; initial-value: #fff; } @property --tw-ring-offset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-blur { syntax: "*"; inherits: false; } @property --tw-brightness { syntax: "*"; inherits: false; } @property --tw-contrast { syntax: "*"; inherits: false; } @property --tw-grayscale { syntax: "*"; inherits: false; } @property --tw-hue-rotate { syntax: "*"; inherits: false; } @property --tw-invert { syntax: "*"; inherits: false; } @property --tw-opacity { syntax: "*"; inherits: false; } @property --tw-saturate { syntax: "*"; inherits: false; } @property --tw-sepia { syntax: "*"; inherits: false; } @property --tw-drop-shadow { syntax: "*"; inherits: false; } @property --tw-drop-shadow-color { syntax: "*"; inherits: false; } @property --tw-drop-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-drop-shadow-size { syntax: "*"; inherits: false; } @property --tw-backdrop-blur { syntax: "*"; inherits: false; } @property --tw-backdrop-brightness { syntax: "*"; inherits: false; } @property --tw-backdrop-contrast { syntax: "*"; inherits: false; } @property --tw-backdrop-grayscale { syntax: "*"; inherits: false; } @property --tw-backdrop-hue-rotate { syntax: "*"; inherits: false; } @property --tw-backdrop-invert { syntax: "*"; inherits: false; } @property --tw-backdrop-opacity { syntax: "*"; inherits: false; } @property --tw-backdrop-saturate { syntax: "*"; inherits: false; } @property --tw-backdrop-sepia { syntax: "*"; inherits: false; } @property --tw-duration { syntax: "*"; inherits: false; } @property --tw-ease { syntax: "*"; inherits: false; } @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; --tw-pan-x: initial; --tw-pan-y: initial; --tw-pinch-zoom: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; --tw-divide-x-reverse: 0; --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-ordinal: initial; --tw-slashed-zero: initial; --tw-numeric-figure: initial; --tw-numeric-spacing: initial; --tw-numeric-fraction: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; --tw-shadow-alpha: 100%; --tw-inset-shadow: 0 0 #0000; --tw-inset-shadow-color: initial; --tw-inset-shadow-alpha: 100%; --tw-ring-color: initial; --tw-ring-shadow: 0 0 #0000; --tw-inset-ring-color: initial; --tw-inset-ring-shadow: 0 0 #0000; --tw-ring-inset: initial; --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; --tw-grayscale: initial; --tw-hue-rotate: initial; --tw-invert: initial; --tw-opacity: initial; --tw-saturate: initial; --tw-sepia: initial; --tw-drop-shadow: initial; --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; --tw-backdrop-blur: initial; --tw-backdrop-brightness: initial; --tw-backdrop-contrast: initial; --tw-backdrop-grayscale: initial; --tw-backdrop-hue-rotate: initial; --tw-backdrop-invert: initial; --tw-backdrop-opacity: initial; --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; --tw-duration: initial; --tw-ease: initial; } } } ================================================ FILE: tsunami/demo/recharts/app.go ================================================ package main import ( "math" "time" "github.com/wavetermdev/waveterm/tsunami/app" "github.com/wavetermdev/waveterm/tsunami/vdom" ) var AppMeta = app.AppMeta{ Title: "Recharts Demo", ShortDesc: "Interactive charts and data visualization using Recharts", } // Global atoms for config and data var ( chartDataAtom = app.DataAtom("chartData", generateInitialData(), &app.AtomMeta{ Desc: "Chart data points for system metrics visualization", }) chartTypeAtom = app.ConfigAtom("chartType", "line", &app.AtomMeta{ Desc: "Type of chart to display", Enum: []string{"line", "area", "bar"}, }) isAnimatingAtom = app.ConfigAtom("isAnimating", false, &app.AtomMeta{ Desc: "Whether the chart is currently animating with live data", }) ) type DataPoint struct { Time int `json:"time"` CPU float64 `json:"cpu"` Mem float64 `json:"mem"` Disk float64 `json:"disk"` } func generateInitialData() []DataPoint { data := make([]DataPoint, 20) for i := 0; i < 20; i++ { data[i] = DataPoint{ Time: i, CPU: 50 + 30*math.Sin(float64(i)*0.3) + 10*math.Sin(float64(i)*0.7), Mem: 40 + 25*math.Cos(float64(i)*0.4) + 15*math.Sin(float64(i)*0.9), Disk: 30 + 20*math.Sin(float64(i)*0.2) + 10*math.Cos(float64(i)*1.1), } } return data } func generateNewDataPoint(currentData []DataPoint) DataPoint { lastTime := 0 if len(currentData) > 0 { lastTime = currentData[len(currentData)-1].Time } newTime := lastTime + 1 return DataPoint{ Time: newTime, CPU: 50 + 30*math.Sin(float64(newTime)*0.3) + 10*math.Sin(float64(newTime)*0.7), Mem: 40 + 25*math.Cos(float64(newTime)*0.4) + 15*math.Sin(float64(newTime)*0.9), Disk: 30 + 20*math.Sin(float64(newTime)*0.2) + 10*math.Cos(float64(newTime)*1.1), } } var InfoSection = app.DefineComponent("InfoSection", func(_ struct{}) any { return vdom.H("div", map[string]any{ "className": "bg-blue-50 border border-blue-200 rounded-lg p-4", }, vdom.H("h3", map[string]any{ "className": "text-lg font-semibold text-blue-900 mb-2", }, "Recharts Integration Features"), vdom.H("ul", map[string]any{ "className": "space-y-2 text-blue-800", }, vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-500 mt-1", }, "•"), "Support for all major Recharts components (LineChart, AreaChart, BarChart, etc.)", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-500 mt-1", }, "•"), "Live data updates with animation support", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-500 mt-1", }, "•"), "Responsive containers that resize with the window", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-500 mt-1", }, "•"), "Full prop support for customization and styling", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", }, vdom.H("span", map[string]any{ "className": "text-blue-500 mt-1", }, "•"), "Uses recharts: namespace to dispatch to the recharts handler", ), ), ) }, ) type MiniChartsProps struct { ChartData []DataPoint `json:"chartData"` } var MiniCharts = app.DefineComponent("MiniCharts", func(props MiniChartsProps) any { return vdom.H("div", map[string]any{ "className": "grid grid-cols-1 md:grid-cols-3 gap-6 mb-6", }, // CPU Mini Chart vdom.H("div", map[string]any{ "className": "bg-white rounded-lg shadow-sm border p-4", }, vdom.H("h3", map[string]any{ "className": "text-lg font-medium text-gray-900 mb-3", }, "CPU Usage"), vdom.H("div", map[string]any{ "className": "h-32", }, vdom.H("recharts:ResponsiveContainer", map[string]any{ "width": "100%", "height": "100%", }, vdom.H("recharts:LineChart", map[string]any{ "data": props.ChartData, }, vdom.H("recharts:Line", map[string]any{ "type": "monotone", "dataKey": "cpu", "stroke": "#8884d8", "strokeWidth": 2, "dot": false, }), ), ), ), ), // Memory Mini Chart vdom.H("div", map[string]any{ "className": "bg-white rounded-lg shadow-sm border p-4", }, vdom.H("h3", map[string]any{ "className": "text-lg font-medium text-gray-900 mb-3", }, "Memory Usage"), vdom.H("div", map[string]any{ "className": "h-32", }, vdom.H("recharts:ResponsiveContainer", map[string]any{ "width": "100%", "height": "100%", }, vdom.H("recharts:AreaChart", map[string]any{ "data": props.ChartData, }, vdom.H("recharts:Area", map[string]any{ "type": "monotone", "dataKey": "mem", "stroke": "#82ca9d", "fill": "#82ca9d", }), ), ), ), ), // Disk Mini Chart vdom.H("div", map[string]any{ "className": "bg-white rounded-lg shadow-sm border p-4", }, vdom.H("h3", map[string]any{ "className": "text-lg font-medium text-gray-900 mb-3", }, "Disk Usage"), vdom.H("div", map[string]any{ "className": "h-32", }, vdom.H("recharts:ResponsiveContainer", map[string]any{ "width": "100%", "height": "100%", }, vdom.H("recharts:BarChart", map[string]any{ "data": props.ChartData, }, vdom.H("recharts:Bar", map[string]any{ "dataKey": "disk", "fill": "#ffc658", }), ), ), ), ), ) }, ) var App = app.DefineComponent("App", func(_ struct{}) any { tickerFn := func() { if !isAnimatingAtom.Get() { return } chartDataAtom.SetFn(func(currentData []DataPoint) []DataPoint { newData := append(currentData, generateNewDataPoint(currentData)) if len(newData) > 20 { newData = newData[1:] } return newData }) } app.UseTicker(time.Second, tickerFn, []any{}) handleStartStop := func() { isAnimatingAtom.Set(!isAnimatingAtom.Get()) } handleReset := func() { chartDataAtom.Set(generateInitialData()) isAnimatingAtom.Set(false) } handleChartTypeChange := func(newType string) { chartTypeAtom.Set(newType) } chartData := chartDataAtom.Get() chartType := chartTypeAtom.Get() isAnimating := isAnimatingAtom.Get() return vdom.H("div", map[string]any{ "className": "min-h-screen bg-gray-50 p-6", }, vdom.H("div", map[string]any{ "className": "max-w-6xl mx-auto", }, // Header vdom.H("div", map[string]any{ "className": "mb-8", }, vdom.H("h1", map[string]any{ "className": "text-3xl font-bold text-gray-900 mb-2", }, "Recharts Integration Demo"), vdom.H("p", map[string]any{ "className": "text-gray-600", }, "Demonstrating recharts components in Tsunami VDOM system"), ), // Controls vdom.H("div", map[string]any{ "className": "bg-white rounded-lg shadow-sm border p-4 mb-6", }, vdom.H("div", map[string]any{ "className": "flex items-center gap-4 flex-wrap", }, // Chart type selector vdom.H("div", map[string]any{ "className": "flex items-center gap-2", }, vdom.H("label", map[string]any{ "className": "text-sm font-medium text-gray-700", }, "Chart Type:"), vdom.H("select", map[string]any{ "className": "px-3 py-1 border border-gray-300 rounded-md text-sm bg-white text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500", "value": chartType, "onChange": func(e vdom.VDomEvent) { handleChartTypeChange(e.TargetValue) }, }, vdom.H("option", map[string]any{"value": "line"}, "Line Chart"), vdom.H("option", map[string]any{"value": "area"}, "Area Chart"), vdom.H("option", map[string]any{"value": "bar"}, "Bar Chart"), ), ), // Animation controls vdom.H("button", map[string]any{ "className": vdom.Classes( "px-4 py-2 rounded-md text-sm font-medium transition-colors", vdom.IfElse(isAnimating, "bg-red-500 hover:bg-red-600 text-white", "bg-green-500 hover:bg-green-600 text-white", ), ), "onClick": handleStartStop, }, vdom.IfElse(isAnimating, "Stop Animation", "Start Animation")), vdom.H("button", map[string]any{ "className": "px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-md text-sm font-medium transition-colors", "onClick": handleReset, }, "Reset Data"), // Status indicator vdom.H("div", map[string]any{ "className": "flex items-center gap-2", }, vdom.H("div", map[string]any{ "className": vdom.Classes( "w-2 h-2 rounded-full", vdom.IfElse(isAnimating, "bg-green-500", "bg-gray-400"), ), }), vdom.H("span", map[string]any{ "className": "text-sm text-gray-600", }, vdom.IfElse(isAnimating, "Live Updates", "Static")), ), ), ), // Main chart vdom.H("div", map[string]any{ "className": "bg-white rounded-lg shadow-sm border p-6 mb-6", }, vdom.H("h2", map[string]any{ "className": "text-xl font-semibold text-gray-900 mb-4", }, "System Metrics Over Time"), vdom.H("div", map[string]any{ "className": "w-full h-96", }, // Main chart - switches based on chartType vdom.IfElse(chartType == "line", // Line Chart vdom.H("recharts:ResponsiveContainer", map[string]any{ "width": "100%", "height": "100%", }, vdom.H("recharts:LineChart", map[string]any{ "data": chartData, }, vdom.H("recharts:CartesianGrid", map[string]any{ "strokeDasharray": "3 3", }), vdom.H("recharts:XAxis", map[string]any{ "dataKey": "time", }), vdom.H("recharts:YAxis", nil), vdom.H("recharts:Tooltip", nil), vdom.H("recharts:Legend", nil), vdom.H("recharts:Line", map[string]any{ "type": "monotone", "dataKey": "cpu", "stroke": "#8884d8", "name": "CPU %", }), vdom.H("recharts:Line", map[string]any{ "type": "monotone", "dataKey": "mem", "stroke": "#82ca9d", "name": "Memory %", }), vdom.H("recharts:Line", map[string]any{ "type": "monotone", "dataKey": "disk", "stroke": "#ffc658", "name": "Disk %", }), ), ), vdom.IfElse(chartType == "area", // Area Chart vdom.H("recharts:ResponsiveContainer", map[string]any{ "width": "100%", "height": "100%", }, vdom.H("recharts:AreaChart", map[string]any{ "data": chartData, }, vdom.H("recharts:CartesianGrid", map[string]any{ "strokeDasharray": "3 3", }), vdom.H("recharts:XAxis", map[string]any{ "dataKey": "time", }), vdom.H("recharts:YAxis", nil), vdom.H("recharts:Tooltip", nil), vdom.H("recharts:Legend", nil), vdom.H("recharts:Area", map[string]any{ "type": "monotone", "dataKey": "cpu", "stackId": "1", "stroke": "#8884d8", "fill": "#8884d8", "name": "CPU %", }), vdom.H("recharts:Area", map[string]any{ "type": "monotone", "dataKey": "mem", "stackId": "1", "stroke": "#82ca9d", "fill": "#82ca9d", "name": "Memory %", }), vdom.H("recharts:Area", map[string]any{ "type": "monotone", "dataKey": "disk", "stackId": "1", "stroke": "#ffc658", "fill": "#ffc658", "name": "Disk %", }), ), ), // Bar Chart vdom.H("recharts:ResponsiveContainer", map[string]any{ "width": "100%", "height": "100%", }, vdom.H("recharts:BarChart", map[string]any{ "data": chartData, }, vdom.H("recharts:CartesianGrid", map[string]any{ "strokeDasharray": "3 3", }), vdom.H("recharts:XAxis", map[string]any{ "dataKey": "time", }), vdom.H("recharts:YAxis", nil), vdom.H("recharts:Tooltip", nil), vdom.H("recharts:Legend", nil), vdom.H("recharts:Bar", map[string]any{ "dataKey": "cpu", "fill": "#8884d8", "name": "CPU %", }), vdom.H("recharts:Bar", map[string]any{ "dataKey": "mem", "fill": "#82ca9d", "name": "Memory %", }), vdom.H("recharts:Bar", map[string]any{ "dataKey": "disk", "fill": "#ffc658", "name": "Disk %", }), ), ), ), ), ), ), // Mini charts row MiniCharts(MiniChartsProps{ ChartData: chartData, }), // Info section InfoSection(struct{}{}), ), ) }, ) ================================================ FILE: tsunami/demo/recharts/go.mod ================================================ module tsunami/app/recharts go 1.25.6 require github.com/wavetermdev/waveterm/tsunami v0.0.0 require ( github.com/google/uuid v1.6.0 // indirect github.com/outrigdev/goid v0.3.0 // indirect ) replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami ================================================ FILE: tsunami/demo/recharts/go.sum ================================================ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= ================================================ FILE: tsunami/demo/recharts/static/tw.css ================================================ /*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { :root, :host { --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; --color-red-500: oklch(63.7% 0.237 25.331); --color-red-600: oklch(57.7% 0.245 27.325); --color-green-500: oklch(72.3% 0.219 149.579); --color-green-600: oklch(62.7% 0.194 149.214); --color-blue-50: oklch(97% 0.014 254.604); --color-blue-200: oklch(88.2% 0.059 254.128); --color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-800: oklch(42.4% 0.199 265.638); --color-blue-900: oklch(37.9% 0.146 265.522); --color-gray-50: oklch(98.5% 0.002 247.839); --color-gray-300: oklch(87.2% 0.01 258.338); --color-gray-400: oklch(70.7% 0.022 261.325); --color-gray-500: oklch(55.1% 0.027 264.364); --color-gray-600: oklch(44.6% 0.03 256.802); --color-gray-700: oklch(37.3% 0.034 259.733); --color-gray-900: oklch(21% 0.034 264.665); --color-white: #fff; --spacing: 0.25rem; --container-6xl: 72rem; --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); --text-base: 1rem; --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; --leading-relaxed: 1.625; --radius-md: 0.375rem; --radius-lg: 0.5rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); --radius: 8px; --color-background: rgb(34, 34, 34); --color-primary: rgb(247, 247, 247); --color-secondary: rgba(215, 218, 224, 0.7); --color-muted: rgba(215, 218, 224, 0.5); --color-accent-300: rgb(110, 231, 133); --color-panel: rgba(255, 255, 255, 0.12); --color-border: rgba(255, 255, 255, 0.16); --color-accent: rgb(88, 193, 66); } } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); font-feature-settings: var(--default-mono-font-feature-settings, normal); font-variation-settings: var(--default-mono-font-variation-settings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: currentcolor; @supports (color: color-mix(in lab, red, red)) { color: color-mix(in oklab, currentcolor 50%, transparent); } } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden="until-found"])) { display: none !important; } } @layer utilities { .collapse { visibility: collapse; } .invisible { visibility: hidden; } .visible { visibility: visible; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } .not-sr-only { position: static; width: auto; height: auto; padding: 0; margin: 0; overflow: visible; clip-path: none; white-space: normal; } .absolute { position: absolute; } .fixed { position: fixed; } .relative { position: relative; } .static { position: static; } .sticky { position: sticky; } .isolate { isolation: isolate; } .isolation-auto { isolation: auto; } .container { width: 100%; @media (width >= 40rem) { max-width: 40rem; } @media (width >= 48rem) { max-width: 48rem; } @media (width >= 64rem) { max-width: 64rem; } @media (width >= 80rem) { max-width: 80rem; } @media (width >= 96rem) { max-width: 96rem; } } .mx-auto { margin-inline: auto; } .my-6 { margin-block: calc(var(--spacing) * 6); } .mt-1 { margin-top: calc(var(--spacing) * 1); } .mt-3 { margin-top: calc(var(--spacing) * 3); } .mt-4 { margin-top: calc(var(--spacing) * 4); } .mt-5 { margin-top: calc(var(--spacing) * 5); } .mt-6 { margin-top: calc(var(--spacing) * 6); } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } .mb-6 { margin-bottom: calc(var(--spacing) * 6); } .mb-8 { margin-bottom: calc(var(--spacing) * 8); } .ml-4 { margin-left: calc(var(--spacing) * 4); } .block { display: block; } .contents { display: contents; } .flex { display: flex; } .flow-root { display: flow-root; } .grid { display: grid; } .hidden { display: none; } .inline { display: inline; } .inline-block { display: inline-block; } .inline-flex { display: inline-flex; } .inline-grid { display: inline-grid; } .inline-table { display: inline-table; } .list-item { display: list-item; } .table { display: table; } .table-caption { display: table-caption; } .table-cell { display: table-cell; } .table-column { display: table-column; } .table-column-group { display: table-column-group; } .table-footer-group { display: table-footer-group; } .table-header-group { display: table-header-group; } .table-row { display: table-row; } .table-row-group { display: table-row-group; } .h-2 { height: calc(var(--spacing) * 2); } .h-32 { height: calc(var(--spacing) * 32); } .h-96 { height: calc(var(--spacing) * 96); } .min-h-full { min-height: 100%; } .min-h-screen { min-height: 100vh; } .w-2 { width: calc(var(--spacing) * 2); } .w-full { width: 100%; } .max-w-6xl { max-width: var(--container-6xl); } .max-w-none { max-width: none; } .min-w-full { min-width: 100%; } .shrink { flex-shrink: 1; } .grow { flex-grow: 1; } .border-collapse { border-collapse: collapse; } .translate-none { translate: none; } .scale-3d { scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z); } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } .touch-pinch-zoom { --tw-pinch-zoom: pinch-zoom; touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,); } .resize { resize: both; } .list-inside { list-style-position: inside; } .list-decimal { list-style-type: decimal; } .list-disc { list-style-type: disc; } .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } .flex-wrap { flex-wrap: wrap; } .items-center { align-items: center; } .items-start { align-items: flex-start; } .gap-2 { gap: calc(var(--spacing) * 2); } .gap-4 { gap: calc(var(--spacing) * 4); } .gap-6 { gap: calc(var(--spacing) * 6); } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-2 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-reverse { :where(& > :not(:last-child)) { --tw-space-y-reverse: 1; } } .space-x-reverse { :where(& > :not(:last-child)) { --tw-space-x-reverse: 1; } } .divide-x { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 0; border-inline-style: var(--tw-border-style); border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); } } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; border-bottom-style: var(--tw-border-style); border-top-style: var(--tw-border-style); border-top-width: calc(1px * var(--tw-divide-y-reverse)); border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); } } .divide-y-reverse { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 1; } } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .overflow-auto { overflow: auto; } .overflow-x-auto { overflow-x: auto; } .rounded { border-radius: var(--radius); } .rounded-full { border-radius: calc(infinity * 1px); } .rounded-lg { border-radius: var(--radius-lg); } .rounded-md { border-radius: var(--radius-md); } .rounded-s { border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); } .rounded-ss { border-start-start-radius: var(--radius); } .rounded-e { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); } .rounded-se { border-start-end-radius: var(--radius); } .rounded-ee { border-end-end-radius: var(--radius); } .rounded-es { border-end-start-radius: var(--radius); } .rounded-t { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .rounded-l { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-tl { border-top-left-radius: var(--radius); } .rounded-r { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); } .rounded-tr { border-top-right-radius: var(--radius); } .rounded-b { border-bottom-right-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-br { border-bottom-right-radius: var(--radius); } .rounded-bl { border-bottom-left-radius: var(--radius); } .border { border-style: var(--tw-border-style); border-width: 1px; } .border-x { border-inline-style: var(--tw-border-style); border-inline-width: 1px; } .border-y { border-block-style: var(--tw-border-style); border-block-width: 1px; } .border-s { border-inline-start-style: var(--tw-border-style); border-inline-start-width: 1px; } .border-e { border-inline-end-style: var(--tw-border-style); border-inline-end-width: 1px; } .border-t { border-top-style: var(--tw-border-style); border-top-width: 1px; } .border-r { border-right-style: var(--tw-border-style); border-right-width: 1px; } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } .border-l { border-left-style: var(--tw-border-style); border-left-width: 1px; } .border-l-4 { border-left-style: var(--tw-border-style); border-left-width: 4px; } .border-blue-200 { border-color: var(--color-blue-200); } .border-border { border-color: var(--color-border); } .border-gray-300 { border-color: var(--color-gray-300); } .bg-background { background-color: var(--color-background); } .bg-blue-50 { background-color: var(--color-blue-50); } .bg-gray-50 { background-color: var(--color-gray-50); } .bg-gray-400 { background-color: var(--color-gray-400); } .bg-gray-500 { background-color: var(--color-gray-500); } .bg-green-500 { background-color: var(--color-green-500); } .bg-panel { background-color: var(--color-panel); } .bg-red-500 { background-color: var(--color-red-500); } .bg-white { background-color: var(--color-white); } .bg-repeat { background-repeat: repeat; } .mask-no-clip { mask-clip: no-clip; } .mask-repeat { mask-repeat: repeat; } .p-4 { padding: calc(var(--spacing) * 4); } .p-6 { padding: calc(var(--spacing) * 6); } .px-1 { padding-inline: calc(var(--spacing) * 1); } .px-3 { padding-inline: calc(var(--spacing) * 3); } .px-4 { padding-inline: calc(var(--spacing) * 4); } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } .py-1 { padding-block: calc(var(--spacing) * 1); } .py-2 { padding-block: calc(var(--spacing) * 2); } .pl-4 { padding-left: calc(var(--spacing) * 4); } .text-left { text-align: left; } .font-mono { font-family: var(--font-mono); } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); } .text-3xl { font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); } .text-base { font-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height)); } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); } .text-xl { font-size: var(--text-xl); line-height: var(--tw-leading, var(--text-xl--line-height)); } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } .font-medium { --tw-font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium); } .font-semibold { --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); } .text-wrap { text-wrap: wrap; } .text-clip { text-overflow: clip; } .text-ellipsis { text-overflow: ellipsis; } .text-accent { color: var(--color-accent); } .text-blue-500 { color: var(--color-blue-500); } .text-blue-800 { color: var(--color-blue-800); } .text-blue-900 { color: var(--color-blue-900); } .text-gray-600 { color: var(--color-gray-600); } .text-gray-700 { color: var(--color-gray-700); } .text-gray-900 { color: var(--color-gray-900); } .text-muted { color: var(--color-muted); } .text-primary { color: var(--color-primary); } .text-secondary { color: var(--color-secondary); } .text-white { color: var(--color-white); } .capitalize { text-transform: capitalize; } .lowercase { text-transform: lowercase; } .normal-case { text-transform: none; } .uppercase { text-transform: uppercase; } .italic { font-style: italic; } .not-italic { font-style: normal; } .diagonal-fractions { --tw-numeric-fraction: diagonal-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .lining-nums { --tw-numeric-figure: lining-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .oldstyle-nums { --tw-numeric-figure: oldstyle-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .ordinal { --tw-ordinal: ordinal; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .proportional-nums { --tw-numeric-spacing: proportional-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .slashed-zero { --tw-slashed-zero: slashed-zero; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .stacked-fractions { --tw-numeric-fraction: stacked-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .normal-nums { font-variant-numeric: normal; } .line-through { text-decoration-line: line-through; } .no-underline { text-decoration-line: none; } .overline { text-decoration-line: overline; } .underline { text-decoration-line: underline; } .antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .subpixel-antialiased { -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .shadow-sm { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .inset-ring { --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .drop-shadow { --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06))); --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06)); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .backdrop-blur { --tw-backdrop-blur: blur(8px); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-grayscale { --tw-backdrop-grayscale: grayscale(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-invert { --tw-backdrop-invert: invert(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-sepia { --tw-backdrop-sepia: sepia(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-filter { -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } .ease-in { --tw-ease: var(--ease-in); transition-timing-function: var(--ease-in); } .ease-in-out { --tw-ease: var(--ease-in-out); transition-timing-function: var(--ease-in-out); } .ease-out { --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } .divide-x-reverse { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 1; } } .ring-inset { --tw-ring-inset: inset; } .hover\:bg-gray-600 { &:hover { @media (hover: hover) { background-color: var(--color-gray-600); } } } .hover\:bg-green-600 { &:hover { @media (hover: hover) { background-color: var(--color-green-600); } } } .hover\:bg-red-600 { &:hover { @media (hover: hover) { background-color: var(--color-red-600); } } } .hover\:text-accent-300 { &:hover { @media (hover: hover) { color: var(--color-accent-300); } } } .focus\:border-blue-500 { &:focus { border-color: var(--color-blue-500); } } .focus\:ring-2 { &:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } } .focus\:ring-blue-500 { &:focus { --tw-ring-color: var(--color-blue-500); } } .md\:grid-cols-3 { @media (width >= 48rem) { grid-template-columns: repeat(3, minmax(0, 1fr)); } } } @property --tw-scale-x { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-y { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-z { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-rotate-x { syntax: "*"; inherits: false; } @property --tw-rotate-y { syntax: "*"; inherits: false; } @property --tw-rotate-z { syntax: "*"; inherits: false; } @property --tw-skew-x { syntax: "*"; inherits: false; } @property --tw-skew-y { syntax: "*"; inherits: false; } @property --tw-pan-x { syntax: "*"; inherits: false; } @property --tw-pan-y { syntax: "*"; inherits: false; } @property --tw-pinch-zoom { syntax: "*"; inherits: false; } @property --tw-space-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-space-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-divide-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } @property --tw-divide-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-leading { syntax: "*"; inherits: false; } @property --tw-font-weight { syntax: "*"; inherits: false; } @property --tw-ordinal { syntax: "*"; inherits: false; } @property --tw-slashed-zero { syntax: "*"; inherits: false; } @property --tw-numeric-figure { syntax: "*"; inherits: false; } @property --tw-numeric-spacing { syntax: "*"; inherits: false; } @property --tw-numeric-fraction { syntax: "*"; inherits: false; } @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-shadow-color { syntax: "*"; inherits: false; } @property --tw-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-inset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-shadow-color { syntax: "*"; inherits: false; } @property --tw-inset-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-ring-color { syntax: "*"; inherits: false; } @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-ring-color { syntax: "*"; inherits: false; } @property --tw-inset-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-ring-inset { syntax: "*"; inherits: false; } @property --tw-ring-offset-width { syntax: "<length>"; inherits: false; initial-value: 0px; } @property --tw-ring-offset-color { syntax: "*"; inherits: false; initial-value: #fff; } @property --tw-ring-offset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-blur { syntax: "*"; inherits: false; } @property --tw-brightness { syntax: "*"; inherits: false; } @property --tw-contrast { syntax: "*"; inherits: false; } @property --tw-grayscale { syntax: "*"; inherits: false; } @property --tw-hue-rotate { syntax: "*"; inherits: false; } @property --tw-invert { syntax: "*"; inherits: false; } @property --tw-opacity { syntax: "*"; inherits: false; } @property --tw-saturate { syntax: "*"; inherits: false; } @property --tw-sepia { syntax: "*"; inherits: false; } @property --tw-drop-shadow { syntax: "*"; inherits: false; } @property --tw-drop-shadow-color { syntax: "*"; inherits: false; } @property --tw-drop-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-drop-shadow-size { syntax: "*"; inherits: false; } @property --tw-backdrop-blur { syntax: "*"; inherits: false; } @property --tw-backdrop-brightness { syntax: "*"; inherits: false; } @property --tw-backdrop-contrast { syntax: "*"; inherits: false; } @property --tw-backdrop-grayscale { syntax: "*"; inherits: false; } @property --tw-backdrop-hue-rotate { syntax: "*"; inherits: false; } @property --tw-backdrop-invert { syntax: "*"; inherits: false; } @property --tw-backdrop-opacity { syntax: "*"; inherits: false; } @property --tw-backdrop-saturate { syntax: "*"; inherits: false; } @property --tw-backdrop-sepia { syntax: "*"; inherits: false; } @property --tw-ease { syntax: "*"; inherits: false; } @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; --tw-pan-x: initial; --tw-pan-y: initial; --tw-pinch-zoom: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; --tw-divide-x-reverse: 0; --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-ordinal: initial; --tw-slashed-zero: initial; --tw-numeric-figure: initial; --tw-numeric-spacing: initial; --tw-numeric-fraction: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; --tw-shadow-alpha: 100%; --tw-inset-shadow: 0 0 #0000; --tw-inset-shadow-color: initial; --tw-inset-shadow-alpha: 100%; --tw-ring-color: initial; --tw-ring-shadow: 0 0 #0000; --tw-inset-ring-color: initial; --tw-inset-ring-shadow: 0 0 #0000; --tw-ring-inset: initial; --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; --tw-grayscale: initial; --tw-hue-rotate: initial; --tw-invert: initial; --tw-opacity: initial; --tw-saturate: initial; --tw-sepia: initial; --tw-drop-shadow: initial; --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; --tw-backdrop-blur: initial; --tw-backdrop-brightness: initial; --tw-backdrop-contrast: initial; --tw-backdrop-grayscale: initial; --tw-backdrop-hue-rotate: initial; --tw-backdrop-invert: initial; --tw-backdrop-opacity: initial; --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; --tw-ease: initial; } } } ================================================ FILE: tsunami/demo/tabletest/app.go ================================================ package main import ( "fmt" "github.com/wavetermdev/waveterm/tsunami/app" "github.com/wavetermdev/waveterm/tsunami/ui" "github.com/wavetermdev/waveterm/tsunami/vdom" ) var AppMeta = app.AppMeta{ Title: "Table Test Demo", ShortDesc: "Testing table component with sortable columns and pagination", } // Sample data structure for the table type Person struct { Name string `json:"name"` Age int `json:"age"` Email string `json:"email"` City string `json:"city"` } // Create the table component for Person data var PersonTable = ui.MakeTableComponent[Person]("PersonTable") // Sample data exposed as DataAtom for external system access var sampleData = app.DataAtom("sampleData", []Person{ {Name: "Alice Johnson", Age: 28, Email: "alice@example.com", City: "New York"}, {Name: "Bob Smith", Age: 34, Email: "bob@example.com", City: "Los Angeles"}, {Name: "Carol Davis", Age: 22, Email: "carol@example.com", City: "Chicago"}, {Name: "David Wilson", Age: 41, Email: "david@example.com", City: "Houston"}, {Name: "Eve Brown", Age: 29, Email: "eve@example.com", City: "Phoenix"}, {Name: "Frank Miller", Age: 37, Email: "frank@example.com", City: "Philadelphia"}, {Name: "Grace Lee", Age: 25, Email: "grace@example.com", City: "San Antonio"}, {Name: "Henry Taylor", Age: 33, Email: "henry@example.com", City: "San Diego"}, {Name: "Ivy Chen", Age: 26, Email: "ivy@example.com", City: "Dallas"}, {Name: "Jack Anderson", Age: 31, Email: "jack@example.com", City: "San Jose"}, }, &app.AtomMeta{ Desc: "Sample person data for table display testing", }) // The App component is the required entry point for every Tsunami application var App = app.DefineComponent("App", func(_ struct{}) any { // Define table columns columns := []ui.TableColumn[Person]{ { AccessorKey: "Name", Header: "Full Name", Sortable: true, Width: "200px", }, { AccessorKey: "Age", Header: "Age", Sortable: true, Width: "80px", }, { AccessorKey: "Email", Header: "Email Address", Sortable: true, Width: "250px", }, { AccessorKey: "City", Header: "City", Sortable: true, Width: "150px", }, } // Handle row clicks handleRowClick := func(person Person, idx int) { fmt.Printf("Clicked on row %d: %s from %s\n", idx, person.Name, person.City) } // Handle sorting handleSort := func(column string, direction string) { fmt.Printf("Sorting by %s in %s order\n", column, direction) } return vdom.H("div", map[string]any{ "className": "max-w-6xl mx-auto p-6 space-y-6", }, vdom.H("div", map[string]any{ "className": "text-center", }, vdom.H("h1", map[string]any{ "className": "text-3xl font-bold text-white mb-2", }, "Table Component Demo"), vdom.H("p", map[string]any{ "className": "text-gray-300", }, "Testing the Tsunami table component with sample data"), ), vdom.H("div", map[string]any{ "className": "bg-gray-800 p-4 rounded-lg", }, PersonTable(ui.TableProps[Person]{ Data: sampleData.Get(), Columns: columns, OnRowClick: handleRowClick, OnSort: handleSort, DefaultSort: "Name", Selectable: true, Pagination: &ui.PaginationConfig{ PageSize: 5, CurrentPage: 0, ShowSizes: []int{5, 10, 25}, }, }), ), vdom.H("div", map[string]any{ "className": "text-center text-gray-400 text-sm", }, "Click on rows to see interactions. Try sorting by clicking column headers."), ) }) ================================================ FILE: tsunami/demo/tabletest/go.mod ================================================ module tsunami/app/tabletest go 1.25.6 require github.com/wavetermdev/waveterm/tsunami v0.0.0 require ( github.com/google/uuid v1.6.0 // indirect github.com/outrigdev/goid v0.3.0 // indirect ) replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami ================================================ FILE: tsunami/demo/tabletest/go.sum ================================================ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= ================================================ FILE: tsunami/demo/tabletest/static/tw.css ================================================ /*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { :root, :host { --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; --color-red-100: oklch(93.6% 0.032 17.717); --color-red-500: oklch(63.7% 0.237 25.331); --color-red-800: oklch(44.4% 0.177 26.899); --color-blue-400: oklch(70.7% 0.165 254.624); --color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-600: oklch(54.6% 0.245 262.881); --color-blue-700: oklch(48.8% 0.243 264.376); --color-blue-900: oklch(37.9% 0.146 265.522); --color-gray-200: oklch(92.8% 0.006 264.531); --color-gray-300: oklch(87.2% 0.01 258.338); --color-gray-400: oklch(70.7% 0.022 261.325); --color-gray-500: oklch(55.1% 0.027 264.364); --color-gray-600: oklch(44.6% 0.03 256.802); --color-gray-700: oklch(37.3% 0.034 259.733); --color-gray-800: oklch(27.8% 0.033 256.848); --color-gray-900: oklch(21% 0.034 264.665); --color-white: #fff; --spacing: 0.25rem; --container-6xl: 72rem; --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); --text-base: 1rem; --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); --font-weight-semibold: 600; --font-weight-bold: 700; --leading-relaxed: 1.625; --radius-lg: 0.5rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); --radius: 8px; --color-background: rgb(34, 34, 34); --color-primary: rgb(247, 247, 247); --color-secondary: rgba(215, 218, 224, 0.7); --color-muted: rgba(215, 218, 224, 0.5); --color-accent-300: rgb(110, 231, 133); --color-panel: rgba(255, 255, 255, 0.12); --color-border: rgba(255, 255, 255, 0.16); --color-accent: rgb(88, 193, 66); } } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); font-feature-settings: var(--default-mono-font-feature-settings, normal); font-variation-settings: var(--default-mono-font-variation-settings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: currentcolor; @supports (color: color-mix(in lab, red, red)) { color: color-mix(in oklab, currentcolor 50%, transparent); } } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden="until-found"])) { display: none !important; } } @layer utilities { .collapse { visibility: collapse; } .invisible { visibility: hidden; } .visible { visibility: visible; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } .not-sr-only { position: static; width: auto; height: auto; padding: 0; margin: 0; overflow: visible; clip-path: none; white-space: normal; } .absolute { position: absolute; } .fixed { position: fixed; } .relative { position: relative; } .static { position: static; } .sticky { position: sticky; } .isolate { isolation: isolate; } .isolation-auto { isolation: auto; } .container { width: 100%; @media (width >= 40rem) { max-width: 40rem; } @media (width >= 48rem) { max-width: 48rem; } @media (width >= 64rem) { max-width: 64rem; } @media (width >= 80rem) { max-width: 80rem; } @media (width >= 96rem) { max-width: 96rem; } } .mx-1 { margin-inline: calc(var(--spacing) * 1); } .mx-auto { margin-inline: auto; } .my-6 { margin-block: calc(var(--spacing) * 6); } .mt-3 { margin-top: calc(var(--spacing) * 3); } .mt-4 { margin-top: calc(var(--spacing) * 4); } .mt-5 { margin-top: calc(var(--spacing) * 5); } .mt-6 { margin-top: calc(var(--spacing) * 6); } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } .ml-4 { margin-left: calc(var(--spacing) * 4); } .block { display: block; } .contents { display: contents; } .flex { display: flex; } .flow-root { display: flow-root; } .grid { display: grid; } .hidden { display: none; } .inline { display: inline; } .inline-block { display: inline-block; } .inline-flex { display: inline-flex; } .inline-grid { display: inline-grid; } .inline-table { display: inline-table; } .list-item { display: list-item; } .table { display: table; } .table-caption { display: table-caption; } .table-cell { display: table-cell; } .table-column { display: table-column; } .table-column-group { display: table-column-group; } .table-footer-group { display: table-footer-group; } .table-header-group { display: table-header-group; } .table-row { display: table-row; } .table-row-group { display: table-row-group; } .min-h-full { min-height: 100%; } .min-h-screen { min-height: 100vh; } .w-full { width: 100%; } .max-w-6xl { max-width: var(--container-6xl); } .max-w-none { max-width: none; } .min-w-full { min-width: 100%; } .shrink { flex-shrink: 1; } .grow { flex-grow: 1; } .border-collapse { border-collapse: collapse; } .translate-none { translate: none; } .scale-3d { scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z); } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } .cursor-pointer { cursor: pointer; } .touch-pinch-zoom { --tw-pinch-zoom: pinch-zoom; touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,); } .resize { resize: both; } .list-inside { list-style-position: inside; } .list-decimal { list-style-type: decimal; } .list-disc { list-style-type: disc; } .flex-wrap { flex-wrap: wrap; } .items-center { align-items: center; } .justify-between { justify-content: space-between; } .gap-2 { gap: calc(var(--spacing) * 2); } .gap-3 { gap: calc(var(--spacing) * 3); } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-6 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-reverse { :where(& > :not(:last-child)) { --tw-space-y-reverse: 1; } } .space-x-reverse { :where(& > :not(:last-child)) { --tw-space-x-reverse: 1; } } .divide-x { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 0; border-inline-style: var(--tw-border-style); border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); } } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; border-bottom-style: var(--tw-border-style); border-top-style: var(--tw-border-style); border-top-width: calc(1px * var(--tw-divide-y-reverse)); border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); } } .divide-y-reverse { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 1; } } .divide-gray-700 { :where(& > :not(:last-child)) { border-color: var(--color-gray-700); } } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .overflow-auto { overflow: auto; } .overflow-x-auto { overflow-x: auto; } .rounded { border-radius: var(--radius); } .rounded-lg { border-radius: var(--radius-lg); } .rounded-s { border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); } .rounded-ss { border-start-start-radius: var(--radius); } .rounded-e { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); } .rounded-se { border-start-end-radius: var(--radius); } .rounded-ee { border-end-end-radius: var(--radius); } .rounded-es { border-end-start-radius: var(--radius); } .rounded-t { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .rounded-l { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-tl { border-top-left-radius: var(--radius); } .rounded-r { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); } .rounded-tr { border-top-right-radius: var(--radius); } .rounded-b { border-bottom-right-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-br { border-bottom-right-radius: var(--radius); } .rounded-bl { border-bottom-left-radius: var(--radius); } .border { border-style: var(--tw-border-style); border-width: 1px; } .border-x { border-inline-style: var(--tw-border-style); border-inline-width: 1px; } .border-y { border-block-style: var(--tw-border-style); border-block-width: 1px; } .border-s { border-inline-start-style: var(--tw-border-style); border-inline-start-width: 1px; } .border-e { border-inline-end-style: var(--tw-border-style); border-inline-end-width: 1px; } .border-t { border-top-style: var(--tw-border-style); border-top-width: 1px; } .border-r { border-right-style: var(--tw-border-style); border-right-width: 1px; } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } .border-l { border-left-style: var(--tw-border-style); border-left-width: 1px; } .border-l-4 { border-left-style: var(--tw-border-style); border-left-width: 4px; } .border-border { border-color: var(--color-border); } .border-gray-600 { border-color: var(--color-gray-600); } .border-red-500 { border-color: var(--color-red-500); } .bg-background { background-color: var(--color-background); } .bg-blue-600 { background-color: var(--color-blue-600); } .bg-blue-900 { background-color: var(--color-blue-900); } .bg-gray-600 { background-color: var(--color-gray-600); } .bg-gray-700 { background-color: var(--color-gray-700); } .bg-gray-800 { background-color: var(--color-gray-800); } .bg-gray-900 { background-color: var(--color-gray-900); } .bg-panel { background-color: var(--color-panel); } .bg-red-100 { background-color: var(--color-red-100); } .bg-repeat { background-repeat: repeat; } .mask-no-clip { mask-clip: no-clip; } .mask-repeat { mask-repeat: repeat; } .p-3 { padding: calc(var(--spacing) * 3); } .p-4 { padding: calc(var(--spacing) * 4); } .p-6 { padding: calc(var(--spacing) * 6); } .px-1 { padding-inline: calc(var(--spacing) * 1); } .px-2 { padding-inline: calc(var(--spacing) * 2); } .px-3 { padding-inline: calc(var(--spacing) * 3); } .px-4 { padding-inline: calc(var(--spacing) * 4); } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } .py-1 { padding-block: calc(var(--spacing) * 1); } .py-1\.5 { padding-block: calc(var(--spacing) * 1.5); } .py-2 { padding-block: calc(var(--spacing) * 2); } .py-3 { padding-block: calc(var(--spacing) * 3); } .pl-4 { padding-left: calc(var(--spacing) * 4); } .text-center { text-align: center; } .text-left { text-align: left; } .font-mono { font-family: var(--font-mono); } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); } .text-3xl { font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); } .text-base { font-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height)); } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); } .text-xl { font-size: var(--text-xl); line-height: var(--tw-leading, var(--text-xl--line-height)); } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } .font-semibold { --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); } .text-wrap { text-wrap: wrap; } .text-clip { text-overflow: clip; } .text-ellipsis { text-overflow: ellipsis; } .text-accent { color: var(--color-accent); } .text-blue-400 { color: var(--color-blue-400); } .text-gray-200 { color: var(--color-gray-200); } .text-gray-300 { color: var(--color-gray-300); } .text-gray-400 { color: var(--color-gray-400); } .text-gray-500 { color: var(--color-gray-500); } .text-muted { color: var(--color-muted); } .text-primary { color: var(--color-primary); } .text-red-800 { color: var(--color-red-800); } .text-secondary { color: var(--color-secondary); } .text-white { color: var(--color-white); } .capitalize { text-transform: capitalize; } .lowercase { text-transform: lowercase; } .normal-case { text-transform: none; } .uppercase { text-transform: uppercase; } .italic { font-style: italic; } .not-italic { font-style: normal; } .diagonal-fractions { --tw-numeric-fraction: diagonal-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .lining-nums { --tw-numeric-figure: lining-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .oldstyle-nums { --tw-numeric-figure: oldstyle-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .ordinal { --tw-ordinal: ordinal; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .proportional-nums { --tw-numeric-spacing: proportional-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .slashed-zero { --tw-slashed-zero: slashed-zero; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .stacked-fractions { --tw-numeric-fraction: stacked-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .normal-nums { font-variant-numeric: normal; } .line-through { text-decoration-line: line-through; } .no-underline { text-decoration-line: none; } .overline { text-decoration-line: overline; } .underline { text-decoration-line: underline; } .antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .subpixel-antialiased { -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .inset-ring { --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .drop-shadow { --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06))); --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06)); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .backdrop-blur { --tw-backdrop-blur: blur(8px); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-grayscale { --tw-backdrop-grayscale: grayscale(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-invert { --tw-backdrop-invert: invert(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-sepia { --tw-backdrop-sepia: sepia(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-filter { -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } .ease-in { --tw-ease: var(--ease-in); transition-timing-function: var(--ease-in); } .ease-in-out { --tw-ease: var(--ease-in-out); transition-timing-function: var(--ease-in-out); } .ease-out { --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } .divide-x-reverse { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 1; } } .ring-inset { --tw-ring-inset: inset; } .hover\:bg-blue-700 { &:hover { @media (hover: hover) { background-color: var(--color-blue-700); } } } .hover\:bg-gray-700 { &:hover { @media (hover: hover) { background-color: var(--color-gray-700); } } } .hover\:bg-gray-800 { &:hover { @media (hover: hover) { background-color: var(--color-gray-800); } } } .hover\:text-accent-300 { &:hover { @media (hover: hover) { color: var(--color-accent-300); } } } .focus\:bg-gray-600 { &:focus { background-color: var(--color-gray-600); } } .focus\:ring-1 { &:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } } .focus\:ring-blue-500 { &:focus { --tw-ring-color: var(--color-blue-500); } } .focus\:outline-none { &:focus { --tw-outline-style: none; outline-style: none; } } } @property --tw-scale-x { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-y { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-z { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-rotate-x { syntax: "*"; inherits: false; } @property --tw-rotate-y { syntax: "*"; inherits: false; } @property --tw-rotate-z { syntax: "*"; inherits: false; } @property --tw-skew-x { syntax: "*"; inherits: false; } @property --tw-skew-y { syntax: "*"; inherits: false; } @property --tw-pan-x { syntax: "*"; inherits: false; } @property --tw-pan-y { syntax: "*"; inherits: false; } @property --tw-pinch-zoom { syntax: "*"; inherits: false; } @property --tw-space-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-space-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-divide-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } @property --tw-divide-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-leading { syntax: "*"; inherits: false; } @property --tw-font-weight { syntax: "*"; inherits: false; } @property --tw-ordinal { syntax: "*"; inherits: false; } @property --tw-slashed-zero { syntax: "*"; inherits: false; } @property --tw-numeric-figure { syntax: "*"; inherits: false; } @property --tw-numeric-spacing { syntax: "*"; inherits: false; } @property --tw-numeric-fraction { syntax: "*"; inherits: false; } @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-shadow-color { syntax: "*"; inherits: false; } @property --tw-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-inset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-shadow-color { syntax: "*"; inherits: false; } @property --tw-inset-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-ring-color { syntax: "*"; inherits: false; } @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-ring-color { syntax: "*"; inherits: false; } @property --tw-inset-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-ring-inset { syntax: "*"; inherits: false; } @property --tw-ring-offset-width { syntax: "<length>"; inherits: false; initial-value: 0px; } @property --tw-ring-offset-color { syntax: "*"; inherits: false; initial-value: #fff; } @property --tw-ring-offset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-blur { syntax: "*"; inherits: false; } @property --tw-brightness { syntax: "*"; inherits: false; } @property --tw-contrast { syntax: "*"; inherits: false; } @property --tw-grayscale { syntax: "*"; inherits: false; } @property --tw-hue-rotate { syntax: "*"; inherits: false; } @property --tw-invert { syntax: "*"; inherits: false; } @property --tw-opacity { syntax: "*"; inherits: false; } @property --tw-saturate { syntax: "*"; inherits: false; } @property --tw-sepia { syntax: "*"; inherits: false; } @property --tw-drop-shadow { syntax: "*"; inherits: false; } @property --tw-drop-shadow-color { syntax: "*"; inherits: false; } @property --tw-drop-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-drop-shadow-size { syntax: "*"; inherits: false; } @property --tw-backdrop-blur { syntax: "*"; inherits: false; } @property --tw-backdrop-brightness { syntax: "*"; inherits: false; } @property --tw-backdrop-contrast { syntax: "*"; inherits: false; } @property --tw-backdrop-grayscale { syntax: "*"; inherits: false; } @property --tw-backdrop-hue-rotate { syntax: "*"; inherits: false; } @property --tw-backdrop-invert { syntax: "*"; inherits: false; } @property --tw-backdrop-opacity { syntax: "*"; inherits: false; } @property --tw-backdrop-saturate { syntax: "*"; inherits: false; } @property --tw-backdrop-sepia { syntax: "*"; inherits: false; } @property --tw-ease { syntax: "*"; inherits: false; } @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; --tw-pan-x: initial; --tw-pan-y: initial; --tw-pinch-zoom: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; --tw-divide-x-reverse: 0; --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-ordinal: initial; --tw-slashed-zero: initial; --tw-numeric-figure: initial; --tw-numeric-spacing: initial; --tw-numeric-fraction: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; --tw-shadow-alpha: 100%; --tw-inset-shadow: 0 0 #0000; --tw-inset-shadow-color: initial; --tw-inset-shadow-alpha: 100%; --tw-ring-color: initial; --tw-ring-shadow: 0 0 #0000; --tw-inset-ring-color: initial; --tw-inset-ring-shadow: 0 0 #0000; --tw-ring-inset: initial; --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; --tw-grayscale: initial; --tw-hue-rotate: initial; --tw-invert: initial; --tw-opacity: initial; --tw-saturate: initial; --tw-sepia: initial; --tw-drop-shadow: initial; --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; --tw-backdrop-blur: initial; --tw-backdrop-brightness: initial; --tw-backdrop-contrast: initial; --tw-backdrop-grayscale: initial; --tw-backdrop-hue-rotate: initial; --tw-backdrop-invert: initial; --tw-backdrop-opacity: initial; --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; --tw-ease: initial; } } } ================================================ FILE: tsunami/demo/todo/app.go ================================================ package main import ( _ "embed" "strconv" "github.com/wavetermdev/waveterm/tsunami/app" "github.com/wavetermdev/waveterm/tsunami/vdom" ) var AppMeta = app.AppMeta{ Title: "Todo App (Tsunami Demo)", ShortDesc: "Feature-rich todo list with component composition and state management", } // Basic domain types with json tags for props type Todo struct { Id int `json:"id"` Text string `json:"text"` Completed bool `json:"completed"` } // Prop types demonstrate parent->child data flow type TodoListProps struct { Todos []Todo `json:"todos"` OnToggle func(int) `json:"onToggle"` OnDelete func(int) `json:"onDelete"` } type TodoItemProps struct { Todo Todo `json:"todo"` OnToggle func() `json:"onToggle"` OnDelete func() `json:"onDelete"` } type InputFieldProps struct { Value string `json:"value"` OnChange func(string) `json:"onChange"` OnEnter func() `json:"onEnter"` } // Reusable input component showing keyboard event handling var InputField = app.DefineComponent("InputField", func(props InputFieldProps) any { // Example of special key handling with VDomFunc keyDown := &vdom.VDomFunc{ Type: vdom.ObjectType_Func, Fn: func(event vdom.VDomEvent) { props.OnEnter() }, StopPropagation: true, PreventDefault: true, Keys: []string{"Enter", "Cmd:Enter"}, } return vdom.H("input", map[string]any{ "className": "flex-1 p-2 border border-border rounded", "type": "text", "placeholder": "What needs to be done?", "value": props.Value, "onChange": func(e vdom.VDomEvent) { props.OnChange(e.TargetValue) }, "onKeyDown": keyDown, }) }, ) // Item component showing conditional classes and event handling var TodoItem = app.DefineComponent("TodoItem", func(props TodoItemProps) any { return vdom.H("div", map[string]any{ "className": vdom.Classes("flex items-center gap-2.5 p-2 border border-border rounded", vdom.If(props.Todo.Completed, "opacity-70")), }, vdom.H("input", map[string]any{ "className": "w-4 h-4", "type": "checkbox", "checked": props.Todo.Completed, "onChange": props.OnToggle, }), vdom.H("span", map[string]any{ "className": vdom.Classes("flex-1", vdom.If(props.Todo.Completed, "line-through")), }, props.Todo.Text), vdom.H("button", map[string]any{ "className": "text-red-500 cursor-pointer px-2 py-1 rounded", "onClick": props.OnDelete, }, "×"), ) }, ) // List component demonstrating mapping over data, using WithKey to set key on a component var TodoList = app.DefineComponent("TodoList", func(props TodoListProps) any { return vdom.H("div", map[string]any{ "className": "flex flex-col gap-2", }, vdom.ForEach(props.Todos, func(todo Todo, _ int) any { return TodoItem(TodoItemProps{ Todo: todo, OnToggle: func() { props.OnToggle(todo.Id) }, OnDelete: func() { props.OnDelete(todo.Id) }, }).WithKey(strconv.Itoa(todo.Id)) })) }, ) // Root component showing state management and composition var App = app.DefineComponent("App", func(_ any) any { // Multiple local atoms example todosAtom := app.UseLocal([]Todo{ {Id: 1, Text: "Learn VDOM", Completed: false}, {Id: 2, Text: "Build a todo app", Completed: false}, }) nextIdAtom := app.UseLocal(3) inputTextAtom := app.UseLocal("") // Event handlers modifying multiple pieces of state addTodo := func() { if inputTextAtom.Get() == "" { return } todosAtom.SetFn(func(todos []Todo) []Todo { return append(todos, Todo{ Id: nextIdAtom.Get(), Text: inputTextAtom.Get(), Completed: false, }) }) nextIdAtom.Set(nextIdAtom.Get() + 1) inputTextAtom.Set("") } // Immutable state update pattern toggleTodo := func(id int) { todosAtom.SetFn(func(todos []Todo) []Todo { for i := range todos { if todos[i].Id == id { todos[i].Completed = !todos[i].Completed break } } return todos }) } deleteTodo := func(id int) { todosAtom.SetFn(func(todos []Todo) []Todo { newTodos := make([]Todo, 0, len(todos)-1) for _, todo := range todos { if todo.Id != id { newTodos = append(newTodos, todo) } } return newTodos }) } return vdom.H("div", map[string]any{ "className": "max-w-[500px] m-5 font-sans", }, vdom.H("div", map[string]any{ "className": "mb-5", }, vdom.H("h1", map[string]any{ "className": "text-2xl font-bold", }, "Todo List")), vdom.H("div", map[string]any{ "className": "flex gap-2.5 mb-5", }, InputField(InputFieldProps{ Value: inputTextAtom.Get(), OnChange: inputTextAtom.Set, OnEnter: addTodo, }), vdom.H("button", map[string]any{ "className": "px-4 py-2 border border-border rounded cursor-pointer", "onClick": addTodo, }, "Add Todo"), ), TodoList(TodoListProps{ Todos: todosAtom.Get(), OnToggle: toggleTodo, OnDelete: deleteTodo, }), ) }, ) ================================================ FILE: tsunami/demo/todo/go.mod ================================================ module tsunami/app/todo go 1.25.6 require github.com/wavetermdev/waveterm/tsunami v0.0.0 require ( github.com/google/uuid v1.6.0 // indirect github.com/outrigdev/goid v0.3.0 // indirect ) replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami ================================================ FILE: tsunami/demo/todo/go.sum ================================================ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= ================================================ FILE: tsunami/demo/todo/static/tw.css ================================================ /*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { :root, :host { --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; --color-red-500: oklch(63.7% 0.237 25.331); --spacing: 0.25rem; --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); --text-base: 1rem; --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); --font-weight-bold: 700; --leading-relaxed: 1.625; --radius-lg: 0.5rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); --radius: 8px; --color-background: rgb(34, 34, 34); --color-primary: rgb(247, 247, 247); --color-secondary: rgba(215, 218, 224, 0.7); --color-muted: rgba(215, 218, 224, 0.5); --color-accent-300: rgb(110, 231, 133); --color-panel: rgba(255, 255, 255, 0.12); --color-border: rgba(255, 255, 255, 0.16); --color-accent: rgb(88, 193, 66); } } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); font-feature-settings: var(--default-mono-font-feature-settings, normal); font-variation-settings: var(--default-mono-font-variation-settings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: currentcolor; @supports (color: color-mix(in lab, red, red)) { color: color-mix(in oklab, currentcolor 50%, transparent); } } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden="until-found"])) { display: none !important; } } @layer utilities { .collapse { visibility: collapse; } .invisible { visibility: hidden; } .visible { visibility: visible; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } .not-sr-only { position: static; width: auto; height: auto; padding: 0; margin: 0; overflow: visible; clip-path: none; white-space: normal; } .absolute { position: absolute; } .fixed { position: fixed; } .relative { position: relative; } .static { position: static; } .sticky { position: sticky; } .isolate { isolation: isolate; } .isolation-auto { isolation: auto; } .container { width: 100%; @media (width >= 40rem) { max-width: 40rem; } @media (width >= 48rem) { max-width: 48rem; } @media (width >= 64rem) { max-width: 64rem; } @media (width >= 80rem) { max-width: 80rem; } @media (width >= 96rem) { max-width: 96rem; } } .m-5 { margin: calc(var(--spacing) * 5); } .my-6 { margin-block: calc(var(--spacing) * 6); } .mt-3 { margin-top: calc(var(--spacing) * 3); } .mt-4 { margin-top: calc(var(--spacing) * 4); } .mt-5 { margin-top: calc(var(--spacing) * 5); } .mt-6 { margin-top: calc(var(--spacing) * 6); } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } .mb-5 { margin-bottom: calc(var(--spacing) * 5); } .ml-4 { margin-left: calc(var(--spacing) * 4); } .block { display: block; } .contents { display: contents; } .flex { display: flex; } .flow-root { display: flow-root; } .grid { display: grid; } .hidden { display: none; } .inline { display: inline; } .inline-block { display: inline-block; } .inline-flex { display: inline-flex; } .inline-grid { display: inline-grid; } .inline-table { display: inline-table; } .list-item { display: list-item; } .table { display: table; } .table-caption { display: table-caption; } .table-cell { display: table-cell; } .table-column { display: table-column; } .table-column-group { display: table-column-group; } .table-footer-group { display: table-footer-group; } .table-header-group { display: table-header-group; } .table-row { display: table-row; } .table-row-group { display: table-row-group; } .h-4 { height: calc(var(--spacing) * 4); } .min-h-full { min-height: 100%; } .min-h-screen { min-height: 100vh; } .w-4 { width: calc(var(--spacing) * 4); } .w-full { width: 100%; } .max-w-\[500px\] { max-width: 500px; } .max-w-none { max-width: none; } .min-w-full { min-width: 100%; } .flex-1 { flex: 1; } .shrink { flex-shrink: 1; } .grow { flex-grow: 1; } .border-collapse { border-collapse: collapse; } .translate-none { translate: none; } .scale-3d { scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z); } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } .cursor-pointer { cursor: pointer; } .touch-pinch-zoom { --tw-pinch-zoom: pinch-zoom; touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,); } .resize { resize: both; } .list-inside { list-style-position: inside; } .list-decimal { list-style-type: decimal; } .list-disc { list-style-type: disc; } .flex-col { flex-direction: column; } .flex-wrap { flex-wrap: wrap; } .items-center { align-items: center; } .gap-2 { gap: calc(var(--spacing) * 2); } .gap-2\.5 { gap: calc(var(--spacing) * 2.5); } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-reverse { :where(& > :not(:last-child)) { --tw-space-y-reverse: 1; } } .space-x-reverse { :where(& > :not(:last-child)) { --tw-space-x-reverse: 1; } } .divide-x { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 0; border-inline-style: var(--tw-border-style); border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); } } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; border-bottom-style: var(--tw-border-style); border-top-style: var(--tw-border-style); border-top-width: calc(1px * var(--tw-divide-y-reverse)); border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); } } .divide-y-reverse { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 1; } } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .overflow-auto { overflow: auto; } .overflow-x-auto { overflow-x: auto; } .rounded { border-radius: var(--radius); } .rounded-lg { border-radius: var(--radius-lg); } .rounded-s { border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); } .rounded-ss { border-start-start-radius: var(--radius); } .rounded-e { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); } .rounded-se { border-start-end-radius: var(--radius); } .rounded-ee { border-end-end-radius: var(--radius); } .rounded-es { border-end-start-radius: var(--radius); } .rounded-t { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .rounded-l { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-tl { border-top-left-radius: var(--radius); } .rounded-r { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); } .rounded-tr { border-top-right-radius: var(--radius); } .rounded-b { border-bottom-right-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-br { border-bottom-right-radius: var(--radius); } .rounded-bl { border-bottom-left-radius: var(--radius); } .border { border-style: var(--tw-border-style); border-width: 1px; } .border-x { border-inline-style: var(--tw-border-style); border-inline-width: 1px; } .border-y { border-block-style: var(--tw-border-style); border-block-width: 1px; } .border-s { border-inline-start-style: var(--tw-border-style); border-inline-start-width: 1px; } .border-e { border-inline-end-style: var(--tw-border-style); border-inline-end-width: 1px; } .border-t { border-top-style: var(--tw-border-style); border-top-width: 1px; } .border-r { border-right-style: var(--tw-border-style); border-right-width: 1px; } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } .border-l { border-left-style: var(--tw-border-style); border-left-width: 1px; } .border-l-4 { border-left-style: var(--tw-border-style); border-left-width: 4px; } .border-border { border-color: var(--color-border); } .bg-background { background-color: var(--color-background); } .bg-panel { background-color: var(--color-panel); } .bg-repeat { background-repeat: repeat; } .mask-no-clip { mask-clip: no-clip; } .mask-repeat { mask-repeat: repeat; } .p-2 { padding: calc(var(--spacing) * 2); } .p-4 { padding: calc(var(--spacing) * 4); } .px-1 { padding-inline: calc(var(--spacing) * 1); } .px-2 { padding-inline: calc(var(--spacing) * 2); } .px-4 { padding-inline: calc(var(--spacing) * 4); } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } .py-1 { padding-block: calc(var(--spacing) * 1); } .py-2 { padding-block: calc(var(--spacing) * 2); } .pl-4 { padding-left: calc(var(--spacing) * 4); } .text-left { text-align: left; } .font-mono { font-family: var(--font-mono); } .font-sans { font-family: var(--font-sans); } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); } .text-3xl { font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); } .text-base { font-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height)); } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); } .text-xl { font-size: var(--text-xl); line-height: var(--tw-leading, var(--text-xl--line-height)); } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } .text-wrap { text-wrap: wrap; } .text-clip { text-overflow: clip; } .text-ellipsis { text-overflow: ellipsis; } .text-accent { color: var(--color-accent); } .text-muted { color: var(--color-muted); } .text-primary { color: var(--color-primary); } .text-red-500 { color: var(--color-red-500); } .text-secondary { color: var(--color-secondary); } .capitalize { text-transform: capitalize; } .lowercase { text-transform: lowercase; } .normal-case { text-transform: none; } .uppercase { text-transform: uppercase; } .italic { font-style: italic; } .not-italic { font-style: normal; } .diagonal-fractions { --tw-numeric-fraction: diagonal-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .lining-nums { --tw-numeric-figure: lining-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .oldstyle-nums { --tw-numeric-figure: oldstyle-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .ordinal { --tw-ordinal: ordinal; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .proportional-nums { --tw-numeric-spacing: proportional-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .slashed-zero { --tw-slashed-zero: slashed-zero; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .stacked-fractions { --tw-numeric-fraction: stacked-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .normal-nums { font-variant-numeric: normal; } .line-through { text-decoration-line: line-through; } .no-underline { text-decoration-line: none; } .overline { text-decoration-line: overline; } .underline { text-decoration-line: underline; } .antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .subpixel-antialiased { -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; } .opacity-70 { opacity: 70%; } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .inset-ring { --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .drop-shadow { --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06))); --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06)); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .backdrop-blur { --tw-backdrop-blur: blur(8px); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-grayscale { --tw-backdrop-grayscale: grayscale(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-invert { --tw-backdrop-invert: invert(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-sepia { --tw-backdrop-sepia: sepia(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-filter { -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .ease-in { --tw-ease: var(--ease-in); transition-timing-function: var(--ease-in); } .ease-in-out { --tw-ease: var(--ease-in-out); transition-timing-function: var(--ease-in-out); } .ease-out { --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } .divide-x-reverse { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 1; } } .ring-inset { --tw-ring-inset: inset; } .hover\:text-accent-300 { &:hover { @media (hover: hover) { color: var(--color-accent-300); } } } } @property --tw-scale-x { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-y { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-z { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-rotate-x { syntax: "*"; inherits: false; } @property --tw-rotate-y { syntax: "*"; inherits: false; } @property --tw-rotate-z { syntax: "*"; inherits: false; } @property --tw-skew-x { syntax: "*"; inherits: false; } @property --tw-skew-y { syntax: "*"; inherits: false; } @property --tw-pan-x { syntax: "*"; inherits: false; } @property --tw-pan-y { syntax: "*"; inherits: false; } @property --tw-pinch-zoom { syntax: "*"; inherits: false; } @property --tw-space-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-space-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-divide-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } @property --tw-divide-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-leading { syntax: "*"; inherits: false; } @property --tw-font-weight { syntax: "*"; inherits: false; } @property --tw-ordinal { syntax: "*"; inherits: false; } @property --tw-slashed-zero { syntax: "*"; inherits: false; } @property --tw-numeric-figure { syntax: "*"; inherits: false; } @property --tw-numeric-spacing { syntax: "*"; inherits: false; } @property --tw-numeric-fraction { syntax: "*"; inherits: false; } @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-shadow-color { syntax: "*"; inherits: false; } @property --tw-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-inset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-shadow-color { syntax: "*"; inherits: false; } @property --tw-inset-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-ring-color { syntax: "*"; inherits: false; } @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-ring-color { syntax: "*"; inherits: false; } @property --tw-inset-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-ring-inset { syntax: "*"; inherits: false; } @property --tw-ring-offset-width { syntax: "<length>"; inherits: false; initial-value: 0px; } @property --tw-ring-offset-color { syntax: "*"; inherits: false; initial-value: #fff; } @property --tw-ring-offset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-blur { syntax: "*"; inherits: false; } @property --tw-brightness { syntax: "*"; inherits: false; } @property --tw-contrast { syntax: "*"; inherits: false; } @property --tw-grayscale { syntax: "*"; inherits: false; } @property --tw-hue-rotate { syntax: "*"; inherits: false; } @property --tw-invert { syntax: "*"; inherits: false; } @property --tw-opacity { syntax: "*"; inherits: false; } @property --tw-saturate { syntax: "*"; inherits: false; } @property --tw-sepia { syntax: "*"; inherits: false; } @property --tw-drop-shadow { syntax: "*"; inherits: false; } @property --tw-drop-shadow-color { syntax: "*"; inherits: false; } @property --tw-drop-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-drop-shadow-size { syntax: "*"; inherits: false; } @property --tw-backdrop-blur { syntax: "*"; inherits: false; } @property --tw-backdrop-brightness { syntax: "*"; inherits: false; } @property --tw-backdrop-contrast { syntax: "*"; inherits: false; } @property --tw-backdrop-grayscale { syntax: "*"; inherits: false; } @property --tw-backdrop-hue-rotate { syntax: "*"; inherits: false; } @property --tw-backdrop-invert { syntax: "*"; inherits: false; } @property --tw-backdrop-opacity { syntax: "*"; inherits: false; } @property --tw-backdrop-saturate { syntax: "*"; inherits: false; } @property --tw-backdrop-sepia { syntax: "*"; inherits: false; } @property --tw-ease { syntax: "*"; inherits: false; } @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; --tw-pan-x: initial; --tw-pan-y: initial; --tw-pinch-zoom: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; --tw-divide-x-reverse: 0; --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-ordinal: initial; --tw-slashed-zero: initial; --tw-numeric-figure: initial; --tw-numeric-spacing: initial; --tw-numeric-fraction: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; --tw-shadow-alpha: 100%; --tw-inset-shadow: 0 0 #0000; --tw-inset-shadow-color: initial; --tw-inset-shadow-alpha: 100%; --tw-ring-color: initial; --tw-ring-shadow: 0 0 #0000; --tw-inset-ring-color: initial; --tw-inset-ring-shadow: 0 0 #0000; --tw-ring-inset: initial; --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; --tw-grayscale: initial; --tw-hue-rotate: initial; --tw-invert: initial; --tw-opacity: initial; --tw-saturate: initial; --tw-sepia: initial; --tw-drop-shadow: initial; --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; --tw-backdrop-blur: initial; --tw-backdrop-brightness: initial; --tw-backdrop-contrast: initial; --tw-backdrop-grayscale: initial; --tw-backdrop-hue-rotate: initial; --tw-backdrop-invert: initial; --tw-backdrop-opacity: initial; --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; --tw-ease: initial; } } } ================================================ FILE: tsunami/demo/todo/style.css ================================================ .todo-app { max-width: 500px; margin: 20px; font-family: sans-serif; } .todo-header { margin-bottom: 20px; } .todo-form { display: flex; gap: 10px; margin-bottom: 20px; } .todo-input { flex: 1; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--input-bg); color: var(--text-color); } .todo-button { padding: 8px 16px; background: var(--button-bg); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-color); cursor: pointer; } .todo-button:hover { background: var(--button-hover-bg); } .todo-list { display: flex; flex-direction: column; gap: 8px; } .todo-item { display: flex; align-items: center; gap: 10px; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--block-bg); } .todo-item.completed { opacity: 0.7; } .todo-item.completed .todo-text { text-decoration: line-through; } .todo-text { flex: 1; } .todo-checkbox { width: 16px; height: 16px; } .todo-delete { color: var(--error-color); cursor: pointer; padding: 4px 8px; border-radius: 4px; } .todo-delete:hover { background: var(--error-bg); } ================================================ FILE: tsunami/demo/tsunamiconfig/app.go ================================================ package main import ( "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strings" "time" "github.com/wavetermdev/waveterm/tsunami/app" "github.com/wavetermdev/waveterm/tsunami/vdom" ) var AppMeta = app.AppMeta{ Title: "Tsunami Config Manager", ShortDesc: "Configuration editor for remote servers with JSON validation", } // Global atoms for config var ( serverURLAtom = app.ConfigAtom("serverURL", "", &app.AtomMeta{ Desc: "Server URL for config API (can be full URL, hostname:port, or just port)", Pattern: `^(https?://.*|[a-zA-Z0-9.-]+:\d+|\d+|[a-zA-Z0-9.-]+)$`, }) ) type URLInputProps struct { Value string `json:"value"` OnChange func(string) `json:"onChange"` OnSubmit func() `json:"onSubmit"` IsLoading bool `json:"isLoading"` } type JSONEditorProps struct { Value string `json:"value"` OnChange func(string) `json:"onChange"` OnSubmit func() `json:"onSubmit"` IsLoading bool `json:"isLoading"` Placeholder string `json:"placeholder"` } type ErrorDisplayProps struct { Message string `json:"message"` } type SuccessDisplayProps struct { Message string `json:"message"` } // parseURL takes flexible URL input and returns a normalized base URL func parseURL(input string) (string, error) { if input == "" { return "", fmt.Errorf("URL cannot be empty") } input = strings.TrimSpace(input) // Handle just port number (e.g., "52848") if portRegex := regexp.MustCompile(`^\d+$`); portRegex.MatchString(input) { return fmt.Sprintf("http://localhost:%s", input), nil } // Add http:// if no protocol specified if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") { input = "http://" + input } // Parse the URL to validate and extract components parsedURL, err := url.Parse(input) if err != nil { return "", fmt.Errorf("invalid URL format: %v", err) } if parsedURL.Host == "" { return "", fmt.Errorf("no host specified in URL") } // Return base URL (scheme + host) baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) return baseURL, nil } // fetchConfig fetches JSON from the /api/config endpoint func fetchConfig(baseURL string) (string, error) { configURL := baseURL + "/api/config" client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(configURL) if err != nil { return "", fmt.Errorf("failed to connect to %s: %v", configURL, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("server returned status %d from %s", resp.StatusCode, configURL) } body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response: %v", err) } // Validate that it's valid JSON var jsonObj interface{} if err := json.Unmarshal(body, &jsonObj); err != nil { return "", fmt.Errorf("response is not valid JSON: %v", err) } // Pretty print the JSON prettyJSON, err := json.MarshalIndent(jsonObj, "", " ") if err != nil { return "", fmt.Errorf("failed to format JSON: %v", err) } return string(prettyJSON), nil } // postConfig sends JSON to the /api/config endpoint func postConfig(baseURL, jsonContent string) error { configURL := baseURL + "/api/config" // Validate JSON before sending var jsonObj interface{} if err := json.Unmarshal([]byte(jsonContent), &jsonObj); err != nil { return fmt.Errorf("invalid JSON: %v", err) } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Post(configURL, "application/json", strings.NewReader(jsonContent)) if err != nil { return fmt.Errorf("failed to send request to %s: %v", configURL, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) } return nil } var URLInput = app.DefineComponent("URLInput", func(props URLInputProps) any { keyHandler := &vdom.VDomFunc{ Type: "func", Fn: func(event vdom.VDomEvent) { if !props.IsLoading { props.OnSubmit() } }, Keys: []string{"Enter"}, PreventDefault: true, } return vdom.H("div", map[string]any{ "className": "flex gap-2 mb-4", }, vdom.H("input", map[string]any{ "className": "flex-1 px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500", "type": "text", "placeholder": "Enter URL (e.g., localhost:52848, http://localhost:52848/api/config, or just 52848)", "value": props.Value, "disabled": props.IsLoading, "onChange": func(e vdom.VDomEvent) { props.OnChange(e.TargetValue) }, "onKeyDown": keyHandler, }), vdom.H("button", map[string]any{ "className": vdom.Classes( "px-4 py-2 rounded font-medium cursor-pointer transition-colors", vdom.IfElse(props.IsLoading, "bg-slate-600 text-slate-400 cursor-not-allowed", "bg-blue-600 text-white hover:bg-blue-700", ), ), "onClick": vdom.If(!props.IsLoading, props.OnSubmit), "disabled": props.IsLoading, }, vdom.IfElse(props.IsLoading, "Loading...", "Fetch")), ) }, ) var JSONEditor = app.DefineComponent("JSONEditor", func(props JSONEditorProps) any { if props.Value == "" && props.Placeholder == "" { return vdom.H("div", map[string]any{ "className": "text-slate-400 text-center py-8", }, "Enter a URL above and click Fetch to load configuration") } return vdom.H("div", map[string]any{ "className": "flex flex-col", }, vdom.H("textarea", map[string]any{ "className": "w-full h-96 px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-100 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500", "value": props.Value, "placeholder": props.Placeholder, "disabled": props.IsLoading, "onChange": func(e vdom.VDomEvent) { props.OnChange(e.TargetValue) }, }), vdom.If(props.Value != "", vdom.H("button", map[string]any{ "className": vdom.Classes( "mt-2 w-full py-2 rounded font-medium cursor-pointer transition-colors", vdom.IfElse(props.IsLoading, "bg-slate-600 text-slate-400 cursor-not-allowed", "bg-green-600 text-white hover:bg-green-700", ), ), "onClick": vdom.If(!props.IsLoading, props.OnSubmit), "disabled": props.IsLoading, }, vdom.IfElse(props.IsLoading, "Submitting...", "Submit Changes")), ), ) }, ) var ErrorDisplay = app.DefineComponent("ErrorDisplay", func(props ErrorDisplayProps) any { if props.Message == "" { return nil } return vdom.H("div", map[string]any{ "className": "bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded mb-4", }, vdom.H("div", map[string]any{ "className": "font-medium", }, "Error"), vdom.H("div", map[string]any{ "className": "text-sm mt-1", }, props.Message), ) }, ) var SuccessDisplay = app.DefineComponent("SuccessDisplay", func(props SuccessDisplayProps) any { if props.Message == "" { return nil } return vdom.H("div", map[string]any{ "className": "bg-green-900 border border-green-700 text-green-100 px-4 py-3 rounded mb-4", }, vdom.H("div", map[string]any{ "className": "font-medium", }, "Success"), vdom.H("div", map[string]any{ "className": "text-sm mt-1", }, props.Message), ) }, ) var App = app.DefineComponent("App", func(_ struct{}) any { // Get atom value once at the top urlInput := serverURLAtom.Get() jsonContent := app.UseLocal("") errorMessage := app.UseLocal("") successMessage := app.UseLocal("") isLoading := app.UseLocal(false) lastFetch := app.UseLocal("") currentBaseURL := app.UseLocal("") clearMessages := func() { errorMessage.Set("") successMessage.Set("") } fetchConfigData := func() { clearMessages() baseURL, err := parseURL(serverURLAtom.Get()) if err != nil { errorMessage.Set(err.Error()) return } isLoading.Set(true) currentBaseURL.Set(baseURL) go func() { defer func() { isLoading.Set(false) }() content, err := fetchConfig(baseURL) if err != nil { errorMessage.Set(err.Error()) return } jsonContent.Set(content) lastFetch.Set(time.Now().Format("2006-01-02 15:04:05")) successMessage.Set(fmt.Sprintf("Successfully fetched config from %s", baseURL)) }() } submitConfigData := func() { if currentBaseURL.Get() == "" { errorMessage.Set("No base URL available. Please fetch config first.") return } clearMessages() isLoading.Set(true) go func() { defer func() { isLoading.Set(false) }() err := postConfig(currentBaseURL.Get(), jsonContent.Get()) if err != nil { errorMessage.Set(fmt.Sprintf("Failed to submit config: %v", err)) return } successMessage.Set(fmt.Sprintf("Successfully submitted config to %s", currentBaseURL.Get())) }() } return vdom.H("div", map[string]any{ "className": "max-w-4xl mx-auto p-6 bg-slate-800 text-slate-100 min-h-screen", }, vdom.H("div", map[string]any{ "className": "mb-6", }, vdom.H("h1", map[string]any{ "className": "text-3xl font-bold mb-2", }, "Tsunami Config Manager"), vdom.H("p", map[string]any{ "className": "text-slate-400", }, "Fetch and edit configuration from remote servers"), ), URLInput(URLInputProps{ Value: urlInput, OnChange: serverURLAtom.Set, OnSubmit: fetchConfigData, IsLoading: isLoading.Get(), }), ErrorDisplay(ErrorDisplayProps{ Message: errorMessage.Get(), }), SuccessDisplay(SuccessDisplayProps{ Message: successMessage.Get(), }), vdom.If(lastFetch.Get() != "", vdom.H("div", map[string]any{ "className": "text-sm text-slate-400 mb-4", }, fmt.Sprintf("Last fetched: %s from %s", lastFetch.Get(), currentBaseURL.Get())), ), JSONEditor(JSONEditorProps{ Value: jsonContent.Get(), OnChange: jsonContent.Set, OnSubmit: submitConfigData, IsLoading: isLoading.Get(), Placeholder: "JSON configuration will appear here after fetching...", }), ) }, ) ================================================ FILE: tsunami/demo/tsunamiconfig/go.mod ================================================ module tsunami/app/tsunamiconfig go 1.25.6 require github.com/wavetermdev/waveterm/tsunami v0.0.0 require ( github.com/google/uuid v1.6.0 // indirect github.com/outrigdev/goid v0.3.0 // indirect ) replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami ================================================ FILE: tsunami/demo/tsunamiconfig/go.sum ================================================ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= ================================================ FILE: tsunami/demo/tsunamiconfig/static/tw.css ================================================ /*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { :root, :host { --font-sans: "Inter", sans-serif; --font-mono: "Hack", monospace; --color-red-100: oklch(93.6% 0.032 17.717); --color-red-500: oklch(63.7% 0.237 25.331); --color-red-700: oklch(50.5% 0.213 27.518); --color-red-800: oklch(44.4% 0.177 26.899); --color-red-900: oklch(39.6% 0.141 25.723); --color-green-100: oklch(96.2% 0.044 156.743); --color-green-600: oklch(62.7% 0.194 149.214); --color-green-700: oklch(52.7% 0.154 150.069); --color-green-900: oklch(39.3% 0.095 152.535); --color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-600: oklch(54.6% 0.245 262.881); --color-blue-700: oklch(48.8% 0.243 264.376); --color-slate-100: oklch(96.8% 0.007 247.896); --color-slate-400: oklch(70.4% 0.04 256.788); --color-slate-600: oklch(44.6% 0.043 257.281); --color-slate-700: oklch(37.2% 0.044 257.287); --color-slate-800: oklch(27.9% 0.041 260.031); --color-white: #fff; --spacing: 0.25rem; --container-4xl: 56rem; --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); --text-base: 1rem; --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); --font-weight-medium: 500; --font-weight-bold: 700; --leading-relaxed: 1.625; --radius-lg: 0.5rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); --radius: 8px; --color-background: rgb(34, 34, 34); --color-primary: rgb(247, 247, 247); --color-secondary: rgba(215, 218, 224, 0.7); --color-muted: rgba(215, 218, 224, 0.5); --color-accent-300: rgb(110, 231, 133); --color-panel: rgba(255, 255, 255, 0.12); --color-border: rgba(255, 255, 255, 0.16); --color-accent: rgb(88, 193, 66); } } @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); font-feature-settings: var(--default-mono-font-feature-settings, normal); font-variation-settings: var(--default-mono-font-variation-settings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: currentcolor; @supports (color: color-mix(in lab, red, red)) { color: color-mix(in oklab, currentcolor 50%, transparent); } } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden="until-found"])) { display: none !important; } } @layer utilities { .collapse { visibility: collapse; } .invisible { visibility: hidden; } .visible { visibility: visible; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; } .not-sr-only { position: static; width: auto; height: auto; padding: 0; margin: 0; overflow: visible; clip-path: none; white-space: normal; } .absolute { position: absolute; } .fixed { position: fixed; } .relative { position: relative; } .static { position: static; } .sticky { position: sticky; } .isolate { isolation: isolate; } .isolation-auto { isolation: auto; } .container { width: 100%; @media (width >= 40rem) { max-width: 40rem; } @media (width >= 48rem) { max-width: 48rem; } @media (width >= 64rem) { max-width: 64rem; } @media (width >= 80rem) { max-width: 80rem; } @media (width >= 96rem) { max-width: 96rem; } } .mx-auto { margin-inline: auto; } .my-6 { margin-block: calc(var(--spacing) * 6); } .mt-1 { margin-top: calc(var(--spacing) * 1); } .mt-2 { margin-top: calc(var(--spacing) * 2); } .mt-3 { margin-top: calc(var(--spacing) * 3); } .mt-4 { margin-top: calc(var(--spacing) * 4); } .mt-5 { margin-top: calc(var(--spacing) * 5); } .mt-6 { margin-top: calc(var(--spacing) * 6); } .mb-2 { margin-bottom: calc(var(--spacing) * 2); } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } .mb-6 { margin-bottom: calc(var(--spacing) * 6); } .ml-4 { margin-left: calc(var(--spacing) * 4); } .block { display: block; } .contents { display: contents; } .flex { display: flex; } .flow-root { display: flow-root; } .grid { display: grid; } .hidden { display: none; } .inline { display: inline; } .inline-block { display: inline-block; } .inline-flex { display: inline-flex; } .inline-grid { display: inline-grid; } .inline-table { display: inline-table; } .list-item { display: list-item; } .table { display: table; } .table-caption { display: table-caption; } .table-cell { display: table-cell; } .table-column { display: table-column; } .table-column-group { display: table-column-group; } .table-footer-group { display: table-footer-group; } .table-header-group { display: table-header-group; } .table-row { display: table-row; } .table-row-group { display: table-row-group; } .h-96 { height: calc(var(--spacing) * 96); } .min-h-full { min-height: 100%; } .min-h-screen { min-height: 100vh; } .w-full { width: 100%; } .max-w-4xl { max-width: var(--container-4xl); } .max-w-none { max-width: none; } .min-w-full { min-width: 100%; } .flex-1 { flex: 1; } .shrink { flex-shrink: 1; } .grow { flex-grow: 1; } .border-collapse { border-collapse: collapse; } .translate-none { translate: none; } .scale-3d { scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z); } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } .cursor-not-allowed { cursor: not-allowed; } .cursor-pointer { cursor: pointer; } .touch-pinch-zoom { --tw-pinch-zoom: pinch-zoom; touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,); } .resize { resize: both; } .resize-y { resize: vertical; } .list-inside { list-style-position: inside; } .list-decimal { list-style-type: decimal; } .list-disc { list-style-type: disc; } .flex-col { flex-direction: column; } .flex-wrap { flex-wrap: wrap; } .gap-2 { gap: calc(var(--spacing) * 2); } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } .space-y-reverse { :where(& > :not(:last-child)) { --tw-space-y-reverse: 1; } } .space-x-reverse { :where(& > :not(:last-child)) { --tw-space-x-reverse: 1; } } .divide-x { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 0; border-inline-style: var(--tw-border-style); border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); } } .divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; border-bottom-style: var(--tw-border-style); border-top-style: var(--tw-border-style); border-top-width: calc(1px * var(--tw-divide-y-reverse)); border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); } } .divide-y-reverse { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 1; } } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .overflow-auto { overflow: auto; } .overflow-x-auto { overflow-x: auto; } .rounded { border-radius: var(--radius); } .rounded-lg { border-radius: var(--radius-lg); } .rounded-s { border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); } .rounded-ss { border-start-start-radius: var(--radius); } .rounded-e { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); } .rounded-se { border-start-end-radius: var(--radius); } .rounded-ee { border-end-end-radius: var(--radius); } .rounded-es { border-end-start-radius: var(--radius); } .rounded-t { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .rounded-l { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-tl { border-top-left-radius: var(--radius); } .rounded-r { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); } .rounded-tr { border-top-right-radius: var(--radius); } .rounded-b { border-bottom-right-radius: var(--radius); border-bottom-left-radius: var(--radius); } .rounded-br { border-bottom-right-radius: var(--radius); } .rounded-bl { border-bottom-left-radius: var(--radius); } .border { border-style: var(--tw-border-style); border-width: 1px; } .border-x { border-inline-style: var(--tw-border-style); border-inline-width: 1px; } .border-y { border-block-style: var(--tw-border-style); border-block-width: 1px; } .border-s { border-inline-start-style: var(--tw-border-style); border-inline-start-width: 1px; } .border-e { border-inline-end-style: var(--tw-border-style); border-inline-end-width: 1px; } .border-t { border-top-style: var(--tw-border-style); border-top-width: 1px; } .border-r { border-right-style: var(--tw-border-style); border-right-width: 1px; } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } .border-l { border-left-style: var(--tw-border-style); border-left-width: 1px; } .border-l-4 { border-left-style: var(--tw-border-style); border-left-width: 4px; } .border-border { border-color: var(--color-border); } .border-green-700 { border-color: var(--color-green-700); } .border-red-500 { border-color: var(--color-red-500); } .border-red-700 { border-color: var(--color-red-700); } .border-slate-600 { border-color: var(--color-slate-600); } .bg-background { background-color: var(--color-background); } .bg-blue-600 { background-color: var(--color-blue-600); } .bg-green-600 { background-color: var(--color-green-600); } .bg-green-900 { background-color: var(--color-green-900); } .bg-panel { background-color: var(--color-panel); } .bg-red-100 { background-color: var(--color-red-100); } .bg-red-900 { background-color: var(--color-red-900); } .bg-slate-600 { background-color: var(--color-slate-600); } .bg-slate-700 { background-color: var(--color-slate-700); } .bg-slate-800 { background-color: var(--color-slate-800); } .bg-repeat { background-repeat: repeat; } .mask-no-clip { mask-clip: no-clip; } .mask-repeat { mask-repeat: repeat; } .p-4 { padding: calc(var(--spacing) * 4); } .p-6 { padding: calc(var(--spacing) * 6); } .px-1 { padding-inline: calc(var(--spacing) * 1); } .px-3 { padding-inline: calc(var(--spacing) * 3); } .px-4 { padding-inline: calc(var(--spacing) * 4); } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } .py-2 { padding-block: calc(var(--spacing) * 2); } .py-3 { padding-block: calc(var(--spacing) * 3); } .py-8 { padding-block: calc(var(--spacing) * 8); } .pl-4 { padding-left: calc(var(--spacing) * 4); } .text-center { text-align: center; } .text-left { text-align: left; } .font-mono { font-family: var(--font-mono); } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); } .text-3xl { font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); } .text-base { font-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height)); } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); } .text-xl { font-size: var(--text-xl); line-height: var(--tw-leading, var(--text-xl--line-height)); } .leading-relaxed { --tw-leading: var(--leading-relaxed); line-height: var(--leading-relaxed); } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } .font-medium { --tw-font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium); } .text-wrap { text-wrap: wrap; } .text-clip { text-overflow: clip; } .text-ellipsis { text-overflow: ellipsis; } .text-accent { color: var(--color-accent); } .text-green-100 { color: var(--color-green-100); } .text-muted { color: var(--color-muted); } .text-primary { color: var(--color-primary); } .text-red-100 { color: var(--color-red-100); } .text-red-800 { color: var(--color-red-800); } .text-secondary { color: var(--color-secondary); } .text-slate-100 { color: var(--color-slate-100); } .text-slate-400 { color: var(--color-slate-400); } .text-white { color: var(--color-white); } .capitalize { text-transform: capitalize; } .lowercase { text-transform: lowercase; } .normal-case { text-transform: none; } .uppercase { text-transform: uppercase; } .italic { font-style: italic; } .not-italic { font-style: normal; } .diagonal-fractions { --tw-numeric-fraction: diagonal-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .lining-nums { --tw-numeric-figure: lining-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .oldstyle-nums { --tw-numeric-figure: oldstyle-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .ordinal { --tw-ordinal: ordinal; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .proportional-nums { --tw-numeric-spacing: proportional-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .slashed-zero { --tw-slashed-zero: slashed-zero; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .stacked-fractions { --tw-numeric-fraction: stacked-fractions; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } .normal-nums { font-variant-numeric: normal; } .line-through { text-decoration-line: line-through; } .no-underline { text-decoration-line: none; } .overline { text-decoration-line: overline; } .underline { text-decoration-line: underline; } .antialiased { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .subpixel-antialiased { -webkit-font-smoothing: auto; -moz-osx-font-smoothing: auto; } .placeholder-slate-400 { &::placeholder { color: var(--color-slate-400); } } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .inset-ring { --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .drop-shadow { --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06))); --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06)); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } .backdrop-blur { --tw-backdrop-blur: blur(8px); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-grayscale { --tw-backdrop-grayscale: grayscale(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-invert { --tw-backdrop-invert: invert(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-sepia { --tw-backdrop-sepia: sepia(100%); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .backdrop-filter { -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } .transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } .ease-in { --tw-ease: var(--ease-in); transition-timing-function: var(--ease-in); } .ease-in-out { --tw-ease: var(--ease-in-out); transition-timing-function: var(--ease-in-out); } .ease-out { --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } .divide-x-reverse { :where(& > :not(:last-child)) { --tw-divide-x-reverse: 1; } } .ring-inset { --tw-ring-inset: inset; } .hover\:bg-blue-700 { &:hover { @media (hover: hover) { background-color: var(--color-blue-700); } } } .hover\:bg-green-700 { &:hover { @media (hover: hover) { background-color: var(--color-green-700); } } } .hover\:text-accent-300 { &:hover { @media (hover: hover) { color: var(--color-accent-300); } } } .focus\:ring-2 { &:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } } .focus\:ring-blue-500 { &:focus { --tw-ring-color: var(--color-blue-500); } } .focus\:outline-none { &:focus { --tw-outline-style: none; outline-style: none; } } } @property --tw-scale-x { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-y { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-scale-z { syntax: "*"; inherits: false; initial-value: 1; } @property --tw-rotate-x { syntax: "*"; inherits: false; } @property --tw-rotate-y { syntax: "*"; inherits: false; } @property --tw-rotate-z { syntax: "*"; inherits: false; } @property --tw-skew-x { syntax: "*"; inherits: false; } @property --tw-skew-y { syntax: "*"; inherits: false; } @property --tw-pan-x { syntax: "*"; inherits: false; } @property --tw-pan-y { syntax: "*"; inherits: false; } @property --tw-pinch-zoom { syntax: "*"; inherits: false; } @property --tw-space-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-space-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-divide-x-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } @property --tw-divide-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } @property --tw-leading { syntax: "*"; inherits: false; } @property --tw-font-weight { syntax: "*"; inherits: false; } @property --tw-ordinal { syntax: "*"; inherits: false; } @property --tw-slashed-zero { syntax: "*"; inherits: false; } @property --tw-numeric-figure { syntax: "*"; inherits: false; } @property --tw-numeric-spacing { syntax: "*"; inherits: false; } @property --tw-numeric-fraction { syntax: "*"; inherits: false; } @property --tw-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-shadow-color { syntax: "*"; inherits: false; } @property --tw-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-inset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-shadow-color { syntax: "*"; inherits: false; } @property --tw-inset-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-ring-color { syntax: "*"; inherits: false; } @property --tw-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-inset-ring-color { syntax: "*"; inherits: false; } @property --tw-inset-ring-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-ring-inset { syntax: "*"; inherits: false; } @property --tw-ring-offset-width { syntax: "<length>"; inherits: false; initial-value: 0px; } @property --tw-ring-offset-color { syntax: "*"; inherits: false; initial-value: #fff; } @property --tw-ring-offset-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } @property --tw-blur { syntax: "*"; inherits: false; } @property --tw-brightness { syntax: "*"; inherits: false; } @property --tw-contrast { syntax: "*"; inherits: false; } @property --tw-grayscale { syntax: "*"; inherits: false; } @property --tw-hue-rotate { syntax: "*"; inherits: false; } @property --tw-invert { syntax: "*"; inherits: false; } @property --tw-opacity { syntax: "*"; inherits: false; } @property --tw-saturate { syntax: "*"; inherits: false; } @property --tw-sepia { syntax: "*"; inherits: false; } @property --tw-drop-shadow { syntax: "*"; inherits: false; } @property --tw-drop-shadow-color { syntax: "*"; inherits: false; } @property --tw-drop-shadow-alpha { syntax: "<percentage>"; inherits: false; initial-value: 100%; } @property --tw-drop-shadow-size { syntax: "*"; inherits: false; } @property --tw-backdrop-blur { syntax: "*"; inherits: false; } @property --tw-backdrop-brightness { syntax: "*"; inherits: false; } @property --tw-backdrop-contrast { syntax: "*"; inherits: false; } @property --tw-backdrop-grayscale { syntax: "*"; inherits: false; } @property --tw-backdrop-hue-rotate { syntax: "*"; inherits: false; } @property --tw-backdrop-invert { syntax: "*"; inherits: false; } @property --tw-backdrop-opacity { syntax: "*"; inherits: false; } @property --tw-backdrop-saturate { syntax: "*"; inherits: false; } @property --tw-backdrop-sepia { syntax: "*"; inherits: false; } @property --tw-ease { syntax: "*"; inherits: false; } @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; --tw-pan-x: initial; --tw-pan-y: initial; --tw-pinch-zoom: initial; --tw-space-y-reverse: 0; --tw-space-x-reverse: 0; --tw-divide-x-reverse: 0; --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-ordinal: initial; --tw-slashed-zero: initial; --tw-numeric-figure: initial; --tw-numeric-spacing: initial; --tw-numeric-fraction: initial; --tw-shadow: 0 0 #0000; --tw-shadow-color: initial; --tw-shadow-alpha: 100%; --tw-inset-shadow: 0 0 #0000; --tw-inset-shadow-color: initial; --tw-inset-shadow-alpha: 100%; --tw-ring-color: initial; --tw-ring-shadow: 0 0 #0000; --tw-inset-ring-color: initial; --tw-inset-ring-shadow: 0 0 #0000; --tw-ring-inset: initial; --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; --tw-grayscale: initial; --tw-hue-rotate: initial; --tw-invert: initial; --tw-opacity: initial; --tw-saturate: initial; --tw-sepia: initial; --tw-drop-shadow: initial; --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; --tw-backdrop-blur: initial; --tw-backdrop-brightness: initial; --tw-backdrop-contrast: initial; --tw-backdrop-grayscale: initial; --tw-backdrop-hue-rotate: initial; --tw-backdrop-invert: initial; --tw-backdrop-opacity: initial; --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; --tw-ease: initial; } } } ================================================ FILE: tsunami/engine/asyncnotify.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import ( "time" ) const NotifyMaxCadence = 10 * time.Millisecond const NotifyDebounceTime = 500 * time.Microsecond const NotifyMaxDebounceTime = 2 * time.Millisecond func (c *ClientImpl) notifyAsyncRenderWork() { c.notifyOnce.Do(func() { c.notifyWakeCh = make(chan struct{}, 1) go c.asyncInitiationLoop() }) nowNs := time.Now().UnixNano() c.notifyLastEventNs.Store(nowNs) // Establish batch start if there's no active batch. if c.notifyBatchStartNs.Load() == 0 { c.notifyBatchStartNs.CompareAndSwap(0, nowNs) } // Coalesced wake-up. select { case c.notifyWakeCh <- struct{}{}: default: } } func (c *ClientImpl) asyncInitiationLoop() { var ( lastSent time.Time timer *time.Timer timerC <-chan time.Time ) schedule := func() { firstNs := c.notifyBatchStartNs.Load() if firstNs == 0 { // No pending batch; stop timer if running. if timer != nil { if !timer.Stop() { select { case <-timer.C: default: } } } timerC = nil return } lastNs := c.notifyLastEventNs.Load() first := time.Unix(0, firstNs) last := time.Unix(0, lastNs) cadenceReady := lastSent.Add(NotifyMaxCadence) // Reset the 2ms "max debounce" window at the cadence boundary: // deadline = max(first, cadenceReady) + 2ms anchor := first if cadenceReady.After(anchor) { anchor = cadenceReady } deadline := anchor.Add(NotifyMaxDebounceTime) // candidate = min(last+500us, deadline) candidate := last.Add(NotifyDebounceTime) if deadline.Before(candidate) { candidate = deadline } // final target = max(cadenceReady, candidate) target := candidate if cadenceReady.After(target) { target = cadenceReady } d := time.Until(target) if d < 0 { d = 0 } if timer == nil { timer = time.NewTimer(d) } else { if !timer.Stop() { select { case <-timer.C: default: } } timer.Reset(d) } timerC = timer.C } for { select { case <-c.notifyWakeCh: schedule() case <-timerC: now := time.Now() // Recompute right before sending; if a late event arrived, // push the fire time out to respect the debounce. firstNs := c.notifyBatchStartNs.Load() if firstNs == 0 { // Nothing to do. continue } lastNs := c.notifyLastEventNs.Load() first := time.Unix(0, firstNs) last := time.Unix(0, lastNs) cadenceReady := lastSent.Add(NotifyMaxCadence) anchor := first if cadenceReady.After(anchor) { anchor = cadenceReady } deadline := anchor.Add(NotifyMaxDebounceTime) candidate := last.Add(NotifyDebounceTime) if deadline.Before(candidate) { candidate = deadline } target := candidate if cadenceReady.After(target) { target = cadenceReady } // If we're early (because a new event just came in), reschedule. if now.Before(target) { d := time.Until(target) if d < 0 { d = 0 } if !timer.Stop() { select { case <-timer.C: default: } } timer.Reset(d) continue } // Fire. _ = c.SendAsyncInitiation() lastSent = now // Close current batch; a concurrent notify will CAS a new start. c.notifyBatchStartNs.Store(0) // If anything is already pending, this will arm the next timer. schedule() } } } ================================================ FILE: tsunami/engine/atomimpl.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import ( "encoding/json" "fmt" "reflect" "sync" ) // AtomMeta provides metadata about an atom for validation and documentation type AtomMeta struct { Description string // short, user-facing Units string // "ms", "GiB", etc. Min *float64 // optional minimum (numeric types) Max *float64 // optional maximum (numeric types) Enum []string // allowed values if finite set Pattern string // regex constraint for strings } type AtomImpl[T any] struct { lock *sync.Mutex val T usedBy map[string]bool // component waveid -> true meta *AtomMeta // optional metadata } func MakeAtomImpl[T any](initialVal T, meta *AtomMeta) *AtomImpl[T] { return &AtomImpl[T]{ lock: &sync.Mutex{}, val: initialVal, usedBy: make(map[string]bool), meta: meta, } } func (a *AtomImpl[T]) GetVal() any { a.lock.Lock() defer a.lock.Unlock() return a.val } func (a *AtomImpl[T]) setVal_nolock(val any) error { if val == nil { var zero T a.val = zero return nil } // Try direct assignment if it's already type T if typed, ok := val.(T); ok { a.val = typed return nil } // Try JSON marshaling/unmarshaling jsonBytes, err := json.Marshal(val) if err != nil { var result T return fmt.Errorf("failed to adapt type from %T => %T, input type failed to marshal: %w", val, result, err) } var result T if err := json.Unmarshal(jsonBytes, &result); err != nil { return fmt.Errorf("failed to adapt type from %T => %T: %w", val, result, err) } a.val = result return nil } func (a *AtomImpl[T]) SetVal(val any) error { a.lock.Lock() defer a.lock.Unlock() return a.setVal_nolock(val) } func (a *AtomImpl[T]) SetUsedBy(waveId string, used bool) { a.lock.Lock() defer a.lock.Unlock() if used { a.usedBy[waveId] = true } else { delete(a.usedBy, waveId) } } func (a *AtomImpl[T]) GetUsedBy() []string { a.lock.Lock() defer a.lock.Unlock() keys := make([]string, 0, len(a.usedBy)) for compId := range a.usedBy { keys = append(keys, compId) } return keys } func (a *AtomImpl[T]) GetMeta() *AtomMeta { a.lock.Lock() defer a.lock.Unlock() return a.meta } func (a *AtomImpl[T]) GetAtomType() reflect.Type { return reflect.TypeOf((*T)(nil)).Elem() } ================================================ FILE: tsunami/engine/clientimpl.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import ( "context" "encoding/base64" "encoding/json" "fmt" "io/fs" "log" "net" "net/http" "os" "strings" "sync" "sync/atomic" "time" "unicode" "github.com/google/uuid" "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) const TsunamiListenAddrEnvVar = "TSUNAMI_LISTENADDR" const DefaultListenAddr = "localhost:0" const DefaultComponentName = "App" type ModalState struct { Config rpctypes.ModalConfig ResultChan chan bool // Channel to receive the result (true = confirmed/ok, false = cancelled) } type ssEvent struct { Event string Data []byte } var defaultClient = makeClient() type AppMeta struct { Title string `json:"title"` ShortDesc string `json:"shortdesc"` Icon string `json:"icon"` // for waveapps, the icon to use (fontawesome names) IconColor string `json:"iconcolor"` // for waveapps, the icon color to use (HTML color -- name, hex, rgb) } type SecretMeta struct { Desc string `json:"desc"` Optional bool `json:"optional"` } type AppManifest struct { AppMeta AppMeta `json:"appmeta"` ConfigSchema map[string]any `json:"configschema"` DataSchema map[string]any `json:"dataschema"` Secrets map[string]SecretMeta `json:"secrets"` } type ClientImpl struct { Lock *sync.Mutex Root *RootElem RootElem *vdom.VDomElem CurrentClientId string Meta AppMeta ServerId string IsDone bool DoneReason string DoneCh chan struct{} SSEChannels map[string]chan ssEvent // map of connectionId to SSE channel SSEChannelsLock *sync.Mutex GlobalEventHandler func(event vdom.VDomEvent) UrlHandlerMux *http.ServeMux AppInitFn func() error AssetsFS fs.FS StaticFS fs.FS ManifestFileBytes []byte // for modals OpenModals map[string]*ModalState // map of modalId to modal state OpenModalsLock *sync.Mutex // for secrets Secrets map[string]SecretMeta // map of secret name to metadata SecretsLock *sync.Mutex // for notification // Atomics so we never drop "last event" timing info even if wakeCh is full. // 0 means "no pending batch". notifyOnce sync.Once notifyWakeCh chan struct{} notifyBatchStartNs atomic.Int64 // ns of first event in current batch notifyLastEventNs atomic.Int64 // ns of most recent event } func makeClient() *ClientImpl { client := &ClientImpl{ Lock: &sync.Mutex{}, DoneCh: make(chan struct{}), SSEChannels: make(map[string]chan ssEvent), SSEChannelsLock: &sync.Mutex{}, OpenModals: make(map[string]*ModalState), OpenModalsLock: &sync.Mutex{}, Secrets: make(map[string]SecretMeta), SecretsLock: &sync.Mutex{}, UrlHandlerMux: http.NewServeMux(), ServerId: uuid.New().String(), RootElem: vdom.H(DefaultComponentName, nil), } client.Root = MakeRoot(client) return client } func GetDefaultClient() *ClientImpl { return defaultClient } func (c *ClientImpl) GetIsDone() bool { c.Lock.Lock() defer c.Lock.Unlock() return c.IsDone } func (c *ClientImpl) checkClientId(clientId string) error { if clientId == "" { return fmt.Errorf("client id cannot be empty") } c.Lock.Lock() defer c.Lock.Unlock() if c.CurrentClientId == "" || c.CurrentClientId == clientId { c.CurrentClientId = clientId return nil } return fmt.Errorf("client id mismatch: expected %s, got %s", c.CurrentClientId, clientId) } func (c *ClientImpl) clientTakeover(clientId string) { c.Lock.Lock() defer c.Lock.Unlock() c.CurrentClientId = clientId } func (c *ClientImpl) doShutdown(reason string) { c.Lock.Lock() defer c.Lock.Unlock() if c.IsDone { return } c.DoneReason = reason c.IsDone = true close(c.DoneCh) } func (c *ClientImpl) SetGlobalEventHandler(handler func(event vdom.VDomEvent)) { c.GlobalEventHandler = handler } func (c *ClientImpl) getFaviconPath() string { if c.StaticFS != nil { faviconNames := []string{"favicon.ico", "favicon.png", "favicon.svg", "favicon.gif", "favicon.jpg"} for _, name := range faviconNames { if _, err := c.StaticFS.Open(name); err == nil { return "/static/" + name } } } return "/wave-logo-256.png" } func (c *ClientImpl) makeBackendOpts() *rpctypes.VDomBackendOpts { appMeta := c.GetAppMeta() return &rpctypes.VDomBackendOpts{ Title: appMeta.Title, ShortDesc: appMeta.ShortDesc, GlobalKeyboardEvents: c.GlobalEventHandler != nil, FaviconPath: c.getFaviconPath(), } } func (c *ClientImpl) runMainE() error { if c.AppInitFn != nil { err := c.AppInitFn() if err != nil { return err } } err := c.listenAndServe(context.Background()) if err != nil { return err } <-c.DoneCh return nil } func (c *ClientImpl) RegisterAppInitFn(fn func() error) { c.AppInitFn = fn } func (c *ClientImpl) RunMain() { err := c.runMainE() if err != nil { fmt.Println(err) os.Exit(1) } } func (c *ClientImpl) listenAndServe(ctx context.Context) error { // Create HTTP handlers handlers := newHTTPHandlers(c) // Create a new ServeMux and register handlers mux := http.NewServeMux() handlers.registerHandlers(mux, handlerOpts{ AssetsFS: c.AssetsFS, StaticFS: c.StaticFS, ManifestFile: c.ManifestFileBytes, }) // Determine listen address from environment variable or use default listenAddr := os.Getenv(TsunamiListenAddrEnvVar) if listenAddr == "" { listenAddr = DefaultListenAddr } // Create server and listen on specified address server := &http.Server{ Addr: listenAddr, Handler: mux, } // Start listening listener, err := net.Listen("tcp", listenAddr) if err != nil { return fmt.Errorf("failed to listen: %v", err) } // Log the address we're listening on port := listener.Addr().(*net.TCPAddr).Port log.Printf("[tsunami] listening at http://localhost:%d", port) // Serve in a goroutine so we don't block go func() { if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { log.Printf("HTTP server error: %v", err) } }() // Wait for context cancellation and shutdown server gracefully go func() { <-ctx.Done() log.Printf("Context canceled, shutting down server...") if err := server.Shutdown(context.Background()); err != nil { log.Printf("Server shutdown error: %v", err) } }() return nil } func (c *ClientImpl) RegisterSSEChannel(connectionId string) chan ssEvent { c.SSEChannelsLock.Lock() defer c.SSEChannelsLock.Unlock() ch := make(chan ssEvent, 100) c.SSEChannels[connectionId] = ch return ch } func (c *ClientImpl) UnregisterSSEChannel(connectionId string) { c.SSEChannelsLock.Lock() defer c.SSEChannelsLock.Unlock() if ch, exists := c.SSEChannels[connectionId]; exists { close(ch) delete(c.SSEChannels, connectionId) } } func (c *ClientImpl) SendSSEvent(event ssEvent) error { if c.GetIsDone() { return fmt.Errorf("client is done") } c.SSEChannelsLock.Lock() defer c.SSEChannelsLock.Unlock() // Send to all registered SSE channels for _, ch := range c.SSEChannels { select { case ch <- event: // Successfully sent default: // silently drop (below is just for debugging). this wont happen in general // log.Printf("SSEvent channel is full for connection %s, skipping event", connectionId) } } return nil } func (c *ClientImpl) SendAsyncInitiation() error { return c.SendSSEvent(ssEvent{Event: "asyncinitiation", Data: nil}) } func (c *ClientImpl) SendTermWrite(refId string, data string) error { payload := rpctypes.TermWritePacket{ RefId: refId, Data64: base64.StdEncoding.EncodeToString([]byte(data)), } jsonData, err := json.Marshal(payload) if err != nil { return err } return c.SendSSEvent(ssEvent{Event: "termwrite", Data: jsonData}) } func makeNullRendered() *rpctypes.RenderedElem { return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } func structToProps(props any) map[string]any { m, err := util.StructToMap(props) if err != nil { return nil } return m } func DefineComponentEx[P any](client *ClientImpl, name string, renderFn func(props P) any) vdom.Component[P] { if name == "" { panic("Component name cannot be empty") } if !unicode.IsUpper(rune(name[0])) { panic("Component name must start with an uppercase letter") } err := client.registerComponent(name, renderFn) if err != nil { panic(err) } return func(props P) *vdom.VDomElem { return vdom.H(name, structToProps(props)) } } func (c *ClientImpl) registerComponent(name string, cfunc any) error { return c.Root.RegisterComponent(name, cfunc) } func (c *ClientImpl) fullRender() (*rpctypes.VDomBackendUpdate, error) { opts := &RenderOpts{Resync: true} c.Root.RunWork(opts) c.Root.Render(c.RootElem, opts) renderedVDom := c.Root.MakeRendered() if renderedVDom == nil { renderedVDom = makeNullRendered() } return &rpctypes.VDomBackendUpdate{ Type: "backendupdate", Ts: time.Now().UnixMilli(), ServerId: c.ServerId, HasWork: len(c.Root.EffectWorkQueue) > 0, FullUpdate: true, Opts: c.makeBackendOpts(), RenderUpdates: []rpctypes.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, RefOperations: c.Root.GetRefOperations(), }, nil } func (c *ClientImpl) incrementalRender() (*rpctypes.VDomBackendUpdate, error) { opts := &RenderOpts{Resync: false} c.Root.RunWork(opts) renderedVDom := c.Root.MakeRendered() if renderedVDom == nil { renderedVDom = makeNullRendered() } return &rpctypes.VDomBackendUpdate{ Type: "backendupdate", Ts: time.Now().UnixMilli(), ServerId: c.ServerId, HasWork: len(c.Root.EffectWorkQueue) > 0, FullUpdate: false, Opts: c.makeBackendOpts(), RenderUpdates: []rpctypes.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, RefOperations: c.Root.GetRefOperations(), }, nil } func (c *ClientImpl) HandleDynFunc(pattern string, fn func(http.ResponseWriter, *http.Request)) { if !strings.HasPrefix(pattern, "/dyn/") { log.Printf("invalid dyn pattern: %s (must start with /dyn/)", pattern) return } c.UrlHandlerMux.HandleFunc(pattern, fn) } func (c *ClientImpl) RunEvents(events []vdom.VDomEvent) { for _, event := range events { c.Root.Event(event, c.GlobalEventHandler) } } func (c *ClientImpl) GetAppMeta() AppMeta { c.Lock.Lock() defer c.Lock.Unlock() return c.Meta } func (c *ClientImpl) SetAppMeta(m AppMeta) { c.Lock.Lock() defer c.Lock.Unlock() c.Meta = m } // addModalToMap adds a modal to the map and returns the result channel func (c *ClientImpl) addModalToMap(config rpctypes.ModalConfig) chan bool { c.OpenModalsLock.Lock() defer c.OpenModalsLock.Unlock() resultChan := make(chan bool, 1) c.OpenModals[config.ModalId] = &ModalState{ Config: config, ResultChan: resultChan, } return resultChan } // ShowModal displays a modal and returns a channel that will receive the result func (c *ClientImpl) ShowModal(config rpctypes.ModalConfig) chan bool { resultChan := c.addModalToMap(config) data, err := json.Marshal(config) if err != nil { log.Printf("failed to marshal modal config: %v", err) c.CloseModal(config.ModalId, false) return resultChan } err = c.SendSSEvent(ssEvent{Event: "showmodal", Data: data}) if err != nil { log.Printf("failed to send modal SSE event: %v", err) c.CloseModal(config.ModalId, false) return resultChan } return resultChan } // removeModalFromMap removes a modal from the map and returns its state func (c *ClientImpl) removeModalFromMap(modalId string) *ModalState { c.OpenModalsLock.Lock() defer c.OpenModalsLock.Unlock() modalState, exists := c.OpenModals[modalId] if exists { delete(c.OpenModals, modalId) } return modalState } // CloseModal closes a modal with the given result func (c *ClientImpl) CloseModal(modalId string, result bool) { modalState := c.removeModalFromMap(modalId) if modalState != nil { modalState.ResultChan <- result close(modalState.ResultChan) } } // CloseAllModals closes all open modals with cancelled result // This is called when the FE requests a resync (page refresh or new client) func (c *ClientImpl) CloseAllModals() { c.OpenModalsLock.Lock() modalIds := make([]string, 0, len(c.OpenModals)) for modalId := range c.OpenModals { modalIds = append(modalIds, modalId) } c.OpenModalsLock.Unlock() for _, modalId := range modalIds { c.CloseModal(modalId, false) } } func (c *ClientImpl) DeclareSecret(name string, desc string, optional bool) { c.SecretsLock.Lock() defer c.SecretsLock.Unlock() if _, exists := c.Secrets[name]; exists { panic(fmt.Sprintf("secret '%s' already declared", name)) } c.Secrets[name] = SecretMeta{ Desc: desc, Optional: optional, } } func (c *ClientImpl) GetSecrets() map[string]SecretMeta { c.SecretsLock.Lock() defer c.SecretsLock.Unlock() secretsCopy := make(map[string]SecretMeta, len(c.Secrets)) for k, v := range c.Secrets { secretsCopy[k] = v } return secretsCopy } func (c *ClientImpl) GetAppManifest() AppManifest { appMeta := c.GetAppMeta() configSchema := GenerateConfigSchema(c.Root) dataSchema := GenerateDataSchema(c.Root) secrets := c.GetSecrets() return AppManifest{ AppMeta: appMeta, ConfigSchema: configSchema, DataSchema: dataSchema, Secrets: secrets, } } func (c *ClientImpl) PrintAppManifest() { manifest := c.GetAppManifest() manifestJSON, err := json.MarshalIndent(manifest, "", " ") if err != nil { fmt.Printf("Error marshaling manifest: %v\n", err) return } fmt.Println("<AppManifest>") fmt.Println(string(manifestJSON)) fmt.Println("</AppManifest>") } ================================================ FILE: tsunami/engine/comp.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import "github.com/wavetermdev/waveterm/tsunami/vdom" // so components either render to another component (or fragment) // or to a base element (text or vdom). base elements can then render children type ChildKey struct { Tag string Idx int Key string } // ComponentImpl represents a node in the persistent shadow component tree. // This is Tsunami's equivalent to React's Fiber nodes - it maintains component // identity, state, and lifecycle across renders while the VDomElem input/output // structures are ephemeral. type ComponentImpl struct { WaveId string // Unique identifier for this component instance Tag string // Component type (HTML tag, custom component name, "#text", etc.) Key string // User-provided key for reconciliation (like React keys) ContainingComp string // Which vdom component's render function created this ComponentImpl Elem *vdom.VDomElem // Reference to the current input VDomElem being rendered Mounted bool // Whether this component is currently mounted // Hooks system (React-like) Hooks []*Hook // Array of hooks (state, effects, etc.) attached to this component // Atom dependency tracking UsedAtoms map[string]bool // atomName -> true, tracks which atoms this component uses // Component content - exactly ONE of these patterns is used: // Pattern 1: Text nodes Text string // For "#text" components - stores the actual text content // Pattern 2: Base/DOM elements with children Children []*ComponentImpl // For HTML tags, fragments - array of child components // Pattern 3: Custom components that render to other components RenderedComp *ComponentImpl // For custom components - points to what this component rendered to } func (c *ComponentImpl) compMatch(tag string, key string) bool { if c == nil { return false } return c.Tag == tag && c.Key == key } ================================================ FILE: tsunami/engine/errcomponent.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import ( "fmt" "github.com/wavetermdev/waveterm/tsunami/vdom" ) // creates an error component for display when a component panics func renderErrorComponent(componentName string, errorMsg string) any { return vdom.H("div", map[string]any{ "className": "p-4 border border-red-500 bg-red-100 text-red-800 rounded font-mono", }, vdom.H("div", map[string]any{ "className": "font-bold mb-2", }, fmt.Sprintf("Component Error: %s", componentName)), vdom.H("div", nil, errorMsg), ) } ================================================ FILE: tsunami/engine/globalctx.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import ( "sync" "github.com/outrigdev/goid" "github.com/wavetermdev/waveterm/tsunami/vdom" ) const ( GlobalContextType_async = "async" GlobalContextType_render = "render" GlobalContextType_effect = "effect" GlobalContextType_event = "event" ) // is set ONLY when we're in the render function of a component // used for hooks, and automatic dependency tracking var globalRenderContext *RenderContextImpl var globalRenderGoId uint64 var globalEventContext *EventContextImpl var globalEventGoId uint64 var globalEffectContext *EffectContextImpl var globalEffectGoId uint64 var globalCtxMutex sync.Mutex type EventContextImpl struct { Event vdom.VDomEvent Root *RootElem } type EffectContextImpl struct { WorkElem EffectWorkElem WorkType string // "run" or "unmount" Root *RootElem } func setGlobalRenderContext(vc *RenderContextImpl) { globalCtxMutex.Lock() defer globalCtxMutex.Unlock() globalRenderContext = vc globalRenderGoId = goid.Get() } func clearGlobalRenderContext() { globalCtxMutex.Lock() defer globalCtxMutex.Unlock() globalRenderContext = nil globalRenderGoId = 0 } func withGlobalRenderCtx[T any](vc *RenderContextImpl, fn func() T) T { setGlobalRenderContext(vc) defer clearGlobalRenderContext() return fn() } func GetGlobalRenderContext() *RenderContextImpl { globalCtxMutex.Lock() defer globalCtxMutex.Unlock() gid := goid.Get() if gid != globalRenderGoId { return nil } return globalRenderContext } func setGlobalEventContext(ec *EventContextImpl) { globalCtxMutex.Lock() defer globalCtxMutex.Unlock() globalEventContext = ec globalEventGoId = goid.Get() } func clearGlobalEventContext() { globalCtxMutex.Lock() defer globalCtxMutex.Unlock() globalEventContext = nil globalEventGoId = 0 } func withGlobalEventCtx[T any](ec *EventContextImpl, fn func() T) T { setGlobalEventContext(ec) defer clearGlobalEventContext() return fn() } func GetGlobalEventContext() *EventContextImpl { globalCtxMutex.Lock() defer globalCtxMutex.Unlock() gid := goid.Get() if gid != globalEventGoId { return nil } return globalEventContext } func setGlobalEffectContext(ec *EffectContextImpl) { globalCtxMutex.Lock() defer globalCtxMutex.Unlock() globalEffectContext = ec globalEffectGoId = goid.Get() } func clearGlobalEffectContext() { globalCtxMutex.Lock() defer globalCtxMutex.Unlock() globalEffectContext = nil globalEffectGoId = 0 } func withGlobalEffectCtx[T any](ec *EffectContextImpl, fn func() T) T { setGlobalEffectContext(ec) defer clearGlobalEffectContext() return fn() } func GetGlobalEffectContext() *EffectContextImpl { globalCtxMutex.Lock() defer globalCtxMutex.Unlock() gid := goid.Get() if gid != globalEffectGoId { return nil } return globalEffectContext } // inContextType returns the current global context type. // Returns one of: // - GlobalContextType_render: when in a component render function // - GlobalContextType_event: when in an event handler // - GlobalContextType_effect: when in an effect function // - GlobalContextType_async: when not in any specific context (default/async) func inContextType() string { globalCtxMutex.Lock() defer globalCtxMutex.Unlock() gid := goid.Get() if globalRenderContext != nil && gid == globalRenderGoId { return GlobalContextType_render } if globalEventContext != nil && gid == globalEventGoId { return GlobalContextType_event } if globalEffectContext != nil && gid == globalEffectGoId { return GlobalContextType_effect } return GlobalContextType_async } ================================================ FILE: tsunami/engine/hooks.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import ( "strconv" "github.com/wavetermdev/waveterm/tsunami/vdom" ) // generic hook structure type Hook struct { Init bool // is initialized Idx int // index in the hook array Fn func() func() // for useEffect UnmountFn func() // for useEffect Val any // for useState, useMemo, useRef Deps []any } type RenderContextImpl struct { Root *RootElem Comp *ComponentImpl HookIdx int RenderOpts *RenderOpts UsedAtoms map[string]bool // Track atoms used during this render } func makeContextVal(root *RootElem, comp *ComponentImpl, opts *RenderOpts) *RenderContextImpl { return &RenderContextImpl{ Root: root, Comp: comp, HookIdx: 0, RenderOpts: opts, UsedAtoms: make(map[string]bool), } } func (vc *RenderContextImpl) GetCompWaveId() string { if vc.Comp == nil { return "" } return vc.Comp.WaveId } func (vc *RenderContextImpl) getOrderedHook() *Hook { if vc.Comp == nil { panic("tsunami hooks must be called within a component (vc.Comp is nil)") } for len(vc.Comp.Hooks) <= vc.HookIdx { vc.Comp.Hooks = append(vc.Comp.Hooks, &Hook{Idx: len(vc.Comp.Hooks)}) } hookVal := vc.Comp.Hooks[vc.HookIdx] vc.HookIdx++ return hookVal } func (vc *RenderContextImpl) getCompName() string { if vc.Comp == nil || vc.Comp.Elem == nil { return "" } return vc.Comp.Elem.Tag } func UseRenderTs(vc *RenderContextImpl) int64 { return vc.Root.RenderTs } func UseId(vc *RenderContextImpl) string { return vc.GetCompWaveId() } func UseLocal(vc *RenderContextImpl, initialVal any) string { hookVal := vc.getOrderedHook() atomName := "$local." + vc.GetCompWaveId() + "#" + strconv.Itoa(hookVal.Idx) if !hookVal.Init { hookVal.Init = true atom := MakeAtomImpl(initialVal, nil) vc.Root.RegisterAtom(atomName, atom) closedAtomName := atomName hookVal.UnmountFn = func() { vc.Root.RemoveAtom(closedAtomName) } } return atomName } func UseVDomRef(vc *RenderContextImpl) any { hookVal := vc.getOrderedHook() if !hookVal.Init { hookVal.Init = true refId := vc.GetCompWaveId() + ":" + strconv.Itoa(hookVal.Idx) hookVal.Val = &vdom.VDomRef{Type: vdom.ObjectType_Ref, RefId: refId} } refVal, ok := hookVal.Val.(*vdom.VDomRef) if !ok { panic("UseVDomRef hook value is not a ref (possible out of order or conditional hooks)") } return refVal } func UseRef(vc *RenderContextImpl, hookInitialVal any) any { hookVal := vc.getOrderedHook() if !hookVal.Init { hookVal.Init = true hookVal.Val = hookInitialVal } return hookVal.Val } func depsEqual(deps1 []any, deps2 []any) bool { if len(deps1) != len(deps2) { return false } for i := range deps1 { if deps1[i] != deps2[i] { return false } } return true } func UseEffect(vc *RenderContextImpl, fn func() func(), deps []any) { hookVal := vc.getOrderedHook() compTag := "" if vc.Comp != nil { compTag = vc.Comp.Tag } if !hookVal.Init { hookVal.Init = true hookVal.Fn = fn hookVal.Deps = deps vc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag) return } // If deps is nil, always run (like React with no dependency array) if deps == nil { hookVal.Fn = fn hookVal.Deps = deps vc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag) return } if depsEqual(hookVal.Deps, deps) { return } hookVal.Fn = fn hookVal.Deps = deps vc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag) } func UseResync(vc *RenderContextImpl) bool { if vc.RenderOpts == nil { return false } return vc.RenderOpts.Resync } ================================================ FILE: tsunami/engine/render.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import ( "fmt" "log" "reflect" "unicode" "github.com/google/uuid" "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) // see render.md for a complete guide to how tsunami rendering, lifecycle, and reconciliation works type RenderOpts struct { Resync bool } func (r *RootElem) Render(elem *vdom.VDomElem, opts *RenderOpts) { r.render(elem, &r.Root, "root", opts) } func getElemKey(elem *vdom.VDomElem) string { if elem == nil { return "" } keyVal, ok := elem.Props[vdom.KeyPropKey] if !ok { return "" } return fmt.Sprint(keyVal) } func (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) { if elem == nil || elem.Tag == "" { r.unmount(comp) return } elemKey := getElemKey(elem) if *comp == nil || !(*comp).compMatch(elem.Tag, elemKey) { r.unmount(comp) r.createComp(elem.Tag, elemKey, containingComp, comp) } (*comp).Elem = elem if elem.Tag == vdom.TextTag { // Pattern 1: Text Nodes r.renderText(elem.Text, comp) return } if isBaseTag(elem.Tag) { // Pattern 2: Base elements r.renderSimple(elem, comp, containingComp, opts) return } cfunc := r.CFuncs[elem.Tag] if cfunc == nil { text := fmt.Sprintf("<%s>", elem.Tag) r.renderText(text, comp) return } // Pattern 3: components r.renderComponent(cfunc, elem, comp, opts) } // Pattern 1 func (r *RootElem) renderText(text string, comp **ComponentImpl) { // No need to clear Children/Comp - text components cannot have them if (*comp).Text != text { (*comp).Text = text } } // Pattern 2 func (r *RootElem) renderSimple(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) { if (*comp).RenderedComp != nil { // Clear Comp since base elements don't use it r.unmount(&(*comp).RenderedComp) } (*comp).Children = r.renderChildren(elem.Children, (*comp).Children, containingComp, opts) } // Pattern 3 func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **ComponentImpl, opts *RenderOpts) { if (*comp).Children != nil { // Clear Children since custom components don't use them for _, child := range (*comp).Children { r.unmount(&child) } (*comp).Children = nil } props := make(map[string]any) for k, v := range elem.Props { props[k] = v } props[ChildrenPropKey] = elem.Children vc := makeContextVal(r, *comp, opts) rtnElemArr := withGlobalRenderCtx(vc, func() []vdom.VDomElem { renderedElem := callCFuncWithErrorGuard(cfunc, props, elem.Tag) return vdom.ToElems(renderedElem) }) // Process atom usage after render r.updateComponentAtomUsage(*comp, vc.UsedAtoms) var rtnElem *vdom.VDomElem if len(rtnElemArr) == 0 { rtnElem = nil } else if len(rtnElemArr) == 1 { rtnElem = &rtnElemArr[0] } else { rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr} } r.render(rtnElem, &(*comp).RenderedComp, elem.Tag, opts) } func (r *RootElem) unmount(comp **ComponentImpl) { if *comp == nil { return } waveId := (*comp).WaveId for _, hook := range (*comp).Hooks { if hook.UnmountFn != nil { hook.UnmountFn() } } if (*comp).RenderedComp != nil { r.unmount(&(*comp).RenderedComp) } if (*comp).Children != nil { for _, child := range (*comp).Children { r.unmount(&child) } } delete(r.CompMap, waveId) r.cleanupUsedByForUnmount(*comp) *comp = nil } func (r *RootElem) createComp(tag string, key string, containingComp string, comp **ComponentImpl) { *comp = &ComponentImpl{WaveId: uuid.New().String(), Tag: tag, Key: key, ContainingComp: containingComp} r.CompMap[(*comp).WaveId] = *comp } // handles reconcilation // maps children via key or index (exclusively) func (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*ComponentImpl, containingComp string, opts *RenderOpts) []*ComponentImpl { newChildren := make([]*ComponentImpl, len(elems)) curCM := make(map[ChildKey]*ComponentImpl) usedMap := make(map[*ComponentImpl]bool) for idx, child := range curChildren { if child.Key != "" { curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child } else { curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: ""}] = child } } for idx, elem := range elems { elemKey := getElemKey(&elem) var curChild *ComponentImpl if elemKey != "" { curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}] } else { curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}] } usedMap[curChild] = true newChildren[idx] = curChild r.render(&elem, &newChildren[idx], containingComp, opts) } for _, child := range curChildren { if !usedMap[child] { r.unmount(&child) } } return newChildren } // safely calls the component function with panic recovery func callCFuncWithErrorGuard(cfunc any, props map[string]any, componentName string) (result any) { defer func() { if panicErr := util.PanicHandler(fmt.Sprintf("render component '%s'", componentName), recover()); panicErr != nil { result = renderErrorComponent(componentName, panicErr.Error()) } }() result = callCFunc(cfunc, props) return result } // uses reflection to call the component function func callCFunc(cfunc any, props map[string]any) any { rval := reflect.ValueOf(cfunc) rtype := rval.Type() if rtype.NumIn() != 1 { fmt.Printf("component function must have exactly 1 parameter, got %d\n", rtype.NumIn()) return nil } argType := rtype.In(0) var arg1Val reflect.Value if argType.Kind() == reflect.Interface && argType.NumMethod() == 0 { arg1Val = reflect.New(argType) } else { arg1Val = reflect.New(argType) if argType.Kind() == reflect.Map { arg1Val.Elem().Set(reflect.ValueOf(props)) } else { err := util.MapToStruct(props, arg1Val.Interface()) if err != nil { fmt.Printf("error converting props: %v\n", err) } } } rtnVal := rval.Call([]reflect.Value{arg1Val.Elem()}) if len(rtnVal) == 0 { return nil } return rtnVal[0].Interface() } func convertPropsToVDom(props map[string]any) map[string]any { if len(props) == 0 { return nil } vdomProps := make(map[string]any) for k, v := range props { if v == nil { continue } if vdomFunc, ok := v.(vdom.VDomFunc); ok { // ensure Type is set on all VDomFuncs vdomFunc.Type = vdom.ObjectType_Func vdomProps[k] = vdomFunc continue } if vdomFuncPtr, ok := v.(*vdom.VDomFunc); ok { if vdomFuncPtr == nil { continue // handled typed-nil } // ensure Type is set on all VDomFuncs (pointer) vdomFuncPtr.Type = vdom.ObjectType_Func vdomProps[k] = vdomFuncPtr continue } if vdomRefPtr, ok := v.(*vdom.VDomRef); ok { if vdomRefPtr == nil { continue // handle typed-nil } // ensure Type is set on all VDomRefs (pointer) vdomRefPtr.Type = vdom.ObjectType_Ref vdomProps[k] = vdomRefPtr continue } val := reflect.ValueOf(v) if val.Type() == reflect.TypeOf(vdom.VDomRef{}) { log.Printf("warning: VDomRef passed as non-pointer for prop %q (VDomRef contains atomics and must be passed as *VDomRef); dropping prop\n", k) continue } if val.Kind() == reflect.Func { // convert go functions passed to event handlers to VDomFuncs vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func} continue } vdomProps[k] = v } return vdomProps } func (r *RootElem) MakeRendered() *rpctypes.RenderedElem { if r.Root == nil { return nil } return r.convertCompToRendered(r.Root) } func (r *RootElem) convertCompToRendered(c *ComponentImpl) *rpctypes.RenderedElem { if c == nil { return nil } if c.RenderedComp != nil { return r.convertCompToRendered(c.RenderedComp) } if len(c.Children) == 0 && r.CFuncs[c.Tag] != nil { return nil } return r.convertBaseToRendered(c) } func (r *RootElem) convertBaseToRendered(c *ComponentImpl) *rpctypes.RenderedElem { elem := &rpctypes.RenderedElem{WaveId: c.WaveId, Tag: c.Tag} if c.Elem != nil { elem.Props = convertPropsToVDom(c.Elem.Props) } for _, child := range c.Children { childElem := r.convertCompToRendered(child) if childElem != nil { elem.Children = append(elem.Children, *childElem) } } if c.Tag == vdom.TextTag { elem.Text = c.Text } return elem } func isBaseTag(tag string) bool { if tag == "" { return false } if tag == vdom.TextTag || tag == vdom.WaveTextTag || tag == vdom.WaveNullTag || tag == vdom.FragmentTag { return true } if tag[0] == '#' { return true } firstChar := rune(tag[0]) return unicode.IsLower(firstChar) } ================================================ FILE: tsunami/engine/render.md ================================================ # Tsunami Rendering Engine The Tsunami rendering engine implements a React-like component system with virtual DOM reconciliation. It maintains a persistent shadow component tree that efficiently updates in response to new VDom input, similar to React's Fiber architecture. ## Core Architecture ### Two-Phase VDom System Tsunami uses separate types for different phases of the rendering pipeline: - **VDomElem**: Input format used by developers (JSX-like elements created with `vdom.H()`) - **ComponentImpl**: Internal shadow tree that maintains component identity and state across renders - **RenderedElem**: Output format sent to the frontend with populated WaveIds This separation mirrors React's approach where JSX elements, Fiber nodes, and DOM operations use different data structures optimized for their specific purposes. ### ComponentImpl: The Shadow Tree The `ComponentImpl` structure is Tsunami's equivalent to React's Fiber nodes. It maintains a persistent tree that survives between renders, preserving component identity, state, and lifecycle information. Each ComponentImpl contains: - **Identity fields**: WaveId (unique identifier), Tag (component type), Key (for reconciliation) - **State management**: Hooks array for React-like state and effects - **Content organization**: Exactly one of three mutually exclusive patterns ## Three Component Patterns The engine organizes components into three distinct patterns, each using different fields in ComponentImpl: ### Pattern 1: Text Components ```go Text string // Text content (Pattern 1: text nodes only) Children = nil // Not used RenderedComp = nil // Not used ``` Used for `#text` components that render string content directly. These are the leaf nodes of the component tree. **Example**: `vdom.H("#text", nil, "Hello World")` creates a ComponentImpl with `Text = "Hello World"` ### Pattern 2: Base/DOM Elements ```go Text = "" // Not used Children []*ComponentImpl // Child components (Pattern 2: containers only) RenderedComp = nil // Not used ``` Used for HTML elements, fragments, and Wave-specific elements that act as containers. These components render multiple children but don't transform into other component types. **Example**: `vdom.H("div", nil, child1, child2)` creates a ComponentImpl with `Children = [child1Comp, child2Comp]` **Base elements include**: - HTML tags with lowercase first letter (`"div"`, `"span"`, `"button"`) - Hash-prefixed special elements (`"#fragment"`, `"#text"`) - Wave-specific elements (`"wave:text"`, `"wave:null"`) ### Pattern 3: Custom Components ```go Text = "" // Not used Children = nil // Not used RenderedComp *ComponentImpl // Rendered output (Pattern 3: custom components only) ``` Used for user-defined components that transform into other components through their render functions. These create component chains where custom components render to base elements. **Example**: A `TodoItem` component renders to a `div`, creating the chain: ``` TodoItem ComponentImpl (Pattern 3) └── RenderedComp → div ComponentImpl (Pattern 2) └── Children → [text, button, etc.] ``` ## Rendering Flow ### 1. Reconciliation and Pattern Routing The main `render()` function performs React-like reconciliation: 1. **Null handling**: `elem == nil` unmounts the component 2. **Component matching**: Existing components are reused if tag and key match 3. **Pattern routing**: Elements are routed to the appropriate pattern based on tag type ```go if elem.Tag == vdom.TextTag { // Pattern 1: Text Nodes r.renderText(elem.Text, comp) } else if isBaseTag(elem.Tag) { // Pattern 2: Base elements r.renderSimple(elem, comp, opts) } else { // Pattern 3: Custom components r.renderComponent(cfunc, elem, comp, opts) } ``` ### 2. Pattern-Specific Rendering Each pattern has its own rendering function that manages field usage: **renderText()**: Simply stores text content, no cleanup needed since text components can't have other patterns. **renderSimple()**: Clears any existing `RenderedComp` (Pattern 3) and renders children into the `Children` field (Pattern 2). **renderComponent()**: Clears any existing `Children` (Pattern 2), calls the component function, and renders the result into `RenderedComp` (Pattern 3). ### 3. Component Function Execution Custom components are Go functions called via reflection: 1. **Props conversion**: The VDomElem props map is converted to the expected Go struct type 2. **Function execution**: The component function is called with context and typed props 3. **Result processing**: Returned elements are converted to VDomElem arrays 4. **Fragment wrapping**: Multiple returned elements are automatically wrapped in fragments ```go // Single element: renders directly to RenderedComp // Multiple elements: wrapped in fragment, then rendered to RenderedComp if len(rtnElemArr) == 1 { rtnElem = &rtnElemArr[0] } else { rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr} } ``` ## Key-Based Reconciliation The children reconciliation system implements React's key-matching logic: ### ChildKey Structure ```go type ChildKey struct { Tag string // Component type must match Idx int // Position index for non-keyed elements Key string // Explicit key for keyed elements } ``` ### Matching Rules 1. **Keyed elements**: Match by tag + key, position ignored - `<div key="a">` only matches `<div key="a">` - Position changes don't break identity 2. **Non-keyed elements**: Match by tag + position - `<div>` at position 0 only matches `<div>` at position 0 - Moving elements breaks identity and causes remount 3. **Key transitions**: Keyed and non-keyed elements never match - `<div>` → `<div key="hello">` causes remount - Adding/removing keys breaks component identity ### Reconciliation Algorithm ```go // Build map of existing children by ChildKey for idx, child := range curChildren { if child.Key != "" { curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child } else { curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: ""}] = child } } // Match new elements against existing map for idx, elem := range elems { elemKey := getElemKey(&elem) if elemKey != "" { curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}] } else { curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}] } // Reuse existing component or create new one } ``` ## Component Lifecycle ### Mounting New components are created with: - Unique WaveId for tracking - Tag and Key for reconciliation - Registration in global ComponentMap - Empty pattern fields (populated during rendering) ### Unmounting The unmounting process ensures complete cleanup: 1. **Hook cleanup**: All hook `UnmountFn` callbacks are executed 2. **Pattern-specific cleanup**: - Pattern 3: Recursively unmount `RenderedComp` - Pattern 2: Recursively unmount all `Children` - Pattern 1: No child cleanup needed 3. **Global cleanup**: Remove from ComponentMap and dependency tracking This prevents memory leaks and ensures proper lifecycle management. ### Component vs Rendered Content Lifecycle A key distinction in Tsunami (matching React) is that component mounting/unmounting is separate from what they render: - **Component returns `nil`**: Component stays mounted (keeps state/hooks), but `RenderedComp` becomes `nil` - **Component returns content again**: Component reuses existing identity, new content gets mounted This preserves component state across rendering/not-rendering cycles. ## Output Generation The shadow tree gets converted to frontend-ready format through `MakeRendered()`: 1. **Component chain following**: For Pattern 3 components, follow `RenderedComp` until reaching a base element 2. **Base element conversion**: Convert Pattern 1/2 components to RenderedElem with WaveIds 3. **Null component filtering**: Components with `RenderedComp == nil` don't appear in output Only base elements (Pattern 1/2) appear in the final output - custom components (Pattern 3) are invisible, having transformed into base elements. ## React Similarities and Differences ### Similarities - **Reconciliation**: Same key-based matching and component reuse logic - **Hooks**: Same lifecycle patterns with cleanup functions - **Component identity**: Persistent component instances across renders - **Null rendering**: Components can render nothing while staying mounted ### Key Differences - **Server-side**: Runs entirely in Go backend, sends VDom to frontend - **Component chaining**: Pattern 3 allows direct component-to-component rendering via `RenderedComp` - **Explicit patterns**: Three mutually exclusive patterns vs React's more flexible structure - **Type separation**: Clear separation between input VDom, shadow tree, and output types ### Performance Optimizations The three-pattern system provides significant optimizations: - **Base element efficiency**: HTML elements use `Children` directly without intermediate transformation nodes - **Component chain efficiency**: Custom components chain via `RenderedComp` without wrapper overhead - **Memory efficiency**: Each pattern only allocates fields it actually uses This avoids React's issue where every element creates wrapper nodes, leading to shorter traversal paths and fewer allocations. ## Pattern Transition Rules Components never transition between patterns - they maintain their pattern for their entire lifecycle: - **Tag determines pattern**: `#text` → Pattern 1, base tags → Pattern 2, custom tags → Pattern 3 - **Tag changes cause remount**: Different tag = different component = complete unmount/remount - **Pattern fields are exclusive**: Only one pattern's fields are populated per component This ensures clean memory management and predictable behavior - no cross-pattern cleanup is needed within individual render functions. ================================================ FILE: tsunami/engine/rootelem.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import ( "fmt" "log" "reflect" "strconv" "strings" "sync" "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) const ChildrenPropKey = "children" type EffectWorkElem struct { WaveId string EffectIndex int CompTag string } type genAtom interface { GetVal() any SetVal(any) error SetUsedBy(string, bool) GetUsedBy() []string GetMeta() *AtomMeta GetAtomType() reflect.Type } type RootElem struct { Root *ComponentImpl RenderTs int64 CFuncs map[string]any // component name => render function CompMap map[string]*ComponentImpl // component waveid -> component EffectWorkQueue []*EffectWorkElem needsRenderMap map[string]bool // key: waveid needsRenderLock sync.Mutex Atoms map[string]genAtom // key: atomName atomLock sync.Mutex RefOperations []vdom.VDomRefOperation Client *ClientImpl } func (r *RootElem) addRenderWork(id string) { defer func() { if inContextType() == GlobalContextType_async { r.Client.notifyAsyncRenderWork() } }() r.needsRenderLock.Lock() defer r.needsRenderLock.Unlock() if r.needsRenderMap == nil { r.needsRenderMap = make(map[string]bool) } r.needsRenderMap[id] = true } func (r *RootElem) getAndClearRenderWork() []string { r.needsRenderLock.Lock() defer r.needsRenderLock.Unlock() if len(r.needsRenderMap) == 0 { return nil } ids := make([]string, 0, len(r.needsRenderMap)) for id := range r.needsRenderMap { ids = append(ids, id) } r.needsRenderMap = nil return ids } func (r *RootElem) addEffectWork(id string, effectIndex int, compTag string) { r.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{WaveId: id, EffectIndex: effectIndex, CompTag: compTag}) } // getAtomsByPrefix extracts all atoms that match the given prefix from RootElem func (r *RootElem) getAtomsByPrefix(prefix string) map[string]genAtom { r.atomLock.Lock() defer r.atomLock.Unlock() result := make(map[string]genAtom) for atomName, atom := range r.Atoms { if strings.HasPrefix(atomName, prefix) { strippedName := strings.TrimPrefix(atomName, prefix) result[strippedName] = atom } } return result } func (r *RootElem) GetDataMap() map[string]any { atoms := r.getAtomsByPrefix("$data.") result := make(map[string]any) for name, atom := range atoms { result[name] = atom.GetVal() } return result } func (r *RootElem) GetConfigMap() map[string]any { atoms := r.getAtomsByPrefix("$config.") result := make(map[string]any) for name, atom := range atoms { result[name] = atom.GetVal() } return result } func MakeRoot(client *ClientImpl) *RootElem { return &RootElem{ Root: nil, CFuncs: make(map[string]any), CompMap: make(map[string]*ComponentImpl), Atoms: make(map[string]genAtom), Client: client, } } func (r *RootElem) RegisterAtom(name string, atom genAtom) { r.atomLock.Lock() defer r.atomLock.Unlock() if _, ok := r.Atoms[name]; ok { panic(fmt.Sprintf("atom %s already exists", name)) } r.Atoms[name] = atom } // cleanupUsedByForUnmount uses the reverse mapping for efficient cleanup func (r *RootElem) cleanupUsedByForUnmount(comp *ComponentImpl) { r.atomLock.Lock() defer r.atomLock.Unlock() // Use reverse mapping for efficient cleanup for atomName := range comp.UsedAtoms { if atom, ok := r.Atoms[atomName]; ok { atom.SetUsedBy(comp.WaveId, false) } } // Clear the component's atom tracking comp.UsedAtoms = nil } func (r *RootElem) updateComponentAtomUsage(comp *ComponentImpl, newUsedAtoms map[string]bool) { r.atomLock.Lock() defer r.atomLock.Unlock() oldUsedAtoms := comp.UsedAtoms // Remove component from atoms it no longer uses for atomName := range oldUsedAtoms { if !newUsedAtoms[atomName] { if atom, ok := r.Atoms[atomName]; ok { atom.SetUsedBy(comp.WaveId, false) } } } // Add component to atoms it now uses for atomName := range newUsedAtoms { if !oldUsedAtoms[atomName] { if atom, ok := r.Atoms[atomName]; ok { atom.SetUsedBy(comp.WaveId, true) } } } // Update component's atom usage map if len(newUsedAtoms) == 0 { comp.UsedAtoms = nil } else { comp.UsedAtoms = make(map[string]bool) for atomName := range newUsedAtoms { comp.UsedAtoms[atomName] = true } } } func (r *RootElem) AtomAddRenderWork(atomName string) { r.atomLock.Lock() defer r.atomLock.Unlock() atom, ok := r.Atoms[atomName] if !ok { return } usedBy := atom.GetUsedBy() if len(usedBy) == 0 { return } for _, compId := range usedBy { r.addRenderWork(compId) } } func (r *RootElem) GetAtomVal(name string) any { r.atomLock.Lock() defer r.atomLock.Unlock() atom, ok := r.Atoms[name] if !ok { return nil } return atom.GetVal() } func (r *RootElem) SetAtomVal(name string, val any) error { r.atomLock.Lock() defer r.atomLock.Unlock() atom, ok := r.Atoms[name] if !ok { return fmt.Errorf("atom %q not found", name) } return atom.SetVal(val) } func (r *RootElem) RemoveAtom(name string) { r.atomLock.Lock() defer r.atomLock.Unlock() delete(r.Atoms, name) } func validateCFunc(cfunc any) error { if cfunc == nil { return fmt.Errorf("Component function cannot b nil") } rval := reflect.ValueOf(cfunc) if rval.Kind() != reflect.Func { return fmt.Errorf("Component function must be a function") } rtype := rval.Type() if rtype.NumIn() != 1 { return fmt.Errorf("Component function must take exactly 1 argument") } if rtype.NumOut() != 1 { return fmt.Errorf("Component function must return exactly 1 value") } // first argument can be a map[string]any, or a struct, or ptr to struct (we'll reflect the value into it) arg1Type := rtype.In(0) if arg1Type.Kind() == reflect.Ptr { arg1Type = arg1Type.Elem() } if arg1Type.Kind() == reflect.Map { if arg1Type.Key().Kind() != reflect.String || !(arg1Type.Elem().Kind() == reflect.Interface && arg1Type.Elem().NumMethod() == 0) { return fmt.Errorf("Map argument must be map[string]any") } } else if arg1Type.Kind() != reflect.Struct && !(arg1Type.Kind() == reflect.Interface && arg1Type.NumMethod() == 0) { return fmt.Errorf("Component function argument must be map[string]any, struct, or any") } return nil } func (r *RootElem) RegisterComponent(name string, cfunc any) error { if err := validateCFunc(cfunc); err != nil { return err } r.CFuncs[name] = cfunc return nil } func callVDomFn(fnVal any, data vdom.VDomEvent) { if fnVal == nil { return } fn := fnVal if vdf, ok := fnVal.(*vdom.VDomFunc); ok { fn = vdf.Fn } if fn == nil { return } rval := reflect.ValueOf(fn) if rval.Kind() != reflect.Func { return } rtype := rval.Type() if rtype.NumIn() == 0 { rval.Call(nil) return } if rtype.NumIn() == 1 { rval.Call([]reflect.Value{reflect.ValueOf(data)}) return } } func (r *RootElem) Event(event vdom.VDomEvent, globalEventHandler func(vdom.VDomEvent)) { defer func() { if event.GlobalEventType != "" { util.PanicHandler(fmt.Sprintf("Global event handler - event:%s", event.GlobalEventType), recover()) } else { comp := r.CompMap[event.WaveId] tag := "" if comp != nil && comp.Elem != nil { tag = comp.Elem.Tag } compName := "" if comp != nil { compName = comp.ContainingComp } util.PanicHandler(fmt.Sprintf("Event handler - comp: %s, tag: %s, prop: %s", compName, tag, event.EventType), recover()) } }() eventCtx := &EventContextImpl{Event: event, Root: r} withGlobalEventCtx(eventCtx, func() any { if event.GlobalEventType != "" { if globalEventHandler == nil { log.Printf("global event %s but no handler", event.GlobalEventType) return nil } globalEventHandler(event) return nil } comp := r.CompMap[event.WaveId] if comp == nil || comp.Elem == nil { return nil } fnVal := comp.Elem.Props[event.EventType] callVDomFn(fnVal, event) return nil }) } func (r *RootElem) runEffectUnmount(work *EffectWorkElem, hook *Hook) { defer func() { comp := r.CompMap[work.WaveId] compName := "" if comp != nil { compName = comp.ContainingComp } util.PanicHandler(fmt.Sprintf("UseEffect unmount - comp: %s", compName), recover()) }() if hook.UnmountFn == nil { return } effectCtx := &EffectContextImpl{ WorkElem: *work, WorkType: "unmount", Root: r, } withGlobalEffectCtx(effectCtx, func() any { hook.UnmountFn() return nil }) } func (r *RootElem) runEffect(work *EffectWorkElem, hook *Hook) { defer func() { comp := r.CompMap[work.WaveId] compName := "" if comp != nil { compName = comp.ContainingComp } util.PanicHandler(fmt.Sprintf("UseEffect run - comp: %s", compName), recover()) }() if hook.Fn == nil { return } effectCtx := &EffectContextImpl{ WorkElem: *work, WorkType: "run", Root: r, } unmountFn := withGlobalEffectCtx(effectCtx, func() func() { return hook.Fn() }) hook.UnmountFn = unmountFn } // this will be called by the frontend to say the DOM has been mounted // it will eventually send any updated "refs" to the backend as well func (r *RootElem) RunWork(opts *RenderOpts) { workQueue := r.EffectWorkQueue r.EffectWorkQueue = nil // first, run effect cleanups for _, work := range workQueue { comp := r.CompMap[work.WaveId] if comp == nil { continue } hook := comp.Hooks[work.EffectIndex] r.runEffectUnmount(work, hook) } // now run, new effects for _, work := range workQueue { comp := r.CompMap[work.WaveId] if comp == nil { continue } hook := comp.Hooks[work.EffectIndex] r.runEffect(work, hook) } // now check if we need a render renderIds := r.getAndClearRenderWork() if len(renderIds) > 0 { r.render(r.Root.Elem, &r.Root, "root", opts) } } func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) { refId := updateRef.RefId split := strings.SplitN(refId, ":", 2) if len(split) != 2 { log.Printf("invalid ref id: %s\n", refId) return } waveId := split[0] hookIdx, err := strconv.Atoi(split[1]) if err != nil { log.Printf("invalid ref id (bad hook idx): %s\n", refId) return } comp := r.CompMap[waveId] if comp == nil { return } if hookIdx < 0 || hookIdx >= len(comp.Hooks) { return } hook := comp.Hooks[hookIdx] if hook == nil { return } ref, ok := hook.Val.(*vdom.VDomRef) if !ok { return } ref.HasCurrent.Store(updateRef.HasCurrent) ref.Position = updateRef.Position if updateRef.TermSize != nil { ref.TermSize = updateRef.TermSize } } func (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) { r.RefOperations = append(r.RefOperations, op) } func (r *RootElem) GetRefOperations() []vdom.VDomRefOperation { ops := r.RefOperations r.RefOperations = nil return ops } ================================================ FILE: tsunami/engine/schema.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import ( "fmt" "reflect" "strconv" "strings" "time" "github.com/wavetermdev/waveterm/tsunami/util" ) // createStructDefinition creates a JSON schema definition for a struct type func createStructDefinition(t reflect.Type) map[string]any { structDef := make(map[string]any) structDef["type"] = "object" properties := make(map[string]any) required := make([]string, 0) for i := 0; i < t.NumField(); i++ { field := t.Field(i) if !field.IsExported() { continue } // Parse JSON tag fieldInfo, shouldInclude := util.ParseJSONTag(field) if !shouldInclude { continue // Skip this field } // If field has "string" option, force schema type to string var fieldSchema map[string]any if fieldInfo.AsString { fieldSchema = map[string]any{"type": "string"} } else { fieldSchema = generateShallowJSONSchema(field.Type, nil) } // Add description from "desc" tag if present if desc := field.Tag.Get("desc"); desc != "" { fieldSchema["description"] = desc } // Add enum values from "enum" tag if present (only for string types) if enumTag := field.Tag.Get("enum"); enumTag != "" && fieldSchema["type"] == "string" { enumValues := make([]any, 0) for _, val := range strings.Split(enumTag, ",") { trimmed := strings.TrimSpace(val) if trimmed != "" { enumValues = append(enumValues, trimmed) } } if len(enumValues) > 0 { fieldSchema["enum"] = enumValues } } // Add units from "units" tag if present if units := field.Tag.Get("units"); units != "" { fieldSchema["units"] = units } // Add min/max constraints for numeric types if fieldSchema["type"] == "number" || fieldSchema["type"] == "integer" { if minTag := field.Tag.Get("min"); minTag != "" { if minVal, err := strconv.ParseFloat(minTag, 64); err == nil { fieldSchema["minimum"] = minVal } } if maxTag := field.Tag.Get("max"); maxTag != "" { if maxVal, err := strconv.ParseFloat(maxTag, 64); err == nil { fieldSchema["maximum"] = maxVal } } } // Add pattern constraint for string types if fieldSchema["type"] == "string" { if pattern := field.Tag.Get("pattern"); pattern != "" { fieldSchema["pattern"] = pattern } } properties[fieldInfo.FieldName] = fieldSchema // Add to required if not a pointer and not marked as omitempty if field.Type.Kind() != reflect.Ptr && !fieldInfo.OmitEmpty { required = append(required, fieldInfo.FieldName) } } if len(properties) > 0 { structDef["properties"] = properties } if len(required) > 0 { structDef["required"] = required } return structDef } // collectStructDefs walks the type tree and adds struct definitions to defs map func collectStructDefs(t reflect.Type, defs map[reflect.Type]any) { switch t.Kind() { case reflect.Slice, reflect.Array: if t.Elem() != nil { collectStructDefs(t.Elem(), defs) } case reflect.Map: if t.Elem() != nil { collectStructDefs(t.Elem(), defs) } case reflect.Struct: // Skip time.Time since we handle it specially if t == reflect.TypeOf(time.Time{}) { return } // Skip if we already have this struct definition if _, exists := defs[t]; exists { return } // Create the struct definition structDef := createStructDefinition(t) // Add the definition before recursing into field types defs[t] = structDef // Now recurse into field types to collect their struct definitions for i := 0; i < t.NumField(); i++ { field := t.Field(i) if field.IsExported() { _, shouldInclude := util.ParseJSONTag(field) if shouldInclude { collectStructDefs(field.Type, defs) } } } case reflect.Ptr: collectStructDefs(t.Elem(), defs) } } // annotateSchemaWithAtomMeta applies AtomMeta annotations to a JSON schema func annotateSchemaWithAtomMeta(schema map[string]any, meta *AtomMeta) { if meta == nil { return } if meta.Description != "" { schema["description"] = meta.Description } if meta.Units != "" { schema["units"] = meta.Units } // Add numeric constraints for number/integer types if schema["type"] == "number" || schema["type"] == "integer" { if meta.Min != nil { schema["minimum"] = *meta.Min } if meta.Max != nil { schema["maximum"] = *meta.Max } } // Add enum values if specified (only for string types) if len(meta.Enum) > 0 && schema["type"] == "string" { enumValues := make([]any, len(meta.Enum)) for i, v := range meta.Enum { enumValues[i] = v } schema["enum"] = enumValues } // Add pattern constraint for strings if schema["type"] == "string" && meta.Pattern != "" { schema["pattern"] = meta.Pattern } } // generateShallowJSONSchema creates a schema that references definitions instead of recursing func generateShallowJSONSchema(t reflect.Type, meta *AtomMeta) map[string]any { schema := make(map[string]any) defer func() { annotateSchemaWithAtomMeta(schema, meta) }() // Special case for time.Time - treat as string with date-time format if t == reflect.TypeOf(time.Time{}) { schema["type"] = "string" schema["format"] = "date-time" return schema } // Special case for []byte - treat as string with base64 encoding if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { schema["type"] = "string" schema["contentEncoding"] = "base64" schema["contentMediaType"] = "application/octet-stream" return schema } switch t.Kind() { case reflect.String: schema["type"] = "string" case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: schema["type"] = "integer" case reflect.Float32, reflect.Float64: schema["type"] = "number" case reflect.Bool: schema["type"] = "boolean" case reflect.Slice, reflect.Array: schema["type"] = "array" if t.Elem() != nil { schema["items"] = generateShallowJSONSchema(t.Elem(), nil) } case reflect.Map: schema["type"] = "object" if t.Elem() != nil { schema["additionalProperties"] = generateShallowJSONSchema(t.Elem(), nil) } case reflect.Struct: // Reference the definition instead of recursing schema["$ref"] = fmt.Sprintf("#/$defs/%s", t.Name()) case reflect.Ptr: return generateShallowJSONSchema(t.Elem(), meta) case reflect.Interface: schema["type"] = "object" default: schema["type"] = "object" } return schema } // getAtomMeta extracts AtomMeta from the atom func getAtomMeta(atom genAtom) *AtomMeta { return atom.GetMeta() } // generateSchemaFromAtoms generates a JSON schema from a map of atoms func generateSchemaFromAtoms(atoms map[string]genAtom, title, description string) map[string]any { // Collect all struct definitions defs := make(map[reflect.Type]any) for _, atom := range atoms { atomType := atom.GetAtomType() if atomType != nil { collectStructDefs(atomType, defs) } } // Generate properties for each atom properties := make(map[string]any) for atomName, atom := range atoms { atomType := atom.GetAtomType() if atomType != nil { atomMeta := getAtomMeta(atom) properties[atomName] = generateShallowJSONSchema(atomType, atomMeta) } } // Build the final schema // schema line unnecessary for AI (and burns tokens) // also dropping title since it is mostly redundant schema := map[string]any{ // "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", // "title": title, "description": description, "properties": properties, "additionalProperties": false, } // Add definitions if any if len(defs) > 0 { definitions := make(map[string]any) for t, def := range defs { definitions[t.Name()] = def } schema["$defs"] = definitions } return schema } // GenerateConfigSchema generates a JSON schema for all config atoms func GenerateConfigSchema(root *RootElem) map[string]any { configAtoms := root.getAtomsByPrefix("$config.") return generateSchemaFromAtoms(configAtoms, "Application Configuration", "Application configuration settings") } // GenerateDataSchema generates a JSON schema for all data atoms func GenerateDataSchema(root *RootElem) map[string]any { dataAtoms := root.getAtomsByPrefix("$data.") return generateSchemaFromAtoms(dataAtoms, "Application Data", "Application data schema") } ================================================ FILE: tsunami/engine/serverhandlers.go ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package engine import ( "encoding/json" "fmt" "io" "io/fs" "log" "mime" "net/http" "os" "strings" "sync" "time" "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) const SSEKeepAliveDuration = 5 * time.Second func init() { // Add explicit mapping for .json files mime.AddExtensionType(".json", "application/json") } type handlerOpts struct { AssetsFS fs.FS StaticFS fs.FS ManifestFile []byte } type httpHandlers struct { Client *ClientImpl renderLock sync.Mutex } func newHTTPHandlers(client *ClientImpl) *httpHandlers { return &httpHandlers{ Client: client, } } func setNoCacheHeaders(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") } func setCORSHeaders(w http.ResponseWriter, r *http.Request) bool { corsOriginsStr := os.Getenv("TSUNAMI_CORS") if corsOriginsStr == "" { return false } origin := r.Header.Get("Origin") if origin == "" { return false } allowedOrigins := strings.Split(corsOriginsStr, ",") for _, allowedOrigin := range allowedOrigins { allowedOrigin = strings.TrimSpace(allowedOrigin) if allowedOrigin == origin { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Credentials", "true") return true } } return false } func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) { mux.HandleFunc("/api/render", h.handleRender) mux.HandleFunc("/api/updates", h.handleSSE) mux.HandleFunc("/api/data", h.handleData) mux.HandleFunc("/api/config", h.handleConfig) mux.HandleFunc("/api/schemas", h.handleSchemas) mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile)) mux.HandleFunc("/api/modalresult", h.handleModalResult) mux.HandleFunc("/api/terminput", h.handleTermInput) mux.HandleFunc("/dyn/", h.handleDynContent) // Add handler for static files at /static/ path if opts.StaticFS != nil { mux.HandleFunc("/static/", h.handleStaticPathFiles(opts.StaticFS)) } // Add fallback handler for embedded static files in production mode if opts.AssetsFS != nil { mux.HandleFunc("/", h.handleStaticFiles(opts.AssetsFS)) } } func (h *httpHandlers) handleRender(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleRender", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() setNoCacheHeaders(w) if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) return } var feUpdate rpctypes.VDomFrontendUpdate if err := json.Unmarshal(body, &feUpdate); err != nil { http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) return } if feUpdate.ForceTakeover { h.Client.clientTakeover(feUpdate.ClientId) } if err := h.Client.checkClientId(feUpdate.ClientId); err != nil { http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest) return } startTime := time.Now() update, err := h.processFrontendUpdate(&feUpdate) duration := time.Since(startTime) if err != nil { http.Error(w, fmt.Sprintf("render error: %v", err), http.StatusInternalServerError) return } if update == nil { w.WriteHeader(http.StatusOK) if os.Getenv("TSUNAMI_DEBUG") != "" { log.Printf("render %4s %4dms %4dk %s", "none", duration.Milliseconds(), 0, feUpdate.Reason) } return } w.Header().Set("Content-Type", "application/json") // Encode to bytes first to calculate size responseBytes, err := json.Marshal(update) if err != nil { log.Printf("failed to encode response: %v", err) http.Error(w, "failed to encode response", http.StatusInternalServerError) return } updateSizeKB := len(responseBytes) / 1024 renderType := "inc" if update.FullUpdate { renderType = "full" } if os.Getenv("TSUNAMI_DEBUG") != "" { log.Printf("render %4s %4dms %4dk %s", renderType, duration.Milliseconds(), updateSizeKB, feUpdate.Reason) } if _, err := w.Write(responseBytes); err != nil { log.Printf("failed to write response: %v", err) } } func (h *httpHandlers) processFrontendUpdate(feUpdate *rpctypes.VDomFrontendUpdate) (*rpctypes.VDomBackendUpdate, error) { h.renderLock.Lock() defer h.renderLock.Unlock() if feUpdate.Dispose { log.Printf("got dispose from frontend\n") h.Client.doShutdown("got dispose from frontend") return nil, nil } if h.Client.GetIsDone() { return nil, nil } h.Client.Root.RenderTs = feUpdate.Ts // Close all open modals on resync (e.g., page refresh) if feUpdate.Resync { h.Client.CloseAllModals() } // run events h.Client.RunEvents(feUpdate.Events) // update refs for _, ref := range feUpdate.RefUpdates { h.Client.Root.UpdateRef(ref) } var update *rpctypes.VDomBackendUpdate var renderErr error if feUpdate.Resync || true { update, renderErr = h.Client.fullRender() } else { update, renderErr = h.Client.incrementalRender() } if renderErr != nil { return nil, renderErr } update.CreateTransferElems() return update, nil } func (h *httpHandlers) handleData(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleData", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() setCORSHeaders(w, r) setNoCacheHeaders(w) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } result := h.Client.Root.GetDataMap() w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(result); err != nil { log.Printf("failed to encode data response: %v", err) http.Error(w, "failed to encode response", http.StatusInternalServerError) } } func (h *httpHandlers) handleConfig(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleConfig", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() setCORSHeaders(w, r) setNoCacheHeaders(w) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } switch r.Method { case http.MethodGet: h.handleConfigGet(w, r) case http.MethodPost: h.handleConfigPost(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *httpHandlers) handleConfigGet(w http.ResponseWriter, _ *http.Request) { result := h.Client.Root.GetConfigMap() w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(result); err != nil { log.Printf("failed to encode config response: %v", err) http.Error(w, "failed to encode response", http.StatusInternalServerError) } } func (h *httpHandlers) handleConfigPost(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) return } var configData map[string]any if err := json.Unmarshal(body, &configData); err != nil { http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) return } var failedKeys []string for key, value := range configData { atomName := "$config." + key if err := h.Client.Root.SetAtomVal(atomName, value); err != nil { failedKeys = append(failedKeys, key) } } w.Header().Set("Content-Type", "application/json") var response map[string]any if len(failedKeys) > 0 { response = map[string]any{ "error": fmt.Sprintf("Failed to update keys: %s", strings.Join(failedKeys, ", ")), } } else { response = map[string]any{ "success": true, } } w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(response) } func (h *httpHandlers) handleSchemas(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleSchemas", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() setCORSHeaders(w, r) setNoCacheHeaders(w) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } configSchema := GenerateConfigSchema(h.Client.Root) dataSchema := GenerateDataSchema(h.Client.Root) result := map[string]any{ "config": configSchema, "data": dataSchema, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(result); err != nil { log.Printf("failed to encode schemas response: %v", err) http.Error(w, "failed to encode response", http.StatusInternalServerError) } } func (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleModalResult", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() setNoCacheHeaders(w) if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) return } var result rpctypes.ModalResult if err := json.Unmarshal(body, &result); err != nil { http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) return } h.Client.CloseModal(result.ModalId, result.Confirm) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{"success": true}) } func (h *httpHandlers) handleTermInput(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleTermInput", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() setNoCacheHeaders(w) if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) return } var event vdom.VDomEvent if err := json.Unmarshal(body, &event); err != nil { http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) return } if strings.TrimSpace(event.WaveId) == "" { http.Error(w, "waveid is required", http.StatusBadRequest) return } if event.TermInput == nil { http.Error(w, "terminput is required", http.StatusBadRequest) return } h.renderLock.Lock() h.Client.Root.Event(event, h.Client.GlobalEventHandler) h.renderLock.Unlock() w.WriteHeader(http.StatusNoContent) } func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleDynContent", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() // Strip /assets prefix and update the request URL r.URL.Path = strings.TrimPrefix(r.URL.Path, "/dyn") if r.URL.Path == "" { r.URL.Path = "/" } h.Client.UrlHandlerMux.ServeHTTP(w, r) } func (h *httpHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleSSE", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } clientId := r.URL.Query().Get("clientId") if err := h.Client.checkClientId(clientId); err != nil { http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest) return } // Generate unique connection ID for this SSE connection connectionId := fmt.Sprintf("%s-%d", clientId, time.Now().UnixNano()) // Register SSE channel for this connection eventCh := h.Client.RegisterSSEChannel(connectionId) defer h.Client.UnregisterSSEChannel(connectionId) // Set SSE headers setNoCacheHeaders(w) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Content-Type-Options", "nosniff") // Use ResponseController for better flushing control rc := http.NewResponseController(w) if err := rc.Flush(); err != nil { http.Error(w, "streaming not supported", http.StatusInternalServerError) return } // Create a ticker for keepalive packets keepaliveTicker := time.NewTicker(SSEKeepAliveDuration) defer keepaliveTicker.Stop() for { select { case <-r.Context().Done(): return case <-keepaliveTicker.C: // Send keepalive comment fmt.Fprintf(w, ": keepalive\n\n") rc.Flush() case event := <-eventCh: if event.Event == "" { break } fmt.Fprintf(w, "event: %s\n", event.Event) fmt.Fprintf(w, "data: %s\n", string(event.Data)) fmt.Fprintf(w, "\n") rc.Flush() } } } // serveFileDirectly serves a file directly from an embed.FS to avoid redirect loops // when serving directory paths that end with "/" func serveFileDirectly(w http.ResponseWriter, r *http.Request, embeddedFS fs.FS, requestPath, fileName string) bool { if !strings.HasSuffix(requestPath, "/") { return false } // Try to serve the specified file from that directory var filePath string if requestPath == "/" { filePath = fileName } else { filePath = strings.TrimPrefix(requestPath, "/") + fileName } file, err := embeddedFS.Open(filePath) if err != nil { return false } defer file.Close() // Get file info for modification time fileInfo, err := file.Stat() if err != nil { return false } // Serve the file directly with proper mod time http.ServeContent(w, r, fileName, fileInfo.ModTime(), file.(io.ReadSeeker)) return true } func (h *httpHandlers) handleStaticFiles(embeddedFS fs.FS) http.HandlerFunc { fileServer := http.FileServer(http.FS(embeddedFS)) return func(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleStaticFiles", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() // Skip if this is an API, files, or static request (already handled by other handlers) if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/files/") || strings.HasPrefix(r.URL.Path, "/static/") { http.NotFound(w, r) return } // Handle any path ending with "/" to avoid redirect loops if serveFileDirectly(w, r, embeddedFS, r.URL.Path, "index.html") { return } // For other files, check if they exist before serving filePath := strings.TrimPrefix(r.URL.Path, "/") _, err := embeddedFS.Open(filePath) if err != nil { http.NotFound(w, r) return } // Serve the file using the file server fileServer.ServeHTTP(w, r) } } func (h *httpHandlers) handleManifest(manifestFileBytes []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleManifest", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() setCORSHeaders(w, r) setNoCacheHeaders(w) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if manifestFileBytes == nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") w.Write(manifestFileBytes) } } func (h *httpHandlers) handleStaticPathFiles(staticFS fs.FS) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleStaticPathFiles", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() // Strip /static/ prefix from the path filePath := strings.TrimPrefix(r.URL.Path, "/static/") if filePath == "" { // Handle requests to "/static/" directly if serveFileDirectly(w, r, staticFS, "/", "index.html") { return } http.NotFound(w, r) return } // Handle directory paths ending with "/" to avoid redirect loops strippedPath := "/" + filePath if serveFileDirectly(w, r, staticFS, strippedPath, "index.html") { return } // Check if file exists in staticFS _, err := staticFS.Open(filePath) if err != nil { http.NotFound(w, r) return } // Create a file server and serve the file fileServer := http.FileServer(http.FS(staticFS)) // Temporarily modify the URL path for the file server originalPath := r.URL.Path r.URL.Path = "/" + filePath fileServer.ServeHTTP(w, r) r.URL.Path = originalPath } } ================================================ FILE: tsunami/frontend/.gitignore ================================================ scaffold/ ================================================ FILE: tsunami/frontend/index.html ================================================ <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Tsunami App
================================================ FILE: tsunami/frontend/package.json ================================================ { "name": "tsunami-frontend", "author": { "name": "Command Line Inc", "email": "info@commandline.dev" }, "description": "Tsunami Frontend - React application", "license": "Apache-2.0", "version": "0.1.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build", "build:dev": "NODE_ENV=development vite build --mode development", "preview": "vite preview", "type-check": "tsc --noEmit" }, "dependencies": { "clsx": "^2.1.1", "debug": "^4.4.3", "jotai": "^2.13.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", "recharts": "^3.1.2", "tailwind-merge": "^3.3.1" }, "devDependencies": { "@tailwindcss/cli": "^4.2.1", "@tailwindcss/vite": "^4.2.1", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react-swc": "^4.2.3", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "vite": "^6.4.1" } } ================================================ FILE: tsunami/frontend/src/app.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { TsunamiModel } from "@/model/tsunami-model"; import { VDomView } from "./vdom"; // Global model instance const globalModel = new TsunamiModel(); function App() { return (
); } export default App; ================================================ FILE: tsunami/frontend/src/element/markdown.tsx ================================================ import React from 'react'; import ReactMarkdown, { Components } from 'react-markdown'; import { twMerge } from 'tailwind-merge'; interface MarkdownProps { text?: string; style?: React.CSSProperties; className?: string; scrollable?: boolean; } const markdownComponents: Partial = { h1: ({ children }) =>

{children}

, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, h4: ({ children }) =>

{children}

, h5: ({ children }) =>
{children}
, h6: ({ children }) =>
{children}
, p: ({ children }) =>

{children}

, a: ({ href, children }) => ( {children} ), ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , code: ({ className, children }) => { const isInline = !className; if (isInline) { return ( {children} ); } return ( {children} ); }, pre: ({ children }) => (
                {children}
            
    ), blockquote: ({ children }) => (
    {children}
    ), hr: () =>
    , table: ({ children }) => (
    {children}
    ), th: ({ children }) => ( {children} ), td: ({ children }) => ( {children} ), }; export function Markdown({ text, style, className, scrollable = true }: MarkdownProps) { const scrollClasses = scrollable ? "overflow-auto" : ""; const baseClasses = "prose prose-sm max-w-none"; return (
    {text || ''}
    ); } ================================================ FILE: tsunami/frontend/src/element/modals.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { useEffect } from "react"; interface ModalProps { config: ModalConfig; onClose: (confirmed: boolean) => void; } export function AlertModal({ config, onClose }: ModalProps) { const handleOk = () => { onClose(true); }; // Handle escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(false); } }; window.addEventListener("keydown", handleEscape); return () => window.removeEventListener("keydown", handleEscape); }, [onClose]); return (
    {config.icon &&
    {config.icon}
    }

    {config.title}

    {config.text &&

    {config.text}

    }
    ); } export function ConfirmModal({ config, onClose }: ModalProps) { const handleConfirm = () => { onClose(true); }; const handleCancel = () => { onClose(false); }; // Handle escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(false); } }; window.addEventListener("keydown", handleEscape); return () => window.removeEventListener("keydown", handleEscape); }, [onClose]); return (
    {config.icon &&
    {config.icon}
    }

    {config.title}

    {config.text &&

    {config.text}

    }
    ); } ================================================ FILE: tsunami/frontend/src/element/tsunamiterm.tsx ================================================ import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import * as React from "react"; import { base64ToArray } from "@/util/base64"; export type TsunamiTermElem = HTMLDivElement & { __termWrite: (data64: string) => void; __termFocus: () => void; __termSize: () => VDomTermSize | null; }; type TsunamiTermProps = React.HTMLAttributes & { onData?: (data: string | null, termsize: VDomTermSize | null) => void; termFontSize?: number; termFontFamily?: string; termScrollback?: number; }; const TsunamiTerm = React.forwardRef(function TsunamiTerm(props, ref) { const { onData, termFontSize, termFontFamily, termScrollback, ...outerProps } = props; const outerRef = React.useRef(null); const termRef = React.useRef(null); const terminalRef = React.useRef(null); const onDataRef = React.useRef(onData); onDataRef.current = onData; const setOuterRef = React.useCallback( (elem: TsunamiTermElem) => { outerRef.current = elem; if (elem != null) { elem.__termWrite = (data64: string) => { if (data64 == null || data64 === "") { return; } try { terminalRef.current?.write(base64ToArray(data64)); } catch (error) { console.error("Failed to write to terminal:", error); } }; elem.__termFocus = () => { terminalRef.current?.focus(); }; elem.__termSize = () => { const terminal = terminalRef.current; if (terminal == null) { return null; } return { rows: terminal.rows, cols: terminal.cols }; }; } if (typeof ref === "function") { ref(elem); return; } if (ref != null) { ref.current = elem; } }, [ref] ); React.useEffect(() => { if (termRef.current == null) { return; } const terminal = new Terminal({ convertEol: false, ...(termFontSize != null ? { fontSize: termFontSize } : {}), ...(termFontFamily != null ? { fontFamily: termFontFamily } : {}), ...(termScrollback != null ? { scrollback: termScrollback } : {}), }); const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(termRef.current); fitAddon.fit(); terminalRef.current = terminal; const onDataDisposable = terminal.onData((data) => { if (onDataRef.current == null) { return; } onDataRef.current(data, null); }); const onResizeDisposable = terminal.onResize((size) => { if (onDataRef.current == null) { return; } onDataRef.current(null, { rows: size.rows, cols: size.cols }); }); if (onDataRef.current != null) { onDataRef.current(null, { rows: terminal.rows, cols: terminal.cols }); } const resizeObserver = new ResizeObserver(() => { fitAddon.fit(); }); if (outerRef.current != null) { resizeObserver.observe(outerRef.current); } return () => { resizeObserver.disconnect(); onResizeDisposable.dispose(); onDataDisposable.dispose(); terminal.dispose(); terminalRef.current = null; }; }, []); React.useEffect(() => { const terminal = terminalRef.current; if (terminal == null) { return; } if (termFontSize != null) { terminal.options.fontSize = termFontSize; } if (termFontFamily != null) { terminal.options.fontFamily = termFontFamily; } if (termScrollback != null) { terminal.options.scrollback = termScrollback; } }, [termFontSize, termFontFamily, termScrollback]); const handleFocus = React.useCallback( (e: React.FocusEvent) => { terminalRef.current?.focus(); outerProps.onFocus?.(e); }, [outerProps.onFocus] ); const handleBlur = React.useCallback( (e: React.FocusEvent) => { terminalRef.current?.blur(); outerProps.onBlur?.(e); }, [outerProps.onBlur] ); return (
    } onFocus={handleFocus} onBlur={handleBlur} >
    ); }); export { TsunamiTerm }; ================================================ FILE: tsunami/frontend/src/input.tsx ================================================ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as React from "react"; type Props = { value?: string; onChange?: (e: React.ChangeEvent) => void; onInput?: (e: React.FormEvent) => void; ttlMs?: number; // default 100 ref?: React.Ref; _tagName: "input" | "textarea"; } & Omit & React.TextareaHTMLAttributes, "value" | "onChange" | "onInput">; /** * OptimisticInput - A React input component that provides optimistic UI updates for Tsunami's framework. * * Problem: In Tsunami's reactive framework, every onChange event is sent to the server, which can cause * the cursor to jump or typing to feel laggy as the server responds with updates. * * Solution: This component applies updates optimistically by maintaining a "shadow" value that shows * immediately in the UI while waiting for server acknowledgment. If the server responds with the same * value within the TTL period (default 100ms), the optimistic update is confirmed. If the server * doesn't respond or responds with a different value, the input reverts to the server value. * * Key behaviors: * - For controlled inputs (value provided): Uses optimistic updates with shadow state * - For uncontrolled inputs (value undefined): Behaves like a normal React input * - Skips optimistic logic when disabled or readonly * - Handles IME composition properly to avoid interfering with multi-byte character input * - Supports both onChange and onInput event handlers * - Preserves cursor position through React's natural behavior (no manual cursor management) * * Example usage: * ```tsx * sendToServer(e.target.value)} * ttlMs={200} * /> * ``` */ function OptimisticInput({ value, onChange, onInput, ttlMs = 100, ref: forwardedRef, _tagName, ...rest }: Props) { const [shadow, setShadow] = React.useState(null); const timer = React.useRef(undefined); const startTTL = React.useCallback(() => { if (timer.current) clearTimeout(timer.current); timer.current = window.setTimeout(() => { // no ack within TTL → revert to server setShadow(null); // caret will follow serverValue; optionally restore selRef here if you track a server caret }, ttlMs); }, [ttlMs]); const handleChange = (e: React.ChangeEvent) => { // Skip validation during IME composition // (works in modern browsers/React via nativeEvent) // @ts-expect-error React typing doesn't surface this directly if (e.nativeEvent?.isComposing) return; // If uncontrolled (value is undefined), skip optimistic logic if (value === undefined) { onChange?.(e); onInput?.(e); return; } // Skip optimistic logic if readonly or disabled if (rest.disabled || rest.readOnly) { onChange?.(e); onInput?.(e); return; } const v = e.currentTarget.value; setShadow(v); // optimistic echo startTTL(); // wait for ack onChange?.(e); onInput?.(e); }; // Ack: backend caught up → drop shadow (and stop the TTL) React.useLayoutEffect(() => { if (shadow !== null && shadow === value) { setShadow(null); if (timer.current) clearTimeout(timer.current); } }, [value, shadow]); React.useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [] ); const realValue = value === undefined ? undefined : (shadow ?? value ?? ""); if (_tagName === "textarea") { return