Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ out/
make/
artifacts/
mikework/
aiplans/
manifests/
.env
out
Expand Down
21 changes: 13 additions & 8 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,18 @@ tasks:
- 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
Expand Down Expand Up @@ -314,14 +326,7 @@ tasks:
vars:
GOOS: windows
GOARCH: arm64
deps:
- go:mod:tidy
- generate
sources:
- "cmd/wsh/**/*.go"
- "pkg/**/*.go"
generates:
- "dist/bin/wsh*"
internal: true

build:wsh:internal:
vars:
Expand Down
2 changes: 1 addition & 1 deletion cmd/wsh/cmd/wshcmd-root.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package cmd
Expand Down
31 changes: 29 additions & 2 deletions cmd/wsh/cmd/wshcmd-ssh.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package cmd
Expand All @@ -7,6 +7,7 @@ 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"
Expand All @@ -15,6 +16,8 @@ import (

var (
identityFiles []string
sshLogin string
sshPort string
newBlock bool
)

Expand All @@ -28,6 +31,8 @@ var sshCmd = &cobra.Command{

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)
}
Expand All @@ -38,6 +43,11 @@ func sshRun(cmd *cobra.Command, args []string) (rtnErr error) {
}()

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)")
Expand Down Expand Up @@ -91,10 +101,27 @@ func sshRun(cmd *cobra.Command, args []string) (rtnErr error) {
waveobj.MetaKey_CmdCwd: nil,
},
}
err := wshclient.SetMetaCommand(RpcClient, data, 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
}
75 changes: 75 additions & 0 deletions cmd/wsh/cmd/wshcmd-ssh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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)
}
})
}
}
1 change: 1 addition & 0 deletions docs/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ wsh editconfig
| 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 |
Expand Down
3 changes: 2 additions & 1 deletion emain/emain-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export function initIpcHandlers() {

electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => {
const menu = new electron.Menu();
const win = getWaveWindowByWebContentsId(event.sender.hostWebContents.id);
const win = getWaveWindowByWebContentsId(event.sender.hostWebContents?.id);
if (win == null) {
return;
}
Expand Down Expand Up @@ -353,6 +353,7 @@ export function initIpcHandlers() {
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",
Expand Down
12 changes: 9 additions & 3 deletions emain/emain-tabview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ function computeBgColor(fullConfig: FullConfigType): string {
const wcIdToWaveTabMap = new Map<number, WaveTabView>();

export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView {
if (webContentsId == null) {
return null;
}
return wcIdToWaveTabMap.get(webContentsId);
}

Expand Down Expand Up @@ -154,14 +157,15 @@ export class WaveTabView extends WebContentsView {
this.waveReadyPromise.then(() => {
this.isWaveReady = true;
});
wcIdToWaveTabMap.set(this.webContents.id, this);
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(this.webContents.id);
wcIdToWaveTabMap.delete(wcId);
removeWaveTabView(this.waveTabId);
this.isDestroyed = true;
});
Expand Down Expand Up @@ -283,7 +287,6 @@ function checkAndEvictCache(): void {
// Otherwise, sort by lastUsedTs
return a.lastUsedTs - b.lastUsedTs;
});
const now = Date.now();
for (let i = 0; i < sorted.length - MaxCacheSize; i++) {
tryEvictEntry(sorted[i].waveTabId);
}
Expand Down Expand Up @@ -313,6 +316,9 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri
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" };
});
Expand Down
3 changes: 3 additions & 0 deletions emain/emain-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,9 @@ export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow {
}

export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow {
if (webContentsId == null) {
return null;
}
const tabView = getWaveTabViewByWebContentsId(webContentsId);
if (tabView == null) {
return null;
Expand Down
15 changes: 12 additions & 3 deletions frontend/app/aipanel/aipanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,11 @@ const ConfigChangeModeFixer = memo(() => {

ConfigChangeModeFixer.displayName = "ConfigChangeModeFixer";

const AIPanelComponentInner = memo(() => {
type AIPanelComponentInnerProps = {
roundTopLeft: boolean;
};

const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps) => {
const [isDragOver, setIsDragOver] = useState(false);
const [isReactDndDragOver, setIsReactDndDragOver] = useState(false);
const [initialLoadDone, setInitialLoadDone] = useState(false);
Expand Down Expand Up @@ -554,6 +558,7 @@ const AIPanelComponentInner = memo(() => {
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,
Expand Down Expand Up @@ -607,10 +612,14 @@ const AIPanelComponentInner = memo(() => {

AIPanelComponentInner.displayName = "AIPanelInner";

const AIPanelComponent = () => {
type AIPanelComponentProps = {
roundTopLeft: boolean;
};

const AIPanelComponent = ({ roundTopLeft }: AIPanelComponentProps) => {
return (
<ErrorBoundary>
<AIPanelComponentInner />
<AIPanelComponentInner roundTopLeft={roundTopLeft} />
</ErrorBoundary>
);
};
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/aipanel/waveai-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export class WaveAIModel {
if (this.inBuilder) {
return true;
}
return get(WorkspaceLayoutModel.getInstance().panelVisibleAtom);
return get(WorkspaceLayoutModel.getInstance().activePanelAtom) === "waveai";
});

this.defaultModeAtom = jotai.atom((get) => {
Expand Down
1 change: 1 addition & 0 deletions frontend/app/block/blockenv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type BlockEnv = WaveEnvSubset<{
| "window:magnifiedblockblurprimarypx"
| "window:magnifiedblockopacity"
>;
showContextMenu: WaveEnv["showContextMenu"];
atoms: {
modalOpen: WaveEnv["atoms"]["modalOpen"];
controlShiftDelayAtom: WaveEnv["atoms"]["controlShiftDelayAtom"];
Expand Down
13 changes: 7 additions & 6 deletions frontend/app/block/blockframe-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,26 @@ import {
import { ConnectionButton } from "@/app/block/connectionbutton";
import { DurableSessionFlyover } from "@/app/block/durable-session-flyover";
import { getBlockBadgeAtom } from "@/app/store/badge";
import { ContextMenuModel } from "@/app/store/contextmenu";
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 { BlockEnv } from "./blockenv";
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
nodeModel: NodeModel,
blockEnv: BlockEnv
) {
e.preventDefault();
e.stopPropagation();
Expand Down Expand Up @@ -59,7 +59,7 @@ function handleHeaderContextMenu(
click: () => uxCloseBlock(blockId),
}
);
ContextMenuModel.getInstance().showContextMenu(menu, e);
blockEnv.showContextMenu(menu, e);
}

type HeaderTextElemsProps = {
Expand Down Expand Up @@ -113,6 +113,7 @@ type HeaderEndIconsProps = {
};

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);
Expand All @@ -128,7 +129,7 @@ const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndI
elemtype: "iconbutton",
icon: "cog",
title: "Settings",
click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel),
click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel, blockEnv),
};
endIconsElem.push(<IconButton key="settings" decl={settingsDecl} className="block-frame-settings" />);
if (ephemeral) {
Expand Down Expand Up @@ -211,7 +212,7 @@ const BlockFrame_Header = ({
className={cn("block-frame-default-header", useTermHeader && "!pl-[2px]")}
data-role="block-header"
ref={dragHandleRef}
onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel)}
onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv)}
>
{!useTermHeader && (
<>
Expand Down
Loading