Skip to content
Merged
921 changes: 921 additions & 0 deletions .kilocode/skills/add-wshcmd/SKILL.md

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions cmd/wsh/cmd/wshcmd-termscrollback.go
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +66 to +68
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Ambiguous error when the view-type assertion fails.

When !ok (key absent or non-string value), viewType is "", producing "block ... is not a terminal block (view type: )". It's worth distinguishing the two cases:

📝 Proposed fix
- 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)
- }
+ viewType, ok := metaData[waveobj.MetaKey_View].(string)
+ if !ok {
+     return fmt.Errorf("block %s is not a terminal block (no view type in metadata)", fullORef.OID)
+ }
+ if viewType != "term" {
+     return fmt.Errorf("block %s is not a terminal block (view type: %s)", fullORef.OID, viewType)
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
viewType, ok := metaData[waveobj.MetaKey_View].(string)
if !ok {
return fmt.Errorf("block %s is not a terminal block (no view type in metadata)", fullORef.OID)
}
if viewType != "term" {
return fmt.Errorf("block %s is not a terminal block (view type: %s)", fullORef.OID, viewType)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/wsh/cmd/wshcmd-termscrollback.go` around lines 66 - 68, The error message
from the view-type check in the TermScRollback handler is ambiguous when the map
assertion fails; update the logic around metaData[waveobj.MetaKey_View] /
viewType / ok so you distinguish "key missing or not a string" from "present but
not 'term'": if !ok return an error that the view key is missing or not a string
(include the raw value via a safe %#v of metaData[waveobj.MetaKey_View] or note
it's absent), otherwise when viewType != "term" return an error stating the
unexpected view type and include viewType and fullORef.OID to aid debugging.

}

// 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
}
51 changes: 51 additions & 0 deletions docs/docs/wsh-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <blockid>` - specify target terminal block (default: current block)
- `--start <line>` - starting line number (0 = beginning, default: 0)
- `--end <line>` - ending line number (0 = all lines, default: 0)
- `--lastcommand` - get output of last command (requires shell integration)
Comment on lines +807 to +812
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

--start/--end descriptions are inverted relative to the implementation.

The docs say --start 0 = "beginning" (implying oldest/top) and --end 0 = "all lines". The frontend handler in term-wsh.tsx implements a bottom-up coordinate system:

const startBufferIndex = totalLines - endLine;
const endBufferIndex   = totalLines - startLine;

With the defaults (linestart=0, lineend=0), endLine = Math.min(totalLines, 0) = 0, so startBufferIndex = endBufferIndex = totalLines — an empty range. Running wsh termscrollback with no flags produces zero output, directly contradicting the claim that it "retrieves all lines from the current terminal block."

The actual semantics appear to be:

  • --start N: skip the N most-recent lines (0 = include all recent lines)
  • --end N: include up to N lines counting from the most recent (0 currently produces nothing, but docs imply it means "all lines")

The docs should be corrected, and the frontend handler needs to handle the lineend=0 sentinel:

📝 Suggested doc correction + code fix

Update docs:

- - `--start <line>` - starting line number (0 = beginning, default: 0)
- - `--end <line>` - ending line number (0 = all lines, default: 0)
+ - `--start <line>` - lines to skip from the most recent (0 = include most recent, default: 0)
+ - `--end <line>` - total lines to return counting from the most recent (0 = all lines, default: 0)

Fix in frontend/app/view/term/term-wsh.tsx (non-lastcommand branch):

- const endLine = Math.min(totalLines, data.lineend);
+ const endLine = data.lineend === 0 ? totalLines : Math.min(totalLines, data.lineend);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/docs/wsh-reference.mdx` around lines 807 - 812, Docs and frontend are
inconsistent: the bottom-up indexing in term-wsh.tsx (using totalLines - endLine
and totalLines - startLine) makes the current defaults produce an empty range;
update docs in docs/wsh-reference.mdx to describe that --start N skips the N
most-recent lines and --end N includes up to N lines from the most-recent (with
0 meaning "all" in docs), and change the frontend handler in
frontend/app/view/term/term-wsh.tsx to treat endLine===0 as a sentinel for "all
lines" (e.g. set endLine = totalLines when endLine === 0 before computing
startBufferIndex/endBufferIndex) and ensure startLine/endLine clamping uses
Math.min/Math.max consistently so startBufferIndex <= endBufferIndex and the
non-lastcommand branch returns all lines by default.

- `-o, --output <file>` - 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.
Expand Down
23 changes: 23 additions & 0 deletions emain/emain-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
}
1 change: 1 addition & 0 deletions emain/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions frontend/app/view/term/term-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.",
});
}
}
Comment on lines +918 to +941
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent no-op when termRef.current is null lacks user feedback

If this.termRef.current is null, the click handler exits silently — no modal, no toast. This is asymmetric with the !content branch which surfaces a MessageModal. While the terminal ref should always be set when a context menu is triggered, the inconsistency is surprising.

🛡️ Proposed fix
-            if (this.termRef.current) {
-                const content = this.termRef.current.getScrollbackContent();
-                if (content) {
-                    // ...
-                } else {
-                    modalsModel.pushModal("MessageModal", {
-                        children: "No scrollback content to save.",
-                    });
-                }
-            }
+            const content = this.termRef.current?.getScrollbackContent();
+            if (content) {
+                // ...
+            } else {
+                modalsModel.pushModal("MessageModal", {
+                    children: "No scrollback content to save.",
+                });
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.",
});
}
}
click: () => {
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.",
});
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/view/term/term-model.ts` around lines 918 - 941, The click
handler currently silently no-ops when this.termRef.current is null; update the
click callback in the same handler (the function that calls
this.termRef.current.getScrollbackContent()) to surface a user-facing message
via modalsModel.pushModal (similar to the !content branch) when termRef.current
is null (e.g., push a MessageModal stating the terminal is unavailable or could
not be accessed) so users receive consistent feedback; keep the existing flow
for the content and error branches unchanged.

},
});
fullMenu.push({ type: "separator" });

const submenu: ContextMenuItem[] = termThemeKeys.map((themeName) => {
return {
label: termThemes[themeName]["display:name"] ?? themeName,
Expand Down
57 changes: 31 additions & 26 deletions frontend/app/view/term/term-wsh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Incorrect buffer index calculation

The conversion from line numbers to buffer indices is backwards. According to the documentation, startLine and endLine are line numbers from the beginning (0 = first line). In xterm.js, buffer index 0 is also the oldest/first line.

Current calculation:

  • If user requests lines 100-200 from a 1000-line buffer
  • startBufferIndex = 1000 - 200 = 800
  • endBufferIndex = 1000 - 100 = 900
  • This returns lines 800-900, NOT lines 100-200

The correct conversion should be:

const startBufferIndex = startLine;
const endBufferIndex = endLine;

This will cause the wsh termscrollback --start X --end Y command to return completely wrong line ranges.

const endBufferIndex = totalLines - startLine;
const lines = bufferLinesToText(buffer, startBufferIndex, endBufferIndex);

return {
totallines: totalLines,
Expand Down
Loading
Loading