From 66266b796677e19ee7dbb44972c8d36af07b47be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:37:55 +0000 Subject: [PATCH 1/3] Initial plan From 0c131d9d68b5a47c15862bf94eb127d86d72f2cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:54:18 +0000 Subject: [PATCH 2/3] feat: add zle buffer readback over osc 16162 Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- aiprompts/wave-osc-16162.md | 25 ++--- frontend/app/view/term/osc-handlers.test.ts | 103 ++++++++++++++++++ frontend/app/view/term/osc-handlers.ts | 37 ++++++- frontend/app/view/term/termwrap.ts | 19 +++- frontend/types/gotypes.d.ts | 3 +- .../shellutil/shellintegration/zsh_zshrc.sh | 34 ++---- pkg/waveobj/objrtinfo.go | 3 +- pkg/wstore/wstore_rtinfo.go | 7 ++ 8 files changed, 188 insertions(+), 43 deletions(-) create mode 100644 frontend/app/view/term/osc-handlers.test.ts diff --git a/aiprompts/wave-osc-16162.md b/aiprompts/wave-osc-16162.md index fe9c8c8352..3b42d52eba 100644 --- a/aiprompts/wave-osc-16162.md +++ b/aiprompts/wave-osc-16162.md @@ -125,23 +125,22 @@ Reports the current state of the command line input buffer. **Data Type:** ```typescript { - inputempty?: boolean; // Whether the command line buffer is empty + buffer64: string; // Base64-encoded command line buffer contents + cursor: number; // ZLE cursor position within the decoded buffer } ``` -**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 +**When:** Sent in response to Wave writing `^_Wr` (`\x1fWr`) into the PTY while ZLE is active -**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. +**Purpose:** Allows Wave Terminal to synchronize the full command line buffer and cursor position in a single round trip. **Example:** ```bash -# When buffer is empty -I;{"inputempty":true} +# Empty buffer at cursor 0 +I;{"buffer64":"","cursor":0} -# When buffer has content -I;{"inputempty":false} +# Buffer contains "echo hello" and cursor is after "echo" +I;{"buffer64":"ZWNobyBoZWxsbw==","cursor":4} ``` ### R - Reset Alternate Buffer @@ -178,12 +177,12 @@ Here's the typical sequence during shell interaction: → A (prompt start) 3. User types command and presses Enter - → I;{"inputempty":false} (input no longer empty - sent as user types) + → Wave writes `^_Wr` + → I;{"buffer64":"...","cursor":...} (full ZLE readback) → 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... @@ -193,7 +192,7 @@ Here's the typical sequence during shell interaction: - 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 I command is produced by a ZLE widget bound to `^_Wr` and returns the exact `BUFFER` contents plus `CURSOR` - 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) @@ -212,4 +211,4 @@ 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 +The path is URL-encoded to safely handle special characters. diff --git a/frontend/app/view/term/osc-handlers.test.ts b/frontend/app/view/term/osc-handlers.test.ts new file mode 100644 index 0000000000..e0b0541dbb --- /dev/null +++ b/frontend/app/view/term/osc-handlers.test.ts @@ -0,0 +1,103 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/global"; +import { stringToBase64 } from "@/util/util"; +import * as jotai from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleOsc16162Command } from "./osc-handlers"; + +const { setRTInfoCommandMock } = vi.hoisted(() => ({ + setRTInfoCommandMock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/app/store/wshclientapi", () => ({ + RpcApi: { + SetRTInfoCommand: setRTInfoCommandMock, + }, +})); + +vi.mock("@/app/store/wshrpcutil", () => ({ + TabRpcClient: {}, +})); + +function makeTermWrap() { + return { + terminal: {}, + shellIntegrationStatusAtom: jotai.atom(null) as jotai.PrimitiveAtom<"ready" | "running-command" | null>, + lastCommandAtom: jotai.atom(null) as jotai.PrimitiveAtom, + shellInputBufferAtom: jotai.atom(null) as jotai.PrimitiveAtom, + shellInputCursorAtom: jotai.atom(null) as jotai.PrimitiveAtom, + } as any; +} + +describe("handleOsc16162Command input readback", () => { + beforeEach(() => { + vi.useFakeTimers(); + setRTInfoCommandMock.mockClear(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it("updates shell input buffer and cursor from buffer64 payload", async () => { + const termWrap = makeTermWrap(); + const buffer = "echo hello λ"; + const buffer64 = stringToBase64(buffer); + + expect(handleOsc16162Command(`I;{"buffer64":"${buffer64}","cursor":4}`, "block-1", true, termWrap)).toBe(true); + + expect(globalStore.get(termWrap.shellInputBufferAtom)).toBe(buffer); + expect(globalStore.get(termWrap.shellInputCursorAtom)).toBe(4); + + await vi.runAllTimersAsync(); + + expect(setRTInfoCommandMock).toHaveBeenCalledWith( + {}, + { + oref: "block:block-1", + data: { + "shell:inputbuffer64": buffer64, + "shell:inputcursor": 4, + }, + } + ); + }); + + it("preserves empty buffer and cursor zero in runtime info", async () => { + const termWrap = makeTermWrap(); + + expect(handleOsc16162Command('I;{"buffer64":"","cursor":0}', "block-2", true, termWrap)).toBe(true); + + expect(globalStore.get(termWrap.shellInputBufferAtom)).toBe(""); + expect(globalStore.get(termWrap.shellInputCursorAtom)).toBe(0); + + await vi.runAllTimersAsync(); + + expect(setRTInfoCommandMock).toHaveBeenCalledWith( + {}, + { + oref: "block:block-2", + data: { + "shell:inputbuffer64": "", + "shell:inputcursor": 0, + }, + } + ); + }); + + it("ignores legacy inputempty payloads", async () => { + const termWrap = makeTermWrap(); + + expect(handleOsc16162Command('I;{"inputempty":false}', "block-3", true, termWrap)).toBe(true); + + expect(globalStore.get(termWrap.shellInputBufferAtom)).toBeNull(); + expect(globalStore.get(termWrap.shellInputCursorAtom)).toBeNull(); + + await vi.runAllTimersAsync(); + + expect(setRTInfoCommandMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index 25fdf0e89b..9f28d4fad5 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -41,7 +41,7 @@ type Osc16162Command = }; } | { command: "D"; data: { exitcode?: number } } - | { command: "I"; data: { inputempty?: boolean } } + | { command: "I"; data: { buffer64?: string; cursor?: number } } | { command: "R"; data: Record }; function checkCommandForTelemetry(decodedCmd: string) { @@ -86,7 +86,11 @@ function handleShellIntegrationCommandStart( rtInfo: ObjRTInfo // this is passed by reference and modified inside of this function ): void { rtInfo["shell:state"] = "running-command"; + rtInfo["shell:inputbuffer64"] = null; + rtInfo["shell:inputcursor"] = null; globalStore.set(termWrap.shellIntegrationStatusAtom, "running-command"); + globalStore.set(termWrap.shellInputBufferAtom, null); + globalStore.set(termWrap.shellInputCursorAtom, null); const connName = globalStore.get(getBlockMetaKeyAtom(blockId, "connection")) ?? ""; const isRemote = isSshConnName(connName); const isWsl = isWslConnName(connName); @@ -116,6 +120,27 @@ function handleShellIntegrationCommandStart( rtInfo["shell:lastcmdexitcode"] = null; } +function handleShellIntegrationInputReadback( + termWrap: TermWrap, + cmd: { command: "I"; data: { buffer64?: string; cursor?: number } }, + rtInfo: ObjRTInfo +): void { + if (cmd.data.buffer64 == null || cmd.data.cursor == null) { + return; + } + let decodedBuffer: string; + try { + decodedBuffer = base64ToString(cmd.data.buffer64); + } catch (e) { + console.error("Error decoding shell input buffer64:", e); + return; + } + rtInfo["shell:inputbuffer64"] = cmd.data.buffer64; + rtInfo["shell:inputcursor"] = cmd.data.cursor; + globalStore.set(termWrap.shellInputBufferAtom, decodedBuffer); + globalStore.set(termWrap.shellInputCursorAtom, cmd.data.cursor); +} + // for xterm OSC handlers, we return true always because we "own" the OSC number. // even if data is invalid we don't want to propagate to other handlers. export function handleOsc52Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean { @@ -286,7 +311,11 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo switch (cmd.command) { case "A": { rtInfo["shell:state"] = "ready"; + rtInfo["shell:inputbuffer64"] = ""; + rtInfo["shell:inputcursor"] = 0; globalStore.set(termWrap.shellIntegrationStatusAtom, "ready"); + globalStore.set(termWrap.shellInputBufferAtom, ""); + globalStore.set(termWrap.shellInputCursorAtom, 0); const marker = terminal.registerMarker(0); if (marker) { termWrap.promptMarkers.push(marker); @@ -331,12 +360,12 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo } break; case "I": - if (cmd.data.inputempty != null) { - rtInfo["shell:inputempty"] = cmd.data.inputempty; - } + handleShellIntegrationInputReadback(termWrap, cmd, rtInfo); break; case "R": globalStore.set(termWrap.shellIntegrationStatusAtom, null); + globalStore.set(termWrap.shellInputBufferAtom, null); + globalStore.set(termWrap.shellInputCursorAtom, null); if (terminal.buffer.active.type === "alternate") { terminal.write("\x1b[?1049l"); } diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 7271c4c3a9..d1c354a1e5 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -18,7 +18,7 @@ import { } from "@/store/global"; import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; -import { base64ToArray, fireAndForget } from "@/util/util"; +import { base64ToArray, base64ToString, fireAndForget } from "@/util/util"; import { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -91,6 +91,8 @@ export class TermWrap { promptMarkers: TermTypes.IMarker[] = []; shellIntegrationStatusAtom: jotai.PrimitiveAtom; lastCommandAtom: jotai.PrimitiveAtom; + shellInputBufferAtom: jotai.PrimitiveAtom; + shellInputCursorAtom: jotai.PrimitiveAtom; nodeModel: BlockNodeModel; // this can be null hoveredLinkUri: string | null = null; onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void; @@ -143,6 +145,8 @@ export class TermWrap { this.promptMarkers = []; this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.shellInputBufferAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.shellInputCursorAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); this.fitAddon.scrollbarWidth = 6; // this needs to match scrollbar width in term.scss @@ -399,6 +403,19 @@ export class TermWrap { const lastCmd = rtInfo ? rtInfo["shell:lastcmd"] : null; globalStore.set(this.lastCommandAtom, lastCmd || null); + const inputBuffer64 = rtInfo ? rtInfo["shell:inputbuffer64"] : null; + if (inputBuffer64 == null) { + globalStore.set(this.shellInputBufferAtom, null); + } else { + try { + globalStore.set(this.shellInputBufferAtom, base64ToString(inputBuffer64)); + } catch (e) { + console.error("Error loading shell input buffer:", e); + globalStore.set(this.shellInputBufferAtom, null); + } + } + const inputCursor = rtInfo ? rtInfo["shell:inputcursor"] : null; + globalStore.set(this.shellInputCursorAtom, inputCursor == null ? null : inputCursor); } catch (e) { console.log("Error loading runtime info:", e); } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 6070f46bef..d8014476de 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1171,7 +1171,8 @@ declare global { "shell:integration"?: boolean; "shell:omz"?: boolean; "shell:comp"?: string; - "shell:inputempty"?: boolean; + "shell:inputbuffer64"?: string; + "shell:inputcursor"?: number; "shell:lastcmd"?: string; "shell:lastcmdexitcode"?: number; "builder:layout"?: {[key: string]: number}; diff --git a/pkg/util/shellutil/shellintegration/zsh_zshrc.sh b/pkg/util/shellutil/shellintegration/zsh_zshrc.sh index 07d2df9a40..d8424a588a 100644 --- a/pkg/util/shellutil/shellintegration/zsh_zshrc.sh +++ b/pkg/util/shellutil/shellintegration/zsh_zshrc.sh @@ -110,33 +110,21 @@ _waveterm_si_preexec() { fi } -typeset -g WAVETERM_SI_INPUTEMPTY=1 - -_waveterm_si_inputempty() { +_waveterm_si_inputreadback() { _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 + local buffer64 cursor + buffer64=$(printf '%s' "$BUFFER" | base64 2>/dev/null | tr -d '\n\r') + cursor=$CURSOR + zle -I + printf '\033]16162;I;{"buffer64":"%s","cursor":%d}\007' "$buffer64" "$cursor" } -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 +zle -N _waveterm_si_inputreadback +bindkey -M emacs '^_Wr' _waveterm_si_inputreadback 2>/dev/null +bindkey -M viins '^_Wr' _waveterm_si_inputreadback 2>/dev/null +bindkey -M vicmd '^_Wr' _waveterm_si_inputreadback 2>/dev/null 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 +add-zsh-hook chpwd _waveterm_si_osc7 diff --git a/pkg/waveobj/objrtinfo.go b/pkg/waveobj/objrtinfo.go index a7b35bbd86..778e42e168 100644 --- a/pkg/waveobj/objrtinfo.go +++ b/pkg/waveobj/objrtinfo.go @@ -15,7 +15,8 @@ type ObjRTInfo struct { ShellIntegration bool `json:"shell:integration,omitempty"` ShellOmz bool `json:"shell:omz,omitempty"` ShellComp string `json:"shell:comp,omitempty"` - ShellInputEmpty bool `json:"shell:inputempty,omitempty"` + ShellInputBuffer64 *string `json:"shell:inputbuffer64,omitempty"` + ShellInputCursor *int `json:"shell:inputcursor,omitempty"` ShellLastCmd string `json:"shell:lastcmd,omitempty"` ShellLastCmdExitCode int `json:"shell:lastcmdexitcode,omitempty"` diff --git a/pkg/wstore/wstore_rtinfo.go b/pkg/wstore/wstore_rtinfo.go index 912a3ccac0..e3d6ef9f86 100644 --- a/pkg/wstore/wstore_rtinfo.go +++ b/pkg/wstore/wstore_rtinfo.go @@ -22,6 +22,13 @@ func setFieldValue(fieldValue reflect.Value, value any) { return } + if fieldValue.Kind() == reflect.Pointer { + ptrValue := reflect.New(fieldValue.Type().Elem()) + setFieldValue(ptrValue.Elem(), value) + fieldValue.Set(ptrValue) + return + } + if valueStr, ok := value.(string); ok && fieldValue.Kind() == reflect.String { fieldValue.SetString(valueStr) return From dd8362ced4c73f1aaff89c43a628024fdddee3ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:00:14 +0000 Subject: [PATCH 3/3] chore: finalize osc 16162 readback validation Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/view/term/osc-handlers.ts | 15 ++++++--------- pkg/util/shellutil/shellintegration/zsh_zshrc.sh | 1 + 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index 9f28d4fad5..5f2c0f6c03 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -125,20 +125,21 @@ function handleShellIntegrationInputReadback( cmd: { command: "I"; data: { buffer64?: string; cursor?: number } }, rtInfo: ObjRTInfo ): void { - if (cmd.data.buffer64 == null || cmd.data.cursor == null) { + const { buffer64, cursor } = cmd.data; + if (buffer64 == null || typeof cursor != "number" || !isFinite(cursor)) { return; } let decodedBuffer: string; try { - decodedBuffer = base64ToString(cmd.data.buffer64); + decodedBuffer = base64ToString(buffer64); } catch (e) { console.error("Error decoding shell input buffer64:", e); return; } - rtInfo["shell:inputbuffer64"] = cmd.data.buffer64; - rtInfo["shell:inputcursor"] = cmd.data.cursor; + rtInfo["shell:inputbuffer64"] = buffer64; + rtInfo["shell:inputcursor"] = cursor; globalStore.set(termWrap.shellInputBufferAtom, decodedBuffer); - globalStore.set(termWrap.shellInputCursorAtom, cmd.data.cursor); + globalStore.set(termWrap.shellInputCursorAtom, cursor); } // for xterm OSC handlers, we return true always because we "own" the OSC number. @@ -311,11 +312,7 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo switch (cmd.command) { case "A": { rtInfo["shell:state"] = "ready"; - rtInfo["shell:inputbuffer64"] = ""; - rtInfo["shell:inputcursor"] = 0; globalStore.set(termWrap.shellIntegrationStatusAtom, "ready"); - globalStore.set(termWrap.shellInputBufferAtom, ""); - globalStore.set(termWrap.shellInputCursorAtom, 0); const marker = terminal.registerMarker(0); if (marker) { termWrap.promptMarkers.push(marker); diff --git a/pkg/util/shellutil/shellintegration/zsh_zshrc.sh b/pkg/util/shellutil/shellintegration/zsh_zshrc.sh index d8424a588a..9dd9833c61 100644 --- a/pkg/util/shellutil/shellintegration/zsh_zshrc.sh +++ b/pkg/util/shellutil/shellintegration/zsh_zshrc.sh @@ -113,6 +113,7 @@ _waveterm_si_preexec() { _waveterm_si_inputreadback() { _waveterm_si_blocked && return local buffer64 cursor + # base64 may wrap lines on some platforms, so strip newlines before embedding JSON buffer64=$(printf '%s' "$BUFFER" | base64 2>/dev/null | tr -d '\n\r') cursor=$CURSOR zle -I