diff --git a/package-lock.json b/package-lock.json index 803f349..51755f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "afkode", - "version": "0.7.4", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "afkode", - "version": "0.7.4", + "version": "0.8.2", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", @@ -602,9 +602,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -622,9 +619,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -642,9 +636,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -662,9 +653,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -682,9 +670,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -702,9 +687,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -722,9 +704,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -742,9 +721,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -762,9 +738,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -788,9 +761,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -814,9 +784,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -840,9 +807,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -866,9 +830,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -892,9 +853,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -918,9 +876,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -944,9 +899,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1151,9 +1103,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1168,9 +1117,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1185,9 +1131,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1202,9 +1145,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1219,9 +1159,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1236,9 +1173,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1253,9 +1187,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1270,9 +1201,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1287,9 +1215,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1304,9 +1229,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1321,9 +1243,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1338,9 +1257,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1355,9 +1271,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1547,9 +1460,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1567,9 +1477,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1587,9 +1494,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1607,9 +1511,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1627,9 +1528,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index e85e512..ac7f054 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "afkode", "private": true, - "version": "0.8.1", + "version": "0.8.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9e01788..0386ae1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "afkode" -version = "0.8.1" +version = "0.8.2" dependencies = [ "base64 0.22.1", "core-foundation", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 17e4493..a9843f8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "afkode" -version = "0.8.1" +version = "0.8.2" description = "In-game overlay to supervise AI coding agents while you play" authors = ["Omar Hernandez"] license = "MIT" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9da5825..1f2f67a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -813,6 +813,22 @@ async fn spawn_pty( std::thread::spawn(move || { let mut chunk = [0u8; 32768]; let mut pending: Vec = Vec::new(); + // A reused id (webview reload) leaves this thread's session + // overwritten in the map by a newer one with a different `gen`. + // Without this check the orphan keeps emitting `pty-output` under + // the shared id, and the frontend writes it into whichever tab + // currently owns that id — duplicated/interleaved lines while the + // user types in the new session. + let is_current = || { + app.try_state::().is_some_and(|state| { + state + .sessions + .lock() + .unwrap() + .get(&id) + .is_some_and(|s| s.gen == gen) + }) + }; loop { match reader.read(&mut chunk) { Ok(0) | Err(_) => break, @@ -820,6 +836,9 @@ async fn spawn_pty( pending.extend_from_slice(&chunk[..n]); let (split, clean) = valid_utf8_split(&pending); if !clean { + if !is_current() { + break; + } let text = String::from_utf8_lossy(&pending).into_owned(); let _ = app.emit( "pty-output", @@ -830,6 +849,9 @@ async fn spawn_pty( ); pending.clear(); } else if split > 0 { + if !is_current() { + break; + } let text = std::str::from_utf8(&pending[..split]).unwrap().to_owned(); let _ = app.emit( "pty-output", @@ -843,7 +865,7 @@ async fn spawn_pty( } } } - if !pending.is_empty() { + if !pending.is_empty() && is_current() { let text = String::from_utf8_lossy(&pending).into_owned(); let _ = app.emit( "pty-output", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 54e90b4..80d338b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "AFKode", - "version": "0.8.1", + "version": "0.8.2", "identifier": "app.afkode.overlay", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/main.ts b/src/main.ts index 1a9d124..48a7350 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { CommandBlocks } from "./blocks"; +import { WebKitDeadKeyAddon } from "./xtermDeadKeyAddon"; // ── Themes ──────────────────────────────────────────────── @@ -887,6 +888,9 @@ async function newSession(cmd: string, baseTitle: string, cwd: string | null) { const themeDef = THEMES[settings.theme]; const term = new Terminal({ + // Needed to reach `term.textarea` below (autocorrect fix) — xterm.js + // gates that accessor behind the proposed-API flag at runtime. + allowProposedApi: true, allowTransparency: true, fontFamily: `"${settings.font}", Consolas, Menlo, monospace`, fontSize: settings.size, @@ -940,6 +944,25 @@ async function newSession(cmd: string, baseTitle: string, cwd: string | null) { (text) => clipWrite(text).catch(() => {}), ); term.open(pane); + // xterm.js's hidden input textarea turns off spellcheck/autocapitalize but + // not `autocorrect` — a WebKit-only attribute. On macOS that leaves Safari's + // predictive-text corrector live on the proxy input, which can silently + // re-insert/duplicate the word or phrase just typed a few words in. Windows/ + // Linux ignore the attribute, so this is a no-op there. + term.textarea?.setAttribute("autocorrect", "off"); + // Separate WebKit feature from autocorrect (Safari/WebKit 18+, macOS + // Sequoia): inline predictive "writing suggestions". Confirmed via a + // debug write_pty log that this is the actual duplicate-lines cause — + // the whole line typed so far gets silently re-emitted as one onData + // chunk mid-typing, which the shell echoes as a duplicate. `autocorrect` + // alone doesn't cover it; only WebKit honors this attribute. + term.textarea?.setAttribute("writingsuggestions", "false"); + // Works around https://github.com/xtermjs/xterm.js/issues/5894 — on macOS + // WKWebView, dead-key layouts (Spanish included) can duplicate the dead + // char and drop the following key. See xtermDeadKeyAddon.ts for the full + // writeup. `handle()` is wired into attachCustomKeyEventHandler below. + const deadKey = new WebKitDeadKeyAddon((data) => invoke("write_pty", { id, data }).catch(() => {})); + term.loadAddon(deadKey); try { term.loadAddon(new WebglAddon()); } catch { @@ -1003,6 +1026,7 @@ async function newSession(cmd: string, baseTitle: string, cwd: string | null) { }; term.attachCustomKeyEventHandler((ev) => { + if (deadKey.handle(ev)) return false; if (ev.type !== "keydown") return true; // App-level chords ride Ctrl on Windows/Linux but Cmd on macOS, where // Ctrl+F/K/V are readline editing keys the shell must keep receiving. diff --git a/src/xtermDeadKeyAddon.ts b/src/xtermDeadKeyAddon.ts new file mode 100644 index 0000000..02c4276 --- /dev/null +++ b/src/xtermDeadKeyAddon.ts @@ -0,0 +1,109 @@ +import type { ITerminalAddon, Terminal } from "@xterm/xterm"; + +/** + * Works around a WKWebView-only xterm.js bug (macOS Tauri, never Chromium): + * https://github.com/xtermjs/xterm.js/issues/5894 + * + * On a dead-key layout (Spanish, Portuguese, US-International, ABNT2, …), + * typing a dead key followed by a non-combining char — e.g. the acute + * accent key then `l` — reaches WebKit as: + * 1. compositionend(data="´") → xterm's own CompositionHelper emits "´". + * Correct. + * 2. A synthetic keypress for the *next* physical key, but with + * `charCode` set to the dead char instead of the key actually + * pressed. xterm's `_keyPress` reads `charCode` first and emits "´" + * again — a duplicate. + * 3. A keydown for that same key reporting `event.key === "´l"` + * (length 2). xterm's keyboard service requires `key.length === 1`, + * so it's dropped, and the "l" is lost entirely. + * Net effect: the dead-key char doubles and the next key vanishes — + * visible as random duplicated/garbled text while typing, worst on + * Spanish keyboards since almost every vowel can be preceded by ´. + * Chromium hosts (Electron) never see this shape of event, so it doesn't + * reproduce there. + * + * Detection has to key off an actual `Dead`/`AltGraph` keydown during the + * composition, not the committed character — several layouts type + * `~`/`^`/`` ` `` directly without ever entering a dead-key state, so + * pattern-matching the committed char is unsound (see jerch's comment on + * the issue above). + * + * Remove this addon once xterm.js fixes the upstream issue natively. + */ +export class WebKitDeadKeyAddon implements ITerminalAddon { + private deadKeyDownSeen = false; + private commit: string | null = null; + private wasDead = false; + private textarea: HTMLTextAreaElement | null = null; + private readonly emit: (data: string) => void; + + constructor(emit: (data: string) => void) { + this.emit = emit; + } + + activate(term: Terminal): void { + this.textarea = term.textarea ?? null; + this.textarea?.addEventListener("keydown", this.onKeyDown, true); + this.textarea?.addEventListener("compositionstart", this.onCompositionStart, true); + this.textarea?.addEventListener("compositionend", this.onCompositionEnd, true); + } + + dispose(): void { + this.textarea?.removeEventListener("keydown", this.onKeyDown, true); + this.textarea?.removeEventListener("compositionstart", this.onCompositionStart, true); + this.textarea?.removeEventListener("compositionend", this.onCompositionEnd, true); + this.textarea = null; + this.deadKeyDownSeen = false; + this.commit = null; + this.wasDead = false; + } + + /** Returns true if the event was handled — caller must suppress xterm's own handling. */ + handle(e: KeyboardEvent): boolean { + if ( + e.type === "keypress" && + this.wasDead && + this.commit !== null && + e.charCode === this.commit.charCodeAt(0) + ) { + this.commit = null; + this.wasDead = false; + return true; + } + if ( + e.type === "keydown" && + this.wasDead && + this.commit !== null && + e.key.length === 2 && + e.key[0] === this.commit + ) { + this.emit(e.key.slice(1)); + return true; + } + return false; + } + + private onKeyDown = (e: Event): void => { + // Recorded in its own listener (not in handle()) so a dead-key keydown + // is never missed regardless of when/whether attachCustomKeyEventHandler + // calls into this addon. On WebKit it fires after compositionstart but + // before compositionend, so the flag is ready in time. + const ke = e as KeyboardEvent; + if (ke.key === "Dead" || ke.key === "AltGraph") { + this.deadKeyDownSeen = true; + } + }; + + private onCompositionStart = (): void => { + this.commit = null; + this.wasDead = false; + this.deadKeyDownSeen = false; + }; + + private onCompositionEnd = (e: Event): void => { + const data = (e as CompositionEvent).data; + this.commit = data || null; + this.wasDead = this.deadKeyDownSeen; + this.deadKeyDownSeen = false; + }; +}