Skip to content

Commit 0c131d9

Browse files
Copilotsawka
andcommitted
feat: add zle buffer readback over osc 16162
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
1 parent 66266b7 commit 0c131d9

8 files changed

Lines changed: 188 additions & 43 deletions

File tree

aiprompts/wave-osc-16162.md

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,23 +125,22 @@ Reports the current state of the command line input buffer.
125125
**Data Type:**
126126
```typescript
127127
{
128-
inputempty?: boolean; // Whether the command line buffer is empty
128+
buffer64: string; // Base64-encoded command line buffer contents
129+
cursor: number; // ZLE cursor position within the decoded buffer
129130
}
130131
```
131132

132-
**When:** Sent during ZLE (Zsh Line Editor) hooks when buffer state changes
133-
- `zle-line-init` - When line editor is initialized
134-
- `zle-line-pre-redraw` - Before line is redrawn
133+
**When:** Sent in response to Wave writing `^_Wr` (`\x1fWr`) into the PTY while ZLE is active
135134

136-
**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.
135+
**Purpose:** Allows Wave Terminal to synchronize the full command line buffer and cursor position in a single round trip.
137136

138137
**Example:**
139138
```bash
140-
# When buffer is empty
141-
I;{"inputempty":true}
139+
# Empty buffer at cursor 0
140+
I;{"buffer64":"","cursor":0}
142141

143-
# When buffer has content
144-
I;{"inputempty":false}
142+
# Buffer contains "echo hello" and cursor is after "echo"
143+
I;{"buffer64":"ZWNobyBoZWxsbw==","cursor":4}
145144
```
146145

147146
### R - Reset Alternate Buffer
@@ -178,12 +177,12 @@ Here's the typical sequence during shell interaction:
178177
→ A (prompt start)
179178
180179
3. User types command and presses Enter
181-
→ I;{"inputempty":false} (input no longer empty - sent as user types)
180+
→ Wave writes `^_Wr`
181+
→ I;{"buffer64":"...","cursor":...} (full ZLE readback)
182182
→ C;{"cmd64":"..."} (command about to execute)
183183
184184
4. Command runs and completes
185185
→ D;{"exitcode":<status>} (exit status)
186-
→ I;{"inputempty":true} (input empty again)
187186
→ A (next prompt start)
188187
189188
5. Repeat from step 3...
@@ -193,7 +192,7 @@ Here's the typical sequence during shell interaction:
193192

194193
- Shell integration is **disabled** when running inside tmux or screen (`TMUX`, `STY` environment variables, or `tmux*`/`screen*` TERM values)
195194
- Commands are base64-encoded in the C sequence to safely handle special characters, newlines, and control characters
196-
- The I (input empty) command is only sent when the state changes (not on every keystroke)
195+
- The I command is produced by a ZLE widget bound to `^_Wr` and returns the exact `BUFFER` contents plus `CURSOR`
197196
- The M (metadata) command is only sent once during the first precmd
198197
- The D (exit status) command is skipped during the first precmd (no previous command to report)
199198

@@ -212,4 +211,4 @@ This is sent:
212211
- During first precmd (after metadata)
213212
- In the `chpwd` hook (whenever directory changes)
214213

