Skip to content
Open
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
135 changes: 135 additions & 0 deletions cmd/wsh/cmd/wshcmd-tab.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package cmd

import (
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)

var tabCmd = &cobra.Command{
Use: "tab",
Short: "Manage tabs",
}

var tabCreateCmd = &cobra.Command{
Use: "create [-w workspaceid] [-n name] [--no-activate]",
Short: "Create a new tab in a workspace",
Args: cobra.NoArgs,
RunE: tabCreateRun,
PreRunE: preRunSetupRpcClient,
DisableFlagsInUseLine: true,
}

var tabRenameCmd = &cobra.Command{
Use: "rename [-t tabid] <name>",
Short: "Rename a tab",
Args: cobra.ExactArgs(1),
RunE: tabRenameRun,
PreRunE: preRunSetupRpcClient,
DisableFlagsInUseLine: true,
}

var tabFocusCmd = &cobra.Command{
Use: "focus <tabid>",
Short: "Focus (activate) a tab",
Args: cobra.ExactArgs(1),
RunE: tabFocusRun,
PreRunE: preRunSetupRpcClient,
DisableFlagsInUseLine: true,
}

var (
tabCreateFlagWorkspaceId string
tabCreateFlagName string
tabCreateFlagNoActivate bool
tabCreateFlagMeta []string
tabRenameFlagTabId string
)

func init() {
rootCmd.AddCommand(tabCmd)
tabCmd.AddCommand(tabCreateCmd)
tabCmd.AddCommand(tabRenameCmd)
tabCmd.AddCommand(tabFocusCmd)

tabCreateCmd.Flags().StringVarP(&tabCreateFlagWorkspaceId, "workspace", "w", "", "workspace id (defaults to the caller's workspace)")
tabCreateCmd.Flags().StringVarP(&tabCreateFlagName, "name", "n", "", "tab name (defaults to next auto-generated name)")
tabCreateCmd.Flags().BoolVar(&tabCreateFlagNoActivate, "no-activate", false, "do not switch focus to the newly created tab")
tabCreateCmd.Flags().StringArrayVar(&tabCreateFlagMeta, "meta", nil, "metadata key=value pairs (repeatable)")

tabRenameCmd.Flags().StringVarP(&tabRenameFlagTabId, "tab", "t", "", "tab id to rename (defaults to WAVETERM_TABID)")
}

func tabCreateRun(cmd *cobra.Command, args []string) (rtnErr error) {
defer func() {
sendActivity("tab:create", rtnErr == nil)
}()

var metaMap map[string]string
if len(tabCreateFlagMeta) > 0 {
metaMap = make(map[string]string, len(tabCreateFlagMeta))
for _, kv := range tabCreateFlagMeta {
idx := strings.IndexByte(kv, '=')
if idx <= 0 {
return fmt.Errorf("--meta value %q must be in key=value format with a non-empty key", kv)
}
metaMap[kv[:idx]] = kv[idx+1:]
}
}
data := wshrpc.CommandCreateTabData{
WorkspaceId: tabCreateFlagWorkspaceId,
TabName: tabCreateFlagName,
ActivateTab: !tabCreateFlagNoActivate,
Meta: metaMap,
}
tabId, err := wshclient.CreateTabCommand(RpcClient, data, &wshrpc.RpcOpts{Timeout: 5000})
if err != nil {
return fmt.Errorf("creating tab: %w", err)
}
WriteStdout("%s", tabId)
if getIsTty() {
WriteStdout("\n")
}
return nil
}

func tabRenameRun(cmd *cobra.Command, args []string) (rtnErr error) {
defer func() {
sendActivity("tab:rename", rtnErr == nil)
}()

tabId := tabRenameFlagTabId
if tabId == "" {
tabId = os.Getenv("WAVETERM_TABID")
}
if tabId == "" {
return fmt.Errorf("tab id required (pass --tab or set WAVETERM_TABID)")
}
name := args[0]
err := wshclient.UpdateTabNameCommand(RpcClient, tabId, name, &wshrpc.RpcOpts{Timeout: 2000})
if err != nil {
return fmt.Errorf("renaming tab: %w", err)
}
WriteStdout("tab renamed\n")
return nil
}

