diff --git a/cmd/wsh/cmd/wshcmd-tab.go b/cmd/wsh/cmd/wshcmd-tab.go new file mode 100644 index 0000000000..75cb1d85fb --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-tab.go @@ -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] ", + Short: "Rename a tab", + Args: cobra.ExactArgs(1), + RunE: tabRenameRun, + PreRunE: preRunSetupRpcClient, + DisableFlagsInUseLine: true, +} + +var tabFocusCmd = &cobra.Command{ + Use: "focus ", + 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 +} diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index 6ed1bcaa3f..ac4729e9bc 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -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 ] [-n ] [--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 ` - target workspace id (defaults to the caller's workspace) +- `-n, --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 ] +``` + +If `--tab` is not provided, the current tab id (`WAVETERM_TABID`) is used. + +Flags: + +- `-t, --tab ` - 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 +``` + +Examples: + +```sh +# Activate a tab by id +wsh tab focus 8f4a3c2d-... +``` + +--- + diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8482be260d..a2dbb0a586 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -192,6 +192,12 @@ export class RpcApiType { return client.wshRpcCall("createsubblock", data, opts); } + // command "createtab" [call] + CreateTabCommand(client: WshClient, data: CommandCreateTabData, opts?: RpcOpts): Promise { + 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 { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "debugterm", data, opts); @@ -390,6 +396,12 @@ export class RpcApiType { return client.wshRpcCall("findgitbash", data, opts); } + // command "focustab" [call] + FocusTabCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + 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 { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "focuswindow", data, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..902fe230f9 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -315,6 +315,14 @@ declare global { blockdef: BlockDef; }; + // wshrpc.CommandCreateTabData + type CommandCreateTabData = { + workspaceid?: string; + tabname?: string; + activatetab?: boolean; + meta?: {[key: string]: string}; + }; + // wshrpc.CommandDebugTermData type CommandDebugTermData = { blockid: string; @@ -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; diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index d5333aec2b..26cad8fbc6 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -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) @@ -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) diff --git a/pkg/wshrpc/wshrpc_tab_test.go b/pkg/wshrpc/wshrpc_tab_test.go new file mode 100644 index 0000000000..c979f11fb5 --- /dev/null +++ b/pkg/wshrpc/wshrpc_tab_test.go @@ -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") + } +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 51e2338ba8..d257f091d9 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -96,6 +96,8 @@ type WshRpcInterface interface { 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 + CreateTabCommand(ctx context.Context, data CommandCreateTabData) (string, error) + FocusTabCommand(ctx context.Context, tabId string) error GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) // connection functions @@ -298,6 +300,13 @@ type CommandCreateSubBlockData struct { BlockDef *waveobj.BlockDef `json:"blockdef"` } +type CommandCreateTabData struct { + WorkspaceId string `json:"workspaceid,omitempty"` + TabName string `json:"tabname,omitempty"` + ActivateTab bool `json:"activatetab,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + type CommandControllerResyncData struct { ForceRestart bool `json:"forcerestart,omitempty"` TabId string `json:"tabid"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 38006fd9a8..2d35e02406 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -175,6 +175,97 @@ func (ws *WshServer) UpdateWorkspaceTabIdsCommand(ctx context.Context, workspace return nil } +// resolveWorkspaceIdFromRpcCtx looks up the workspace id implied by the calling +// rpc context (via its BlockId). Returns "" if no implicit workspace can be +// determined (e.g. caller is not associated with a block). +func resolveWorkspaceIdFromRpcCtx(ctx context.Context) (string, error) { + handler := wshutil.GetRpcResponseHandlerFromContext(ctx) + if handler == nil { + return "", nil + } + rpcCtx := handler.GetRpcContext() + if rpcCtx.BlockId == "" { + return "", nil + } + tabId, err := wstore.DBFindTabForBlockId(ctx, rpcCtx.BlockId) + if err != nil { + return "", fmt.Errorf("error finding tab for caller block: %w", err) + } + workspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId) + if err != nil { + return "", fmt.Errorf("error finding workspace for caller tab: %w", err) + } + return workspaceId, nil +} + +func (ws *WshServer) CreateTabCommand(ctx context.Context, data wshrpc.CommandCreateTabData) (string, error) { + ctx = waveobj.ContextWithUpdates(ctx) + workspaceId := data.WorkspaceId + if workspaceId == "" { + resolved, err := resolveWorkspaceIdFromRpcCtx(ctx) + if err != nil { + return "", err + } + if resolved == "" { + return "", fmt.Errorf("workspaceid is required (caller has no implicit workspace)") + } + workspaceId = resolved + } + tabId, err := wcore.CreateTab(ctx, workspaceId, data.TabName, data.ActivateTab, false) + if err != nil { + return "", fmt.Errorf("error creating tab: %w", err) + } + if len(data.Meta) > 0 { + tabORef := waveobj.ORef{OType: waveobj.OType_Tab, OID: tabId} + metaAny := make(waveobj.MetaMapType, len(data.Meta)) + for k, v := range data.Meta { + metaAny[k] = v + } + if metaErr := wstore.UpdateObjectMeta(ctx, tabORef, metaAny, false); metaErr != nil { + log.Printf("CreateTabCommand: tab %s created but meta update failed: %v", tabId, metaErr) + // Tab creation succeeded; meta is auxiliary and can be re-applied via wsh setmeta. + } + } + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + defer func() { + panichandler.PanicHandler("WshServer:CreateTabCommand:SendUpdateEvents", recover()) + }() + wps.Broker.SendUpdateEvents(updates) + }() + if data.ActivateTab { + wcore.SendActiveTabUpdate(ctx, workspaceId, tabId) + } + return tabId, nil +} + +func (ws *WshServer) FocusTabCommand(ctx context.Context, tabId string) error { + if tabId == "" { + return fmt.Errorf("tabid is required") + } + ctx = waveobj.ContextWithUpdates(ctx) + workspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId) + if err != nil { + return fmt.Errorf("error finding workspace for tab %s: %w", tabId, err) + } + if workspaceId == "" { + return fmt.Errorf("tab %s not found in any workspace", tabId) + } + err = wcore.SetActiveTab(ctx, workspaceId, tabId) + if err != nil { + return fmt.Errorf("error setting active tab: %w", err) + } + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + defer func() { + panichandler.PanicHandler("WshServer:FocusTabCommand:SendUpdateEvents", recover()) + }() + wps.Broker.SendUpdateEvents(updates) + }() + wcore.SendActiveTabUpdate(ctx, workspaceId, tabId) + 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