Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions aiprompts/wave-osc-16162.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":<status>} (exit status)
→ I;{"inputempty":true} (input empty again)
→ A (next prompt start)

5. Repeat from step 3...
Expand All @@ -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)

Expand All @@ -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.
The path is URL-encoded to safely handle special characters.
103 changes: 103 additions & 0 deletions frontend/app/view/term/osc-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>,
shellInputBufferAtom: jotai.atom(null) as jotai.PrimitiveAtom<string | null>,
shellInputCursorAtom: jotai.atom(null) as jotai.PrimitiveAtom<number | null>,
} 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();
});
});
34 changes: 30 additions & 4 deletions frontend/app/view/term/osc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, never> };

function checkCommandForTelemetry(decodedCmd: string) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -116,6 +120,28 @@ function handleShellIntegrationCommandStart(
rtInfo["shell:lastcmdexitcode"] = null;
}

function handleShellIntegrationInputReadback(
termWrap: TermWrap,
cmd: { command: "I"; data: { buffer64?: string; cursor?: number } },
rtInfo: ObjRTInfo
): void {
const { buffer64, cursor } = cmd.data;
if (buffer64 == null || typeof cursor != "number" || !isFinite(cursor)) {
return;
}
let decodedBuffer: string;
try {
decodedBuffer = base64ToString(buffer64);
} catch (e) {
console.error("Error decoding shell input buffer64:", e);
return;
}
rtInfo["shell:inputbuffer64"] = buffer64;
rtInfo["shell:inputcursor"] = cursor;
globalStore.set(termWrap.shellInputBufferAtom, decodedBuffer);
globalStore.set(termWrap.shellInputCursorAtom, 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 {
Expand Down Expand Up @@ -331,12 +357,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");
}
Expand Down
19 changes: 18 additions & 1 deletion frontend/app/view/term/termwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -91,6 +91,8 @@ export class TermWrap {
promptMarkers: TermTypes.IMarker[] = [];
shellIntegrationStatusAtom: jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
lastCommandAtom: jotai.PrimitiveAtom<string | null>;
shellInputBufferAtom: jotai.PrimitiveAtom<string | null>;
shellInputCursorAtom: jotai.PrimitiveAtom<number | null>;
nodeModel: BlockNodeModel; // this can be null
hoveredLinkUri: string | null = null;
onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void;
Expand Down Expand Up @@ -143,6 +145,8 @@ export class TermWrap {
this.promptMarkers = [];
this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
this.shellInputBufferAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
this.shellInputCursorAtom = jotai.atom(null) as jotai.PrimitiveAtom<number | null>;
this.terminal = new Terminal(options);
this.fitAddon = new FitAddon();
this.fitAddon.scrollbarWidth = 6; // this needs to match scrollbar width in term.scss
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
35 changes: 12 additions & 23 deletions pkg/util/shellutil/shellintegration/zsh_zshrc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -110,33 +110,22 @@ _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
# 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
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
add-zsh-hook chpwd _waveterm_si_osc7
3 changes: 2 additions & 1 deletion pkg/waveobj/objrtinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
7 changes: 7 additions & 0 deletions pkg/wstore/wstore_rtinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down