mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge pull request #726: fix(terminal): don't double a desktop dead key, and bracket Ctrl+V after replay
This commit is contained in:
commit
5add56eaf6
10 files changed
+318
-26
No files matched your search
+2
-2
@@ -20,7 +20,7 @@
|
||||
|
||||
**OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`).
|
||||
|
||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||
|
||||
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## Frontend modules and UI subsystems
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module (`create()` returns null unless `matchMedia('(pointer: coarse)')` matches, so a desktop dead key is not forwarded twice, #716): the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](../../docs/architecture-invariants.md#entrance-animations)
|
||||
|
||||
|
||||
@@ -193,7 +193,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||
// `target` names the terminal the Ctrl+V came from and its session; both
|
||||
// default to the primary pane. A second terminal (the split pane) passes its
|
||||
// own, so text pastes into THAT xterm and images upload to THAT session.
|
||||
// own. Images upload to THAT session. Plain text goes to that pane's session
|
||||
// as a Codeman-built bracketed-paste frame: the terminal is only refocused
|
||||
// and receives input(framed). onData delivers the frame; it is not written
|
||||
// into the xterm buffer.
|
||||
_handleImagePaste(target = {}) {
|
||||
const self = this;
|
||||
const terminal = target.terminal || this.terminal;
|
||||
@@ -244,14 +247,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
||||
} else {
|
||||
// No image -- route text through xterm's paste() so bracketed-paste
|
||||
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
||||
// has enabled bracketed-paste mode (Claude Code does). Sending text
|
||||
// via raw sendInput() strips those markers and makes pasted input
|
||||
// indistinguishable from typed input, weakening the CLI's
|
||||
// prompt-injection defenses.
|
||||
if (text && terminal) terminal.paste(text);
|
||||
} else if (text && terminal) {
|
||||
// No image. xterm's paste() emits CSI 200~/201~ only while its DECSET
|
||||
// 2004 mirror is on, and a terminal replay clears that mirror, so the
|
||||
// markers never arrive and a copied newline submits (#716). Build the
|
||||
// frame here and hand it to this terminal through input(). That fires
|
||||
// onData as user input (scroll to the bottom, clear the selection).
|
||||
// The main pane's bracketed-paste branch and a tile's _onTerminalData
|
||||
// deliver it, including the local-echo flush (#219). Do not also write
|
||||
// the frame from here.
|
||||
const body = text.replace(/\x1b\[20[01]~/g, '').replace(/\r?\n/g, '\r');
|
||||
terminal.input(`\x1b[200~${body}\x1b[201~`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
* that guard, so xterm returns without emitting and the committed character is
|
||||
* silently dropped.
|
||||
*
|
||||
* ⚠ Desktop fine pointers must not run this (#716). xterm already forwards the
|
||||
* composed character, and a second forward doubles a dead-key backtick.
|
||||
* `create()` returns null unless `matchMedia('(pointer: coarse)')` matches, so
|
||||
* soft keyboards keep the recovery and a desktop does not attach it. A missing
|
||||
* or throwing `matchMedia` stands down: a missed recovery is acceptable, a
|
||||
* duplicated keystroke is not.
|
||||
*
|
||||
* ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a
|
||||
* controller that looks useful while doing nothing. For a keydown that really
|
||||
* does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper
|
||||
@@ -64,7 +71,20 @@
|
||||
return { deleted: a.length - prefix, inserted: b.slice(prefix).join('') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch / coarse pointer only. See the file header: on a fine pointer this
|
||||
* forward runs on top of xterm's own composition handling.
|
||||
*/
|
||||
function isCoarsePointer() {
|
||||
try {
|
||||
return global.matchMedia?.('(pointer: coarse)')?.matches === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
if (!isCoarsePointer()) return null;
|
||||
const textarea = options?.textarea;
|
||||
const emitRecovered = options?.emitRecovered;
|
||||
if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') {
|
||||
|
||||
@@ -344,9 +344,10 @@
|
||||
if (global.app?.tileShortcutFor?.(ev)) return false;
|
||||
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
|
||||
// a pasted image uploads to this pane's session and its path is typed
|
||||
// here, and pasted text goes into this xterm with its bracketed-paste
|
||||
// markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it
|
||||
// xterm's default only ever pasted text.
|
||||
// here. Plain text goes to this pane's session as a Codeman-built
|
||||
// bracketed-paste frame; this terminal is only refocused and receives
|
||||
// input(framed), which onData delivers. Mirrors terminal-ui.js's own
|
||||
// Ctrl+V gate; without it xterm's default only ever pasted text.
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
|
||||
global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId });
|
||||
return false;
|
||||
@@ -646,9 +647,10 @@
|
||||
// Recovered text goes straight to _onTerminalData(), never through xterm's
|
||||
// onData, so it is not counted as xterm's own (see connect()'s onData).
|
||||
// Created after terminal.open(): xterm's capture `input` listener on the
|
||||
// textarea is registered there, and must run before the controller's. No
|
||||
// device or mode gate, as in the primary pane: with a hardware keyboard it
|
||||
// costs one assignment per keydown. A failure leaves xterm's own handling.
|
||||
// textarea is registered there, and must run before the controller's.
|
||||
// create() returns null on a fine pointer (#716): the recovery is for soft
|
||||
// keyboards, and on a desktop it delivers a dead-key character twice.
|
||||
// A failure leaves xterm's own handling.
|
||||
_createKeyCode229Recovery() {
|
||||
this._destroyKeyCode229Recovery();
|
||||
if (!this.terminal) return;
|
||||
|
||||
@@ -2108,6 +2108,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Twin: TerminalTile (terminal-tile.js _createKeyCode229Recovery and its
|
||||
// connect() key handler and onData) wires its own controller the same way
|
||||
// for every grid tile and the split's Pane B; keep the two in step.
|
||||
// create() returns null unless the primary pointer is coarse (#716).
|
||||
try {
|
||||
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
||||
textarea: this.terminal.textarea,
|
||||
|
||||
Reference in new issue
Block a user