diff --git a/Taskfile.yml b/Taskfile.yml index 006cbf7289..fa0ad2a3e9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -157,6 +157,7 @@ tasks: - go.mod - go.sum - pkg/**/*.go + - pkg/**/*.sh - cmd/**/*.go - tsunami/go.mod - tsunami/go.sum @@ -188,6 +189,7 @@ tasks: - "cmd/server/*.go" - "pkg/**/*.go" - "pkg/**/*.json" + - "pkg/**/*.sh" - tsunami/**/*.go generates: - dist/bin/wavesrv.* @@ -215,6 +217,7 @@ tasks: - "cmd/server/*.go" - "pkg/**/*.go" - "pkg/**/*.json" + - "pkg/**/*.sh" - "tsunami/**/*.go" generates: - dist/bin/wavesrv.* diff --git a/aiprompts/wave-osc-16162.md b/aiprompts/wave-osc-16162.md new file mode 100644 index 0000000000..7d403606d8 --- /dev/null +++ b/aiprompts/wave-osc-16162.md @@ -0,0 +1,214 @@ +# Wave Terminal OSC 16162 Escape Sequences + +Wave Terminal uses a custom OSC (Operating System Command) escape sequence numbered **16162** for shell integration. This allows the shell to communicate its state and events to the terminal. + +## Format + +All commands use this escape sequence format: + +``` +ESC ] 16162 ; command [;] BEL +``` + +Where: +- `ESC` = `\033` (escape character) +- `BEL` = `\007` (bell character) +- `command` = Single letter (A, C, M, D, I, or R) +- `` = Optional JSON payload (depends on command) + +## Commands + +### A - Prompt Start + +Marks the beginning of a new shell prompt. + +**Format:** `A` + +**When:** Sent in `precmd` hook (after previous command completes, before new prompt is displayed) + +**Purpose:** Signals to the terminal that a new prompt is being drawn. This helps Wave Terminal distinguish between prompt output and command output. + +**Example:** +```bash +printf '\033]16162;A\007' +``` + +--- + +### C - Command Execution + +Sent immediately before a command is executed, optionally including the command text. + +**Format:** `C[;]` + +**Data Type:** +```typescript +{ + cmd64?: string; // base64-encoded command text +} +``` + +**When:** Sent in `preexec` hook (after user presses Enter, before command runs) + +**Purpose:** Notifies the terminal that a command is about to execute. The command text is base64-encoded to handle special characters safely. + +**Example:** +```bash +cmd64=$(printf '%s' "ls -la" | base64) +printf '\033]16162;C;{"cmd64":"%s"}\007' "$cmd64" +``` + +--- + +### M - Metadata + +Sends shell metadata information (typically only once at shell initialization). + +**Format:** `M;` + +**Data Type:** +```typescript +{ + shell?: string; // Shell name (e.g., "zsh", "bash") + shellversion?: string; // Version string of the shell + uname?: string; // Output of "uname -smr" (e.g., "Darwin 23.0.0 arm64") +} +``` + +**When:** Sent during first `precmd` hook (on shell startup) + +**Purpose:** Provides Wave Terminal with information about the shell environment and operating system. + +**Example:** +```bash +uname_info=$(uname -smr 2>/dev/null) +printf '\033]16162;M;{"shell":"zsh","shellversion":"5.9","uname":"%s"}\007' "$uname_info" +``` + +--- + +### D - Done (Exit Status) + +Reports the exit status of the previously executed command. + +**Format:** `D;` + +**Data Type:** +```typescript +{ + exitcode?: number; // Exit status code of the previous command +} +``` + +**When:** Sent in `precmd` hook (after command completes) + +**Purpose:** Communicates whether the previous command succeeded or failed, allowing Wave Terminal to display success/failure indicators. + +**Example:** +```bash +# After command exits with status 0 +printf '\033]16162;D;{"exitcode":0}\007' + +# After command exits with status 1 +printf '\033]16162;D;{"exitcode":1}\007' +``` + +--- + +### I - Input Status + +Reports the current state of the command line input buffer. + +**Format:** `I;` + +**Data Type:** +```typescript +{ + inputempty?: boolean; // Whether the command line buffer is empty +} +``` + +**When:** Sent during ZLE (Zsh Line Editor) hooks when buffer state changes +- `zle-line-init` - When line editor is initialized +- `zle-line-pre-redraw` - Before line is redrawn + +**Purpose:** Allows Wave Terminal to track the state of the command line input. Currently reports whether the buffer is empty, but may be extended to include additional input state information in the future. + +**Example:** +```bash +# When buffer is empty +I;{"inputempty":true} + +# When buffer has content +I;{"inputempty":false} +``` + +### R - Reset Alternate Buffer + +Resets the terminal if it's in alternate buffer mode. + +**Format:** `R` + +**When:** Can be sent at any time to ensure terminal is not stuck in alternate buffer mode + +**Purpose:** If the terminal is currently displaying the alternate screen buffer, this command switches back to the normal buffer. This is useful for recovering from programs that crash without properly restoring the screen. + +**Behavior:** +- Checks if terminal is in alternate buffer mode (`terminal.buffer.active.type === "alternate"`) +- If in alternate mode, sends `ESC [ ? 1049 l` to exit alternate buffer +- If not in alternate mode, does nothing + +**Example:** +```bash +R +``` + +--- + +## Typical Command Flow + +Here's the typical sequence during shell interaction: + +``` +1. Shell starts + → M; (metadata - shell info) + +2. First prompt appears + → A (prompt start) + +3. User types command and presses Enter + → I;{"inputempty":false} (input no longer empty - sent as user types) + → C;{"cmd64":"..."} (command about to execute) + +4. Command runs and completes + → D;{"exitcode":} (exit status) + → I;{"inputempty":true} (input empty again) + → A (next prompt start) + +5. Repeat from step 3... +``` + +## Implementation Notes + +- Shell integration is **disabled** when running inside tmux or screen (`TMUX`, `STY` environment variables, or `tmux*`/`screen*` TERM values) +- Commands are base64-encoded in the C sequence to safely handle special characters, newlines, and control characters +- The I (input empty) command is only sent when the state changes (not on every keystroke) +- The M (metadata) command is only sent once during the first precmd +- The D (exit status) command is skipped during the first precmd (no previous command to report) + +## Related Files + +- [`pkg/util/shellutil/shellintegration/zsh_zshrc.sh`](pkg/util/shellutil/shellintegration/zsh_zshrc.sh) - Zsh shell integration implementation +- Similar integrations exist for bash and other shells + +## Standard OSC 7 + +Wave Terminal also uses the standard **OSC 7** sequence for reporting the current working directory: + +**Format:** `7;file://` + +This is sent: +- During first precmd (after metadata) +- In the `chpwd` hook (whenever directory changes) + +The path is URL-encoded to safely handle special characters. \ No newline at end of file diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 760faa48e4..427e6ad2e0 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -218,6 +218,11 @@ func startupActivityUpdate(firstLaunch bool) { } autoUpdateChannel := telemetry.AutoUpdateChannel() autoUpdateEnabled := telemetry.IsAutoUpdateEnabled() + shellType, shellVersion, shellErr := shellutil.DetectShellTypeAndVersion() + if shellErr != nil { + shellType = "error" + shellVersion = "" + } props := telemetrydata.TEventProps{ UserSet: &telemetrydata.TEventUserProps{ ClientVersion: "v" + WaveVersion, @@ -227,6 +232,8 @@ func startupActivityUpdate(firstLaunch bool) { ClientIsDev: wavebase.IsDevMode(), AutoUpdateChannel: autoUpdateChannel, AutoUpdateEnabled: autoUpdateEnabled, + LocalShellType: shellType, + LocalShellVersion: shellVersion, }, UserSetOnce: &telemetrydata.TEventUserProps{ ClientInitialVersion: "v" + WaveVersion, @@ -401,8 +408,9 @@ func main() { go stdinReadWatch() go telemetryLoop() go updateTelemetryCountsLoop() - startupActivityUpdate(firstLaunch) // must be after startConfigWatcher() + go startupActivityUpdate(firstLaunch) // must be after startConfigWatcher() blocklogger.InitBlockLogger() + go wavebase.GetSystemSummary() // get this cached (used in AI) webListener, err := web.MakeTCPListener("web") if err != nil { diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index e84f05744e..6409a86bd7 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -47,11 +47,11 @@ type TermWrapOptions = { function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): boolean { if (!loaded) { - return false; + return true; } if (!data || data.length === 0) { console.log("Invalid Wave OSC command received (empty)"); - return false; + return true; } // Expected formats: @@ -60,7 +60,7 @@ function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): b const parts = data.split(";"); if (parts[0] !== "setmeta") { console.log("Invalid Wave OSC command received (bad command)", data); - return false; + return true; } let jsonPayload: string; let waveId: string | undefined; @@ -71,7 +71,7 @@ function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): b jsonPayload = parts.slice(2).join(";"); } else { console.log("Invalid Wave OSC command received (1 part)", data); - return false; + return true; } let meta: any; @@ -79,7 +79,7 @@ function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): b meta = JSON.parse(jsonPayload); } catch (e) { console.error("Invalid JSON in Wave OSC command:", e); - return false; + return true; } if (waveId) { @@ -107,33 +107,50 @@ function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): b return true; } +// for xterm handlers, we return true always because we "own" OSC 7. +// even if it is invalid we dont want to propagate to other handlers function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean { if (!loaded) { - return false; + return true; } if (data == null || data.length == 0) { console.log("Invalid OSC 7 command received (empty)"); - return false; + return true; + } + if (data.length > 1024) { + console.log("Invalid OSC 7, data length too long", data.length); + return true; } - if (data.startsWith("file://")) { - data = data.substring(7); - const nextSlashIdx = data.indexOf("/"); - if (nextSlashIdx == -1) { - console.log("Invalid OSC 7 command received (bad path)", data); - return false; + + let pathPart: string; + try { + const url = new URL(data); + if (url.protocol !== "file:") { + console.log("Invalid OSC 7 command received (non-file protocol)", data); + return true; + } + pathPart = decodeURIComponent(url.pathname); + + // Handle Windows paths (e.g., /C:/... or /D:\...) + if (/^\/[a-zA-Z]:[\\/]/.test(pathPart)) { + // Strip leading slash and normalize to forward slashes + pathPart = pathPart.substring(1).replace(/\\/g, "/"); } - data = data.substring(nextSlashIdx); + } catch (e) { + console.log("Invalid OSC 7 command received (parse error)", data, e); + return true; } + setTimeout(() => { fireAndForget(async () => { await services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), { - "cmd:cwd": data, + "cmd:cwd": pathPart, }); - + const rtInfo = { "cmd:hascurcwd": true }; const rtInfoData: CommandSetRTInfoData = { oref: WOS.makeORef("block", blockId), - data: rtInfo + data: rtInfo, }; await RpcApi.SetRTInfoCommand(TabRpcClient, rtInfoData).catch((e) => console.log("error setting RT info", e) @@ -143,6 +160,108 @@ function handleOsc7Command(data: string, blockId: string, loaded: boolean): bool return true; } +// OSC 16162 - Shell Integration Commands +// See aiprompts/wave-osc-16162.md for full documentation +type Osc16162Command = + | { command: "A"; data: {} } + | { command: "C"; data: { cmd64?: string } } + | { command: "M"; data: { shell?: string; shellversion?: string; uname?: string } } + | { command: "D"; data: { exitcode?: number } } + | { command: "I"; data: { inputempty?: boolean } } + | { command: "R"; data: {} }; + +function handleOsc16162Command(data: string, blockId: string, loaded: boolean, terminal: Terminal): boolean { + if (!loaded) { + return true; + } + if (!data || data.length === 0) { + return true; + } + + const parts = data.split(";"); + const commandStr = parts[0]; + const jsonDataStr = parts.length > 1 ? parts.slice(1).join(";") : null; + let parsedData: Record = {}; + if (jsonDataStr) { + try { + parsedData = JSON.parse(jsonDataStr); + } catch (e) { + console.error("Error parsing OSC 16162 JSON data:", e); + } + } + + const cmd: Osc16162Command = { command: commandStr, data: parsedData } as Osc16162Command; + const rtInfo: ObjRTInfo = {}; + switch (cmd.command) { + case "A": + rtInfo["shell:state"] = "ready"; + break; + case "C": + rtInfo["shell:state"] = "running-command"; + if (cmd.data.cmd64) { + const decodedLen = Math.ceil(cmd.data.cmd64.length * 0.75); + if (decodedLen > 8192) { + rtInfo["shell:lastcmd"] = `# command too large (${decodedLen} bytes)`; + } else { + try { + const decodedCmd = atob(cmd.data.cmd64); + rtInfo["shell:lastcmd"] = decodedCmd; + } catch (e) { + console.error("Error decoding cmd64:", e); + rtInfo["shell:lastcmd"] = null; + } + } + } else { + rtInfo["shell:lastcmd"] = null; + } + break; + case "M": + if (cmd.data.shell) { + rtInfo["shell:type"] = cmd.data.shell; + } + if (cmd.data.shellversion) { + rtInfo["shell:version"] = cmd.data.shellversion; + } + if (cmd.data.uname) { + rtInfo["shell:uname"] = cmd.data.uname; + } + break; + case "D": + if (cmd.data.exitcode != null) { + rtInfo["shell:lastcmdexitcode"] = cmd.data.exitcode; + } else { + rtInfo["shell:lastcmdexitcode"] = null; + } + break; + case "I": + if (cmd.data.inputempty != null) { + rtInfo["shell:inputempty"] = cmd.data.inputempty; + } + break; + case "R": + if (terminal.buffer.active.type === "alternate") { + terminal.write("\x1b[?1049l"); + } + break; + } + + if (Object.keys(rtInfo).length > 0) { + setTimeout(() => { + fireAndForget(async () => { + const rtInfoData: CommandSetRTInfoData = { + oref: WOS.makeORef("block", blockId), + data: rtInfo, + }; + await RpcApi.SetRTInfoCommand(TabRpcClient, rtInfoData).catch((e) => + console.log("error setting RT info (OSC 16162)", e) + ); + }); + }, 0); + } + + return true; +} + export class TermWrap { blockId: string; ptyOffset: number; @@ -222,6 +341,9 @@ export class TermWrap { this.terminal.parser.registerOscHandler(7, (data: string) => { return handleOsc7Command(data, this.blockId, this.loaded); }); + this.terminal.parser.registerOscHandler(16162, (data: string) => { + return handleOsc16162Command(data, this.blockId, this.loaded, this.terminal); + }); this.terminal.attachCustomKeyEventHandler(waveOptions.keydownHandler); this.connectElem = connectElem; this.mainFileSubject = null; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 123576e21c..0d2270bcd2 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -710,6 +710,13 @@ declare global { "tsunami:shortdesc"?: string; "tsunami:schemas"?: any; "cmd:hascurcwd"?: boolean; + "shell:state"?: string; + "shell:type"?: string; + "shell:version"?: string; + "shell:uname"?: string; + "shell:inputempty"?: boolean; + "shell:lastcmd"?: string; + "shell:lastcmdexitcode"?: number; }; // iochantypes.Packet @@ -931,6 +938,8 @@ declare global { "client:isdev"?: boolean; "autoupdate:channel"?: string; "autoupdate:enabled"?: boolean; + "localshell:type"?: string; + "localshell:version"?: string; "loc:countrycode"?: string; "loc:regioncode"?: string; "settings:customwidgets"?: number; @@ -999,6 +1008,8 @@ declare global { "client:isdev"?: boolean; "autoupdate:channel"?: string; "autoupdate:enabled"?: boolean; + "localshell:type"?: string; + "localshell:version"?: string; "loc:countrycode"?: string; "loc:regioncode"?: string; "settings:customwidgets"?: number; diff --git a/frontend/util/platformutil.ts b/frontend/util/platformutil.ts index 08deefca44..1a73fce55d 100644 --- a/frontend/util/platformutil.ts +++ b/frontend/util/platformutil.ts @@ -1,4 +1,8 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + export const PlatformMacOS = "darwin"; +export const PlatformWindows = "win32"; export let PLATFORM: NodeJS.Platform = PlatformMacOS; export function setPlatform(platform: NodeJS.Platform) { @@ -9,13 +13,17 @@ export function isMacOS(): boolean { return PLATFORM == PlatformMacOS; } +export function isWindows(): boolean { + return PLATFORM == PlatformWindows; +} + export function makeNativeLabel(isDirectory: boolean) { let managerName: string; if (!isDirectory) { managerName = "Default Application"; } else if (PLATFORM === PlatformMacOS) { managerName = "Finder"; - } else if (PLATFORM == "win32") { + } else if (PLATFORM == PlatformWindows) { managerName = "Explorer"; } else { managerName = "File Manager"; diff --git a/pkg/aiusechat/tools.go b/pkg/aiusechat/tools.go index 091d149806..2a504caa2e 100644 --- a/pkg/aiusechat/tools.go +++ b/pkg/aiusechat/tools.go @@ -6,11 +6,13 @@ package aiusechat import ( "context" "fmt" + "os/user" "strings" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/blockcontroller" + "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -156,9 +158,16 @@ func GenerateCurrentTabStatePrompt(blocks []*waveobj.Block, widgetAccess bool) s var prompt strings.Builder prompt.WriteString("\n") + systemInfo := wavebase.GetSystemSummary() + if currentUser, err := user.Current(); err == nil && currentUser.Username != "" { + prompt.WriteString(fmt.Sprintf("Local Machine: %s, User: %s\n", systemInfo, currentUser.Username)) + } else { + prompt.WriteString(fmt.Sprintf("Local Machine: %s\n", systemInfo)) + } if len(widgetDescriptions) == 0 { prompt.WriteString("No widgets open\n") } else { + prompt.WriteString("Open Widgets:\n") for _, desc := range widgetDescriptions { prompt.WriteString("* ") prompt.WriteString(desc) @@ -166,7 +175,9 @@ func GenerateCurrentTabStatePrompt(blocks []*waveobj.Block, widgetAccess bool) s } } prompt.WriteString("") - return prompt.String() + rtn := prompt.String() + // log.Printf("%s\n", rtn) + return rtn } func generateToolsForTsunamiBlock(block *waveobj.Block) []uctypes.ToolDefinition { @@ -193,6 +204,7 @@ func generateToolsForTsunamiBlock(block *waveobj.Block) []uctypes.ToolDefinition return tools } +// Used for internal testing of tool loops func GetAdderToolDefinition() uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "adder", diff --git a/pkg/aiusechat/tools_readdir.go b/pkg/aiusechat/tools_readdir.go index b887427493..8a25547b93 100644 --- a/pkg/aiusechat/tools_readdir.go +++ b/pkg/aiusechat/tools_readdir.go @@ -192,7 +192,7 @@ func GetReadDirToolDefinition() uctypes.ToolDefinition { "properties": map[string]any{ "path": map[string]any{ "type": "string", - "description": "Path to the directory to read", + "description": "Path to the directory to read. Supports '~' for the user's home directory.", }, "max_entries": map[string]any{ "type": "integer", diff --git a/pkg/aiusechat/tools_readfile.go b/pkg/aiusechat/tools_readfile.go index cab94fd26c..459ed3c4b9 100644 --- a/pkg/aiusechat/tools_readfile.go +++ b/pkg/aiusechat/tools_readfile.go @@ -202,7 +202,7 @@ func GetReadTextFileToolDefinition() uctypes.ToolDefinition { "properties": map[string]any{ "filename": map[string]any{ "type": "string", - "description": "Path to the file to read", + "description": "Path to the file to read. Supports '~' for the user's home directory.", }, "origin": map[string]any{ "type": "string", diff --git a/pkg/blockcontroller/shellcontroller.go b/pkg/blockcontroller/shellcontroller.go index 2aabec9eaf..04124153f1 100644 --- a/pkg/blockcontroller/shellcontroller.go +++ b/pkg/blockcontroller/shellcontroller.go @@ -200,10 +200,11 @@ func (sc *ShellController) resetTerminalState(logCtx context.Context) { blocklogger.Debugf(logCtx, "[conndebug] resetTerminalState: resetting terminal state\n") // controller type = "shell" var buf bytes.Buffer - // buf.WriteString("\x1b[?1049l") // disable alternative buffer buf.WriteString("\x1b[0m") // reset attributes buf.WriteString("\x1b[?25h") // show cursor buf.WriteString("\x1b[?1000l") // disable mouse tracking + buf.WriteString("\x1b[?1007l") // disable alternate scroll mode + buf.WriteString(shellutil.FormatOSC(16162, "R")) // OSC 16162 "R" - disable alternate screen mode (only if active) buf.WriteString("\r\n\r\n") err := HandleAppendBlockFile(sc.BlockId, wavebase.BlockFile_Term, buf.Bytes()) if err != nil { diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go index 5865bedc46..9a68d092af 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -62,6 +62,9 @@ type TEventUserProps struct { AutoUpdateChannel string `json:"autoupdate:channel,omitempty"` AutoUpdateEnabled bool `json:"autoupdate:enabled,omitempty"` + LocalShellType string `json:"localshell:type,omitempty"` + LocalShellVersion string `json:"localshell:version,omitempty"` + LocCountryCode string `json:"loc:countrycode,omitempty"` LocRegionCode string `json:"loc:regioncode,omitempty"` diff --git a/pkg/util/shellutil/shellintegration/bash_bashrc.sh b/pkg/util/shellutil/shellintegration/bash_bashrc.sh new file mode 100644 index 0000000000..f8960f06d6 --- /dev/null +++ b/pkg/util/shellutil/shellintegration/bash_bashrc.sh @@ -0,0 +1,71 @@ + +# Source /etc/profile if it exists +if [ -f /etc/profile ]; then + . /etc/profile +fi + +WAVETERM_WSHBINDIR={{.WSHBINDIR}} + +# after /etc/profile which is likely to clobber the path +export PATH="$WAVETERM_WSHBINDIR:$PATH" + +# Source the dynamic script from wsh token +eval "$(wsh token "$WAVETERM_SWAPTOKEN" bash 2> /dev/null)" +unset WAVETERM_SWAPTOKEN + +# Source the first of ~/.bash_profile, ~/.bash_login, or ~/.profile that exists +if [ -f ~/.bash_profile ]; then + . ~/.bash_profile +elif [ -f ~/.bash_login ]; then + . ~/.bash_login +elif [ -f ~/.profile ]; then + . ~/.profile +fi + +if [[ ":$PATH:" != *":$WAVETERM_WSHBINDIR:"* ]]; then + export PATH="$WAVETERM_WSHBINDIR:$PATH" +fi +unset WAVETERM_WSHBINDIR +if type _init_completion &>/dev/null; then + source <(wsh completion bash) +fi + +# shell integration +_waveterm_si_blocked() { + [[ -n "$TMUX" || -n "$STY" || "$TERM" == tmux* || "$TERM" == screen* ]] +} + +_waveterm_si_urlencode() { + local s="$1" + # Escape % first + s="${s//%/%25}" + # Common reserved characters in file paths + s="${s// /%20}" + s="${s//#/%23}" + s="${s//\?/%3F}" + s="${s//&/%26}" + s="${s//;/%3B}" + s="${s//+/%2B}" + printf '%s' "$s" +} + +_waveterm_si_osc7() { + _waveterm_si_blocked && return + local encoded_pwd=$(_waveterm_si_urlencode "$PWD") + printf '\033]7;file://%s%s\007' "$HOSTNAME" "$encoded_pwd" +} + +# Hook OSC 7 into PROMPT_COMMAND +_waveterm_si_prompt_command() { + _waveterm_si_osc7 +} + +# Append _waveterm_si_prompt_command to PROMPT_COMMAND (v3-safe) +_waveterm_si_append_pc() { + if [[ $(declare -p PROMPT_COMMAND 2>/dev/null) == "declare -a"* ]]; then + PROMPT_COMMAND+=(_waveterm_si_prompt_command) + else + PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}_waveterm_si_prompt_command" + fi +} +_waveterm_si_append_pc \ No newline at end of file diff --git a/pkg/util/shellutil/shellintegration/fish_wavefish.sh b/pkg/util/shellutil/shellintegration/fish_wavefish.sh new file mode 100644 index 0000000000..65db91600e --- /dev/null +++ b/pkg/util/shellutil/shellintegration/fish_wavefish.sh @@ -0,0 +1,10 @@ +# this file is sourced with -C +# Add Wave binary directory to PATH +set -x PATH {{.WSHBINDIR}} $PATH + +# Source dynamic script from wsh token (the echo is to prevent fish from complaining about empty input) +wsh token "$WAVETERM_SWAPTOKEN" fish 2>/dev/null | source +set -e WAVETERM_SWAPTOKEN + +# Load Wave completions +wsh completion fish | source \ No newline at end of file diff --git a/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh b/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh new file mode 100644 index 0000000000..840072c362 --- /dev/null +++ b/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh @@ -0,0 +1,13 @@ +# We source this file with -NoExit -File +$env:PATH = {{.WSHBINDIR_PWSH}} + "{{.PATHSEP}}" + $env:PATH + +# Source dynamic script from wsh token +$waveterm_swaptoken_output = wsh token $env:WAVETERM_SWAPTOKEN pwsh 2>$null | Out-String +if ($waveterm_swaptoken_output -and $waveterm_swaptoken_output -ne "") { + Invoke-Expression $waveterm_swaptoken_output +} +Remove-Variable -Name waveterm_swaptoken_output +Remove-Item Env:WAVETERM_SWAPTOKEN + +# Load Wave completions +wsh completion powershell | Out-String | Invoke-Expression \ No newline at end of file diff --git a/pkg/util/shellutil/shellintegration/zsh_zlogin.sh b/pkg/util/shellutil/shellintegration/zsh_zlogin.sh new file mode 100644 index 0000000000..a4bc901414 --- /dev/null +++ b/pkg/util/shellutil/shellintegration/zsh_zlogin.sh @@ -0,0 +1,7 @@ +# Source the original zlogin +[ -f ~/.zlogin ] && source ~/.zlogin + +# Unset ZDOTDIR only if it hasn't been modified +if [ "$ZDOTDIR" = "$WAVETERM_ZDOTDIR" ]; then + unset ZDOTDIR +fi \ No newline at end of file diff --git a/pkg/util/shellutil/shellintegration/zsh_zprofile.sh b/pkg/util/shellutil/shellintegration/zsh_zprofile.sh new file mode 100644 index 0000000000..60ec1e8c37 --- /dev/null +++ b/pkg/util/shellutil/shellintegration/zsh_zprofile.sh @@ -0,0 +1,2 @@ +# Source the original zprofile +[ -f ~/.zprofile ] && source ~/.zprofile \ No newline at end of file diff --git a/pkg/util/shellutil/shellintegration/zsh_zshenv.sh b/pkg/util/shellutil/shellintegration/zsh_zshenv.sh new file mode 100644 index 0000000000..345fdb3a89 --- /dev/null +++ b/pkg/util/shellutil/shellintegration/zsh_zshenv.sh @@ -0,0 +1,11 @@ +# Store the initial ZDOTDIR value +WAVETERM_ZDOTDIR="$ZDOTDIR" + +# Source the original zshenv +[ -f ~/.zshenv ] && source ~/.zshenv + +# Detect if ZDOTDIR has changed +if [ "$ZDOTDIR" != "$WAVETERM_ZDOTDIR" ]; then + # If changed, manually source your custom zshrc from the original WAVETERM_ZDOTDIR + [ -f "$WAVETERM_ZDOTDIR/.zshrc" ] && source "$WAVETERM_ZDOTDIR/.zshrc" +fi \ No newline at end of file diff --git a/pkg/util/shellutil/shellintegration/zsh_zshrc.sh b/pkg/util/shellutil/shellintegration/zsh_zshrc.sh new file mode 100644 index 0000000000..89054f39d2 --- /dev/null +++ b/pkg/util/shellutil/shellintegration/zsh_zshrc.sh @@ -0,0 +1,114 @@ +# add wsh to path, source dynamic script from wsh token +WAVETERM_WSHBINDIR={{.WSHBINDIR}} +export PATH="$WAVETERM_WSHBINDIR:$PATH" +source <(wsh token "$WAVETERM_SWAPTOKEN" zsh 2>/dev/null) +unset WAVETERM_SWAPTOKEN + +# Source the original zshrc only if ZDOTDIR has not been changed +if [ "$ZDOTDIR" = "$WAVETERM_ZDOTDIR" ]; then + [ -f ~/.zshrc ] && source ~/.zshrc +fi + +if [[ ":$PATH:" != *":$WAVETERM_WSHBINDIR:"* ]]; then + export PATH="$WAVETERM_WSHBINDIR:$PATH" +fi +unset WAVETERM_WSHBINDIR + +if [[ -n ${_comps+x} ]]; then + source <(wsh completion zsh) +fi + +typeset -g _WAVETERM_SI_FIRSTPRECMD=1 + +# shell integration +_waveterm_si_blocked() { + [[ -n "$TMUX" || -n "$STY" || "$TERM" == tmux* || "$TERM" == screen* ]] +} + +_waveterm_si_urlencode() { + if (( $+functions[omz_urlencode] )); then + omz_urlencode "$1" + else + local s="$1" + # Escape % first + s=${s//%/%25} + # Common reserved characters in file paths + s=${s// /%20} + s=${s//#/%23} + s=${s//\?/%3F} + s=${s//&/%26} + s=${s//;/%3B} + s=${s//+/%2B} + printf '%s' "$s" + fi +} + +_waveterm_si_osc7() { + _waveterm_si_blocked && return + local encoded_pwd=$(_waveterm_si_urlencode "$PWD") + printf '\033]7;file://%s%s\007' "$HOST" "$encoded_pwd" # OSC 7 - current directory +} + +_waveterm_si_precmd() { + local _waveterm_si_status=$? + _waveterm_si_blocked && return + # D;status for previous command (skip before first prompt) + if (( !_WAVETERM_SI_FIRSTPRECMD )); then + printf '\033]16162;D;{"exitcode":%d}\007' $_waveterm_si_status + else + local uname_info=$(uname -smr 2>/dev/null) + printf '\033]16162;M;{"shell":"zsh","shellversion":"%s","uname":"%s"}\007' "$ZSH_VERSION" "$uname_info" + _waveterm_si_osc7 + fi + printf '\033]16162;A\007' # start of new prompt + _WAVETERM_SI_FIRSTPRECMD=0 +} + +_waveterm_si_preexec() { + _waveterm_si_blocked && return + local cmd_length=${#1} + if [ "$cmd_length" -gt 8192 ]; then + local cmd64 + cmd64=$(printf '# command too large (%d bytes)' "$cmd_length" | base64 2>/dev/null | tr -d '\n\r') + printf '\033]16162;C;{"cmd64":"%s"}\007' "$cmd64" + else + local cmd64 + cmd64=$(printf '%s' "$1" | base64 2>/dev/null | tr -d '\n\r') + if [ -n "$cmd64" ]; then + printf '\033]16162;C;{"cmd64":"%s"}\007' "$cmd64" + else + printf '\033]16162;C\007' + fi + fi +} + +typeset -g WAVETERM_SI_INPUTEMPTY=1 + +_waveterm_si_inputempty() { + _waveterm_si_blocked && return + + local current_empty=1 + if [[ -n "$BUFFER" ]]; then + current_empty=0 + fi + + if (( current_empty != WAVETERM_SI_INPUTEMPTY )); then + WAVETERM_SI_INPUTEMPTY=$current_empty + if (( current_empty )); then + printf '\033]16162;I;{"inputempty":true}\007' + else + printf '\033]16162;I;{"inputempty":false}\007' + fi + fi +} + +autoload -Uz add-zle-hook-widget 2>/dev/null +if (( $+functions[add-zle-hook-widget] )); then + add-zle-hook-widget zle-line-init _waveterm_si_inputempty + add-zle-hook-widget zle-line-pre-redraw _waveterm_si_inputempty +fi + +autoload -U add-zsh-hook +add-zsh-hook precmd _waveterm_si_precmd +add-zsh-hook preexec _waveterm_si_preexec +add-zsh-hook chpwd _waveterm_si_osc7 \ No newline at end of file diff --git a/pkg/util/shellutil/shellutil.go b/pkg/util/shellutil/shellutil.go index d9b625a848..f6b60b4d81 100644 --- a/pkg/util/shellutil/shellutil.go +++ b/pkg/util/shellutil/shellutil.go @@ -5,6 +5,7 @@ package shellutil import ( "context" + _ "embed" "fmt" "log" "os" @@ -22,6 +23,29 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" ) +var ( + //go:embed shellintegration/zsh_zprofile.sh + ZshStartup_Zprofile string + + //go:embed shellintegration/zsh_zshrc.sh + ZshStartup_Zshrc string + + //go:embed shellintegration/zsh_zlogin.sh + ZshStartup_Zlogin string + + //go:embed shellintegration/zsh_zshenv.sh + ZshStartup_Zshenv string + + //go:embed shellintegration/bash_bashrc.sh + BashStartup_Bashrc string + + //go:embed shellintegration/fish_wavefish.sh + FishStartup_Wavefish string + + //go:embed shellintegration/pwsh_wavepwsh.sh + PwshStartup_wavepwsh string +) + const DefaultTermType = "xterm-256color" const DefaultTermRows = 24 const DefaultTermCols = 80 @@ -47,122 +71,6 @@ const ( PwshIntegrationDir = "shell/pwsh" FishIntegrationDir = "shell/fish" WaveHomeBinDir = "bin" - - ZshStartup_Zprofile = ` -# Source the original zprofile -[ -f ~/.zprofile ] && source ~/.zprofile -` - - ZshStartup_Zshrc = ` -# add wsh to path, source dynamic script from wsh token -WAVETERM_WSHBINDIR={{.WSHBINDIR}} -export PATH="$WAVETERM_WSHBINDIR:$PATH" -source <(wsh token "$WAVETERM_SWAPTOKEN" zsh 2>/dev/null) -unset WAVETERM_SWAPTOKEN - -# Source the original zshrc only if ZDOTDIR has not been changed -if [ "$ZDOTDIR" = "$WAVETERM_ZDOTDIR" ]; then - [ -f ~/.zshrc ] && source ~/.zshrc -fi - -if [[ ":$PATH:" != *":$WAVETERM_WSHBINDIR:"* ]]; then - export PATH="$WAVETERM_WSHBINDIR:$PATH" -fi -unset WAVETERM_WSHBINDIR - -if [[ -n ${_comps+x} ]]; then - source <(wsh completion zsh) -fi -` - - ZshStartup_Zlogin = ` -# Source the original zlogin -[ -f ~/.zlogin ] && source ~/.zlogin - -# Unset ZDOTDIR only if it hasn't been modified -if [ "$ZDOTDIR" = "$WAVETERM_ZDOTDIR" ]; then - unset ZDOTDIR -fi -` - - ZshStartup_Zshenv = ` -# Store the initial ZDOTDIR value -WAVETERM_ZDOTDIR="$ZDOTDIR" - -# Source the original zshenv -[ -f ~/.zshenv ] && source ~/.zshenv - -# Detect if ZDOTDIR has changed -if [ "$ZDOTDIR" != "$WAVETERM_ZDOTDIR" ]; then - # If changed, manually source your custom zshrc from the original WAVETERM_ZDOTDIR - [ -f "$WAVETERM_ZDOTDIR/.zshrc" ] && source "$WAVETERM_ZDOTDIR/.zshrc" -fi - -` - - BashStartup_Bashrc = ` - -# Source /etc/profile if it exists -if [ -f /etc/profile ]; then - . /etc/profile -fi - -WAVETERM_WSHBINDIR={{.WSHBINDIR}} - -# after /etc/profile which is likely to clobber the path -export PATH="$WAVETERM_WSHBINDIR:$PATH" - -# Source the dynamic script from wsh token -eval "$(wsh token "$WAVETERM_SWAPTOKEN" bash 2> /dev/null)" -unset WAVETERM_SWAPTOKEN - -# Source the first of ~/.bash_profile, ~/.bash_login, or ~/.profile that exists -if [ -f ~/.bash_profile ]; then - . ~/.bash_profile -elif [ -f ~/.bash_login ]; then - . ~/.bash_login -elif [ -f ~/.profile ]; then - . ~/.profile -fi - -if [[ ":$PATH:" != *":$WAVETERM_WSHBINDIR:"* ]]; then - export PATH="$WAVETERM_WSHBINDIR:$PATH" -fi -unset WAVETERM_WSHBINDIR -if type _init_completion &>/dev/null; then - source <(wsh completion bash) -fi - -` - - FishStartup_Wavefish = ` -# this file is sourced with -C -# Add Wave binary directory to PATH -set -x PATH {{.WSHBINDIR}} $PATH - -# Source dynamic script from wsh token (the echo is to prevent fish from complaining about empty input) -wsh token "$WAVETERM_SWAPTOKEN" fish 2>/dev/null | source -set -e WAVETERM_SWAPTOKEN - -# Load Wave completions -wsh completion fish | source -` - - PwshStartup_wavepwsh = ` -# We source this file with -NoExit -File -$env:PATH = {{.WSHBINDIR_PWSH}} + "{{.PATHSEP}}" + $env:PATH - -# Source dynamic script from wsh token -$waveterm_swaptoken_output = wsh token $env:WAVETERM_SWAPTOKEN pwsh 2>$null | Out-String -if ($waveterm_swaptoken_output -and $waveterm_swaptoken_output -ne "") { - Invoke-Expression $waveterm_swaptoken_output -} -Remove-Variable -Name waveterm_swaptoken_output -Remove-Item Env:WAVETERM_SWAPTOKEN - -# Load Wave completions -wsh completion powershell | Out-String | Invoke-Expression -` ) func DetectLocalShellPath() string { @@ -437,3 +345,79 @@ func GetShellTypeFromShellPath(shellPath string) string { } return ShellType_unknown } + +var ( + bashVersionRegexp = regexp.MustCompile(`\bversion\s+(\d+\.\d+)`) + zshVersionRegexp = regexp.MustCompile(`\bzsh\s+(\d+\.\d+)`) + fishVersionRegexp = regexp.MustCompile(`\bversion\s+(\d+\.\d+)`) + pwshVersionRegexp = regexp.MustCompile(`(?:PowerShell\s+)?(\d+\.\d+)`) +) + +func DetectShellTypeAndVersion() (string, string, error) { + shellPath := DetectLocalShellPath() + return DetectShellTypeAndVersionFromPath(shellPath) +} + +func DetectShellTypeAndVersionFromPath(shellPath string) (string, string, error) { + shellType := GetShellTypeFromShellPath(shellPath) + if shellType == ShellType_unknown { + return shellType, "", fmt.Errorf("unknown shell type: %s", shellPath) + } + + shellBase := filepath.Base(shellPath) + if shellType == ShellType_pwsh && strings.Contains(shellBase, "powershell") && !strings.Contains(shellBase, "pwsh") { + return "powershell", "", nil + } + + version, err := getShellVersion(shellPath, shellType) + if err != nil { + return shellType, "", err + } + + return shellType, version, nil +} + +func getShellVersion(shellPath string, shellType string) (string, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + + var cmd *exec.Cmd + var versionRegex *regexp.Regexp + + switch shellType { + case ShellType_bash: + cmd = exec.CommandContext(ctx, shellPath, "--version") + versionRegex = bashVersionRegexp + case ShellType_zsh: + cmd = exec.CommandContext(ctx, shellPath, "--version") + versionRegex = zshVersionRegexp + case ShellType_fish: + cmd = exec.CommandContext(ctx, shellPath, "--version") + versionRegex = fishVersionRegexp + case ShellType_pwsh: + cmd = exec.CommandContext(ctx, shellPath, "--version") + versionRegex = pwshVersionRegexp + default: + return "", fmt.Errorf("unsupported shell type: %s", shellType) + } + + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to get version for %s: %w", shellType, err) + } + + outputStr := strings.TrimSpace(string(output)) + matches := versionRegex.FindStringSubmatch(outputStr) + if len(matches) < 2 { + return "", fmt.Errorf("failed to parse version from output: %q", outputStr) + } + + return matches[1], nil +} + +func FormatOSC(oscNum int, parts ...string) string { + if len(parts) == 0 { + return fmt.Sprintf("\x1b]%d\x07", oscNum) + } + return fmt.Sprintf("\x1b]%d;%s\x07", oscNum, strings.Join(parts, ";")) +} diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go index 1757429956..930b8a5e08 100644 --- a/pkg/wavebase/wavebase.go +++ b/pkg/wavebase/wavebase.go @@ -311,9 +311,61 @@ func UnameKernelRelease() string { return osRelease } +var systemSummaryOnce = &sync.Once{} +var systemSummary string + +func GetSystemSummary() string { + systemSummaryOnce.Do(func() { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + systemSummary = getSystemSummary(ctx) + }) + return systemSummary +} + func ValidateWshSupportedArch(os string, arch string) error { if SupportedWshBinaries[fmt.Sprintf("%s-%s", os, arch)] { return nil } return fmt.Errorf("unsupported wsh platform: %s-%s", os, arch) } + +func getSystemSummary(ctx context.Context) string { + osName := runtime.GOOS + + switch osName { + case "darwin": + out, _ := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output() + return fmt.Sprintf("macOS %s (%s)", strings.TrimSpace(string(out)), runtime.GOARCH) + case "linux": + // Read /etc/os-release directly (standard location since 2012) + data, err := os.ReadFile("/etc/os-release") + var prettyName string + if err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "PRETTY_NAME=") { + prettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"") + break + } + } + } + if prettyName == "" { + prettyName = "Linux" + } else if !strings.Contains(strings.ToLower(prettyName), "linux") { + prettyName = "Linux " + prettyName + } + return fmt.Sprintf("%s (%s)", prettyName, runtime.GOARCH) + case "windows": + var details string + out, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", "(Get-CimInstance Win32_OperatingSystem).Caption").Output() + if err == nil && len(out) > 0 { + details = strings.TrimSpace(string(out)) + } else { + details = "Windows" + } + return fmt.Sprintf("%s (%s)", details, runtime.GOARCH) + default: + return fmt.Sprintf("%s (%s)", runtime.GOOS, runtime.GOARCH) + } +} diff --git a/pkg/waveobj/blockrtinfo.go b/pkg/waveobj/blockrtinfo.go index 72e1118a43..dc72762d36 100644 --- a/pkg/waveobj/blockrtinfo.go +++ b/pkg/waveobj/blockrtinfo.go @@ -7,5 +7,14 @@ type ObjRTInfo struct { TsunamiTitle string `json:"tsunami:title,omitempty"` TsunamiShortDesc string `json:"tsunami:shortdesc,omitempty"` TsunamiSchemas any `json:"tsunami:schemas,omitempty"` - CmdHasCurCwd bool `json:"cmd:hascurcwd,omitempty"` + + CmdHasCurCwd bool `json:"cmd:hascurcwd,omitempty"` + + ShellState string `json:"shell:state,omitempty"` + ShellType string `json:"shell:type,omitempty"` + ShellVersion string `json:"shell:version,omitempty"` + ShellUname string `json:"shell:uname,omitempty"` + ShellInputEmpty bool `json:"shell:inputempty,omitempty"` + ShellLastCmd string `json:"shell:lastcmd,omitempty"` + ShellLastCmdExitCode int `json:"shell:lastcmdexitcode,omitempty"` } diff --git a/pkg/wcloud/wcloud.go b/pkg/wcloud/wcloud.go index eb6a64d9f5..01ab8b29d5 100644 --- a/pkg/wcloud/wcloud.go +++ b/pkg/wcloud/wcloud.go @@ -169,7 +169,6 @@ func sendTEventsBatch(clientId string) (bool, int, error) { if len(events) == 0 { return true, 0, nil } - log.Printf("[wcloud] sending %d tevents\n", len(events)) input := TEventsInputType{ ClientId: clientId, Events: events, @@ -178,7 +177,10 @@ func sendTEventsBatch(clientId string) (bool, int, error) { if err != nil { return true, 0, err } + startTime := time.Now() _, err = doRequest(req, nil) + latency := time.Since(startTime) + log.Printf("[wcloud] sent %d tevents (latency: %v)\n", len(events), latency) if err != nil { return true, 0, err } diff --git a/pkg/wstore/blockrtinfo.go b/pkg/wstore/blockrtinfo.go index 434b04abae..3f76d701bf 100644 --- a/pkg/wstore/blockrtinfo.go +++ b/pkg/wstore/blockrtinfo.go @@ -67,6 +67,16 @@ func SetRTInfo(oref waveobj.ORef, info map[string]any) { fieldValue.SetString(valueStr) } else if valueBool, ok := value.(bool); ok && fieldValue.Kind() == reflect.Bool { fieldValue.SetBool(valueBool) + } else if fieldValue.Kind() == reflect.Int { + // Handle int fields - need to convert from various numeric types + switch v := value.(type) { + case int: + fieldValue.SetInt(int64(v)) + case int64: + fieldValue.SetInt(v) + case float64: + fieldValue.SetInt(int64(v)) + } } else if fieldValue.Kind() == reflect.Interface { // Handle any/interface{} fields fieldValue.Set(reflect.ValueOf(value)) diff --git a/tsunami/frontend/src/util/platformutil.ts b/tsunami/frontend/src/util/platformutil.ts index 410f248fb3..78dc143073 100644 --- a/tsunami/frontend/src/util/platformutil.ts +++ b/tsunami/frontend/src/util/platformutil.ts @@ -2,29 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 export const PlatformMacOS = "darwin"; +export const PlatformWindows = "win32"; export let PLATFORM: NodeJS.Platform = PlatformMacOS; export function setPlatform(platform: NodeJS.Platform) { PLATFORM = platform; } -export function makeNativeLabel(isDirectory: boolean) { - let managerName: string; - if (!isDirectory) { - managerName = "Default Application"; - } else if (PLATFORM === PlatformMacOS) { - managerName = "Finder"; - } else if (PLATFORM == "win32") { - managerName = "Explorer"; - } else { - managerName = "File Manager"; - } +export function isMacOS(): boolean { + return PLATFORM == PlatformMacOS; +} - let fileAction: string; - if (isDirectory) { - fileAction = "Reveal"; - } else { - fileAction = "Open File"; - } - return `${fileAction} in ${managerName}`; +export function isWindows(): boolean { + return PLATFORM == PlatformWindows; }