215-
The path is URL-encoded to safely handle special characters.
214+
The path is URL-encoded to safely handle special characters.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { globalStore } from "@/app/store/global";
5+
import { stringToBase64 } from "@/util/util";
6+
import * as jotai from "jotai";
7+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8+
import { handleOsc16162Command } from "./osc-handlers";
9+
10+
const { setRTInfoCommandMock } = vi.hoisted(() => ({
11+
setRTInfoCommandMock: vi.fn().mockResolvedValue(undefined),
12+
}));
13+
14+
vi.mock("@/app/store/wshclientapi", () => ({
15+
RpcApi: {
16+
SetRTInfoCommand: setRTInfoCommandMock,
17+
},
18+
}));
19+
20+
vi.mock("@/app/store/wshrpcutil", () => ({
21+
TabRpcClient: {},
22+
}));
23+
24+
function makeTermWrap() {
25+
return {
26+
terminal: {},
27+
shellIntegrationStatusAtom: jotai.atom(null) as jotai.PrimitiveAtom<"ready" | "running-command" | null>,
28+
lastCommandAtom: jotai.atom(null) as jotai.PrimitiveAtom<string | null>,
29+
shellInputBufferAtom: jotai.atom(null) as jotai.PrimitiveAtom<string | null>,
30+
shellInputCursorAtom: jotai.atom(null) as jotai.PrimitiveAtom<number | null>,
31+
} as any;
32+
}
33+
34+
describe("handleOsc16162Command input readback", () => {
35+
beforeEach(() => {
36+
vi.useFakeTimers();
37+
setRTInfoCommandMock.mockClear();
38+
});
39+
40+
afterEach(() => {
41+
vi.runOnlyPendingTimers();
42+
vi.useRealTimers();
43+
});
44+
45+
it("updates shell input buffer and cursor from buffer64 payload", async () => {
46+
const termWrap = makeTermWrap();
47+
const buffer = "echo hello λ";
48+
const buffer64 = stringToBase64(buffer);
49+
50+
expect(handleOsc16162Command(`I;{"buffer64":"${buffer64}","cursor":4}`, "block-1", true, termWrap)).toBe(true);
51+
52+
expect(globalStore.get(termWrap.shellInputBufferAtom)).toBe(buffer);
53+
expect(globalStore.get(termWrap.shellInputCursorAtom)).toBe(4);
54+
55+
await vi.runAllTimersAsync();
56+
57+
expect(setRTInfoCommandMock).toHaveBeenCalledWith(
58+
{},
59+
{
60+
oref: "block:block-1",
61+
data: {
62+
"shell:inputbuffer64": buffer64,
63+
"shell:inputcursor": 4,
64+
},
65+
}
66+
);
67+
});
68+
69+
it("preserves empty buffer and cursor zero in runtime info", async () => {
70+
const termWrap = makeTermWrap();
71+
72+
expect(handleOsc16162Command('I;{"buffer64":"","cursor":0}', "block-2", true, termWrap)).toBe(true);
73+
74+
expect(globalStore.get(termWrap.shellInputBufferAtom)).toBe("");
75+
expect(globalStore.get(termWrap.shellInputCursorAtom)).toBe(0);
76+
77+
await vi.runAllTimersAsync();
78+
79+
expect(setRTInfoCommandMock).toHaveBeenCalledWith(
80+
{},
81+
{
82+
oref: "block:block-2",
83+
data: {
84+
"shell:inputbuffer64": "",
85+
"shell:inputcursor": 0,
86+
},
87+
}
88+
);
89+
});
90+
91+
it("ignores legacy inputempty payloads", async () => {
92+
const termWrap = makeTermWrap();
93+
94+
expect(handleOsc16162Command('I;{"inputempty":false}', "block-3", true, termWrap)).toBe(true);
95+
96+
expect(globalStore.get(termWrap.shellInputBufferAtom)).toBeNull();
97+
expect(globalStore.get(termWrap.shellInputCursorAtom)).toBeNull();
98+
99+
await vi.runAllTimersAsync();
100+
101+
expect(setRTInfoCommandMock).not.toHaveBeenCalled();
102+
});
103+
});

frontend/app/view/term/osc-handlers.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ type Osc16162Command =
4141
};
4242
}
4343
| { command: "D"; data: { exitcode?: number } }
44-
| { command: "I"; data: { inputempty?: boolean } }
44+
| { command: "I"; data: { buffer64?: string; cursor?: number } }
4545
| { command: "R"; data: Record<string, never> };
4646

