mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
fix(tiles): send a tile's click report ephemeral, review follow-up
The tile's hand-encoded click report went through _handleDesktopTerminalClick and _sendSyntheticSgrTap to _sendInputAsync, so it took a seq, was persisted and would be redelivered after a reload. The documented TerminalTile rule (CLAUDE.md, Split-pane sessions) is that only typed input enters that queue and focus/mouse reports go out ephemeral, and the tile's own _onTerminalData says the same. Before #555 an opencode tile's click went through xterm's encoder and that ephemeral path. A click still unacknowledged when the page reloads, or sent during a server restart, could be replayed onto a later screen, where a press+release can pick a dialog option. _sendSyntheticSgrTap now takes an opt-in `ephemeral` field on its target and sends through _sendInputEphemeral when it is set; _handleDesktopTerminalClick passes the target through unchanged, and TerminalTile._installClickListener sets it. Without the flag nothing changes, so the primary pane's own click and touch tap reports stay on _sendInputAsync exactly as before (whether the primary pane should also go ephemeral is a separate question, out of scope here). Tests: the tile case now requires a frame with no seq and nothing pending in the reliable queue, and the targeted-click case in terminal-touch-tap spies on both send paths: a target with the flag goes ephemeral, an untargeted click and an untargeted tap stay durable. Dropping `ephemeral: true` from the tile, or the branch in _sendSyntheticSgrTap, turns the matching test red. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -28,7 +28,8 @@
|
|||||||
* wheel-down from those overflow rows is xterm's, and brings it home).
|
* wheel-down from those overflow rows is xterm's, and brings it home).
|
||||||
* - The desktop click report: a plain left-click hand-encoded as SGR while
|
* - The desktop click report: a plain left-click hand-encoded as SGR while
|
||||||
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
|
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
|
||||||
* whose mouse DECSETs the server strips (_installClickListener).
|
* whose mouse DECSETs the server strips (_installClickListener), sent
|
||||||
|
* ephemeral, like every mouse report from this pane (_onTerminalData).
|
||||||
* - The soft-keyboard controller (terminal-keycode229-recovery.js), one per
|
* - The soft-keyboard controller (terminal-keycode229-recovery.js), one per
|
||||||
* pane, on this pane's own textarea and composition helper and sending to
|
* pane, on this pane's own textarea and composition helper and sending to
|
||||||
* this pane's session (_createKeyCode229Recovery): it forwards an
|
* this pane's session (_createKeyCode229Recovery): it forwards an
|
||||||
@@ -860,6 +861,9 @@
|
|||||||
terminal: this.terminal,
|
terminal: this.terminal,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
linkHovered: this._linkHovered,
|
linkHovered: this._linkHovered,
|
||||||
|
// Like every mouse report from this pane (_onTerminalData): once,
|
||||||
|
// never persisted, so a reload cannot replay it onto a later screen.
|
||||||
|
ephemeral: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
this.mountEl.addEventListener('click', this._onClick);
|
this.mountEl.addEventListener('click', this._onClick);
|
||||||
|
|||||||
@@ -5465,8 +5465,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
// Encode a tap as an SGR mouse report (press + release at button 0) and send it
|
// Encode a tap as an SGR mouse report (press + release at button 0) and send it
|
||||||
// to the PTY directly, bypassing xterm's mouse encoder. `target` ({ terminal,
|
// to the PTY directly, bypassing xterm's mouse encoder. `target` ({ terminal,
|
||||||
// sessionId }) aims it at a TerminalTile instead of the primary pane; either
|
// sessionId, ephemeral }) aims it at a TerminalTile instead of the primary
|
||||||
// field left out means the primary pane's.
|
// pane; either of the first two left out means the primary pane's.
|
||||||
|
// `ephemeral: true` sends it through _sendInputEphemeral instead of the
|
||||||
|
// persisted exactly-once queue: a TerminalTile's mouse reports never enter
|
||||||
|
// that queue (CLAUDE.md, Split-pane sessions), or a reload would replay one
|
||||||
|
// onto a later screen. Left out, the report stays on _sendInputAsync, as the
|
||||||
|
// primary pane always sent it.
|
||||||
_sendSyntheticSgrTap(clientX, clientY, target = {}) {
|
_sendSyntheticSgrTap(clientX, clientY, target = {}) {
|
||||||
const sessionId = target.sessionId || this.activeSessionId;
|
const sessionId = target.sessionId || this.activeSessionId;
|
||||||
const terminal = target.terminal || this.terminal;
|
const terminal = target.terminal || this.terminal;
|
||||||
@@ -5474,7 +5479,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!this._terminalViewportAtBottom(terminal)) return; // scrollback click → misfire, do nothing
|
if (!this._terminalViewportAtBottom(terminal)) return; // scrollback click → misfire, do nothing
|
||||||
const pos = this._clientPointToCell(clientX, clientY, terminal);
|
const pos = this._clientPointToCell(clientX, clientY, terminal);
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
this._sendInputAsync(sessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`);
|
const report = `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`;
|
||||||
|
if (target.ephemeral) this._sendInputEphemeral(sessionId, report);
|
||||||
|
else this._sendInputAsync(sessionId, report);
|
||||||
},
|
},
|
||||||
|
|
||||||
// True when a parsed CLI version string ('2.1.187' — banner-parsed on the
|
// True when a parsed CLI version string ('2.1.187' — banner-parsed on the
|
||||||
@@ -5758,10 +5765,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// clicks outside the cell grid, and sessions where xterm's own encoder is
|
// clicks outside the cell grid, and sessions where xterm's own encoder is
|
||||||
// live (it reported the click itself — a second report would double-move).
|
// live (it reported the click itself — a second report would double-move).
|
||||||
//
|
//
|
||||||
// `target` ({ terminal, sessionId, linkHovered }) runs the same skips for a
|
// `target` ({ terminal, sessionId, linkHovered, ephemeral }) runs the same
|
||||||
// TerminalTile's click: its own terminal, its own session's tracking flag and
|
// skips for a TerminalTile's click: its own terminal, its own session's
|
||||||
// its own link hover (the primary pane's _linkHovered belongs to its terminal
|
// tracking flag and its own link hover (the primary pane's _linkHovered
|
||||||
// alone). Every field left out means the primary pane's.
|
// belongs to its terminal alone). `ephemeral` reaches _sendSyntheticSgrTap,
|
||||||
|
// so a TerminalTile's mouse report never enters the persisted input queue.
|
||||||
|
// Every field left out means the primary pane's.
|
||||||
_handleDesktopTerminalClick(ev, target = {}) {
|
_handleDesktopTerminalClick(ev, target = {}) {
|
||||||
const terminal = target.terminal || this.terminal;
|
const terminal = target.terminal || this.terminal;
|
||||||
if (!terminal || !ev?.isTrusted) return;
|
if (!terminal || !ev?.isTrusted) return;
|
||||||
|
|||||||
@@ -514,13 +514,16 @@ describe('a tile reports a plain click to a CLI with mouse tracking on', () => {
|
|||||||
});
|
});
|
||||||
const TAP = '\x1b[<0;21;6M\x1b[<0;21;6m';
|
const TAP = '\x1b[<0;21;6M\x1b[<0;21;6m';
|
||||||
|
|
||||||
it("sends one seq-tagged SGR press+release on the tile's socket, from the tile's own geometry", async () => {
|
it("sends one ephemeral SGR press+release on the tile's socket, from the tile's own geometry", async () => {
|
||||||
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode', cliMouseTracking: true } });
|
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode', cliMouseTracking: true } });
|
||||||
const { ws, mount } = await connectTile(app);
|
const { ws, mount } = await connectTile(app);
|
||||||
|
|
||||||
mount.fire('click', click());
|
mount.fire('click', click());
|
||||||
|
|
||||||
expect(ws.inputFrames().map((f) => [f.d, typeof f.seq])).toEqual([[TAP, 'number']]);
|
// No seq: like every mouse report from a tile, it never enters the
|
||||||
|
// persisted exactly-once queue, so a reload cannot replay it.
|
||||||
|
expect(ws.inputFrames().map((f) => [f.d, typeof f.seq])).toEqual([[TAP, 'undefined']]);
|
||||||
|
expect((app._pendingDeliveries as Map<string, unknown[]>).get('s-tile')?.length ?? 0).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends nothing without the flag, over a selection, over its own hovered link, or scrolled up', async () => {
|
it('sends nothing without the flag, over a selection, over its own hovered link, or scrolled up', async () => {
|
||||||
|
|||||||
@@ -583,12 +583,16 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
// primary pane's terminal and active session are not consulted.
|
// primary pane's terminal and active session are not consulted.
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
const sent: Array<{ id: string; data: string }> = [];
|
const sent: Array<{ id: string; data: string }> = [];
|
||||||
|
// A tile's report must stay out of the persisted queue: it asks for the
|
||||||
|
// ephemeral path, and only a target that says so gets it.
|
||||||
|
const durable: Array<{ id: string; data: string }> = [];
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([
|
app.sessions = new Map([
|
||||||
['sess-1', { mode: 'claude', cliMouseTracking: false }],
|
['sess-1', { mode: 'claude', cliMouseTracking: false }],
|
||||||
['s2', { mode: 'opencode', cliMouseTracking: true }],
|
['s2', { mode: 'opencode', cliMouseTracking: true }],
|
||||||
]);
|
]);
|
||||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
|
||||||
|
app._sendInputAsync = (id: string, data: string) => durable.push({ id, data });
|
||||||
app._linkHovered = true; // the PRIMARY pane's hover: must not block the tile
|
app._linkHovered = true; // the PRIMARY pane's hover: must not block the tile
|
||||||
app.terminal = {
|
app.terminal = {
|
||||||
cols: 80,
|
cols: 80,
|
||||||
@@ -618,14 +622,16 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||||
};
|
};
|
||||||
|
|
||||||
app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: false });
|
const tile = { terminal: other, sessionId: 's2', linkHovered: false, ephemeral: true };
|
||||||
|
app._handleDesktopTerminalClick(click, tile);
|
||||||
expect(sent).toEqual([{ id: 's2', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]);
|
expect(sent).toEqual([{ id: 's2', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]);
|
||||||
|
expect(durable).toEqual([]);
|
||||||
|
|
||||||
// The tile's own selection and its own link hover do block it.
|
// The tile's own selection and its own link hover do block it.
|
||||||
otherSelected = true;
|
otherSelected = true;
|
||||||
app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: false });
|
app._handleDesktopTerminalClick(click, tile);
|
||||||
otherSelected = false;
|
otherSelected = false;
|
||||||
app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: true });
|
app._handleDesktopTerminalClick(click, { ...tile, linkHovered: true });
|
||||||
expect(sent).toHaveLength(1);
|
expect(sent).toHaveLength(1);
|
||||||
|
|
||||||
// With no target the primary pane answers for itself, exactly as before.
|
// With no target the primary pane answers for itself, exactly as before.
|
||||||
@@ -633,6 +639,19 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
expect(app._shouldReportMouseToCli('s2')).toBe(true);
|
expect(app._shouldReportMouseToCli('s2')).toBe(true);
|
||||||
app._handleDesktopTerminalClick(click);
|
app._handleDesktopTerminalClick(click);
|
||||||
expect(sent).toHaveLength(1);
|
expect(sent).toHaveLength(1);
|
||||||
|
|
||||||
|
// And the primary pane's own reports stay on the durable queue: an
|
||||||
|
// untargeted click and an untargeted touch tap.
|
||||||
|
app.sessions.set('sess-1', { mode: 'claude', cliMouseTracking: true });
|
||||||
|
app.terminal = { ...app.terminal, hasSelection: () => false, buffer: { active: { viewportY: 50, baseY: 50 } } };
|
||||||
|
app._linkHovered = false;
|
||||||
|
app._handleDesktopTerminalClick(click);
|
||||||
|
app._sendSyntheticSgrTap(50, 50);
|
||||||
|
expect(durable).toEqual([
|
||||||
|
{ id: 'sess-1', data: '\x1b[<0;22;7M\x1b[<0;22;7m' }, // the primary's own origin (0, 0)
|
||||||
|
{ id: 'sess-1', data: '\x1b[<0;7;4M\x1b[<0;7;4m' },
|
||||||
|
]);
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tap: a target uses that pane's geometry and scroll position", () => {
|
it("tap: a target uses that pane's geometry and scroll position", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user