From 397b1dfbd80e35d678b232ff7b5b8d3d5f1e88af Mon Sep 17 00:00:00 2001 From: Michael Doyle Date: Tue, 26 May 2026 18:46:30 -0700 Subject: [PATCH 1/3] add wsh tab commands (create, rename, focus) (#3285) Adds three new wsh subcommands so external tooling can orchestrate tabs programmatically: - wsh tab create [-w workspaceid] [-n name] [--no-activate] - wsh tab rename [-t tabid] - wsh tab focus Wires CreateTabCommand and FocusTabCommand through WshRpcInterface, with handlers in wshserver that delegate to wcore.CreateTab / wcore.SetActiveTab and fan out the same UpdateEvents + SendActiveTabUpdate the existing WorkspaceService.CreateTab path uses, so the visible tab actually switches. The pre-existing UpdateTabNameCommand is reused for rename. CreateTab defaults the workspace id to the caller's workspace (resolved via the rpc context's blockid) when -w is omitted. Includes unit tests for the new command declarations and JSON tags, plus docs in wsh-reference. --- cmd/wsh/cmd/wshcmd-tab.go | 120 +++++++++++++++++++++++++++++ docs/docs/wsh-reference.mdx | 81 +++++++++++++++++++ frontend/app/store/wshclientapi.ts | 12 +++ frontend/types/gotypes.d.ts | 8 ++ pkg/wshrpc/wshclient/wshclient.go | 12 +++ pkg/wshrpc/wshrpc_tab_test.go | 81 +++++++++++++++++++ pkg/wshrpc/wshrpctypes.go | 8 ++ pkg/wshrpc/wshserver/wshserver.go | 80 +++++++++++++++++++ 8 files changed, 402 insertions(+) create mode 100644 cmd/wsh/cmd/wshcmd-tab.go create mode 100644 pkg/wshrpc/wshrpc_tab_test.go diff --git a/cmd/wsh/cmd/wshcmd-tab.go b/cmd/wsh/cmd/wshcmd-tab.go new file mode 100644 index 0000000000..376fc1f80c --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-tab.go @@ -0,0 +1,120 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var 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 + 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") + + 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) + }() + + data := wshrpc.CommandCreateTabData{ + WorkspaceId: tabCreateFlagWorkspaceId, + TabName: tabCreateFlagName, + ActivateTab: !tabCreateFlagNoActivate, + } + 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..dda41adcf0 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -1120,4 +1120,85 @@ 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] +``` + +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 + +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 +``` + +--- + +### 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..abdb0b8583 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -315,6 +315,13 @@ declare global { blockdef: BlockDef; }; + // wshrpc.CommandCreateTabData + type CommandCreateTabData = { + workspaceid?: string; + tabname?: string; + activatetab?: boolean; + }; + // wshrpc.CommandDebugTermData type CommandDebugTermData = { blockid: string; @@ -1589,6 +1596,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..f927e0f4cf --- /dev/null +++ b/pkg/wshrpc/wshrpc_tab_test.go @@ -0,0 +1,81 @@ +// 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", + } + 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) + } + } +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 51e2338ba8..c9edc5b67f 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,12 @@ 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"` +} + 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..9e98e3feee 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -175,6 +175,86 @@ 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) + } + 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 From 8d7487fd26abb1499bf535b3ea30a5496d93f09a Mon Sep 17 00:00:00 2001 From: Michael Doyle Date: Thu, 28 May 2026 18:44:33 -0700 Subject: [PATCH 2/3] feat: add --meta support to wsh tab create Allows callers to pre-populate tab metadata at creation time via --meta key=value (repeatable), addressing the meta payload capability requested in #3285. The new field is optional and additive; existing call sites continue to work unchanged. --- cmd/wsh/cmd/wshcmd-tab.go | 15 +++++++++++++++ docs/docs/wsh-reference.mdx | 6 +++++- frontend/types/gotypes.d.ts | 1 + pkg/wshrpc/wshrpc_tab_test.go | 17 +++++++++++++++++ pkg/wshrpc/wshrpctypes.go | 7 ++++--- pkg/wshrpc/wshserver/wshserver.go | 10 ++++++++++ 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-tab.go b/cmd/wsh/cmd/wshcmd-tab.go index 376fc1f80c..75cb1d85fb 100644 --- a/cmd/wsh/cmd/wshcmd-tab.go +++ b/cmd/wsh/cmd/wshcmd-tab.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "os" + "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" @@ -48,6 +49,7 @@ var ( tabCreateFlagWorkspaceId string tabCreateFlagName string tabCreateFlagNoActivate bool + tabCreateFlagMeta []string tabRenameFlagTabId string ) @@ -60,6 +62,7 @@ func init() { 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)") } @@ -69,10 +72,22 @@ func tabCreateRun(cmd *cobra.Command, args []string) (rtnErr error) { 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 { diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index dda41adcf0..ac4729e9bc 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -1132,7 +1132,7 @@ Manage tabs from the command line. Provides `create`, `rename`, and `focus` subc Create a new tab in a workspace. ```sh -wsh tab create [-w ] [-n ] [--no-activate] +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. @@ -1142,6 +1142,7 @@ 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: @@ -1154,6 +1155,9 @@ 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 ``` --- diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index abdb0b8583..902fe230f9 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -320,6 +320,7 @@ declare global { workspaceid?: string; tabname?: string; activatetab?: boolean; + meta?: {[key: string]: string}; }; // wshrpc.CommandDebugTermData diff --git a/pkg/wshrpc/wshrpc_tab_test.go b/pkg/wshrpc/wshrpc_tab_test.go index f927e0f4cf..c979f11fb5 100644 --- a/pkg/wshrpc/wshrpc_tab_test.go +++ b/pkg/wshrpc/wshrpc_tab_test.go @@ -67,6 +67,7 @@ func TestCommandCreateTabDataJSONTags(t *testing.T) { "WorkspaceId": "workspaceid,omitempty", "TabName": "tabname,omitempty", "ActivateTab": "activatetab,omitempty", + "Meta": "meta,omitempty", } for fieldName, want := range expected { field, ok := rtype.FieldByName(fieldName) @@ -79,3 +80,19 @@ func TestCommandCreateTabDataJSONTags(t *testing.T) { } } } + +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 c9edc5b67f..d257f091d9 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -301,9 +301,10 @@ type CommandCreateSubBlockData struct { } type CommandCreateTabData struct { - WorkspaceId string `json:"workspaceid,omitempty"` - TabName string `json:"tabname,omitempty"` - ActivateTab bool `json:"activatetab,omitempty"` + 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 { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 9e98e3feee..9ee5a4aae8 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -215,6 +215,16 @@ func (ws *WshServer) CreateTabCommand(ctx context.Context, data wshrpc.CommandCr 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 { + return "", fmt.Errorf("error setting tab meta: %w", metaErr) + } + } updates := waveobj.ContextGetUpdatesRtn(ctx) go func() { defer func() { From df4978cef4e19fcb1a53a214cf67a5727f50c6e1 Mon Sep 17 00:00:00 2001 From: Michael Doyle Date: Thu, 28 May 2026 18:53:04 -0700 Subject: [PATCH 3/3] fix: log meta error instead of failing tab create When the auxiliary meta update fails after a successful tab creation, log the error with context but return the tabId. This avoids orphan tabs from retry loops that would otherwise see a failed creation despite the tab being persisted. Users can re-apply meta with wsh setmeta if needed. --- pkg/wshrpc/wshserver/wshserver.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 9ee5a4aae8..2d35e02406 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -222,7 +222,8 @@ func (ws *WshServer) CreateTabCommand(ctx context.Context, data wshrpc.CommandCr metaAny[k] = v } if metaErr := wstore.UpdateObjectMeta(ctx, tabORef, metaAny, false); metaErr != nil { - return "", fmt.Errorf("error setting tab meta: %w", metaErr) + 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)