Skip to content

Commit 7681214

Browse files
authored
feat(cli): add blocks list with filtering and JSON output (#2337)
### What This Does Adds a new `wsh blocks list` subcommand that lists all blocks in all or specified workspace, window, or tab. Includes filtering options and JSON output for automation. ### Motivation Wave users had no simple way to programmatically discover block IDs for scripting and automation. This feature: - Enables workflows like syncing Preview widgets with `cd` changes. - Simplifies debugging and introspection. - Provides a foundation for future CLI enhancements (focus/close blocks). ### Usage ```wsh blocks [list|ls|get] [--workspace=<workspace-id>] [--window=<window-id>] [--tab=<tab-id>] [--view=<view-type>] [--json]``` Where `<view-type>` can be one of: term, terminal, shell, console, web, browser, url, preview, edit, sysinfo, sys, system, waveai, ai, or assistant. ### Notes - Fully backward compatible. - Code follows existing CLI patterns.
1 parent 2312752 commit 7681214

File tree

6 files changed

+402
-0
lines changed

6 files changed

+402
-0
lines changed

cmd/wsh/cmd/wshcmd-blocks.go

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cmd
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"sort"
10+
"strings"
11+
"text/tabwriter"
12+
13+
"github.com/spf13/cobra"
14+
"github.com/wavetermdev/waveterm/pkg/waveobj"
15+
"github.com/wavetermdev/waveterm/pkg/wshrpc"
16+
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
17+
)
18+
19+
// Command-line flags for the blocks commands
20+
var (
21+
blocksWindowId string // Window ID to filter blocks by
22+
blocksWorkspaceId string // Workspace ID to filter blocks by
23+
blocksTabId string // Tab ID to filter blocks by
24+
blocksView string // View type to filter blocks by (term, web, etc.)
25+
blocksJSON bool // Whether to output as JSON
26+
blocksTimeout int // Timeout in milliseconds for RPC calls
27+
)
28+
29+
// BlockDetails represents the information about a block returned by the list command
30+
type BlockDetails struct {
31+
BlockId string `json:"blockid"` // Unique identifier for the block
32+
WorkspaceId string `json:"workspaceid"` // ID of the workspace containing the block
33+
TabId string `json:"tabid"` // ID of the tab containing the block
34+
View string `json:"view"` // Canonical view type (term, web, preview, edit, sysinfo, waveai)
35+
Meta waveobj.MetaMapType `json:"meta"` // Block metadata including view type
36+
}
37+
38+
// blocksListCmd represents the 'blocks list' command
39+
var blocksListCmd = &cobra.Command{
40+
Use: "list",
41+
Aliases: []string{"ls", "get"},
42+
Short: "List blocks in workspaces/windows",
43+
Long: `List blocks with optional filtering by workspace, window, tab, or view type.
44+
45+
Examples:
46+
# List blocks from all workspaces
47+
wsh blocks list
48+
49+
# List only terminal blocks
50+
wsh blocks list --view=term
51+
52+
# Filter by window ID (get IDs from 'wsh workspace list')
53+
wsh blocks list --window=dbca23b5-f89b-4780-a0fe-452f5bc7d900
54+
55+
# Filter by workspace ID
56+
wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114
57+
58+
# Filter by tab ID
59+
wsh blocks list --tab=a0459921-cc1a-48cc-ae7b-5f4821e1c9e1
60+
61+
# Output as JSON for scripting
62+
wsh blocks list --json
63+
64+
# Set a different timeout (in milliseconds)
65+
wsh blocks list --timeout=10000`,
66+
RunE: blocksListRun,
67+
PreRunE: preRunSetupRpcClient,
68+
SilenceUsage: true,
69+
}
70+
71+
// init registers the blocks commands with the root command
72+
// It configures all the flags and command options
73+
func init() {
74+
blocksListCmd.Flags().StringVar(&blocksWindowId, "window", "", "restrict to window id")
75+
blocksListCmd.Flags().StringVar(&blocksWorkspaceId, "workspace", "", "restrict to workspace id")
76+
blocksListCmd.Flags().StringVar(&blocksTabId, "tab", "", "restrict to specific tab id")
77+
blocksListCmd.Flags().StringVar(&blocksView, "view", "", "restrict to view type (term/terminal, web/browser, preview/edit, sysinfo, waveai)")
78+
blocksListCmd.Flags().BoolVar(&blocksJSON, "json", false, "output as JSON")
79+
blocksListCmd.Flags().IntVar(&blocksTimeout, "timeout", 5000, "timeout in milliseconds for RPC calls (default: 5000)")
80+
81+
for _, cmd := range rootCmd.Commands() {
82+
if cmd.Use == "blocks" {
83+
cmd.AddCommand(blocksListCmd)
84+
return
85+
}
86+
}
87+
88+
blocksCmd := &cobra.Command{
89+
Use: "blocks",
90+
Short: "Manage blocks",
91+
Long: "Commands for working with blocks",
92+
}
93+
94+
blocksCmd.AddCommand(blocksListCmd)
95+
rootCmd.AddCommand(blocksCmd)
96+
}
97+
98+
// blocksListRun implements the 'blocks list' command
99+
// It retrieves and displays blocks with optional filtering by workspace, window, tab, or view type
100+
func blocksListRun(cmd *cobra.Command, args []string) error {
101+
if v := strings.TrimSpace(blocksView); v != "" {
102+
if !isKnownViewFilter(v) {
103+
return fmt.Errorf("unknown --view %q; try one of: term, web, preview, edit, sysinfo, waveai", v)
104+
}
105+
}
106+
107+
var allBlocks []BlockDetails
108+
109+
workspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)})
110+
if err != nil {
111+
return fmt.Errorf("failed to list workspaces: %v", err)
112+
}
113+
114+
if len(workspaces) == 0 {
115+
return fmt.Errorf("no workspaces found")
116+
}
117+
118+
var workspaceIdsToQuery []string
119+
120+
// Determine which workspaces to query
121+
if blocksWorkspaceId != "" && blocksWindowId != "" {
122+
return fmt.Errorf("--workspace and --window are mutually exclusive; specify only one")
123+
}
124+
if blocksWorkspaceId != "" {
125+
workspaceIdsToQuery = []string{blocksWorkspaceId}
126+
} else if blocksWindowId != "" {
127+
// Find workspace ID for this window
128+
windowFound := false
129+
for _, ws := range workspaces {
130+
if ws.WindowId == blocksWindowId {
131+
workspaceIdsToQuery = []string{ws.WorkspaceData.OID}
132+
windowFound = true
133+
break
134+
}
135+
}
136+
if !windowFound {
137+
return fmt.Errorf("window %s not found", blocksWindowId)
138+
}
139+
} else {
140+
// Default to all workspaces
141+
for _, ws := range workspaces {
142+
workspaceIdsToQuery = append(workspaceIdsToQuery, ws.WorkspaceData.OID)
143+
}
144+
}
145+
146+
// Query each selected workspace
147+
hadSuccess := false
148+
for _, wsId := range workspaceIdsToQuery {
149+
req := wshrpc.BlocksListRequest{WorkspaceId: wsId}
150+
if blocksWindowId != "" {
151+
req.WindowId = blocksWindowId
152+
}
153+
154+
blocks, err := wshclient.BlocksListCommand(RpcClient, req, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)})
155+
if err != nil {
156+
WriteStderr("Warning: couldn't list blocks for workspace %s: %v\n", wsId, err)
157+
continue
158+
}
159+
hadSuccess = true
160+
161+
// Apply filters
162+
for _, b := range blocks {
163+
if blocksTabId != "" && b.TabId != blocksTabId {
164+
continue
165+
}
166+
167+
if blocksView != "" {
168+
view := b.Meta.GetString(waveobj.MetaKey_View, "")
169+
170+
// Support view type aliases
171+
if !matchesViewType(view, blocksView) {
172+
continue
173+
}
174+
}
175+
176+
v := b.Meta.GetString(waveobj.MetaKey_View, "")
177+
allBlocks = append(allBlocks, BlockDetails{
178+
BlockId: b.BlockId,
179+
WorkspaceId: b.WorkspaceId,
180+
TabId: b.TabId,
181+
View: v,
182+
Meta: b.Meta,
183+
})
184+
}
185+
}
186+
187+
// No blocks found check
188+
if len(allBlocks) == 0 {
189+
if !hadSuccess {
190+
return fmt.Errorf("failed to list blocks from all %d workspace(s)", len(workspaceIdsToQuery))
191+
}
192+
WriteStdout("No blocks found\n")
193+
return nil
194+
}
195+
196+
// Stable ordering for both JSON and table output
197+
sort.SliceStable(allBlocks, func(i, j int) bool {
198+
if allBlocks[i].WorkspaceId != allBlocks[j].WorkspaceId {
199+
return allBlocks[i].WorkspaceId < allBlocks[j].WorkspaceId
200+
}
201+
if allBlocks[i].TabId != allBlocks[j].TabId {
202+
return allBlocks[i].TabId < allBlocks[j].TabId
203+
}
204+
return allBlocks[i].BlockId < allBlocks[j].BlockId
205+
})
206+
207+
// Output results
208+
if blocksJSON {
209+
bytes, err := json.MarshalIndent(allBlocks, "", " ")
210+
if err != nil {
211+
return fmt.Errorf("failed to marshal JSON: %v", err)
212+
}
213+
WriteStdout("%s\n", string(bytes))
214+
return nil
215+
}
216+
w := tabwriter.NewWriter(WrappedStdout, 0, 0, 2, ' ', 0)
217+
defer w.Flush()
218+
fmt.Fprintf(w, "BLOCK ID\tWORKSPACE\tTAB ID\tVIEW\tCONTENT\n")
219+
220+
for _, b := range allBlocks {
221+
blockID := b.BlockId
222+
if len(blockID) > 36 {
223+
blockID = blockID[:34] + ".."
224+
}
225+
view := strings.ToLower(b.View)
226+
if view == "" {
227+
view = "<unknown>"
228+
}
229+
var content string
230+
231+
switch view {
232+
case "preview", "edit":
233+
content = b.Meta.GetString(waveobj.MetaKey_File, "<no file>")
234+
case "web":
235+
content = b.Meta.GetString(waveobj.MetaKey_Url, "<no url>")
236+
case "term":
237+
content = b.Meta.GetString(waveobj.MetaKey_CmdCwd, "<no cwd>")
238+
default:
239+
content = ""
240+
}
241+
242+
wsID := b.WorkspaceId
243+
if len(wsID) > 36 {
244+
wsID = wsID[:34] + ".."
245+
}
246+
247+
tabID := b.TabId
248+
if len(tabID) > 36 {
249+
tabID = tabID[0:34] + ".."
250+
}
251+
252+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", blockID, wsID, tabID, view, content)
253+
}
254+
255+
return nil
256+
}
257+
258+
// matchesViewType checks if a view type matches a filter, supporting aliases
259+
func matchesViewType(actual, filter string) bool {
260+
// Direct match (case insensitive)
261+
if strings.EqualFold(actual, filter) {
262+
return true
263+
}
264+
265+
// Handle aliases
266+
switch strings.ToLower(filter) {
267+
case "preview", "edit":
268+
return strings.EqualFold(actual, "preview") || strings.EqualFold(actual, "edit")
269+
case "terminal", "term", "shell", "console":
270+
return strings.EqualFold(actual, "term")
271+
case "web", "browser", "url":
272+
return strings.EqualFold(actual, "web")
273+
case "ai", "waveai", "assistant":
274+
return strings.EqualFold(actual, "waveai")
275+
case "sys", "sysinfo", "system":
276+
return strings.EqualFold(actual, "sysinfo")
277+
}
278+
279+
return false
280+
}
281+
282+
// isKnownViewFilter checks if a filter value is recognized
283+
func isKnownViewFilter(f string) bool {
284+
switch strings.ToLower(strings.TrimSpace(f)) {
285+
case "term", "terminal", "shell", "console",
286+
"web", "browser", "url",
287+
"preview", "edit",
288+
"sysinfo", "sys", "system",
289+
"waveai", "ai", "assistant":
290+
return true
291+
default:
292+
return false
293+
}
294+
}

