diff --git a/cmd/wsh/cmd/wshcmd-blocks.go b/cmd/wsh/cmd/wshcmd-blocks.go new file mode 100644 index 0000000000..7e4b935ee3 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-blocks.go @@ -0,0 +1,294 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +// Command-line flags for the blocks commands +var ( + blocksWindowId string // Window ID to filter blocks by + blocksWorkspaceId string // Workspace ID to filter blocks by + blocksTabId string // Tab ID to filter blocks by + blocksView string // View type to filter blocks by (term, web, etc.) + blocksJSON bool // Whether to output as JSON + blocksTimeout int // Timeout in milliseconds for RPC calls +) + +// BlockDetails represents the information about a block returned by the list command +type BlockDetails struct { + BlockId string `json:"blockid"` // Unique identifier for the block + WorkspaceId string `json:"workspaceid"` // ID of the workspace containing the block + TabId string `json:"tabid"` // ID of the tab containing the block + View string `json:"view"` // Canonical view type (term, web, preview, edit, sysinfo, waveai) + Meta waveobj.MetaMapType `json:"meta"` // Block metadata including view type +} + +// blocksListCmd represents the 'blocks list' command +var blocksListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls", "get"}, + Short: "List blocks in workspaces/windows", + Long: `List blocks with optional filtering by workspace, window, tab, or view type. + +Examples: + # List blocks from all workspaces + wsh blocks list + + # List only terminal blocks + wsh blocks list --view=term + + # Filter by window ID (get IDs from 'wsh workspace list') + wsh blocks list --window=dbca23b5-f89b-4780-a0fe-452f5bc7d900 + + # Filter by workspace ID + wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114 + + # Filter by tab ID + wsh blocks list --tab=a0459921-cc1a-48cc-ae7b-5f4821e1c9e1 + + # Output as JSON for scripting + wsh blocks list --json + + # Set a different timeout (in milliseconds) + wsh blocks list --timeout=10000`, + RunE: blocksListRun, + PreRunE: preRunSetupRpcClient, + SilenceUsage: true, +} + +// init registers the blocks commands with the root command +// It configures all the flags and command options +func init() { + blocksListCmd.Flags().StringVar(&blocksWindowId, "window", "", "restrict to window id") + blocksListCmd.Flags().StringVar(&blocksWorkspaceId, "workspace", "", "restrict to workspace id") + blocksListCmd.Flags().StringVar(&blocksTabId, "tab", "", "restrict to specific tab id") + blocksListCmd.Flags().StringVar(&blocksView, "view", "", "restrict to view type (term/terminal, web/browser, preview/edit, sysinfo, waveai)") + blocksListCmd.Flags().BoolVar(&blocksJSON, "json", false, "output as JSON") + blocksListCmd.Flags().IntVar(&blocksTimeout, "timeout", 5000, "timeout in milliseconds for RPC calls (default: 5000)") + + for _, cmd := range rootCmd.Commands() { + if cmd.Use == "blocks" { + cmd.AddCommand(blocksListCmd) + return + } + } + + blocksCmd := &cobra.Command{ + Use: "blocks", + Short: "Manage blocks", + Long: "Commands for working with blocks", + } + + blocksCmd.AddCommand(blocksListCmd) + rootCmd.AddCommand(blocksCmd) +} + +// blocksListRun implements the 'blocks list' command +// It retrieves and displays blocks with optional filtering by workspace, window, tab, or view type +func blocksListRun(cmd *cobra.Command, args []string) error { + if v := strings.TrimSpace(blocksView); v != "" { + if !isKnownViewFilter(v) { + return fmt.Errorf("unknown --view %q; try one of: term, web, preview, edit, sysinfo, waveai", v) + } + } + + var allBlocks []BlockDetails + + workspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)}) + if err != nil { + return fmt.Errorf("failed to list workspaces: %v", err) + } + + if len(workspaces) == 0 { + return fmt.Errorf("no workspaces found") + } + + var workspaceIdsToQuery []string + + // Determine which workspaces to query + if blocksWorkspaceId != "" && blocksWindowId != "" { + return fmt.Errorf("--workspace and --window are mutually exclusive; specify only one") + } + if blocksWorkspaceId != "" { + workspaceIdsToQuery = []string{blocksWorkspaceId} + } else if blocksWindowId != "" { + // Find workspace ID for this window + windowFound := false + for _, ws := range workspaces { + if ws.WindowId == blocksWindowId { + workspaceIdsToQuery = []string{ws.WorkspaceData.OID} + windowFound = true + break + } + } + if !windowFound { + return fmt.Errorf("window %s not found", blocksWindowId) + } + } else { + // Default to all workspaces + for _, ws := range workspaces { + workspaceIdsToQuery = append(workspaceIdsToQuery, ws.WorkspaceData.OID) + } + } + + // Query each selected workspace + hadSuccess := false + for _, wsId := range workspaceIdsToQuery { + req := wshrpc.BlocksListRequest{WorkspaceId: wsId} + if blocksWindowId != "" { + req.WindowId = blocksWindowId + } + + blocks, err := wshclient.BlocksListCommand(RpcClient, req, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)}) + if err != nil { + WriteStderr("Warning: couldn't list blocks for workspace %s: %v\n", wsId, err) + continue + } + hadSuccess = true + + // Apply filters + for _, b := range blocks { + if blocksTabId != "" && b.TabId != blocksTabId { + continue + } + + if blocksView != "" { + view := b.Meta.GetString(waveobj.MetaKey_View, "") + + // Support view type aliases + if !matchesViewType(view, blocksView) { + continue + } + } + + v := b.Meta.GetString(waveobj.MetaKey_View, "") + allBlocks = append(allBlocks, BlockDetails{ + BlockId: b.BlockId, + WorkspaceId: b.WorkspaceId, + TabId: b.TabId, + View: v, + Meta: b.Meta, + }) + } + } + + // No blocks found check + if len(allBlocks) == 0 { + if !hadSuccess { + return fmt.Errorf("failed to list blocks from all %d workspace(s)", len(workspaceIdsToQuery)) + } + WriteStdout("No blocks found\n") + return nil + } + + // Stable ordering for both JSON and table output + sort.SliceStable(allBlocks, func(i, j int) bool { + if allBlocks[i].WorkspaceId != allBlocks[j].WorkspaceId { + return allBlocks[i].WorkspaceId < allBlocks[j].WorkspaceId + } + if allBlocks[i].TabId != allBlocks[j].TabId { + return allBlocks[i].TabId < allBlocks[j].TabId + } + return allBlocks[i].BlockId < allBlocks[j].BlockId + }) + + // Output results + if blocksJSON { + bytes, err := json.MarshalIndent(allBlocks, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %v", err) + } + WriteStdout("%s\n", string(bytes)) + return nil + } + w := tabwriter.NewWriter(WrappedStdout, 0, 0, 2, ' ', 0) + defer w.Flush() + fmt.Fprintf(w, "BLOCK ID\tWORKSPACE\tTAB ID\tVIEW\tCONTENT\n") + + for _, b := range allBlocks { + blockID := b.BlockId + if len(blockID) > 36 { + blockID = blockID[:34] + ".." + } + view := strings.ToLower(b.View) + if view == "" { + view = "" + } + var content string + + switch view { + case "preview", "edit": + content = b.Meta.GetString(waveobj.MetaKey_File, "") + case "web": + content = b.Meta.GetString(waveobj.MetaKey_Url, "") + case "term": + content = b.Meta.GetString(waveobj.MetaKey_CmdCwd, "") + default: + content = "" + } + + wsID := b.WorkspaceId + if len(wsID) > 36 { + wsID = wsID[:34] + ".." + } + + tabID := b.TabId + if len(tabID) > 36 { + tabID = tabID[0:34] + ".." + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", blockID, wsID, tabID, view, content) + } + + return nil +} + +// matchesViewType checks if a view type matches a filter, supporting aliases +func matchesViewType(actual, filter string) bool { + // Direct match (case insensitive) + if strings.EqualFold(actual, filter) { + return true + } + + // Handle aliases + switch strings.ToLower(filter) { + case "preview", "edit": + return strings.EqualFold(actual, "preview") || strings.EqualFold(actual, "edit") + case "terminal", "term", "shell", "console": + return strings.EqualFold(actual, "term") + case "web", "browser", "url": + return strings.EqualFold(actual, "web") + case "ai", "waveai", "assistant": + return strings.EqualFold(actual, "waveai") + case "sys", "sysinfo", "system": + return strings.EqualFold(actual, "sysinfo") + } + + return false +} + +// isKnownViewFilter checks if a filter value is recognized +func isKnownViewFilter(f string) bool { + switch strings.ToLower(strings.TrimSpace(f)) { + case "term", "terminal", "shell", "console", + "web", "browser", "url", + "preview", "edit", + "sysinfo", "sys", "system", + "waveai", "ai", "assistant": + return true + default: + return false + } +} diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 3ac3b3d4fe..1bb2d71bc6 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -32,6 +32,11 @@ class RpcApiType { return client.wshRpcCall("blockinfo", data, opts); } + // command "blockslist" [call] + BlocksListCommand(client: WshClient, data: BlocksListRequest, opts?: RpcOpts): Promise { + return client.wshRpcCall("blockslist", data, opts); + } + // command "captureblockscreenshot" [call] CaptureBlockScreenshotCommand(client: WshClient, data: CommandCaptureBlockScreenshotData, opts?: RpcOpts): Promise { return client.wshRpcCall("captureblockscreenshot", data, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 14d6c36f83..d4dbfb6ab8 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -87,6 +87,21 @@ declare global { inputdata64: string; }; + // wshrpc.BlocksListEntry + type BlocksListEntry = { + windowid: string; + workspaceid: string; + tabid: string; + blockid: string; + meta: MetaType; + }; + + // wshrpc.BlocksListRequest + type BlocksListRequest = { + windowid?: string; + workspaceid?: string; + }; + // waveobj.Client type Client = WaveObj & { windowids: string[]; diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index ee01fef61a..f4aad41c5b 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -47,6 +47,12 @@ func BlockInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*ws return resp, err } +// command "blockslist", wshserver.BlocksListCommand +func BlocksListCommand(w *wshutil.WshRpc, data wshrpc.BlocksListRequest, opts *wshrpc.RpcOpts) ([]wshrpc.BlocksListEntry, error) { + resp, err := sendRpcRequestCallHelper[[]wshrpc.BlocksListEntry](w, "blockslist", data, opts) + return resp, err +} + // command "captureblockscreenshot", wshserver.CaptureBlockScreenshotCommand func CaptureBlockScreenshotCommand(w *wshutil.WshRpc, data wshrpc.CommandCaptureBlockScreenshotData, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "captureblockscreenshot", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index bcb151dee6..13e937b1bd 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -68,6 +68,7 @@ const ( Command_Mkdir = "mkdir" Command_ResolveIds = "resolveids" Command_BlockInfo = "blockinfo" + Command_BlocksList = "blockslist" Command_CreateBlock = "createblock" Command_DeleteBlock = "deleteblock" @@ -207,6 +208,7 @@ type WshRpcInterface interface { SetConnectionsConfigCommand(ctx context.Context, data ConnConfigRequest) error GetFullConfigCommand(ctx context.Context) (wconfig.FullConfigType, error) BlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error) + BlocksListCommand(ctx context.Context, data BlocksListRequest) ([]BlocksListEntry, error) WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) WshActivityCommand(ct context.Context, data map[string]int) error ActivityCommand(ctx context.Context, data ActivityUpdate) error @@ -703,6 +705,19 @@ type WorkspaceInfoData struct { WorkspaceData *waveobj.Workspace `json:"workspacedata"` } +type BlocksListRequest struct { + WindowId string `json:"windowid,omitempty"` + WorkspaceId string `json:"workspaceid,omitempty"` +} + +type BlocksListEntry struct { + WindowId string `json:"windowid"` + WorkspaceId string `json:"workspaceid"` + TabId string `json:"tabid"` + BlockId string `json:"blockid"` + Meta waveobj.MetaMapType `json:"meta"` +} + type AiMessageData struct { Message string `json:"message,omitempty"` } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 12775ba91c..554fffab4e 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -800,6 +800,73 @@ func (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData, }, nil } +// BlocksListCommand returns every block visible in the requested +// scope (current workspace by default). +func (ws *WshServer) BlocksListCommand( + ctx context.Context, + req wshrpc.BlocksListRequest) ([]wshrpc.BlocksListEntry, error) { + var results []wshrpc.BlocksListEntry + + // Resolve the set of workspaces to inspect + var workspaceIDs []string + if req.WorkspaceId != "" { + workspaceIDs = []string{req.WorkspaceId} + } else if req.WindowId != "" { + win, err := wcore.GetWindow(ctx, req.WindowId) + if err != nil { + return nil, err + } + workspaceIDs = []string{win.WorkspaceId} + } else { + // "current" == first workspace in client focus list + client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + return nil, err + } + if len(client.WindowIds) == 0 { + return nil, fmt.Errorf("no active window") + } + win, err := wcore.GetWindow(ctx, client.WindowIds[0]) + if err != nil { + return nil, err + } + workspaceIDs = []string{win.WorkspaceId} + } + + for _, wsID := range workspaceIDs { + wsData, err := wcore.GetWorkspace(ctx, wsID) + if err != nil { + return nil, err + } + + windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, wsID) + if err != nil { + log.Printf("error finding window for workspace %s: %v", wsID, err) + } + + for _, tabID := range append(wsData.PinnedTabIds, wsData.TabIds...) { + tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabID) + if err != nil { + return nil, err + } + for _, blkID := range tab.BlockIds { + blk, err := wstore.DBMustGet[*waveobj.Block](ctx, blkID) + if err != nil { + return nil, err + } + results = append(results, wshrpc.BlocksListEntry{ + WindowId: windowId, + WorkspaceId: wsID, + TabId: tabID, + BlockId: blkID, + Meta: blk.Meta, + }) + } + } + } + return results, nil +} + func (ws *WshServer) WorkspaceListCommand(ctx context.Context) ([]wshrpc.WorkspaceInfoData, error) { workspaceList, err := wcore.ListWorkspaces(ctx) if err != nil {