From 03ec4866e17a158d3f2617521cbd87e1fc048a44 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 17 Feb 2026 11:25:42 -0800 Subject: [PATCH 01/11] first cut at saving session output to a file --- emain/emain-ipc.ts | 23 +++++++++++++++++ emain/preload.ts | 1 + frontend/app/view/term/term-model.ts | 31 +++++++++++++++++++++++ frontend/app/view/term/termwrap.ts | 37 ++++++++++++++++++++++++++++ frontend/types/custom.d.ts | 1 + 5 files changed, 93 insertions(+) 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/termwrap.ts b/frontend/app/view/term/termwrap.ts index 45ba48351c..4b8b688c3f 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -528,4 +528,41 @@ export class TermWrap { }, 30); } } + + getScrollbackContent(): string { + if (!this.terminal) { + return ""; + } + // Get the entire buffer including scrollback as plain text + const buffer = this.terminal.buffer.active; + const lines: string[] = []; + let currentLine = ""; + let isFirstLine = true; + + // Get all lines including scrollback, handling wrapped lines + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line) { + const lineText = line.translateToString(true); + // If this line is wrapped (continuation of previous line), concatenate without newline + if (line.isWrapped && !isFirstLine) { + currentLine += lineText; + } else { + // This is a new logical line + if (!isFirstLine) { + lines.push(currentLine); + } + currentLine = lineText; + isFirstLine = false; + } + } + } + + // Don't forget the last line + if (!isFirstLine) { + lines.push(currentLine); + } + + 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 = { From 62e2849ca81a1688fbb8494aa0e904654a753ecc Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 17 Feb 2026 11:33:44 -0800 Subject: [PATCH 02/11] utility function to handle text extraction with line breaks. fix termscrollback function to use it --- frontend/app/view/term/term-wsh.tsx | 34 +++++++---------------- frontend/app/view/term/termutil.ts | 42 +++++++++++++++++++++++++++++ frontend/app/view/term/termwrap.ts | 32 ++-------------------- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx index 782a174913..44be92c9b2 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,27 @@ 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; if (termWrap.promptMarkers.length > 0) { const lastMarker = termWrap.promptMarkers[termWrap.promptMarkers.length - 1]; const markerLine = lastMarker.line; - startLine = totalLines - markerLine; + startBufferIndex = 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)); - } - } - - lines.reverse(); + const endBufferIndex = totalLines; + const lines = bufferLinesToText(buffer, startBufferIndex, endBufferIndex); let returnLines = lines; - let returnStartLine = startLine; + let returnStartLine = startBufferIndex; if (lines.length > 1000) { returnLines = lines.slice(lines.length - 1000); - returnStartLine = startLine + (lines.length - 1000); + returnStartLine = startBufferIndex + (lines.length - 1000); } return { @@ -163,15 +155,9 @@ 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)); - } - } - - 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..76916c9dbf 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,44 @@ export async function extractAllClipboardData(e?: ClipboardEvent): Promise Date: Tue, 17 Feb 2026 11:57:17 -0800 Subject: [PATCH 03/11] wshcmd skill --- .kilocode/skills/add-wshcmd/SKILL.md | 921 +++++++++++++++++++++++++++ 1 file changed, 921 insertions(+) create mode 100644 .kilocode/skills/add-wshcmd/SKILL.md diff --git a/.kilocode/skills/add-wshcmd/SKILL.md b/.kilocode/skills/add-wshcmd/SKILL.md new file mode 100644 index 0000000000..123e4251fb --- /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 From 36130502867c021bf9b2388a8a38f319455e6780 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 17 Feb 2026 11:57:32 -0800 Subject: [PATCH 04/11] new wsh command to save the scrollback --- cmd/wsh/cmd/wshcmd-termscrollback.go | 105 +++++++++++++++++++++++++++ docs/docs/wsh-reference.mdx | 51 +++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 cmd/wsh/cmd/wshcmd-termscrollback.go diff --git a/cmd/wsh/cmd/wshcmd-termscrollback.go b/cmd/wsh/cmd/wshcmd-termscrollback.go new file mode 100644 index 0000000000..f1bf411135 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-termscrollback.go @@ -0,0 +1,105 @@ +// 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" +) + +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: fullORef.String(), + Timeout: 5000, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "error getting terminal scrollback: %v\n", err) + return 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 { + fmt.Fprintf(os.Stderr, "error writing to file %s: %v\n", termScrollbackOutputFile, err) + return 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. From 4a8a18b08acda2cf01740517251f649f62b674fd Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 17 Feb 2026 12:10:36 -0800 Subject: [PATCH 05/11] fix bug in termscrollback (lastcommand) and fix bug with wrong route --- cmd/wsh/cmd/wshcmd-termscrollback.go | 3 ++- frontend/app/view/term/term-wsh.tsx | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-termscrollback.go b/cmd/wsh/cmd/wshcmd-termscrollback.go index f1bf411135..fafd488b55 100644 --- a/cmd/wsh/cmd/wshcmd-termscrollback.go +++ b/cmd/wsh/cmd/wshcmd-termscrollback.go @@ -12,6 +12,7 @@ import ( "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{ @@ -75,7 +76,7 @@ func termScrollbackRun(cmd *cobra.Command, args []string) (rtnErr error) { } result, err := wshclient.TermGetScrollbackLinesCommand(RpcClient, scrollbackData, &wshrpc.RpcOpts{ - Route: fullORef.String(), + Route: wshutil.MakeFeBlockRouteId(fullORef.OID), Timeout: 5000, }) if err != nil { diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx index 44be92c9b2..5502f20ffd 100644 --- a/frontend/app/view/term/term-wsh.tsx +++ b/frontend/app/view/term/term-wsh.tsx @@ -128,13 +128,23 @@ export class TermWshClient extends WshClient { } let startBufferIndex = 0; + let endBufferIndex = totalLines; if (termWrap.promptMarkers.length > 0) { - const lastMarker = termWrap.promptMarkers[termWrap.promptMarkers.length - 1]; - const markerLine = lastMarker.line; - startBufferIndex = totalLines - markerLine; + // 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; + } } - const endBufferIndex = totalLines; const lines = bufferLinesToText(buffer, startBufferIndex, endBufferIndex); let returnLines = lines; From 42a935384731b6d909850f3b709b4fd9a4931b65 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 19 Feb 2026 10:35:43 -0800 Subject: [PATCH 06/11] fix range issue on getLine call --- frontend/app/view/term/termutil.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 76916c9dbf..51d043ac92 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -336,7 +336,12 @@ export function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number, let currentLine = ""; let isFirstLine = true; - for (let i = startIndex; i < endIndex; i++) { + // Clamp indices to valid buffer range to avoid out-of-bounds access on the + // underlying circular buffer, which could return stale/wrong data. + const clampedStart = Math.max(0, Math.min(startIndex, buffer.length)); + const clampedEnd = Math.max(0, Math.min(endIndex, buffer.length)); + + for (let i = clampedStart; i < clampedEnd; i++) { const line = buffer.getLine(i); if (line) { const lineText = line.translateToString(true); From 25227c8e7abd26c4bdb7fb8a06771d592631842e Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 19 Feb 2026 10:38:59 -0800 Subject: [PATCH 07/11] fix nested backticks --- .kilocode/skills/add-wshcmd/SKILL.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.kilocode/skills/add-wshcmd/SKILL.md b/.kilocode/skills/add-wshcmd/SKILL.md index 123e4251fb..0cdae64702 100644 --- a/.kilocode/skills/add-wshcmd/SKILL.md +++ b/.kilocode/skills/add-wshcmd/SKILL.md @@ -342,7 +342,7 @@ if err != nil { Add your command to `docs/docs/wsh-reference.mdx`: -```markdown +````markdown ## mycommand Brief description of what the command does. @@ -377,7 +377,7 @@ wsh mycommand -v --name "example" arg1 arg2 Additional notes, tips, or warnings about the command. --- -``` +```` **Documentation Guidelines:** - Place in alphabetical order with other commands @@ -454,7 +454,7 @@ func versionRun(cmd *cobra.Command, args []string) (rtnErr error) { #### Documentation -```markdown +````markdown ## version Print the current Wave Terminal version. @@ -469,7 +469,7 @@ Examples: # Print version wsh version ``` -``` +```` ### Example 2: Command with Flags and RPC @@ -544,7 +544,7 @@ func setTitleRun(cmd *cobra.Command, args []string) (rtnErr error) { #### Documentation -```markdown +````markdown ## settitle Set the title for a block. @@ -571,7 +571,7 @@ wsh settitle --icon "terminal" "Development Shell" # Set title for specific block wsh settitle -b 2 "Build Output" ``` -``` +```` ### Example 3: Subcommands @@ -645,7 +645,7 @@ func myGroupAddRun(cmd *cobra.Command, args []string) (rtnErr error) { #### Documentation -```markdown +````markdown ## mygroup Manage something with subcommands. @@ -675,7 +675,7 @@ wsh mygroup list # Add an item wsh mygroup add "new-item" ``` -``` +```` ## Common Patterns From 328724b4377458bba88175e5b57a36009661e2c9 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 19 Feb 2026 10:40:31 -0800 Subject: [PATCH 08/11] fix errors from being double printed --- cmd/wsh/cmd/wshcmd-termscrollback.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-termscrollback.go b/cmd/wsh/cmd/wshcmd-termscrollback.go index fafd488b55..6368e1559d 100644 --- a/cmd/wsh/cmd/wshcmd-termscrollback.go +++ b/cmd/wsh/cmd/wshcmd-termscrollback.go @@ -80,8 +80,7 @@ func termScrollbackRun(cmd *cobra.Command, args []string) (rtnErr error) { Timeout: 5000, }) if err != nil { - fmt.Fprintf(os.Stderr, "error getting terminal scrollback: %v\n", err) - return err + return fmt.Errorf("error getting terminal scrollback: %w", err) } // Format the output @@ -94,8 +93,7 @@ func termScrollbackRun(cmd *cobra.Command, args []string) (rtnErr error) { if termScrollbackOutputFile != "" { err = os.WriteFile(termScrollbackOutputFile, []byte(output), 0644) if err != nil { - fmt.Fprintf(os.Stderr, "error writing to file %s: %v\n", termScrollbackOutputFile, err) - return err + 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 { From bc49698faffc3b9d5ca9970eb60f846552a4d94e Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 19 Feb 2026 11:13:25 -0800 Subject: [PATCH 09/11] fix blank end lines --- frontend/app/view/term/termutil.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 51d043ac92..595ca2edc5 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -364,5 +364,21 @@ export function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number, lines.push(currentLine); } + // Trim trailing blank lines only when the requested range extends to the + // actual end of the buffer. A terminal allocates a fixed number of rows + // (e.g. 80) but only the first few may contain real content; the rest are + // empty placeholder rows. We strip those so callers don't receive a wall + // of empty strings. + // + // Crucially, if the caller requested a specific sub-range (e.g. lines + // 100-150) and lines 140-150 happen to be blank, those blanks are + // intentional and must NOT be removed. We only trim when the range + // reaches the very end of the buffer. + if (clampedEnd >= buffer.length) { + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + } + return lines; } From 4a5dee686dc7000c4d2c016bda104db9c128aa83 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 19 Feb 2026 11:21:04 -0800 Subject: [PATCH 10/11] fix data.lineend 0 case --- frontend/app/view/term/term-wsh.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx index 5502f20ffd..5852f244c8 100644 --- a/frontend/app/view/term/term-wsh.tsx +++ b/frontend/app/view/term/term-wsh.tsx @@ -132,12 +132,13 @@ export class TermWshClient extends WshClient { if (termWrap.promptMarkers.length > 0) { // 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 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]; @@ -163,7 +164,7 @@ export class TermWshClient extends WshClient { } const startLine = Math.max(0, data.linestart); - const endLine = Math.min(totalLines, data.lineend); + const endLine = data.lineend === 0 ? totalLines : Math.min(totalLines, data.lineend); const startBufferIndex = totalLines - endLine; const endBufferIndex = totalLines - startLine; From 98b64446abf4967ca830c84dafb85742afa61c36 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 19 Feb 2026 12:47:19 -0800 Subject: [PATCH 11/11] fix returnstartline... to be the same as startline... (computed correctly that is) --- frontend/app/view/term/term-wsh.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx index 5852f244c8..c1b4aa5478 100644 --- a/frontend/app/view/term/term-wsh.tsx +++ b/frontend/app/view/term/term-wsh.tsx @@ -148,11 +148,19 @@ export class TermWshClient extends WshClient { 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 = startBufferIndex; + 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 = startBufferIndex + (lines.length - 1000); + returnStartLine = (totalLines - endBufferIndex) + (lines.length - 1000); } return {