frontend/app/store/wshclientapi.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ class RpcApiType {
3232
return client.wshRpcCall("blockinfo", data, opts);
3333
}
3434

35+
// command "blockslist" [call]
36+
BlocksListCommand(client: WshClient, data: BlocksListRequest, opts?: RpcOpts): Promise<BlocksListEntry[]> {
37+
return client.wshRpcCall("blockslist", data, opts);
38+
}
39+
3540
// command "captureblockscreenshot" [call]
3641
CaptureBlockScreenshotCommand(client: WshClient, data: CommandCaptureBlockScreenshotData, opts?: RpcOpts): Promise<string> {
3742
return client.wshRpcCall("captureblockscreenshot", data, opts);

frontend/types/gotypes.d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,21 @@ declare global {
8787
inputdata64: string;
8888
};
8989

90+
// wshrpc.BlocksListEntry
91+
type BlocksListEntry = {
92+
windowid: string;
93+
workspaceid: string;
94+
tabid: string;
95+
blockid: string;
96+
meta: MetaType;
97+
};
98+
99+
// wshrpc.BlocksListRequest
100+
type BlocksListRequest = {
101+
windowid?: string;
102+
workspaceid?: string;
103+
};
104+
90105
// waveobj.Client
91106
type Client = WaveObj & {
92107
windowids: string[];

pkg/wshrpc/wshclient/wshclient.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ func BlockInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*ws
4747
return resp, err
4848
}
4949

50+
// command "blockslist", wshserver.BlocksListCommand
51+
func BlocksListCommand(w *wshutil.WshRpc, data wshrpc.BlocksListRequest, opts *wshrpc.RpcOpts) ([]wshrpc.BlocksListEntry, error) {
52+
resp, err := sendRpcRequestCallHelper[[]wshrpc.BlocksListEntry](w, "blockslist", data, opts)
53+
return resp, err
54+
}
55+
5056
// command "captureblockscreenshot", wshserver.CaptureBlockScreenshotCommand
5157
func CaptureBlockScreenshotCommand(w *wshutil.WshRpc, data wshrpc.CommandCaptureBlockScreenshotData, opts *wshrpc.RpcOpts) (string, error) {
5258
resp, err := sendRpcRequestCallHelper[string](w, "captureblockscreenshot", data, opts)

pkg/wshrpc/wshrpctypes.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const (
6868
Command_Mkdir = "mkdir"
6969
Command_ResolveIds = "resolveids"
7070
Command_BlockInfo = "blockinfo"
71+
Command_BlocksList = "blockslist"
7172
Command_CreateBlock = "createblock"
7273
Command_DeleteBlock = "deleteblock"
7374

@@ -207,6 +208,7 @@ type WshRpcInterface interface {
207208
SetConnectionsConfigCommand(ctx context.Context, data ConnConfigRequest) error
208209
GetFullConfigCommand(ctx context.Context) (wconfig.FullConfigType, error)
209210
BlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error)
211+
BlocksListCommand(ctx context.Context, data BlocksListRequest) ([]BlocksListEntry, error)
210212
WaveInfoCommand(ctx context.Context) (*WaveInfoData, error)
211213
WshActivityCommand(ct context.Context, data map[string]int) error
212214
ActivityCommand(ctx context.Context, data ActivityUpdate) error
@@ -703,6 +705,19 @@ type WorkspaceInfoData struct {
703705
WorkspaceData *waveobj.Workspace `json:"workspacedata"`
704706
}
705707

708+
type BlocksListRequest struct {
709+
WindowId string `json:"windowid,omitempty"`
710+
WorkspaceId string `json:"workspaceid,omitempty"`
711+
}
712+
713+
type BlocksListEntry struct {
714+
WindowId string `json:"windowid"`
715+
WorkspaceId string `json:"workspaceid"`
716+
TabId string `json:"tabid"`
717+
BlockId string `json:"blockid"`
718+
Meta waveobj.MetaMapType `json:"meta"`
719+
}
720+
706721
type AiMessageData struct {
707722
Message string `json:"message,omitempty"`
708723
}

0 commit comments

Comments
 (0)