diff --git a/.kilocode/skills/add-wshcmd/SKILL.md b/.kilocode/skills/add-wshcmd/SKILL.md new file mode 100644 index 0000000000..0cdae64702 --- /dev/null +++ b/.kilocode/skills/add-wshcmd/SKILL.md @@ -0,0 +1,921 @@ +--- +name: add-wshcmd +description: Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. +--- + +# Adding a New wsh Command to Wave Terminal + +This guide explains how to add a new command to the `wsh` CLI tool. + +## wsh Command System Overview + +Wave Terminal's `wsh` command provides CLI access to Wave Terminal features. The system uses: + +1. **Cobra Framework** - CLI command structure and parsing +2. **Command Files** - Individual command implementations in `cmd/wsh/cmd/wshcmd-*.go` +3. **RPC Client** - Communication with Wave Terminal backend via `RpcClient` +4. **Activity Tracking** - Telemetry for command usage analytics +5. **Documentation** - User-facing docs in `docs/docs/wsh-reference.mdx` + +Commands are registered in their `init()` functions and execute through the Cobra framework. + +## Step-by-Step Guide + +### Step 1: Create Command File + +Create a new file in `cmd/wsh/cmd/` named `wshcmd-[commandname].go`: + +```go +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var myCommandCmd = &cobra.Command{ + Use: "mycommand [args]", + Short: "Brief description of what this command does", + Long: `Detailed description of the command. +Can include multiple lines and examples of usage.`, + RunE: myCommandRun, + PreRunE: preRunSetupRpcClient, // Include if command needs RPC + DisableFlagsInUseLine: true, +} + +// Flag variables +var ( + myCommandFlagExample string + myCommandFlagVerbose bool +) + +func init() { + // Add command to root + rootCmd.AddCommand(myCommandCmd) + + // Define flags + myCommandCmd.Flags().StringVarP(&myCommandFlagExample, "example", "e", "", "example flag description") + myCommandCmd.Flags().BoolVarP(&myCommandFlagVerbose, "verbose", "v", false, "enable verbose output") +} + +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + // Always track activity for telemetry + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + // Validate arguments + if len(args) == 0 { + OutputHelpMessage(cmd) + return fmt.Errorf("requires at least one argument") + } + + // Command implementation + fmt.Printf("Command executed successfully\n") + return nil +} +``` + +**File Naming Convention:** +- Use `wshcmd-[commandname].go` format +- Use lowercase, hyphenated names for multi-word commands +- Examples: `wshcmd-getvar.go`, `wshcmd-setmeta.go`, `wshcmd-ai.go` + +### Step 2: Command Structure + +#### Basic Command Structure + +```go +var myCommandCmd = &cobra.Command{ + Use: "mycommand [required] [optional...]", + Short: "One-line description (shown in help)", + Long: `Detailed multi-line description`, + + // Argument validation + Args: cobra.MinimumNArgs(1), // Or cobra.ExactArgs(1), cobra.NoArgs, etc. + + // Execution function + RunE: myCommandRun, + + // Pre-execution setup (if needed) + PreRunE: preRunSetupRpcClient, // Sets up RPC client for backend communication + + // Example usage (optional) + Example: " wsh mycommand foo\n wsh mycommand --flag bar", + + // Disable flag notation in usage line + DisableFlagsInUseLine: true, +} +``` + +**Key Fields:** +- `Use`: Command name and argument pattern +- `Short`: Brief description for command list +- `Long`: Detailed description shown in help +- `Args`: Argument validator (optional) +- `RunE`: Main execution function (returns error) +- `PreRunE`: Setup function that runs before `RunE` +- `Example`: Usage examples (optional) +- `DisableFlagsInUseLine`: Clean up help display + +#### When to Use PreRunE + +Include `PreRunE: preRunSetupRpcClient` if your command: +- Communicates with the Wave Terminal backend +- Needs access to `RpcClient` +- Requires JWT authentication (WAVETERM_JWT env var) +- Makes RPC calls via `wshclient.*Command()` functions + +**Don't include PreRunE** for commands that: +- Only manipulate local state +- Don't need backend communication +- Are purely informational/local operations + +### Step 3: Implement Command Logic + +#### Command Function Pattern + +```go +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + // Step 1: Always track activity (for telemetry) + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + // Step 2: Validate arguments and flags + if len(args) != 1 { + OutputHelpMessage(cmd) + return fmt.Errorf("requires exactly one argument") + } + + // Step 3: Parse/prepare data + targetArg := args[0] + + // Step 4: Make RPC call if needed + result, err := wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{ + Field: targetArg, + }, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("executing command: %w", err) + } + + // Step 5: Output results + fmt.Printf("Result: %s\n", result) + return nil +} +``` + +**Important Patterns:** + +1. **Activity Tracking**: Always include deferred `sendActivity()` call + ```go + defer func() { + sendActivity("commandname", rtnErr == nil) + }() + ``` + +2. **Error Handling**: Return errors, don't call `os.Exit()` + ```go + if err != nil { + return fmt.Errorf("context: %w", err) + } + ``` + +3. **Output**: Use standard `fmt` package for output + ```go + fmt.Printf("Success message\n") + fmt.Fprintf(os.Stderr, "Error message\n") + ``` + +4. **Help Messages**: Show help when arguments are invalid + ```go + if len(args) == 0 { + OutputHelpMessage(cmd) + return fmt.Errorf("requires arguments") + } + ``` + +5. **Exit Codes**: Set custom exit code via `WshExitCode` + ```go + if notFound { + WshExitCode = 1 + return nil // Don't return error, just set exit code + } + ``` + +### Step 4: Define Flags + +Add flags in the `init()` function: + +```go +var ( + // Declare flag variables at package level + myCommandFlagString string + myCommandFlagBool bool + myCommandFlagInt int +) + +func init() { + rootCmd.AddCommand(myCommandCmd) + + // String flag with short version + myCommandCmd.Flags().StringVarP(&myCommandFlagString, "name", "n", "default", "description") + + // Boolean flag + myCommandCmd.Flags().BoolVarP(&myCommandFlagBool, "verbose", "v", false, "enable verbose") + + // Integer flag + myCommandCmd.Flags().IntVar(&myCommandFlagInt, "count", 10, "set count") + + // Flag without short version + myCommandCmd.Flags().StringVar(&myCommandFlagString, "longname", "", "description") +} +``` + +**Flag Types:** +- `StringVar/StringVarP` - String values +- `BoolVar/BoolVarP` - Boolean flags +- `IntVar/IntVarP` - Integer values +- The `P` suffix versions include a short flag name + +**Flag Naming:** +- Use camelCase for variable names: `myCommandFlagName` +- Use kebab-case for flag names: `--flag-name` +- Prefix variable names with command name for clarity + +### Step 5: Working with Block Arguments + +Many commands operate on blocks. Use the standard block resolution pattern: + +```go +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + // Resolve block using the -b/--block flag + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + // Use the blockid in RPC call + err = wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{ + BlockId: fullORef.OID, + }, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("command failed: %w", err) + } + + return nil +} +``` + +**Block Resolution:** +- The `-b/--block` flag is defined globally in `wshcmd-root.go` +- `resolveBlockArg()` resolves the block argument to a full ORef +- Supports: `this`, `tab`, full UUIDs, 8-char prefixes, block numbers +- Default is `"this"` (current block) + +**Alternative: Manual Block Resolution** + +```go +// Get tab ID from environment +tabId := os.Getenv("WAVETERM_TABID") +if tabId == "" { + return fmt.Errorf("WAVETERM_TABID not set") +} + +// Create route for tab-level operations +route := wshutil.MakeTabRouteId(tabId) + +// Use route in RPC call +err := wshclient.SomeCommand(RpcClient, commandData, &wshrpc.RpcOpts{ + Route: route, + Timeout: 2000, +}) +``` + +### Step 6: Making RPC Calls + +Use the `wshclient` package to make RPC calls: + +```go +import ( + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +// Simple RPC call +result, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ + ORef: *fullORef, +}, &wshrpc.RpcOpts{Timeout: 2000}) +if err != nil { + return fmt.Errorf("getting metadata: %w", err) +} + +// RPC call with routing +err := wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{ + ORef: *fullORef, + Meta: metaMap, +}, &wshrpc.RpcOpts{ + Route: route, + Timeout: 5000, +}) +if err != nil { + return fmt.Errorf("setting metadata: %w", err) +} +``` + +**RPC Options:** +- `Timeout`: Request timeout in milliseconds (typically 2000-5000) +- `Route`: Route ID for targeting specific components +- Available routes: `wshutil.ControlRoute`, `wshutil.MakeTabRouteId(tabId)` + +### Step 7: Add Documentation + +Add your command to `docs/docs/wsh-reference.mdx`: + +````markdown +## mycommand + +Brief description of what the command does. + +```sh +wsh mycommand [args] [flags] +``` + +Detailed explanation of the command's purpose and behavior. + +Flags: +- `-n, --name ` - description of this flag +- `-v, --verbose` - enable verbose output +- `-b, --block ` - specify target block (default: current block) + +Examples: + +```sh +# Basic usage +wsh mycommand arg1 + +# With flags +wsh mycommand --name value arg1 + +# With block targeting +wsh mycommand -b 2 arg1 + +# Complex example +wsh mycommand -v --name "example" arg1 arg2 +``` + +Additional notes, tips, or warnings about the command. + +--- +```` + +**Documentation Guidelines:** +- Place in alphabetical order with other commands +- Include command signature with argument pattern +- List all flags with short and long versions +- Provide practical examples (at least 3-5) +- Explain common use cases and patterns +- Add tips or warnings if relevant +- Use `---` separator between commands + +### Step 8: Test Your Command + +Build and test the command: + +```bash +# Build wsh +task build:wsh + +# Or build everything +task build + +# Test the command +./bin/wsh/wsh mycommand --help +./bin/wsh/wsh mycommand arg1 arg2 +``` + +**Testing Checklist:** +- [ ] Help message displays correctly +- [ ] Required arguments validated +- [ ] Flags work as expected +- [ ] Error messages are clear +- [ ] Success cases work correctly +- [ ] RPC calls complete successfully +- [ ] Output is formatted correctly + +## Complete Examples + +### Example 1: Simple Command with No RPC + +**Use case:** A command that prints Wave Terminal version info + +#### Command File (`cmd/wsh/cmd/wshcmd-version.go`) + +```go +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print Wave Terminal version", + RunE: versionRun, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + +func versionRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("version", rtnErr == nil) + }() + + fmt.Printf("Wave Terminal %s\n", wavebase.WaveVersion) + return nil +} +``` + +#### Documentation + +````markdown +## version + +Print the current Wave Terminal version. + +```sh +wsh version +``` + +Examples: + +```sh +# Print version +wsh version +``` +```` + +### Example 2: Command with Flags and RPC + +**Use case:** A command to update block title + +#### Command File (`cmd/wsh/cmd/wshcmd-settitle.go`) + +```go +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var setTitleCmd = &cobra.Command{ + Use: "settitle [title]", + Short: "Set block title", + Long: `Set the title for the current or specified block.`, + Args: cobra.ExactArgs(1), + RunE: setTitleRun, + PreRunE: preRunSetupRpcClient, + DisableFlagsInUseLine: true, +} + +var setTitleIcon string + +func init() { + rootCmd.AddCommand(setTitleCmd) + setTitleCmd.Flags().StringVarP(&setTitleIcon, "icon", "i", "", "set block icon") +} + +func setTitleRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("settitle", rtnErr == nil) + }() + + title := args[0] + + // Resolve block + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + // Build metadata map + meta := make(map[string]interface{}) + meta["title"] = title + if setTitleIcon != "" { + meta["icon"] = setTitleIcon + } + + // Make RPC call + err = wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{ + ORef: *fullORef, + Meta: meta, + }, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("setting title: %w", err) + } + + fmt.Printf("title updated\n") + return nil +} +``` + +#### Documentation + +````markdown +## settitle + +Set the title for a block. + +```sh +wsh settitle [title] +``` + +Update the display title for the current or specified block. Optionally set an icon as well. + +Flags: +- `-i, --icon ` - set block icon along with title +- `-b, --block ` - specify target block (default: current block) + +Examples: + +```sh +# Set title for current block +wsh settitle "My Terminal" + +# Set title and icon +wsh settitle --icon "terminal" "Development Shell" + +# Set title for specific block +wsh settitle -b 2 "Build Output" +``` +```` + +### Example 3: Subcommands + +**Use case:** Command with multiple subcommands (like `wsh conn`) + +#### Command File (`cmd/wsh/cmd/wshcmd-mygroup.go`) + +```go +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var myGroupCmd = &cobra.Command{ + Use: "mygroup", + Short: "Manage something", +} + +var myGroupListCmd = &cobra.Command{ + Use: "list", + Short: "List items", + RunE: myGroupListRun, + PreRunE: preRunSetupRpcClient, +} + +var myGroupAddCmd = &cobra.Command{ + Use: "add [name]", + Short: "Add an item", + Args: cobra.ExactArgs(1), + RunE: myGroupAddRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + // Add parent command + rootCmd.AddCommand(myGroupCmd) + + // Add subcommands + myGroupCmd.AddCommand(myGroupListCmd) + myGroupCmd.AddCommand(myGroupAddCmd) +} + +func myGroupListRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mygroup:list", rtnErr == nil) + }() + + // Implementation + fmt.Printf("Listing items...\n") + return nil +} + +func myGroupAddRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mygroup:add", rtnErr == nil) + }() + + name := args[0] + fmt.Printf("Adding item: %s\n", name) + return nil +} +``` + +#### Documentation + +````markdown +## mygroup + +Manage something with subcommands. + +### list + +List all items. + +```sh +wsh mygroup list +``` + +### add + +Add a new item. + +```sh +wsh mygroup add [name] +``` + +Examples: + +```sh +# List items +wsh mygroup list + +# Add an item +wsh mygroup add "new-item" +``` +```` + +## Common Patterns + +### Reading from Stdin + +```go +import "io" + +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + // Check if reading from stdin (using "-" convention) + var data []byte + var err error + + if len(args) > 0 && args[0] == "-" { + data, err = io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("reading stdin: %w", err) + } + } else { + // Read from file or other source + data, err = os.ReadFile(args[0]) + if err != nil { + return fmt.Errorf("reading file: %w", err) + } + } + + // Process data + fmt.Printf("Read %d bytes\n", len(data)) + return nil +} +``` + +### JSON File Input + +```go +import ( + "encoding/json" + "io" +) + +func loadJSONFile(filepath string) (map[string]interface{}, error) { + var data []byte + var err error + + if filepath == "-" { + data, err = io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("reading stdin: %w", err) + } + } else { + data, err = os.ReadFile(filepath) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parsing JSON: %w", err) + } + + return result, nil +} +``` + +### Conditional Output (TTY Detection) + +```go +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + isTty := getIsTty() + + // Output value + fmt.Printf("%s", value) + + // Add newline only if TTY (for better piping experience) + if isTty { + fmt.Printf("\n") + } + + return nil +} +``` + +### Environment Variable Access + +```go +func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("mycommand", rtnErr == nil) + }() + + // Get block ID from environment + blockId := os.Getenv("WAVETERM_BLOCKID") + if blockId == "" { + return fmt.Errorf("WAVETERM_BLOCKID not set") + } + + // Get tab ID from environment + tabId := os.Getenv("WAVETERM_TABID") + if tabId == "" { + return fmt.Errorf("WAVETERM_TABID not set") + } + + fmt.Printf("Block: %s, Tab: %s\n", blockId, tabId) + return nil +} +``` + +## Best Practices + +### Command Design + +1. **Single Responsibility**: Each command should do one thing well +2. **Composable**: Design commands to work with pipes and other commands +3. **Consistent**: Follow existing wsh command patterns and conventions +4. **Documented**: Provide clear help text and examples + +### Error Handling + +1. **Context**: Wrap errors with context using `fmt.Errorf("context: %w", err)` +2. **User-Friendly**: Make error messages clear and actionable +3. **No Panics**: Return errors instead of calling `os.Exit()` or `panic()` +4. **Exit Codes**: Use `WshExitCode` for custom exit codes + +### Output + +1. **Structured**: Use consistent formatting for output +2. **Quiet by Default**: Only output what's necessary +3. **Verbose Flag**: Optionally provide `-v` for detailed output +4. **Stderr for Errors**: Use `fmt.Fprintf(os.Stderr, ...)` for error messages + +### Flags + +1. **Short Versions**: Provide `-x` short versions for common flags +2. **Sensible Defaults**: Choose defaults that work for most users +3. **Boolean Flags**: Use for on/off options +4. **String Flags**: Use for values that need user input + +### RPC Calls + +1. **Timeouts**: Always specify reasonable timeouts +2. **Error Context**: Wrap RPC errors with operation context +3. **Retries**: Don't retry automatically; let user retry command +4. **Routes**: Use appropriate routes for different operations + +## Common Pitfalls + +### 1. Forgetting Activity Tracking + +**Problem**: Command usage not tracked in telemetry + +**Solution**: Always include deferred `sendActivity()` call: +```go +defer func() { + sendActivity("commandname", rtnErr == nil) +}() +``` + +### 2. Using os.Exit() Instead of Returning Error + +**Problem**: Breaks defer statements and cleanup + +**Solution**: Return errors from RunE function: +```go +// Bad +if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) +} + +// Good +if err != nil { + return fmt.Errorf("operation failed: %w", err) +} +``` + +### 3. Not Validating Arguments + +**Problem**: Command crashes with nil pointer or index out of range + +**Solution**: Validate arguments early and show help: +```go +if len(args) == 0 { + OutputHelpMessage(cmd) + return fmt.Errorf("requires at least one argument") +} +``` + +### 4. Forgetting to Add to init() + +**Problem**: Command not available when running wsh + +**Solution**: Always add command in `init()` function: +```go +func init() { + rootCmd.AddCommand(myCommandCmd) +} +``` + +### 5. Inconsistent Output + +**Problem**: Inconsistent use of output methods + +**Solution**: Use standard `fmt` package functions: +```go +// For stdout +fmt.Printf("output\n") + +// For stderr +fmt.Fprintf(os.Stderr, "error message\n") +``` + +## Quick Reference Checklist + +When adding a new wsh command: + +- [ ] Create `cmd/wsh/cmd/wshcmd-[commandname].go` +- [ ] Define command struct with Use, Short, Long descriptions +- [ ] Add `PreRunE: preRunSetupRpcClient` if using RPC +- [ ] Implement command function with activity tracking +- [ ] Add command to `rootCmd` in `init()` function +- [ ] Define flags in `init()` function if needed +- [ ] Add documentation to `docs/docs/wsh-reference.mdx` +- [ ] Build and test: `task build:wsh` +- [ ] Test help: `wsh [commandname] --help` +- [ ] Test all flag combinations +- [ ] Test error cases + +## Related Files + +- **Root Command**: `cmd/wsh/cmd/wshcmd-root.go` - Main command setup and utilities +- **RPC Client**: `pkg/wshrpc/wshclient/` - Client functions for RPC calls +- **RPC Types**: `pkg/wshrpc/wshrpctypes.go` - RPC request/response data structures +- **Documentation**: `docs/docs/wsh-reference.mdx` - User-facing command reference +- **Examples**: `cmd/wsh/cmd/wshcmd-*.go` - Existing command implementations diff --git a/cmd/wsh/cmd/wshcmd-termscrollback.go b/cmd/wsh/cmd/wshcmd-termscrollback.go new file mode 100644 index 0000000000..6368e1559d --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-termscrollback.go @@ -0,0 +1,104 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +var termScrollbackCmd = &cobra.Command{ + Use: "termscrollback", + Short: "Get terminal scrollback from a terminal block", + Long: `Get the terminal scrollback from a terminal block. + +By default, retrieves all lines. You can specify line ranges or get the +output of the last command using the --lastcommand flag.`, + RunE: termScrollbackRun, + PreRunE: preRunSetupRpcClient, + DisableFlagsInUseLine: true, +} + +var ( + termScrollbackLineStart int + termScrollbackLineEnd int + termScrollbackLastCmd bool + termScrollbackOutputFile string +) + +func init() { + rootCmd.AddCommand(termScrollbackCmd) + + termScrollbackCmd.Flags().IntVar(&termScrollbackLineStart, "start", 0, "starting line number (0 = beginning)") + termScrollbackCmd.Flags().IntVar(&termScrollbackLineEnd, "end", 0, "ending line number (0 = all lines)") + termScrollbackCmd.Flags().BoolVar(&termScrollbackLastCmd, "lastcommand", false, "get output of last command (requires shell integration)") + termScrollbackCmd.Flags().StringVarP(&termScrollbackOutputFile, "output", "o", "", "write output to file instead of stdout") +} + +func termScrollbackRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("termscrollback", rtnErr == nil) + }() + + // Resolve the block argument + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + // Get block metadata to verify it's a terminal block + metaData, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ + ORef: *fullORef, + }, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("error getting block metadata: %w", err) + } + + // Check if the block is a terminal block + viewType, ok := metaData[waveobj.MetaKey_View].(string) + if !ok || viewType != "term" { + return fmt.Errorf("block %s is not a terminal block (view type: %s)", fullORef.OID, viewType) + } + + // Make the RPC call to get scrollback + scrollbackData := wshrpc.CommandTermGetScrollbackLinesData{ + LineStart: termScrollbackLineStart, + LineEnd: termScrollbackLineEnd, + LastCommand: termScrollbackLastCmd, + } + + result, err := wshclient.TermGetScrollbackLinesCommand(RpcClient, scrollbackData, &wshrpc.RpcOpts{ + Route: wshutil.MakeFeBlockRouteId(fullORef.OID), + Timeout: 5000, + }) + if err != nil { + return fmt.Errorf("error getting terminal scrollback: %w", err) + } + + // Format the output + output := strings.Join(result.Lines, "\n") + if len(result.Lines) > 0 { + output += "\n" // Add final newline + } + + // Write to file or stdout + if termScrollbackOutputFile != "" { + err = os.WriteFile(termScrollbackOutputFile, []byte(output), 0644) + if err != nil { + return fmt.Errorf("error writing to file %s: %w", termScrollbackOutputFile, err) + } + fmt.Printf("terminal scrollback written to %s (%d lines)\n", termScrollbackOutputFile, len(result.Lines)) + } else { + fmt.Print(output) + } + + return nil +} diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index 1aa28c8c50..9dd8bc0b3e 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -792,6 +792,57 @@ wsh setvar -b client MYVAR=value Variables set with these commands persist across sessions and can be used to store configuration values, secrets, or any other string data that needs to be accessible across blocks or tabs. +--- + +## termscrollback + +Get the terminal scrollback from a terminal block. This is useful for capturing terminal output for processing or archiving. + +```sh +wsh termscrollback [-b blockid] [flags] +``` + +By default, retrieves all lines from the current terminal block. You can specify line ranges or get only the output of the last command. + +Flags: + +- `-b, --block ` - specify target terminal block (default: current block) +- `--start ` - starting line number (0 = beginning, default: 0) +- `--end ` - ending line number (0 = all lines, default: 0) +- `--lastcommand` - get output of last command (requires shell integration) +- `-o, --output ` - write output to file instead of stdout + +Examples: + +```sh +# Get all scrollback from current terminal +wsh termscrollback + +# Get scrollback from a specific terminal block +wsh termscrollback -b 2 + +# Get only the last command's output +wsh termscrollback --lastcommand + +# Get a specific line range (lines 100-200) +wsh termscrollback --start 100 --end 200 + +# Save scrollback to a file +wsh termscrollback -o terminal-log.txt + +# Save last command output to a file +wsh termscrollback --lastcommand -o last-output.txt + +# Process last command output with grep +wsh termscrollback --lastcommand | grep "ERROR" +``` + +:::note +The `--lastcommand` flag requires shell integration to be enabled. This feature allows you to capture just the output from the most recent command, which is particularly useful for scripting and automation. +::: + +--- + ## wavepath The `wavepath` command lets you get the paths to various Wave Terminal directories and files, including configuration, data storage, and logs. diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index dcf0f4d083..aaf3736431 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -470,4 +470,27 @@ export function initIpcHandlers() { electron.ipcMain.on("do-refresh", (event) => { event.sender.reloadIgnoringCache(); }); + + electron.ipcMain.handle("save-text-file", async (event, fileName: string, content: string) => { + const ww = focusedWaveWindow; + if (ww == null) { + return false; + } + const result = await electron.dialog.showSaveDialog(ww, { + title: "Save Scrollback", + defaultPath: fileName || "session.log", + filters: [{ name: "Text Files", extensions: ["txt", "log"] }], + }); + if (result.canceled || !result.filePath) { + return false; + } + try { + await fs.promises.writeFile(result.filePath, content, "utf-8"); + console.log("saved scrollback to", result.filePath); + return true; + } catch (err) { + console.error("error saving scrollback file", err); + return false; + } + }); } diff --git a/emain/preload.ts b/emain/preload.ts index 3ad8d25828..c819e098db 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld("api", { openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), doRefresh: () => ipcRenderer.send("do-refresh"), + saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content), }); // Custom event for "new-window" diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index b54c41d12f..cfa4fced76 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -4,6 +4,7 @@ import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { BlockNodeModel } from "@/app/block/blocktypes"; import { appHandleKeyDown } from "@/app/store/keymodel"; +import { modalsModel } from "@/app/store/modalmodel"; import type { TabModel } from "@/app/store/tab-model"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; @@ -912,6 +913,36 @@ export class TermViewModel implements ViewModel { fullMenu.push({ type: "separator" }); } + fullMenu.push({ + label: "Save Session As...", + click: () => { + if (this.termRef.current) { + const content = this.termRef.current.getScrollbackContent(); + if (content) { + fireAndForget(async () => { + try { + const success = await getApi().saveTextFile("session.log", content); + if (!success) { + console.log("Save scrollback cancelled by user"); + } + } catch (error) { + console.error("Failed to save scrollback:", error); + const errorMessage = error?.message || "An unknown error occurred"; + modalsModel.pushModal("MessageModal", { + children: `Failed to save session scrollback: ${errorMessage}`, + }); + } + }); + } else { + modalsModel.pushModal("MessageModal", { + children: "No scrollback content to save.", + }); + } + } + }, + }); + fullMenu.push({ type: "separator" }); + const submenu: ContextMenuItem[] = termThemeKeys.map((themeName) => { return { label: termThemes[themeName]["display:name"] ?? themeName, diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx index 782a174913..c1b4aa5478 100644 --- a/frontend/app/view/term/term-wsh.tsx +++ b/frontend/app/view/term/term-wsh.tsx @@ -7,6 +7,7 @@ import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { TermViewModel } from "@/app/view/term/term-model"; +import { bufferLinesToText } from "@/app/view/term/termutil"; import { isBlank } from "@/util/util"; import debug from "debug"; @@ -120,36 +121,46 @@ export class TermWshClient extends WshClient { const buffer = termWrap.terminal.buffer.active; const totalLines = buffer.length; - const lines: string[] = []; if (data.lastcommand) { if (globalStore.get(termWrap.shellIntegrationStatusAtom) == null) { throw new Error("Cannot get last command data without shell integration"); } - let startLine = 0; + let startBufferIndex = 0; + let endBufferIndex = totalLines; if (termWrap.promptMarkers.length > 0) { - const lastMarker = termWrap.promptMarkers[termWrap.promptMarkers.length - 1]; - const markerLine = lastMarker.line; - startLine = totalLines - markerLine; - } - - const endLine = totalLines; - for (let i = startLine; i < endLine; i++) { - const bufferIndex = totalLines - 1 - i; - const line = buffer.getLine(bufferIndex); - if (line) { - lines.push(line.translateToString(true)); + // The last marker is the current prompt, so we want the second-to-last for the previous command + // If there's only one marker, use it (edge case for first command) + const markerIndex = + termWrap.promptMarkers.length > 1 + ? termWrap.promptMarkers.length - 2 + : termWrap.promptMarkers.length - 1; + const commandStartMarker = termWrap.promptMarkers[markerIndex]; + startBufferIndex = commandStartMarker.line; + + // End at the last marker (current prompt) if there are multiple markers + if (termWrap.promptMarkers.length > 1) { + const currentPromptMarker = termWrap.promptMarkers[termWrap.promptMarkers.length - 1]; + endBufferIndex = currentPromptMarker.line; } } - lines.reverse(); + const lines = bufferLinesToText(buffer, startBufferIndex, endBufferIndex); + // Convert buffer indices to "from bottom" line numbers. + // "from bottom" 0 = most recent line; higher numbers = older lines. + // The buffer range [startBufferIndex, endBufferIndex) maps to + // "from bottom" range [totalLines - endBufferIndex, totalLines - startBufferIndex). + // The first returned line is at "from bottom" position: totalLines - endBufferIndex. let returnLines = lines; - let returnStartLine = startLine; + let returnStartLine = totalLines - endBufferIndex; if (lines.length > 1000) { + // there is a small bug here since this is computing a physical start line + // after the lines have already been combined (because of potential wrapping) + // for now this isn't worth fixing, just noted returnLines = lines.slice(lines.length - 1000); - returnStartLine = startLine + (lines.length - 1000); + returnStartLine = (totalLines - endBufferIndex) + (lines.length - 1000); } return { @@ -161,17 +172,11 @@ export class TermWshClient extends WshClient { } const startLine = Math.max(0, data.linestart); - const endLine = Math.min(totalLines, data.lineend); - - for (let i = startLine; i < endLine; i++) { - const bufferIndex = totalLines - 1 - i; - const line = buffer.getLine(bufferIndex); - if (line) { - lines.push(line.translateToString(true)); - } - } + const endLine = data.lineend === 0 ? totalLines : Math.min(totalLines, data.lineend); - lines.reverse(); + const startBufferIndex = totalLines - endLine; + const endBufferIndex = totalLines - startLine; + const lines = bufferLinesToText(buffer, startBufferIndex, endBufferIndex); return { totallines: totalLines, diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 8a7459e6cf..595ca2edc5 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -4,6 +4,7 @@ export const DefaultTermTheme = "default-dark"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import * as TermTypes from "@xterm/xterm"; import base64 from "base64-js"; import { colord } from "colord"; @@ -319,3 +320,65 @@ export async function extractAllClipboardData(e?: ClipboardEvent): Promise= buffer.length) { + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + } + + return lines; +} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 45ba48351c..cbb46b89f7 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -34,7 +34,7 @@ import { handleOsc7Command, type ShellIntegrationStatus, } from "./osc-handlers"; -import { createTempFileFromBlob, extractAllClipboardData } from "./termutil"; +import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData } from "./termutil"; const dlog = debug("wave:termwrap"); @@ -528,4 +528,13 @@ export class TermWrap { }, 30); } } + + getScrollbackContent(): string { + if (!this.terminal) { + return ""; + } + const buffer = this.terminal.buffer.active; + const lines = bufferLinesToText(buffer, 0, buffer.length); + return lines.join("\n"); + } } diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index d6d2d98f01..ae4fae35ed 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -135,6 +135,7 @@ declare global { openBuilder: (appId?: string) => void; // open-builder setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid doRefresh: () => void; // do-refresh + saveTextFile: (fileName: string, content: string) => Promise; // save-text-file }; type ElectronContextMenuItem = {