fix(tui): deliver buffered double-Esc as two events and re-arm loader after task completion

StdinBuffer held a bare `\x1b\x1b` chunk and timer-flushed it as one
sequence. `parseKey("\x1b\x1b")` returns undefined, so CustomEditor
fell through to the base editor and never fired the configured `onEscape` —
the double-escape gesture and the second-press single-Esc handler both went
dead whenever the terminal batched the two presses into one stdin read.

Split an exact bare `\x1b\x1b` into two ESC events only after the
flush window proves no follower arrived. If a follower does arrive, emit the
first ESC and restart parsing at the second ESC so legacy Alt chords
(`\x1bd`, `\x1b\x7f`) remain one downstream keypress. Meta-CSI/SS3
chords (`\x1b\x1b[A`, `\x1b\x1bO…`) still emit as one combined
sequence.

EventController.tool_execution_update re-armed the working loader when a
transient overlay (auto-compaction / auto-retry / handoff) had torn it down
mid-tool; tool_execution_end did not. A subagent (`task`) call only fires
_end, so a task result landing after such an overlay left the UI looking
idle even though the session was still streaming. Mirror the reconciler
call in #handleToolExecutionEnd.

Fixes #3857
This commit is contained in:
roboomp
2026-06-30 03:41:37 +00:00
parent 64734021a7
commit c2d6c8cf18
5 changed files with 32 additions and 34 deletions
@@ -7,15 +7,14 @@ import { StdinBuffer } from "@oh-my-pi/pi-tui/stdin-buffer";
* Regression for #3857.
*
* A fast double-Esc lands as one `"\x1b\x1b"` chunk on stdin. Before the fix,
* `StdinBuffer` either held it as the buffered remainder and timer-flushed it
* as one sequence, or emitted it as one sequence when followed by a non-CSI
* byte. Either way `parseKey("\x1b\x1b")` returns `undefined`, so
* `StdinBuffer` held it as the buffered remainder, then timer-flushed it as
* one sequence. `parseKey("\x1b\x1b")` returns `undefined`, so
* `CustomEditor.handleInput` fell through to the base editor and never fired
* the configured `onEscape` — breaking the double-escape gesture (and any
* single-Esc handler the second press should have hit).
* the configured `onEscape` — breaking the double-escape gesture.
*
* The fix splits a bare `"\x1b\x1b"` into two ESC events at the buffer layer,
* matching the existing split for `"\x1b" + "\x1b[<…"` SGR mouse reports.
* The fix splits a bare `"\x1b\x1b"` into two ESC events only when no follower
* arrives in the disambiguation window. If a follower arrives, the second ESC
* remains attached to that follower so legacy Alt chords survive.
*/
describe("buffered double-Esc reaches CustomEditor.onEscape", () => {
beforeAll(async () => {
@@ -47,27 +46,20 @@ describe("buffered double-Esc reaches CustomEditor.onEscape", () => {
buf.destroy();
});
it("fires onEscape twice when a double-Esc arrives as one inline chunk followed by a non-CSI byte", () => {
it("preserves a legacy Alt chord batched after a bare ESC", () => {
const editor = new CustomEditor(getEditorTheme());
const onEscape = vi.fn();
editor.onEscape = onEscape;
const forwardedToBase: string[] = [];
const baseHandleInput = vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(editor)), "handleInput");
baseHandleInput.mockImplementation(function (this: unknown, data: unknown) {
forwardedToBase.push(data as string);
});
editor.setText("foo bar");
const buf = new StdinBuffer({ timeout: 5, partialHoldTimeout: 5 });
buf.on("data", chunk => editor.handleInput(chunk));
buf.process("\x1b\x1bX");
buf.process("\x1b\x1b\x7f");
vi.runAllTimers();
expect(onEscape).toHaveBeenCalledTimes(2);
// The trailing printable still reaches the base editor in order, after
// both ESC keypresses have fired their handler.
expect(forwardedToBase).toEqual(["X"]);
expect(onEscape).toHaveBeenCalledTimes(1);
expect(editor.getText()).toBe("foo ");
buf.destroy();
});
@@ -124,7 +124,6 @@ const TASK_TOOL_EXECUTION_END = {
isError: false,
} as unknown as AgentSessionEvent;
describe("EventController loader recovery after overflow maintenance", () => {
beforeAll(async () => {
await initTheme(false);
+1 -1
View File
@@ -4,7 +4,7 @@
### Fixed
- Fixed `StdinBuffer` swallowing a fast double-Esc that arrived as one `"\x1b\x1b"` chunk: `parseKey` returns `undefined` for the combined chunk, so the editor's double-escape gesture and any single-Esc handler the second press should have hit never fired. The buffer now splits a bare `"\x1b\x1b"` (whether buffered alone or followed by a non-CSI/SS3 byte) into two ESC events, while meta-CSI/SS3 chords like `"\x1b\x1b[A"` still emit as one sequence ([#3857](https://github.com/can1357/oh-my-pi/issues/3857)).
- Fixed `StdinBuffer` swallowing a fast double-Esc that arrived as one `"\x1b\x1b"` chunk: `parseKey` returns `undefined` for the combined chunk, so the editor's double-escape gesture and any single-Esc handler the second press should have hit never fired. The buffer now splits a bare `"\x1b\x1b"` into two ESC events only when no follower arrives in the disambiguation window; when a follower arrives, the second ESC stays attached so legacy Alt chords like `"\x1bd"` and meta-CSI/SS3 chords like `"\x1b\x1b[A"` still emit as parseable sequences ([#3857](https://github.com/can1357/oh-my-pi/issues/3857)).
## [16.2.3] - 2026-06-28
+10 -11
View File
@@ -241,20 +241,19 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
end++;
continue;
}
// "\x1b\x1b" is one of three things, and only the first should reach the
// caller as a combined chunk:
// "\x1b\x1b" is one of three things:
// 1. ESC prefixing CSI/SS3 (meta-CSI, held Esc joined by a follower):
// next byte is "[" or "O" — keep growing so the full sequence stays
// together. Consuming two bytes here would tear the follower and
// leak its tail as typed text (settings search filling with "[B"
// or "[<35;22;17M").
// 2. Two real Esc keypresses bursted by terminal input batching, or
// legacy alt+esc — `parseKey` returns undefined for the combined
// chunk, so a single emission swallows double-escape gestures
// (#3857). Split into two ESC events so the caller observes both.
// When the buffer ends here, hold the partial for the flush window so
// the disambiguating byte (case 1) can still arrive; if it does not,
// the timeout-driven flush splits the held remainder (see `flush`).
// 2. ESC followed by a legacy Alt chord (`\x1bd`, `\x1b\x7f`, …):
// emit the first ESC, then restart at the second ESC so downstream
// parsing still sees the Alt chord as one keypress (#3860 review).
// 3. Two real Esc keypresses bursted by terminal input batching:
// when the buffer ends here, hold the partial for the flush window
// so case 1/2 can still arrive; if no follower arrives, `flush()`
// splits the held remainder into two ESC events (#3857).
if (candidate === `${ESC}${ESC}`) {
if (end >= length) {
return { sequences, remainder: buffer.slice(pos) };
@@ -264,8 +263,8 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
end++;
continue;
}
sequences.push(ESC, ESC);
pos = end;
sequences.push(ESC);
pos += 1;
consumed = true;
break;
}
+10 -2
View File
@@ -192,9 +192,17 @@ describe("StdinBuffer", () => {
expect(emittedSequences).toEqual(["\x1b", "\x1b"]);
});
it("splits double-ESC followed by a non-CSI byte into two ESC events plus the byte", () => {
it("preserves legacy Alt chords batched after a bare ESC", () => {
processInput("\x1b\x1bX");
expect(emittedSequences).toEqual(["\x1b", "\x1b", "X"]);
expect(emittedSequences).toEqual(["\x1b", "\x1bX"]);
emittedSequences = [];
processInput("\x1b\x1bd");
expect(emittedSequences).toEqual(["\x1b", "\x1bd"]);
emittedSequences = [];
processInput("\x1b\x1b\x7f");
expect(emittedSequences).toEqual(["\x1b", "\x1b\x7f"]);
});
it("consumes a whole meta-CSI arrow in one chunk", () => {