4747
function checkCommandForTelemetry(decodedCmd: string) {
@@ -86,7 +86,11 @@ function handleShellIntegrationCommandStart(
8686
rtInfo: ObjRTInfo // this is passed by reference and modified inside of this function
8787
): void {
8888
rtInfo["shell:state"] = "running-command";
89+
rtInfo["shell:inputbuffer64"] = null;
90+
rtInfo["shell:inputcursor"] = null;
8991
globalStore.set(termWrap.shellIntegrationStatusAtom, "running-command");
92+
globalStore.set(termWrap.shellInputBufferAtom, null);
93+
globalStore.set(termWrap.shellInputCursorAtom, null);
9094
const connName = globalStore.get(getBlockMetaKeyAtom(blockId, "connection")) ?? "";
9195
const isRemote = isSshConnName(connName);
9296
const isWsl = isWslConnName(connName);
@@ -116,6 +120,27 @@ function handleShellIntegrationCommandStart(
116120
rtInfo["shell:lastcmdexitcode"] = null;
117121
}
118122

123+
function handleShellIntegrationInputReadback(
124+
termWrap: TermWrap,
125+
cmd: { command: "I"; data: { buffer64?: string; cursor?: number } },
126+
rtInfo: ObjRTInfo
127+
): void {
128+
if (cmd.data.buffer64 == null || cmd.data.cursor == null) {
129+
return;
130+
}
131+
let decodedBuffer: string;
132+
try {
133+
decodedBuffer = base64ToString(cmd.data.buffer64);
134+
} catch (e) {
135+
console.error("Error decoding shell input buffer64:", e);
136+
return;
137+
}
138+
rtInfo["shell:inputbuffer64"] = cmd.data.buffer64;
139+
rtInfo["shell:inputcursor"] = cmd.data.cursor;
140+
globalStore.set(termWrap.shellInputBufferAtom, decodedBuffer);
141+
globalStore.set(termWrap.shellInputCursorAtom, cmd.data.cursor);
142+
}
143+
119144
// for xterm OSC handlers, we return true always because we "own" the OSC number.
120145
// even if data is invalid we don't want to propagate to other handlers.
121146
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
286311
switch (cmd.command) {
287312
case "A": {
288313
rtInfo["shell:state"] = "ready";
314+
rtInfo["shell:inputbuffer64"] = "";
315+
rtInfo["shell:inputcursor"] = 0;
289316
globalStore.set(termWrap.shellIntegrationStatusAtom, "ready");
317+
globalStore.set(termWrap.shellInputBufferAtom, "");
318+
globalStore.set(termWrap.shellInputCursorAtom, 0);
290319
const marker = terminal.registerMarker(0);
291320
if (marker) {
292321
termWrap.promptMarkers.push(marker);
@@ -331,12 +360,12 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo
331360
}
332361
break;
333362
case "I":
334-
if (cmd.data.inputempty != null) {
335-
rtInfo["shell:inputempty"] = cmd.data.inputempty;
336-
}
363+
handleShellIntegrationInputReadback(termWrap, cmd, rtInfo);
337364
break;
338365
case "R":
339366
globalStore.set(termWrap.shellIntegrationStatusAtom, null);
367+
globalStore.set(termWrap.shellInputBufferAtom, null);
368+
globalStore.set(termWrap.shellInputCursorAtom, null);
340369
if (terminal.buffer.active.type === "alternate") {
341370
terminal.write("\x1b[?1049l");
342371
}

frontend/app/view/term/termwrap.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
} from "@/store/global";
1919
import * as services from "@/store/services";
2020
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
21-
import { base64ToArray, fireAndForget } from "@/util/util";
21+
import { base64ToArray, base64ToString, fireAndForget } from "@/util/util";
2222
import { SearchAddon } from "@xterm/addon-search";
2323
import { SerializeAddon } from "@xterm/addon-serialize";
2424
import { WebLinksAddon } from "@xterm/addon-web-links";
@@ -91,6 +91,8 @@ export class TermWrap {
9191
promptMarkers: TermTypes.IMarker[] = [];
9292
shellIntegrationStatusAtom: jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
9393
lastCommandAtom: jotai.PrimitiveAtom<string | null>;
94+
shellInputBufferAtom: jotai.PrimitiveAtom<string | null>;
95+
shellInputCursorAtom: jotai.PrimitiveAtom<number | null>;
9496
nodeModel: BlockNodeModel; // this can be null
9597
hoveredLinkUri: string | null = null;
9698
onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void;
@@ -143,6 +145,8 @@ export class TermWrap {
143145
this.promptMarkers = [];
144146
this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
145147
this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
148+
this.shellInputBufferAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
149+
this.shellInputCursorAtom = jotai.atom(null) as jotai.PrimitiveAtom<number | null>;
146150
this.terminal = new Terminal(options);
147151
this.fitAddon = new FitAddon();
148152
this.fitAddon.scrollbarWidth = 6; // this needs to match scrollbar width in term.scss
@@ -399,6 +403,19 @@ export class TermWrap {
399403

400404
const lastCmd = rtInfo ? rtInfo["shell:lastcmd"] : null;
401405
globalStore.set(this.lastCommandAtom, lastCmd || null);
406+
const inputBuffer64 = rtInfo ? rtInfo["shell:inputbuffer64"] : null;
407+
if (inputBuffer64 == null) {
408+
globalStore.set(this.shellInputBufferAtom, null);
409+
} else {
410+
try {
411+
globalStore.set(this.shellInputBufferAtom, base64ToString(inputBuffer64));
412+
} catch (e) {
413+
console.error("Error loading shell input buffer:", e);
414+
globalStore.set(this.shellInputBufferAtom, null);
415+
}
416+
}
417+
const inputCursor = rtInfo ? rtInfo["shell:inputcursor"] : null;
418+
globalStore.set(this.shellInputCursorAtom, inputCursor == null ? null : inputCursor);
402419
} catch (e) {
403420
console.log("Error loading runtime info:", e);
404421
}

frontend/types/gotypes.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1171,7 +1171,8 @@ declare global {
11711171
"shell:integration"?: boolean;
11721172
"shell:omz"?: boolean;
11731173
"shell:comp"?: string;
1174-
"shell:inputempty"?: boolean;
1174+
"shell:inputbuffer64"?: string;
1175+
"shell:inputcursor"?: number;
11751176
"shell:lastcmd"?: string;
11761177
"shell:lastcmdexitcode"?: number;
11771178
"builder:layout"?: {[key: string]: number};

pkg/util/shellutil/shellintegration/zsh_zshrc.sh

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -110,33 +110,21 @@ _waveterm_si_preexec() {
110110
fi
111111
}
112112

113-
typeset -g WAVETERM_SI_INPUTEMPTY=1
114-
115-
_waveterm_si_inputempty() {
113+
_waveterm_si_inputreadback() {
116114
_waveterm_si_blocked && return
117-
118-
local current_empty=1
119-
if [[ -n "$BUFFER" ]]; then
120-
current_empty=0
121-
fi
122-
123-
if (( current_empty != WAVETERM_SI_INPUTEMPTY )); then
124-
WAVETERM_SI_INPUTEMPTY=$current_empty
125-
if (( current_empty )); then
126-
printf '\033]16162;I;{"inputempty":true}\007'
127-
else
128-
printf '\033]16162;I;{"inputempty":false}\007'
129-
fi
130-
fi
115+
local buffer64 cursor
116+
buffer64=$(printf '%s' "$BUFFER" | base64 2>/dev/null | tr -d '\n\r')
117+
cursor=$CURSOR
118+
zle -I
119+
printf '\033]16162;I;{"buffer64":"%s","cursor":%d}\007' "$buffer64" "$cursor"
131120
}
132121

133-
autoload -Uz add-zle-hook-widget 2>/dev/null
134-
if (( $+functions[add-zle-hook-widget] )); then
135-
add-zle-hook-widget zle-line-init _waveterm_si_inputempty
136-
add-zle-hook-widget zle-line-pre-redraw _waveterm_si_inputempty
137-
fi
122+
zle -N _waveterm_si_inputreadback
123+
bindkey -M emacs '^_Wr' _waveterm_si_inputreadback 2>/dev/null
124+
bindkey -M viins '^_Wr' _waveterm_si_inputreadback 2>/dev/null
125+
bindkey -M vicmd '^_Wr' _waveterm_si_inputreadback 2>/dev/null
138126

139127
autoload -U add-zsh-hook
140128
add-zsh-hook precmd _waveterm_si_precmd
141129
add-zsh-hook preexec _waveterm_si_preexec
142-
add-zsh-hook chpwd _waveterm_si_osc7
130+
add-zsh-hook chpwd _waveterm_si_osc7

pkg/waveobj/objrtinfo.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ type ObjRTInfo struct {
1515
ShellIntegration bool `json:"shell:integration,omitempty"`
1616
ShellOmz bool `json:"shell:omz,omitempty"`
1717
ShellComp string `json:"shell:comp,omitempty"`
18-
ShellInputEmpty bool `json:"shell:inputempty,omitempty"`
18+
ShellInputBuffer64 *string `json:"shell:inputbuffer64,omitempty"`
19+
ShellInputCursor *int `json:"shell:inputcursor,omitempty"`
1920
ShellLastCmd string `json:"shell:lastcmd,omitempty"`
2021
ShellLastCmdExitCode int `json:"shell:lastcmdexitcode,omitempty"`
2122

pkg/wstore/wstore_rtinfo.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ func setFieldValue(fieldValue reflect.Value, value any) {
2222
return
2323
}
2424

25+
if fieldValue.Kind() == reflect.Pointer {
26+
ptrValue := reflect.New(fieldValue.Type().Elem())
27+
setFieldValue(ptrValue.Elem(), value)
28+
fieldValue.Set(ptrValue)
29+
return
30+
}
31+
2532
if valueStr, ok := value.(string); ok && fieldValue.Kind() == reflect.String {
2633
fieldValue.SetString(valueStr)
2734
return

0 commit comments

Comments
 (0)