func tabFocusRun(cmd *cobra.Command, args []string) (rtnErr error) {
defer func() {
sendActivity("tab:focus", rtnErr == nil)
}()

tabId := args[0]
err := wshclient.FocusTabCommand(RpcClient, tabId, &wshrpc.RpcOpts{Timeout: 2000})
if err != nil {
return fmt.Errorf("focusing tab: %w", err)
}
return nil
}
85 changes: 85 additions & 0 deletions docs/docs/wsh-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1120,4 +1120,89 @@ The secrets UI provides a convenient visual way to browse, add, edit, and delete
:::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.
:::

---

## tab

Manage tabs from the command line. Provides `create`, `rename`, and `focus` subcommands for programmatic tab orchestration.

### tab create

Create a new tab in a workspace.

```sh
wsh tab create [-w <workspaceid>] [-n <name>] [--no-activate] [--meta key=value ...]
```

When `--workspace` is omitted, the tab is created in the workspace of the calling block. The new tab id is printed to stdout, making it easy to capture from scripts.

Flags:

- `-w, --workspace <id>` - target workspace id (defaults to the caller's workspace)
- `-n, --name <name>` - tab name (defaults to the next auto-generated name, e.g. `T3`)
- `--no-activate` - create the tab without switching focus to it
- `--meta key=value` - set a metadata key on the new tab; may be repeated for multiple keys

Examples:

```sh
# Create a tab in the current workspace and switch to it
wsh tab create

# Create a named tab and capture its id
tabid=$(wsh tab create --name "build-output")

# Create a background tab in a specific workspace
wsh tab create -w 9b1d... -n "logs" --no-activate

# Create a tab with custom metadata
wsh tab create --name "agent" --meta env=prod --meta owner=ci
```

---

### tab rename

Rename an existing tab.

```sh
wsh tab rename [-t <tabid>] <name>
```

If `--tab` is not provided, the current tab id (`WAVETERM_TABID`) is used.

Flags:

- `-t, --tab <tabid>` - tab id to rename (defaults to the current tab)

Examples:

```sh
# Rename the current tab
wsh tab rename "agent-1"

# Rename a specific tab
wsh tab rename --tab 8f4a... "build-output"
```

---

### tab focus

Switch focus to the specified tab. Unlike `wsh focusblock`, which only operates within the current tab, `wsh tab focus` can switch the active tab from any block, including blocks in other tabs.

```sh
wsh tab focus <tabid>
```

Examples:

```sh
# Activate a tab by id
wsh tab focus 8f4a3c2d-...
```

---

</PlatformProvider>
12 changes: 12 additions & 0 deletions frontend/app/store/wshclientapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ export class RpcApiType {
return client.wshRpcCall("createsubblock", data, opts);
}

// command "createtab" [call]
CreateTabCommand(client: WshClient, data: CommandCreateTabData, opts?: RpcOpts): Promise<string> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "createtab", data, opts);
return client.wshRpcCall("createtab", 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);
Expand Down Expand Up @@ -390,6 +396,12 @@ export class RpcApiType {
return client.wshRpcCall("findgitbash", data, opts);
}

// command "focustab" [call]
FocusTabCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "focustab", data, opts);
return client.wshRpcCall("focustab", 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);
Expand Down
9 changes: 9 additions & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,14 @@ declare global {
blockdef: BlockDef;
};

// wshrpc.CommandCreateTabData
type CommandCreateTabData = {
workspaceid?: string;
tabname?: string;
activatetab?: boolean;
meta?: {[key: string]: string};
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// wshrpc.CommandDebugTermData
type CommandDebugTermData = {
blockid: string;
Expand Down Expand Up @@ -1589,6 +1597,7 @@ declare global {
"debug:panictype"?: string;
"block:view"?: string;
"block:controller"?: string;
"block:subblock"?: boolean;
"ai:backendtype"?: string;
"ai:local"?: boolean;
"wsh:cmd"?: string;
Expand Down
12 changes: 12 additions & 0 deletions pkg/wshrpc/wshclient/wshclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ func CreateSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateSubBlockD
return resp, err
}

// command "createtab", wshserver.CreateTabCommand
func CreateTabCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateTabData, opts *wshrpc.RpcOpts) (string, error) {
resp, err := sendRpcRequestCallHelper[string](w, "createtab", 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)
Expand Down Expand Up @@ -388,6 +394,12 @@ func FindGitBashCommand(w *wshutil.WshRpc, data bool, opts *wshrpc.RpcOpts) (str
return resp, err
}

// command "focustab", wshserver.FocusTabCommand
func FocusTabCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "focustab", data, opts)
return err
}

