-
-
Notifications
You must be signed in to change notification settings - Fork 799
New Context Menu Item + Wsh Command to Save Scrollback of a Terminal Widget #2892
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
03ec486
62e2849
597754a
3613050
4a8a18b
42a9353
25227c8
328724b
bc49698
4a5dee6
98b6444
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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) | ||
| } | ||
|
|
||
| // 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The docs say const startBufferIndex = totalLines - endLine;
const endBufferIndex = totalLines - startLine;With the defaults ( The actual semantics appear to be:
The docs should be corrected, and the frontend handler needs to handle the 📝 Suggested doc correction + code fixUpdate 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 - const endLine = Math.min(totalLines, data.lineend);
+ const endLine = data.lineend === 0 ? totalLines : Math.min(totalLines, data.lineend);🤖 Prompt for AI Agents |
||
| - `-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. | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+918
to
+941
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silent no-op when If 🛡️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fullMenu.push({ type: "separator" }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const submenu: ContextMenuItem[] = termThemeKeys.map((themeName) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| label: termThemes[themeName]["display:name"] ?? themeName, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Current calculation:
The correct conversion should be: const startBufferIndex = startLine;
const endBufferIndex = endLine;This will cause the |
||
| const endBufferIndex = totalLines - startLine; | ||
| const lines = bufferLinesToText(buffer, startBufferIndex, endBufferIndex); | ||
|
|
||
| return { | ||
| totallines: totalLines, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ambiguous error when the view-type assertion fails.
When
!ok(key absent or non-string value),viewTypeis"", producing"block ... is not a terminal block (view type: )". It's worth distinguishing the two cases:📝 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents