diff --git a/.vscode/settings.json b/.vscode/settings.json index d0b01c951..831ce237f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,9 @@ "[less]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[scss]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, diff --git a/README.md b/README.md index 8af65d91a..28f2004d3 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,23 @@ [![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) [![waveterm](https://snapcraft.io/waveterm/trending.svg?name=0)](https://snapcraft.io/waveterm) -Wave is an open-source terminal that can launch graphical widgets, controlled and integrated directly with the CLI. It includes a base terminal, directory browser, file previews (images, media, markdown), a graphical editor (for code/text files), a web browser, and integrated AI chat. +Wave is an open-source terminal that combines traditional terminal features with graphical capabilities like file previews, web browsing, and AI assistance. It runs on MacOS, Linux, and Windows. -Wave isn't just another terminal emulator; it's a rethink on how terminals are built. For too long there has been a disconnect between the CLI and the web. If you want fast, keyboard-accessible, easy-to-write applications, you use the CLI, but if you want graphical interfaces, native widgets, copy/paste, scrolling, variable font sizes, then you'd have to turn to the web. Wave's goal is to bridge that gap. +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. ![WaveTerm Screenshot](./assets/wave-screenshot.webp) +## Key Features + +- Flexible drag & drop interface to organize terminal blocks, editors, web browsers, and AI assistants +- Built-in editor for seamlessly editing remote files with syntax highlighting and modern editor features +- Rich file preview system for remote files (markdown, images, video, PDFs, CSVs, directories) +- Integrated AI chat with support for multiple models (OpenAI, Claude, Azure, Perplexity, Ollama) +- Command Blocks for isolating and monitoring individual commands with auto-close options +- One-click remote connections with full terminal and file system access +- 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 + ## Installation Wave Terminal works on macOS, Linux, and Windows. @@ -30,12 +41,18 @@ You can also install Wave Terminal directly from: [www.waveterm.dev/download](ht ### Minimum requirements -Wave Terminal and WSH run on the following platforms: +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 (arm64, x64) +- Linux Kernel 2.6.32 or later (x64), Linux Kernel 3.1 or later (arm64) + ## Links - Homepage — https://www.waveterm.dev @@ -43,6 +60,7 @@ Wave Terminal and WSH run on the following platforms: - Documentation — https://docs.waveterm.dev - Legacy Documentation — https://legacydocs.waveterm.dev - Blog — https://blog.waveterm.dev +- X — https://x.com/wavetermdev - Discord Community — https://discord.gg/XfvZ334gwU ## Building from Source diff --git a/Taskfile.yml b/Taskfile.yml index 98af8336a..beb8622e7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -96,7 +96,7 @@ tasks: cmds: - cmd: '{{.RMRF}} "make"' ignore_error: true - - yarn build:prod && yarn electron-builder -c electron-builder.config.cjs -p never + - yarn build:prod && yarn electron-builder -c electron-builder.config.cjs -p never {{.CLI_ARGS}} deps: - yarn - docsite:build:embedded diff --git a/cmd/wsh/cmd/wshcmd-wavepath.go b/cmd/wsh/cmd/wshcmd-wavepath.go new file mode 100644 index 000000000..fcfcbc7f3 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-wavepath.go @@ -0,0 +1,135 @@ +// Copyright 2024, 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") + + path, err := wshclient.PathCommand(RpcClient, wshrpc.PathCommandData{ + PathType: pathType, + Open: open, + OpenExternal: openExternal, + }, 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 +} diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 2c6f341ef..b1c7b89c5 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -25,6 +25,8 @@ wsh editconfig | 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). | | 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 | @@ -40,12 +42,16 @@ wsh editconfig | 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 | +| 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) | | 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) | +| 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 | @@ -188,3 +194,52 @@ wsh editconfig termthemes.json | 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    +- `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` diff --git a/docs/docs/connections.mdx b/docs/docs/connections.mdx index 4fb79ba62..066410a92 100644 --- a/docs/docs/connections.mdx +++ b/docs/docs/connections.mdx @@ -76,6 +76,72 @@ In addition to the regular ssh config file, wave also has its own config file to | 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. | | 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. | +### 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. + +### 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). diff --git a/docs/docs/customwidgets.mdx b/docs/docs/customwidgets.mdx index ca61fdb0f..44e6a95d0 100644 --- a/docs/docs/customwidgets.mdx +++ b/docs/docs/customwidgets.mdx @@ -109,9 +109,71 @@ The `WidgetConfigType` takes the usual options common to all widgets. The `MetaT | "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. | -## Example Terminal Widgets +## Example Shell Widgets -Here are a few simple widgets to serve as examples. +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 "C:\\Program Files\\PowerShell\\7\\pwsh.exe" 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 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 @@ -142,6 +204,7 @@ Using `"cmd"` for the `"controller"` is the simplest way to accomplish this. `"c 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", @@ -155,6 +218,7 @@ Now suppose I wanted to run a TUI app, for instance, `dua`. Well, it turns out t } }, <... other widgets go here ...> +} ``` This adds an icon to the widget bar that you can press to launch a terminal running the `dua` command. @@ -196,6 +260,7 @@ The `WidgetConfigType` takes the usual options common to all widgets. The `MetaT 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", @@ -209,6 +274,7 @@ Say you want a widget that automatically starts at YouTube and will use YouTube } }, <... 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. @@ -217,6 +283,7 @@ This adds an icon to the widget bar that you can press to launch a web widget on 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", @@ -230,6 +297,7 @@ Alternatively, say you want a web widget that opens to github as if it were a bo } }, <... 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. @@ -270,6 +338,7 @@ The `WidgetConfigType` takes the usual options common to all widgets. The `MetaT 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", @@ -283,6 +352,7 @@ Suppose you have a build process that lasts 3 minutes and you'd like to be able } }, <... 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. @@ -291,6 +361,7 @@ This adds an icon to the widget bar that you can press to launch the CPU and Mem 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", @@ -303,6 +374,7 @@ Now, suppose you are fine with the default 100 points (and 100 seconds) but woul } }, <... other widgets go here ...> +} ``` This adds an icon to the widget bar that you can press to launch All CPU plots by default. diff --git a/docs/docs/faq.mdx b/docs/docs/faq.mdx index dac3754ed..08797f690 100644 --- a/docs/docs/faq.mdx +++ b/docs/docs/faq.mdx @@ -28,5 +28,32 @@ the location of the Git Bash "bash.exe" binary. By default it is located at "C:\ 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" +"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.mdx). + +## 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 ``` diff --git a/docs/docs/gettingstarted.mdx b/docs/docs/gettingstarted.mdx index 9330ed77f..47e8c0273 100644 --- a/docs/docs/gettingstarted.mdx +++ b/docs/docs/gettingstarted.mdx @@ -14,6 +14,35 @@ Wave Terminal is a modern terminal that includes graphical capabilities like web +### Platform requirements + + + +- Supported architectures: Apple Silicon, x64 +- Supported OS version: macOS 11 Big Sur or later + + + + + +- 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)). + +::: + + + + + +- Supported architectures: x64, ARM64 +- Supported OS version: must have glibc-2.28 or later (Debian >=10, RHEL >=8, Ubuntu >=20.04, etc.) + + + ### Package managers diff --git a/docs/docs/img/widget-example-fish.webp b/docs/docs/img/widget-example-fish.webp new file mode 100644 index 000000000..0819ce981 Binary files /dev/null and b/docs/docs/img/widget-example-fish.webp differ diff --git a/docs/docs/img/widget-example-pwsh.webp b/docs/docs/img/widget-example-pwsh.webp new file mode 100644 index 000000000..3eb46f1e7 Binary files /dev/null and b/docs/docs/img/widget-example-pwsh.webp differ diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index 2313bacc3..7bd2775d6 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -71,4 +71,16 @@ replace "Cmd" with "Alt" (note that "Ctrl" is "Ctrl" on both Mac, Windows, and L | ---------------- | ------------- | | | Clear AI Chat | +## Terminal Keybindings + +| Key | Function | +| ----------------------- | -------------- | +| | Copy | +| | Paste | +| | Clear Terminal | + +## 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). + diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index b5c095ed5..2931c4683 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -693,4 +693,52 @@ 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. +## wavepath + +The `wavepath` command lets you get the paths to various Wave Terminal directories and files, including configuration, data storage, and logs. + +```bash +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: + +```bash +# 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. +::: + diff --git a/docs/package.json b/docs/package.json index d86e9b505..792182646 100644 --- a/docs/package.json +++ b/docs/package.json @@ -53,7 +53,7 @@ "remark-preset-lint-consistent": "^6.0.0", "remark-preset-lint-recommended": "^7.0.0", "typescript": "^5.7.2", - "typescript-eslint": "^8.17.0" + "typescript-eslint": "^8.18.0" }, "resolutions": { "path-to-regexp@npm:2.2.1": "^3", diff --git a/emain/emain-util.ts b/emain/emain-util.ts index ffdffb370..601b1b7f3 100644 --- a/emain/emain-util.ts +++ b/emain/emain-util.ts @@ -166,3 +166,90 @@ export function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rec } return bounds; } + +export function waveKeyToElectronKey(waveKey: string): string { + const waveParts = waveKey.split(":"); + const electronParts: Array = 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("+"); +} diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 9f3112616..7cc076ad5 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -3,7 +3,7 @@ import { ClientService, FileService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services"; import { fireAndForget } from "@/util/util"; -import { BaseWindow, BaseWindowConstructorOptions, dialog, ipcMain, screen } from "electron"; +import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen } from "electron"; import path from "path"; import { debounce } from "throttle-debounce"; import { @@ -14,7 +14,7 @@ import { setWasInFg, } from "./emain-activity"; import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview"; -import { delay, ensureBoundsAreVisible } from "./emain-util"; +import { delay, ensureBoundsAreVisible, waveKeyToElectronKey } from "./emain-util"; import { log } from "./log"; import { getElectronAppBasePath, unamePlatform } from "./platform"; import { updater } from "./updater"; @@ -766,3 +766,23 @@ export async function relaunchBrowserWindows() { 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); + } +} diff --git a/emain/emain.ts b/emain/emain.ts index 487f17c22..157033588 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -38,6 +38,7 @@ import { getWaveWindowById, getWaveWindowByWebContentsId, getWaveWindowByWorkspaceId, + registerGlobalHotkey, relaunchBrowserWindows, WaveBrowserWindow, } from "./emain-window"; @@ -46,6 +47,7 @@ import { getLaunchSettings } from "./launchsettings"; import { log } from "./log"; import { makeAppMenu } from "./menu"; import { + checkIfRunningUnderARM64Translation, getElectronAppBasePath, getElectronAppUnpackedBasePath, getWaveConfigDir, @@ -587,6 +589,7 @@ async function appMain() { await electronApp.whenReady(); configureAuthKeyRequestInjection(electron.session.defaultSession); const fullConfig = await services.FileService.GetFullConfig(); + checkIfRunningUnderARM64Translation(fullConfig); ensureHotSpareTab(fullConfig); await relaunchBrowserWindows(); await initDocsite(); @@ -610,6 +613,10 @@ async function appMain() { fireAndForget(createNewWaveWindow); } }); + const rawGlobalHotKey = launchSettings?.["app:globalhotkey"]; + if (rawGlobalHotKey) { + registerGlobalHotkey(rawGlobalHotKey); + } } appMain().catch((e) => { diff --git a/emain/platform.ts b/emain/platform.ts index 2bab197f6..8e9038918 100644 --- a/emain/platform.ts +++ b/emain/platform.ts @@ -1,7 +1,8 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { app, ipcMain } from "electron"; +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"; @@ -40,6 +41,32 @@ 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. diff --git a/frontend/app/block/block.scss b/frontend/app/block/block.scss index 9ad0482d6..87c5c645b 100644 --- a/frontend/app/block/block.scss +++ b/frontend/app/block/block.scss @@ -124,9 +124,9 @@ opacity: 0.7; flex-grow: 1; - &.flex-nogrow { - flex-grow: 0; - } + &.flex-nogrow { + flex-grow: 0; + } &.preview-filename { direction: rtl; @@ -166,7 +166,7 @@ flex: 1 2 auto; overflow: hidden; padding-right: 4px; - @include mixins.ellipsis() + @include mixins.ellipsis(); } .connecting-svg { @@ -211,12 +211,12 @@ } } - .button { + .wave-button { margin-left: 3px; } // webview specific. for refresh button - .iconbutton { + .wave-iconbutton { height: 100%; width: 27px; display: flex; @@ -226,7 +226,7 @@ } .menubutton { - .button { + .wave-button { font-size: 11px; } } @@ -236,7 +236,7 @@ display: flex; flex-shrink: 0; - .iconbutton { + .wave-iconbutton { display: flex; width: 24px; padding: 4px 6px; @@ -268,7 +268,7 @@ align-items: center; justify-content: center; - .iconbutton { + .wave-iconbutton { opacity: 0.7; font-size: 45px; margin: -30px 0 0 0; @@ -373,7 +373,7 @@ } } - .button:last-child { + .wave-button:last-child { margin-top: 1.5px; } } diff --git a/frontend/app/element/button.scss b/frontend/app/element/button.scss index 24a327a43..f547cf991 100644 --- a/frontend/app/element/button.scss +++ b/frontend/app/element/button.scss @@ -3,7 +3,7 @@ @use "../mixins.scss"; -.button { +.wave-button { // override default button appearance border: 1px solid transparent; outline: 1px solid transparent; diff --git a/frontend/app/element/button.tsx b/frontend/app/element/button.tsx index 499bcdb8f..7cf501c86 100644 --- a/frontend/app/element/button.tsx +++ b/frontend/app/element/button.tsx @@ -32,7 +32,7 @@ const Button = memo( diff --git a/frontend/app/element/iconbutton.scss b/frontend/app/element/iconbutton.scss index 2cb4a688b..571a0e4ab 100644 --- a/frontend/app/element/iconbutton.scss +++ b/frontend/app/element/iconbutton.scss @@ -1,4 +1,4 @@ -.iconbutton { +.wave-iconbutton { display: flex; cursor: pointer; opacity: 0.7; diff --git a/frontend/app/element/iconbutton.tsx b/frontend/app/element/iconbutton.tsx index b0a5a794f..85986314e 100644 --- a/frontend/app/element/iconbutton.tsx +++ b/frontend/app/element/iconbutton.tsx @@ -16,7 +16,7 @@ export const IconButton = memo( return ( + + + ); +}; + +export const WorkspaceEditor = memo(WorkspaceEditorComponent) as typeof WorkspaceEditorComponent; diff --git a/frontend/app/tab/workspaceswitcher.scss b/frontend/app/tab/workspaceswitcher.scss index a1b5545b8..c8b3e1fa9 100644 --- a/frontend/app/tab/workspaceswitcher.scss +++ b/frontend/app/tab/workspaceswitcher.scss @@ -26,25 +26,6 @@ } } -.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%; -} - .workspace-switcher-content { min-height: auto; display: flex; @@ -55,6 +36,25 @@ 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; @@ -100,17 +100,17 @@ gap: 5px; } - .iconbutton.edit { + .wave-iconbutton.edit { visibility: hidden; } - .iconbutton.window { + .wave-iconbutton.window { cursor: default; opacity: 1 !important; } } - &:hover .iconbutton.edit { + &:hover .wave-iconbutton.edit { visibility: visible; } @@ -144,83 +144,6 @@ padding: 0; } - .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; - } - } - .actions { width: 100%; padding: 3px 0; diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index 9f204b2ae..bd995a611 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -1,7 +1,6 @@ // Copyright 2024, Command Line // SPDX-License-Identifier: Apache-2.0 -import { Button } from "@/element/button"; import { ExpandableMenu, ExpandableMenuItem, @@ -10,139 +9,22 @@ import { ExpandableMenuItemLeftElement, ExpandableMenuItemRightElement, } from "@/element/expandablemenu"; -import { Input } from "@/element/input"; 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, memo, useCallback, useEffect, useRef, useState } from "react"; +import { CSSProperties, forwardRef, useCallback, useEffect } from "react"; import WorkspaceSVG from "../asset/workspace.svg"; import { IconButton } from "../element/iconbutton"; import { atoms, getApi } from "../store/global"; import { WorkspaceService } from "../store/services"; -import { getObjectValue, makeORef, setObjectValue } from "../store/wos"; +import { getObjectValue, makeORef } from "../store/wos"; +import { waveEventSubscribe } from "../store/wps"; +import { WorkspaceEditor } from "./workspaceeditor"; import "./workspaceswitcher.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 ( -
- {colors.map((color) => ( -
handleColorClick(color)} - /> - ))} -
- ); -}); - -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 ( -
- {icons.map((icon) => { - const iconClass = makeIconClass(icon, true); - return ( - handleIconClick(icon)} - /> - ); - })} -
- ); -}); - -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 WorkspaceEditor = memo( - ({ - title, - icon, - color, - focusInput, - onTitleChange, - onColorChange, - onIconChange, - onDeleteWorkspace, - }: WorkspaceEditorProps) => { - const inputRef = useRef(null); - - const [colors, setColors] = useState([]); - const [icons, setIcons] = useState([]); - - 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 ( -
- - - -
- -
-
- ); - } -); - type WorkspaceListEntry = { windowId: string; workspace: Workspace; @@ -175,15 +57,21 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { setWorkspaceList(newList); }, []); + useEffect( + () => + waveEventSubscribe({ + eventType: "workspace:update", + handler: () => fireAndForget(updateWorkspaceList), + }), + [] + ); + useEffect(() => { fireAndForget(updateWorkspaceList); }, []); const onDeleteWorkspace = useCallback((workspaceId: string) => { getApi().deleteWorkspace(workspaceId); - setTimeout(() => { - fireAndForget(updateWorkspaceList); - }, 10); }, []); const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon); @@ -266,9 +154,16 @@ const WorkspaceSwitcherItem = ({ const setWorkspace = useCallback((newWorkspace: Workspace) => { if (newWorkspace.name != "") { - setObjectValue({ ...newWorkspace, otype: "workspace" }, undefined, true); + fireAndForget(() => + WorkspaceService.UpdateWorkspace( + workspace.oid, + newWorkspace.name, + newWorkspace.icon, + newWorkspace.color, + false + ) + ); } - setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace }); }, []); const isActive = !!workspaceEntry.windowId; diff --git a/frontend/app/theme.scss b/frontend/app/theme.scss index ad9931a4b..9f25430c4 100644 --- a/frontend/app/theme.scss +++ b/frontend/app/theme.scss @@ -1,8 +1,6 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -// Used for syntax highlighting in markdown - :root { --main-text-color: #f7f7f7; --title-font-size: 18px; @@ -16,8 +14,10 @@ --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: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, + --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); diff --git a/frontend/app/view/codeeditor/codeeditor.tsx b/frontend/app/view/codeeditor/codeeditor.tsx index 256a13846..aca418143 100644 --- a/frontend/app/view/codeeditor/codeeditor.tsx +++ b/frontend/app/view/codeeditor/codeeditor.tsx @@ -122,6 +122,7 @@ export function CodeEditor({ blockId, text, language, filename, meta, onChange, const minimapEnabled = useOverrideConfigAtom(blockId, "editor:minimapenabled") ?? false; const stickyScrollEnabled = useOverrideConfigAtom(blockId, "editor:stickyscrollenabled") ?? false; const wordWrap = useOverrideConfigAtom(blockId, "editor:wordwrap") ?? false; + const fontSize = useOverrideConfigAtom(blockId, "editor:fontsize"); const theme = "wave-theme-dark"; React.useEffect(() => { @@ -150,8 +151,9 @@ export function CodeEditor({ blockId, text, language, filename, meta, onChange, opts.minimap.enabled = minimapEnabled; opts.stickyScroll.enabled = stickyScrollEnabled; opts.wordWrap = wordWrap ? "on" : "off"; + opts.fontSize = fontSize; return opts; - }, [minimapEnabled, stickyScrollEnabled, wordWrap]); + }, [minimapEnabled, stickyScrollEnabled, wordWrap, fontSize]); return (
diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 3a5c1e1bd..0c89d9428 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -783,6 +783,8 @@ function makePreviewModel(blockId: string, nodeModel: BlockNodeModel): PreviewMo function MarkdownPreview({ model }: SpecializedViewProps) { 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(() => { return { connName: connName, @@ -791,7 +793,13 @@ function MarkdownPreview({ model }: SpecializedViewProps) { }, [connName, fileInfo.dir]); return (
- +
); } diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index a834adfd5..d6ad8eec9 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -24,6 +24,7 @@ import { } from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; +import { boundNumber } from "@/util/util"; import clsx from "clsx"; import debug from "debug"; import * as jotai from "jotai"; @@ -62,6 +63,7 @@ class TermViewModel implements ViewModel { vdomToolbarTarget: jotai.PrimitiveAtom; fontSizeAtom: jotai.Atom; termThemeNameAtom: jotai.Atom; + termTransparencyAtom: jotai.Atom; noPadding: jotai.PrimitiveAtom; endIconButtons: jotai.Atom; shellProcFullStatus: jotai.PrimitiveAtom; @@ -203,10 +205,17 @@ class TermViewModel implements ViewModel { return get(getOverrideConfigAtom(this.blockId, "term:theme")) ?? DefaultTermTheme; }); }); + this.termTransparencyAtom = useBlockAtom(blockId, "termtransparencyatom", () => { + return jotai.atom((get) => { + let 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 [_, bgcolor] = computeTheme(fullConfig, themeName); + const termTransparency = get(this.termTransparencyAtom); + const [_, bgcolor] = computeTheme(fullConfig, themeName, termTransparency); if (bgcolor != null) { return { bg: bgcolor }; } @@ -407,6 +416,11 @@ class TermViewModel implements ViewModel { event.preventDefault(); event.stopPropagation(); 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")) { @@ -453,6 +467,7 @@ class TermViewModel implements ViewModel { const termThemeKeys = Object.keys(termThemes); const curThemeName = globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:theme")); const defaultFontSize = globalStore.get(getSettingsKeyAtom("term:fontsize")) ?? 12; + const transparencyMeta = globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:transparency")); const blockData = globalStore.get(this.blockAtom); const overrideFontSize = blockData?.meta?.["term:fontsize"]; @@ -474,6 +489,41 @@ class TermViewModel implements ViewModel { 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 { @@ -508,6 +558,10 @@ class TermViewModel implements ViewModel { label: "Font Size", submenu: fontSizeSubMenu, }); + fullMenu.push({ + label: "Transparency", + submenu: transparencySubMenu, + }); fullMenu.push({ type: "separator" }); fullMenu.push({ label: "Force Restart Controller", @@ -734,7 +788,8 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { React.useEffect(() => { const fullConfig = globalStore.get(atoms.fullConfigAtom); const termThemeName = globalStore.get(model.termThemeNameAtom); - const [termTheme, _] = computeTheme(fullConfig, termThemeName); + const termTransparency = globalStore.get(model.termTransparencyAtom); + const [termTheme, _] = computeTheme(fullConfig, termThemeName, termTransparency); let termScrollback = 1000; if (termSettings?.["term:scrollback"]) { termScrollback = Math.floor(termSettings["term:scrollback"]); diff --git a/frontend/app/view/term/termtheme.ts b/frontend/app/view/term/termtheme.ts index 8852ae15a..32937b5be 100644 --- a/frontend/app/view/term/termtheme.ts +++ b/frontend/app/view/term/termtheme.ts @@ -17,7 +17,8 @@ interface TermThemeProps { const TermThemeUpdater = ({ blockId, model, termRef }: TermThemeProps) => { const fullConfig = useAtomValue(atoms.fullConfigAtom); const blockTermTheme = useAtomValue(model.termThemeNameAtom); - const [theme, _] = computeTheme(fullConfig, blockTermTheme); + const transparency = useAtomValue(model.termTransparencyAtom); + const [theme, _] = computeTheme(fullConfig, blockTermTheme, transparency); useEffect(() => { if (termRef.current?.terminal) { termRef.current.terminal.options.theme = theme; diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 1bed0e6d5..6b2eb357c 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -2,14 +2,32 @@ // SPDX-License-Identifier: Apache-2.0 export const DefaultTermTheme = "default-dark"; +import { colord } from "colord"; -// returns (theme, bgcolor) -function computeTheme(fullConfig: FullConfigType, themeName: string): [TermThemeType, string] { +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)) +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); + } + } let bgcolor = themeCopy.background; themeCopy.background = "#00000000"; return [themeCopy, bgcolor]; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 84c940ca3..0ab2ea634 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -165,6 +165,9 @@ export class TermWrap { } handleTermData(data: string) { + if (!this.loaded) { + return; + } const b64data = util.stringToBase64(data); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } diff --git a/frontend/app/view/waveai/waveai.scss b/frontend/app/view/waveai/waveai.scss index c35a5e24e..4a2020e5c 100644 --- a/frontend/app/view/waveai/waveai.scss +++ b/frontend/app/view/waveai/waveai.scss @@ -131,9 +131,6 @@ outline: none; overflow: auto; overflow-wrap: anywhere; - font-family: var(--termfontfamily); - font-weight: normal; - line-height: var(--termlineheight); height: 21px; } } diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index 6c0c02a38..0bdc2412b 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -8,7 +8,7 @@ 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 { atoms, createBlock, fetchWaveFile, getApi, globalStore, WOS } from "@/store/global"; +import { atoms, createBlock, fetchWaveFile, getApi, globalStore, useOverrideConfigAtom, WOS } from "@/store/global"; import { BlockService, ObjectService } from "@/store/services"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; @@ -30,6 +30,7 @@ const slidingWindowSize = 30; interface ChatItemProps { chatItem: ChatMessageType; + model: WaveAiModel; } function promptToMsg(prompt: OpenAIPromptMessageType): ChatMessageType { @@ -430,11 +431,12 @@ function makeWaveAiViewModel(blockId: string): WaveAiModel { return waveAiModel; } -const ChatItem = ({ chatItem }: ChatItemProps) => { +const ChatItem = ({ chatItem, model }: ChatItemProps) => { const { user, text } = chatItem; const cssVar = "--panel-bg-color"; const panelBgColor = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim(); - + const fontSize = useOverrideConfigAtom(model.blockId, "ai:fontsize"); + const fixedFontSize = useOverrideConfigAtom(model.blockId, "ai:fixedfontsize"); const renderContent = useMemo(() => { if (user == "error") { return ( @@ -445,7 +447,12 @@ const ChatItem = ({ chatItem }: ChatItemProps) => {
- +
); @@ -459,7 +466,12 @@ const ChatItem = ({ chatItem }: ChatItemProps) => {
- +
) : ( @@ -474,11 +486,17 @@ const ChatItem = ({ chatItem }: ChatItemProps) => { return ( <>
- +
); - }, [text, user]); + }, [text, user, fontSize, fixedFontSize]); return
{renderContent}
; }; @@ -487,10 +505,11 @@ interface ChatWindowProps { chatWindowRef: React.RefObject; messages: ChatMessageType[]; msgWidths: Object; + model: WaveAiModel; } const ChatWindow = memo( - forwardRef(({ chatWindowRef, messages, msgWidths }, ref) => { + forwardRef(({ chatWindowRef, messages, msgWidths, model }, ref) => { const [isUserScrolling, setIsUserScrolling] = useState(false); const osRef = useRef(null); @@ -559,7 +578,7 @@ const ChatWindow = memo(
{messages.map((chitem, idx) => ( - + ))}
@@ -569,7 +588,7 @@ const ChatWindow = memo( interface ChatInputProps { value: string; - termFontSize: number; + baseFontSize: number; onChange: (e: React.ChangeEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; onMouseDown: (e: React.MouseEvent) => void; @@ -577,7 +596,7 @@ interface ChatInputProps { } const ChatInput = forwardRef( - ({ value, onChange, onKeyDown, onMouseDown, termFontSize, model }, ref) => { + ({ value, onChange, onKeyDown, onMouseDown, baseFontSize, model }, ref) => { const textAreaRef = useRef(null); useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement); @@ -594,7 +613,7 @@ const ChatInput = forwardRef( // Adjust the height of the textarea to fit the text const textAreaMaxLines = 5; - const textAreaLineHeight = termFontSize * 1.5; + const textAreaLineHeight = baseFontSize * 1.5; const textAreaMinHeight = textAreaLineHeight; const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines; @@ -608,7 +627,7 @@ const ChatInput = forwardRef( const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); textAreaRef.current.style.height = newHeight + "px"; }, - [termFontSize] + [baseFontSize] ); useEffect(() => { @@ -624,7 +643,7 @@ const ChatInput = forwardRef( onMouseDown={onMouseDown} // When the user clicks on the textarea onChange={onChange} onKeyDown={onKeyDown} - style={{ fontSize: termFontSize }} + style={{ fontSize: baseFontSize }} placeholder="Ask anything..." value={value} > @@ -642,7 +661,7 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { const [value, setValue] = useState(""); const [selectedBlockIdx, setSelectedBlockIdx] = useState(null); - const termFontSize: number = 14; + const baseFontSize: number = 14; const msgWidths = {}; const locked = useAtomValue(model.locked); @@ -804,7 +823,13 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { return (
- +
@@ -815,7 +840,7 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { onChange={handleTextAreaChange} onKeyDown={handleTextAreaKeyDown} onMouseDown={handleTextAreaMouseDown} - termFontSize={termFontSize} + baseFontSize={baseFontSize} />