// command "focuswindow", wshserver.FocusWindowCommand
func FocusWindowCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "focuswindow", data, opts)
Expand Down
98 changes: 98 additions & 0 deletions pkg/wshrpc/wshrpc_tab_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package wshrpc

import (
"reflect"
"testing"
)

func TestCreateTabCommandRegistered(t *testing.T) {
decl := GenerateWshCommandDeclMap()["createtab"]
if decl == nil {
t.Fatalf("expected createtab command declaration")
}
if decl.MethodName != "CreateTabCommand" {
t.Fatalf("expected CreateTabCommand method name, got %q", decl.MethodName)
}
dataTypes := decl.GetCommandDataTypes()
if len(dataTypes) != 1 {
t.Fatalf("expected 1 command arg, got %d", len(dataTypes))
}
if dataTypes[0] != reflect.TypeOf(CommandCreateTabData{}) {
t.Fatalf("expected CommandCreateTabData arg, got %v", dataTypes[0])
}
if decl.DefaultResponseDataType == nil || decl.DefaultResponseDataType.Kind() != reflect.String {
t.Fatalf("expected createtab to return a string, got %v", decl.DefaultResponseDataType)
}
}

func TestFocusTabCommandRegistered(t *testing.T) {
decl := GenerateWshCommandDeclMap()["focustab"]
if decl == nil {
t.Fatalf("expected focustab command declaration")
}
if decl.MethodName != "FocusTabCommand" {
t.Fatalf("expected FocusTabCommand method name, got %q", decl.MethodName)
}
dataTypes := decl.GetCommandDataTypes()
if len(dataTypes) != 1 {
t.Fatalf("expected 1 command arg, got %d", len(dataTypes))
}
if dataTypes[0].Kind() != reflect.String {
t.Fatalf("expected focustab arg to be string, got %v", dataTypes[0])
}
}

func TestUpdateTabNameCommandRegistered(t *testing.T) {
decl := GenerateWshCommandDeclMap()["updatetabname"]
if decl == nil {
t.Fatalf("expected updatetabname command declaration")
}
dataTypes := decl.GetCommandDataTypes()
if len(dataTypes) != 2 {
t.Fatalf("expected 2 command args, got %d", len(dataTypes))
}
for i, dt := range dataTypes {
if dt.Kind() != reflect.String {
t.Fatalf("expected updatetabname arg %d to be string, got %v", i, dt)
}
}
}

func TestCommandCreateTabDataJSONTags(t *testing.T) {
rtype := reflect.TypeOf(CommandCreateTabData{})
expected := map[string]string{
"WorkspaceId": "workspaceid,omitempty",
"TabName": "tabname,omitempty",
"ActivateTab": "activatetab,omitempty",
"Meta": "meta,omitempty",
}
for fieldName, want := range expected {
field, ok := rtype.FieldByName(fieldName)
if !ok {
t.Fatalf("field %s not found on CommandCreateTabData", fieldName)
}
got := field.Tag.Get("json")
if got != want {
t.Fatalf("field %s json tag = %q, want %q", fieldName, got, want)
}
}
}

func TestCommandCreateTabDataMetaField(t *testing.T) {
rtype := reflect.TypeOf(CommandCreateTabData{})
field, ok := rtype.FieldByName("Meta")
if !ok {
t.Fatalf("Meta field not found on CommandCreateTabData")
}
expected := reflect.TypeOf(map[string]string{})
if field.Type != expected {
t.Fatalf("Meta field type = %v, want %v", field.Type, expected)
}
got := field.Tag.Get("json")
if got != "meta,omitempty" {
t.Fatalf("Meta json tag = %q, want %q", got, "meta,omitempty")
}
}
Loading