diff --git a/README.md b/README.md index 754cb27..eb62bff 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ no SSH client and no external terminal needed. - **Shell picker** — run zsh, bash, fish, PowerShell… whatever the machine actually has - **Mobile key bar** — Esc, Tab, Ctrl, Alt, `^C`/`^D`/`^Z`, arrows, Home/End/PgUp/PgDn and the punctuation soft keyboards hide. The Ctrl and Alt keys apply to the next character you type, - so `Ctrl+` works from a phone. + so `Ctrl+` works from a phone; double-tap one to lock it for repeated chords (such as + a double `Ctrl+C`) until it is tapped again. - **Undo close** — closing a tab is recoverable for 8 seconds before the shell is killed - **Offline-ready** — xterm.js is bundled into the plugin, not fetched from a CDN at runtime - **Accessible** — keyboard-operable tabs and settings, ARIA labels, optional screen-reader mode, diff --git a/src/index.ts b/src/index.ts index c80de11..8c6a49b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,8 +117,8 @@ const MOBILE_KEYS: KeyDef[] = [ { label: IC.paste, svg: true, action: 'paste', title: 'Paste' }, { label: 'ESC', seq: '\x1b' }, { label: 'TAB', seq: '\t' }, - { label: 'CTRL', action: 'ctrl', title: 'Ctrl — applies to the next key you type' }, - { label: 'ALT', action: 'alt', title: 'Alt — applies to the next key you type' }, + { label: 'CTRL', action: 'ctrl', title: 'Ctrl — applies to the next key you type; double-tap to lock' }, + { label: 'ALT', action: 'alt', title: 'Alt — applies to the next key you type; double-tap to lock' }, // Ctrl+C used to be impossible from a phone: the bar had a CTRL toggle but // no letters for it to combine with. { label: '^C', seq: '\x03', title: 'Ctrl+C (interrupt)' }, @@ -627,7 +627,9 @@ export async function mount(container: HTMLElement, api: PluginAPI): Promise { const session = activeSession(); if (!session) return; - if (key.action === 'ctrl') session.pendingCtrl = !session.pendingCtrl; - else session.pendingAlt = !session.pendingAlt; + session.tapModifier(modifier); syncModifierButtons(); session.focus(); }); diff --git a/src/session.ts b/src/session.ts index 89b236b..0c51b54 100644 --- a/src/session.ts +++ b/src/session.ts @@ -25,6 +25,8 @@ export type SessionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disco const CONNECT_TIMEOUT_MS = 12000; const MAX_RECONNECT_DELAY_MS = 15000; const PING_INTERVAL_MS = 25000; +/** Two taps on a key bar modifier within this window lock it. */ +const DOUBLE_TAP_MS = 500; const encoder = new TextEncoder(); @@ -78,6 +80,9 @@ export class TerminalSession { /** Set while a modifier key from the mobile bar is armed for the next press. */ pendingCtrl = false; pendingAlt = false; + /** A locked modifier stays armed after each key until it is tapped again. */ + ctrlLocked = false; + altLocked = false; onModifiersChange: (() => void) | null = null; private readonly overlayEl: HTMLElement; @@ -106,6 +111,7 @@ export class TerminalSession { private pendingResume: string | null; /** Set while restart() is in flight, so the fresh shell is not reported as an expiry. */ private restarting = false; + private modifierTapAt = { ctrl: 0, alt: 0 }; private readonly onOnline = (): void => { if (this.status === 'reconnecting') this.connect(true); }; private readonly onVisible = (): void => { @@ -369,6 +375,24 @@ export class TerminalSession { } } + /** + * A tap on CTRL or ALT in the key bar. A single tap arms the modifier for + * the next key, as before; a second tap right after locks it, so a chord + * that repeats — Claude Code's double Ctrl+C to exit — can be typed at all. + * Any tap on a locked modifier releases it. + */ + tapModifier(which: 'ctrl' | 'alt'): void { + const now = performance.now(); + const armed = which === 'ctrl' ? this.pendingCtrl : this.pendingAlt; + const locked = which === 'ctrl' ? this.ctrlLocked : this.altLocked; + const doubleTap = armed && !locked && now - this.modifierTapAt[which] < DOUBLE_TAP_MS; + const nextLocked = doubleTap; + const nextArmed = doubleTap || (!armed && !locked); + this.modifierTapAt[which] = nextArmed && !nextLocked ? now : 0; + if (which === 'ctrl') { this.pendingCtrl = nextArmed; this.ctrlLocked = nextLocked; } + else { this.pendingAlt = nextArmed; this.altLocked = nextLocked; } + } + private handleInput(data: string): void { // A modifier armed from the on-screen key bar applies to the next // character the soft keyboard produces, which is the only way to type @@ -381,8 +405,8 @@ export class TerminalSession { else if (code >= 64 && code <= 95) out = String.fromCharCode(code - 64); } if (this.pendingAlt) out = `\x1b${out}`; - this.pendingCtrl = false; - this.pendingAlt = false; + if (!this.ctrlLocked) this.pendingCtrl = false; + if (!this.altLocked) this.pendingAlt = false; this.onModifiersChange?.(); this.sendBytes(encoder.encode(out)); return; diff --git a/src/ui/styles.ts b/src/ui/styles.ts index 9e9c883..62a15f5 100644 --- a/src/ui/styles.ts +++ b/src/ui/styles.ts @@ -171,6 +171,7 @@ const PLUGIN_CSS = ` user-select:none; -webkit-tap-highlight-color:transparent; } .wt-key:active, .wt-key.wt-active { background:var(--accent); color:#fff; border-color:var(--accent); } +.wt-key.wt-locked { box-shadow:inset 0 0 0 2px rgba(255,255,255,.85); } .wt-key, .wt-btn, .wt-tab, .wt-tab-close, .wt-new-tab { touch-action:manipulation; } .wt-key svg { width:16px; height:16px; }