/** * @fileoverview Terminal setup (xterm.js config, input, resize, link provider), rendering pipeline * (batch writes, flicker filter, chunked writes, local echo), terminal controls (clear, font, resize), * and directory input. * * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp class, this.terminal, this.fitAddon, this.sessions) * @dependency constants.js (DEC_SYNC_STRIP_RE, TIMING constants) * @dependency mobile-handlers.js (MobileDetection) * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js * @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay) * @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js */ (function (global) { const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/; const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/; // Pointer and focus reports xterm emits through onData on the terminal's OWN // initiative, with no key pressed: SGR mouse (DECSET 1006, also 1016), legacy // X10 mouse (DECSET 1000 — three raw bytes after CSI M) and focus in/out // (DECSET 1004). They are not query REPLIES, so the query-response filter // above does not match them, and they must keep reaching the PTY. What they // must NOT do is stand in for a keystroke: see isTerminalFocusOrMouseReport. const MOUSE_SGR_REPORT_PATTERN = /^\x1b\[<\d+;\d+;\d+[Mm]$/; const MOUSE_X10_REPORT_PATTERN = /^\x1b\[M[\s\S]{3}$/; const FOCUS_REPORT_PATTERN = /^\x1b\[[IO]$/; // Grace window after a manual scroll-up gesture during which sticky-scroll is // suppressed, so high-frequency Codex status redraws don't snap the viewport // back to the bottom while the user is inspecting earlier output. const USER_SCROLL_STICKY_SUPPRESS_MS = 1500; // Mobile browsers synthesize trusted mouse events after touchend. During this // short window, only the app's synthetic tap-to-position mouse event should // reach xterm. const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450; // Escape sequences occupy no terminal cells, so they must come out before a // captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC, // CSI, charset designators and the short escapes tmux emits; deliberately // approximate — this feeds a size comparison, not a renderer. // eslint-disable-next-line no-control-regex const REPLAY_ESCAPE_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g; // PageUp / PageDown as xterm.js encodes them. Used as the LAST-RESORT scroll // gesture for a repaint-mode CLI whose local buffer holds no scrollback // (_maybePageCliTranscript). const KEY_PAGE_UP = '\x1b[5~'; const KEY_PAGE_DOWN = '\x1b[6~'; // Wheel/touch travel (in lines) that adds up to one PageUp/PageDown. Half a // screen rather than a full one: the page key always jumps a whole screen, so // a 1:1 mapping made the fallback feel unreachably slow with a discrete mouse // wheel (Firefox reports 3 lines a notch → 12 notches per page). Overshooting // the finger is the right trade against a gesture that otherwise does nothing. const PAGE_KEY_SCREEN_FRACTION = 0.5; // Bound on page keys emitted from one gesture batch, mirroring the SGR tick // cap: a fling must not build a backlog that keeps paging after it stops. const PAGE_KEY_MAX_PER_BATCH = 3; // Composer navigation keys as xterm.js encodes user keystrokes: plain and // modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3 // H/F, CSI 1~/4~), Insert/Delete/PgUp/PgDn (CSI 2~/3~/5~/6~, optional // modifier). Deliberately EXCLUDES terminal query responses that also // arrive via onData (DA `\x1b[?1;2c`, CPR `\x1b[12;34R`) and function // keys, so only genuine cursor/editing keys trigger the local-echo flush. // eslint-disable-next-line no-control-regex const COMPOSER_NAV_KEY_PATTERN = /^\x1b(?:\[(?:[ABCDHF]|1;[2-8][ABCDHF]|[1-8](?:;[2-8])?~)|O[ABCDHF])$/; // Prefix xterm.js puts on terminal.paste() payloads while the application // has bracketed-paste mode (DECSET 2004) enabled. Codex, Claude Code and // tmux all enable it, so browser pastes arrive as one onData chunk of // `\x1b[200~\x1b[201~`. const BRACKETED_PASTE_START = '\x1b[200~'; function isComposerNavKey(data) { return COMPOSER_NAV_KEY_PATTERN.test(data); } // Codex composer-row signature, measured against codex-cli 0.147.0 // (docs/predictive-echo-plan.md): the composer's cursor row starts with // "› " (U+203A + space) when empty (placeholder text), while typing, and // while the slash picker filters. Modal rows ("Press enter to continue") // and wrapped continuation rows (2-space indent) do NOT match — that is // the ghost eliminator: no prediction is ever painted there. const CODEX_COMPOSER_ROW_RE = /^› /; // Classify onData for the predictive echo hook. Terminal query responses // never reach this (suppressed earlier in onData); bracketed pastes, nav // keys and mouse reports all start with ESC => 'clear'. function classifyPredictInput(data) { const cps = Array.from(data); // astral-safe if (cps.length === 1) { const cp = cps[0].codePointAt(0); if (cp === 0x7f) return 'backspace'; if (cp >= 0x20) return 'char'; // incl. a single astral emoji return 'clear'; // \r \n \t \x03, bare ESC, ... } if (data.charCodeAt(0) === 0x1b) return 'clear'; // ESC seq: nav, paste, mouse SGR if (data.charCodeAt(0) >= 0x20) return 'text'; // multi-char printable (plain paste, return 'clear'; // ZWJ emoji cluster): wire only, no visual } // Predictive-echo gate: predict only while the cursor sits on the codex // composer row. cursorY is baseY-relative (xterm API), hence baseY + cursorY. function isCodexComposerRow(terminal) { try { const buf = terminal.buffer.active; const line = buf.getLine(buf.baseY + buf.cursorY); return !!line && CODEX_COMPOSER_ROW_RE.test(line.translateToString(true)); } catch { return false; } } function isTerminalQueryResponse(data) { return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data); } function shouldSuppressTerminalQueryResponse(data) { return isTerminalQueryResponse(data); } /** * Did the terminal generate this chunk itself, rather than a human pressing a * key? True for mouse and focus reports (issue #262). * * Consumers that treat one onData chunk as "the next keystroke" must skip * these. The one-shot Ctrl modifier is why this exists, and the MOUSE half is * the live one: a shell session keeps the narrow scrollback strip, so mouse * DECSETs reach the browser and anything the user runs that enables tracking * (vim, htop, less) turns a tap into `\x1b[<0;31;23M`. Measured in a real * shell session: with Ctrl armed, one tap on the terminal spent it silently. * * Focus reports are the same class and cost nothing to cover, but they cannot * reach xterm today: `FOCUS_ESCAPE_FILTER` in session.ts strips `\x1b[?1004h` * (and the reports themselves) from every PTY read, so `sendFocusMode` never * turns on. Were that filter to go, the Ctrl button would spend the modifier * on its OWN refocus — the bar refocuses the terminal after every key so the * keyboard stays open, and that refocus emits `\x1b[I`. */ function isTerminalFocusOrMouseReport(data) { return ( FOCUS_REPORT_PATTERN.test(data) || MOUSE_SGR_REPORT_PATTERN.test(data) || MOUSE_X10_REPORT_PATTERN.test(data) ); } // Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded // theme, so default behavior is unchanged. Shared at module scope and exported on the // global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals, // a separate IIFE) can read the current skin's palette. const CODEMAN_XTERM_THEMES = { og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' }, 'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, 'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, 'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selection: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' }, 'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selection: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' }, 'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selection: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' }, 'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selection: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' }, }; const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']); function currentSkin() { return (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue'; } function currentXtermTheme() { const skin = currentSkin(); return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue']; } function currentSkinIsLight(skin = currentSkin()) { return CODEMAN_LIGHT_SKINS.has(skin); } global.CodemanTerminalInput = { isTerminalQueryResponse, shouldSuppressTerminalQueryResponse, isTerminalFocusOrMouseReport, isComposerNavKey, classifyPredictInput, isCodexComposerRow, CODEX_COMPOSER_ROW_RE, BRACKETED_PASTE_START, USER_SCROLL_STICKY_SUPPRESS_MS, TOUCH_COMPAT_MOUSE_SUPPRESS_MS, REPLAY_ESCAPE_RE, KEY_PAGE_UP, KEY_PAGE_DOWN, PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; global.codemanCurrentSkinIsLight = currentSkinIsLight; })(window); Object.assign(CodemanApp.prototype, { // ═══════════════════════════════════════════════════════════════ // Terminal Setup — xterm.js config and input handling // ═══════════════════════════════════════════════════════════════ initTerminal() { // Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor // so users who picked up the previous (smaller) default get the new minimum on upgrade. const stored = parseInt(localStorage.getItem('codeman-scrollback')); const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK; this.terminal = new Terminal({ theme: { ...window.codemanCurrentXtermTheme() }, fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', // Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line) fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14, lineHeight: 1.2, cursorBlink: false, cursorStyle: 'block', minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1, scrollback: scrollback, allowTransparency: true, allowProposedApi: true, }); this.fitAddon = new FitAddon.FitAddon(); this.terminal.loadAddon(this.fitAddon); // SerializeAddon: lets us snapshot the xterm rendered state (viewport + // scrollback + colors/attrs) when switching away from a tab and restore // it on switch-back. Needed primarily for codex tabs — codex's TUI drops // earlier conversation from its current frame, so replaying the server // byte buffer on tab-switch shows only the latest (idle) frame. The // snapshot captures what the user was actually looking at. this._xtermSnapshots = new Map(); // Map if (typeof SerializeAddon !== 'undefined') { try { this._serializeAddon = new SerializeAddon.SerializeAddon(); this.terminal.loadAddon(this._serializeAddon); } catch (_e) { /* SerializeAddon failed — snapshot/restore disabled, fallback to buffer-fetch */ this._serializeAddon = null; } } if (typeof Unicode11Addon !== 'undefined') { try { const unicode11Addon = new Unicode11Addon.Unicode11Addon(); this.terminal.loadAddon(unicode11Addon); this.terminal.unicode.activeVersion = '11'; } catch (_e) { /* Unicode11 addon failed — default Unicode handling used */ } } const container = document.getElementById('terminalContainer'); this.terminal.open(container); this._installMobileTapMouseGuard(); // Suppress xterm key handling during CJK IME composition. // Without this, xterm processes raw keyDown events (e.g., "Process" key) // during composition, causing duplicate or garbled input. this.terminal.attachCustomKeyEventHandler((ev) => { if (ev.isComposing || ev.keyCode === 229) return false; // Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler // (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC into // the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so // macOS Option layouts (Option+1 -> "¡", Option+[ -> "“", Option+K -> "˚") are suppressed here too and // don't leak an escape sequence into the focused terminal on every tab switch. if ( ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '') ) { return false; } // Command palette chord (COD-153): keep it out of the PTY. The document // CAPTURE handler has already opened the palette by the time xterm sees // this keydown, but its preventDefault() does NOT stop xterm — without // this gate Ctrl+K would ALSO write 0x0b (readline kill-line) into the // live session behind the palette, truncating whatever the user had // typed. Route through the registry-aware checker so a rebound or // disabled palette shortcut restores normal terminal Ctrl+K. if (ev.type === 'keydown' && this.shouldOpenCommandPaletteFromShortcut?.(ev)) { return false; } // Smart copy (#211): with a selection, Ctrl+C copies it instead of sending // ^C. With NO selection the branch must fall through (return true, and no // preventDefault) or the interrupt key is lost, which is the whole reason // the selection check runs before any registry dispatch. Ctrl+Shift+C is // the explicit copy chord and never falls through: an "explicit copy" that // interrupts a running agent because the selection happened to be empty is // a footgun with no upside. // NOTE: returning false does NOT cancel the event (xterm's _keyDown calls // this handler before its own cancel()), so preventDefault is explicit: // without it the browser runs its native copy on top of ours. if (this.shouldCopyTerminalSelectionFromShortcut?.(ev)) { const selection = this.terminal.hasSelection?.() ? this.terminal.getSelection() : ''; if (selection) { ev.preventDefault(); void this.copyTerminalSelection(selection); return false; } if (ev.shiftKey) { ev.preventDefault(); return false; } return true; } // Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY. // Route through our paste trap which handles both images and text. if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') { if (this.activeSessionId && this._handleImagePaste) { this._handleImagePaste(); } return false; } // Shift+Enter / Ctrl+Enter: insert newline for multi-line input. // xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't // distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a) // which the inner application recognizes as "insert newline" vs carriage return. if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') { if (this.activeSessionId) { if (this._localEchoEnabled) { const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); this._localEchoOverlay?.suppressBufferDetection(); this._flushedOffsets?.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); if (text) { this._pendingInput += text; flushInput(); } setTimeout(() => { fetch(`/api/sessions/${this.activeSessionId}/send-key`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }), }); }, text ? 80 : 0); } else { fetch(`/api/sessions/${this.activeSessionId}/send-key`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }), }); } } return false; } return true; }); // Android virtual keyboard fix: catch non-composition input events. // On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard) // sends keyCode 229 + input event WITHOUT compositionstart/end wrapping. // The custom key handler above returns false for keyCode 229, telling xterm // to ignore the keydown. xterm.js expects the character to arrive via // composition events, but since there's no composition, the character is lost. // This listener catches those orphaned input events and forwards them to onData. { const xtermTextarea = container.querySelector('.xterm-helper-textarea'); if (xtermTextarea && MobileDetection.isTouchDevice()) { let composing = false; let lastKeydownHandled = 0; xtermTextarea.addEventListener('compositionstart', () => { composing = true; }); xtermTextarea.addEventListener('compositionend', () => { composing = false; }); // Track when xterm handles a keydown normally (non-229 keyCode). // If xterm processed the keydown, it will emit onData itself -- // the input event handler below must NOT re-send the character. xtermTextarea.addEventListener('keydown', (e) => { if (!e.isComposing && e.keyCode !== 229) { lastKeydownHandled = Date.now(); } }); xtermTextarea.addEventListener('input', (e) => { // Only handle insertText events outside of composition -- these are // the ones xterm.js misses on Android virtual keyboards. if (composing || e.isComposing) return; if (e.inputType !== 'insertText' || !e.data) return; // If xterm just handled a keydown (within 50ms), it already sent the // char via onData. Skip to avoid double-send (e.g., Shift+A => AA). if (Date.now() - lastKeydownHandled < 50) return; // xterm.js may have already processed this via its own input handler. // Check if the textarea was cleared by xterm (value is empty or just // whitespace) -- if so, xterm handled it and we should not double-send. // Use a microtask to check after xterm's own handlers have run. const data = e.data; const pendingBefore = this._localEchoOverlay?.pendingText || ''; Promise.resolve().then(() => { if ( this._lastTerminalData?.data === data && performance.now() - this._lastTerminalData.time < 100 ) { xtermTextarea.value = ''; return; } const pendingAfter = this._localEchoOverlay?.pendingText || ''; if ( this._localEchoEnabled && pendingAfter.length > pendingBefore.length && pendingAfter.endsWith(data) ) { xtermTextarea.value = ''; return; } // If xterm cleared the textarea, it processed the input -- skip. const val = xtermTextarea.value; if (!val || (val.trim() === '' && data !== ' ')) return; // xterm didn't process it -- forward to terminal as if typed. // Emit via onData path by writing to terminal's input handler. this.terminal._core.coreService.triggerDataEvent(data, true); // Clear the textarea to prevent xterm from processing it later. xtermTextarea.value = ''; }); }); } } // WebGL renderer for GPU-accelerated terminal rendering. // Previously caused "page unresponsive" crashes from synchronous GPU stalls, // but the mode-aware 32/64KB frame cap in flushPendingWrites() now prevents // oversized terminal.write() calls that triggered the stalls. // Disable with ?nowebgl URL param if GPU issues return. // Auto-fallback: _initWebGL installs a long-task watchdog that disables // WebGL sticky in localStorage after repeated GPU stalls (see app.js). // Force re-enable after sticky disable with ?webgl=force. // Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile). this._webglAddon = null; const _params = new URLSearchParams(location.search); const _stickyDisabled = (() => { try { const raw = localStorage.getItem('codeman-webgl-disabled'); if (!raw) return false; const { at } = JSON.parse(raw); // Auto-expire after WEBGL_FALLBACK.STICKY_EXPIRY_MS so we retry // (driver/Chrome may have been updated). if (Date.now() - at > WEBGL_FALLBACK.STICKY_EXPIRY_MS) { localStorage.removeItem('codeman-webgl-disabled'); return false; } return true; } catch { return false; } })(); // User's "WebGL Renderer" toggle (Settings > Appearance). undefined = untouched // (desktop default on); false = explicit opt-out; true = explicit opt-in. const _webglSettings = this.loadAppSettingsFromStorage(); const _webglDefaults = this.getDefaultSettings(); const _webglPref = _webglSettings.webglRendererEnabled ?? _webglDefaults.webglRendererEnabled; const { skip: skipWebGL, clearSticky: _clearWebglSticky } = shouldSkipWebGL({ deviceType: MobileDetection.getDeviceType(), noWebglParam: _params.has('nowebgl'), forceParam: _params.get('webgl') === 'force', stickyDisabled: _stickyDisabled, userPrefEnabled: _webglPref, }); // Only ?webgl=force retires the auto-fallback marker at init — a stored // toggle ON is incidental (checkbox defaults checked) and must not defeat // the sticky safety net. An OFF→ON flip clears it in saveAppSettings(). if (_clearWebglSticky) { try { localStorage.removeItem('codeman-webgl-disabled'); } catch {} } if (skipWebGL && _stickyDisabled) { console.log('[CRASH-DIAG] WebGL sticky-disabled from prior stalls — DOM renderer in use. Re-enable: ?webgl=force'); } if (!skipWebGL) { if (typeof WebglAddon !== 'undefined') { this._initWebGL(); } else { // Lazy-load WebGL addon — not bundled in to avoid blocking mobile const wglScript = document.createElement('script'); wglScript.src = 'vendor/xterm-addon-webgl.min.js'; wglScript.onload = () => this._initWebGL(); wglScript.onerror = () => console.warn('[CRASH-DIAG] Failed to load WebGL addon — using canvas renderer'); document.head.appendChild(wglScript); } } this._localEchoOverlay = new LocalEchoOverlay(this.terminal); // Predictive write-through echo (codex): separate opt-in bundle // (vendor/xterm-predictive-echo.js); when it is missing or failed to // load, codex falls back to plain PTY echo exactly like 1.12.2. this._predictiveEcho = typeof PredictiveEchoOverlay !== 'undefined' ? new PredictiveEchoOverlay(this.terminal) : null; this._predictiveEcho?.setPredictWhen((terminal) => window.CodemanTerminalInput.isCodexComposerRow(terminal)); if (MobileDetection.isTouchDevice()) { this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor()); this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor()); } // CJK IME input — textarea in index.html, just wire up send this._cjkInput = null; if (typeof CjkInput !== 'undefined') { this._cjkInput = CjkInput.init({ send: (text) => { this._handleCjkInput(text); }, }); } // ── Focus router ── // While the CJK field is visible, EVERY terminal.focus() call must land on // the CJK field instead. Focusing xterm's hidden textarea in CJK mode sends // the IME's output into a black hole: the keyboard composes normally, but // onData is gated by cjkActive, so nothing reaches the field OR the PTY. // Session select / SSE-reconnect restore paths call terminal.focus() and // were silently stealing focus after every app switch on mobile (the // intermittent "Chinese input goes nowhere" bug). One chokepoint here // covers all ~15 call sites plus any future ones. const _xtermFocus = this.terminal.focus.bind(this.terminal); this.terminal.focus = () => { const cjkEl = document.getElementById('cjkInput'); if (cjkEl?.classList.contains('cjk-input-visible')) { cjkEl.focus(); } else { _xtermFocus(); } }; // On mobile Safari, delay initial fit() to allow layout to settle // This prevents 0-column terminals caused by fit() running before container is sized const isMobileSafari = MobileDetection.getDeviceType() === 'mobile' && document.body.classList.contains('safari-browser'); if (isMobileSafari) { // Wait for layout, then fit multiple times to ensure proper sizing requestAnimationFrame(() => { this.fitAddon.fit(); // Double-check after another frame requestAnimationFrame(() => this.fitAddon.fit()); }); } else { this.fitAddon.fit(); } // Register link provider for clickable file paths in Bash tool output this.registerFilePathLinkProvider(); // Mouse wheel: forward to the TUI only for sessions verified to handle SGR // wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local // scrollback otherwise. Claude Code 2.1.187+ scrolls its own // transcript on SGR wheel reports — scrolled-away tool blocks re-render // live and stay clickable — and its select menus no longer capture wheel // as option navigation (verified against 2.1.202: /model menu highlight // ignores wheel reports); older versions DO capture wheel as option // navigation, so they keep the local wheel. // Shift+wheel always scrolls xterm's local scrollback (Codeman's restored // history lives there); the plain wheel stays on the CLI's transcript for // those modes regardless of scroll position, so the CLI's input box never // slides off the screen (see _shouldForwardWheelToApp). // // CAPTURE phase, deliberately, and Codeman owns the scroll. xterm's // viewport is a vscode-style ScrollableElement that consumes wheel events // itself (preventDefault + stopPropagation) whenever it believes a // scrollbar exists, does NOT consult attachCustomWheelEventHandler, and — // measured on the live instance — goes DEAF after terminal.reset(): a tab // switch or full-history replay leaves its scroll dimensions stale, after // which wheel events neither scroll nor propagate reliably. A bubble-phase // listener here therefore never fired once local scrollback existed // (measured: _shouldForwardWheelToApp call count stayed 0 while xterm // scrolled), and after a tab switch NOTHING scrolled at all — the "input // box scrolls up then it fights", "works at first, breaks after a tab // switch" reports on #205. // // So: capture runs ancestors-first; this handler sees every wheel first // and stops propagation, keeping xterm's scroller out of it entirely. // Local scrolling goes through terminal.scrollLines() — buffer-level, so // it keeps working after resets — with our own deltaMode normalization // (_wheelScrollLines) covering Firefox's line-unit wheels. Two cases still // belong to xterm and are passed through untouched: // - mouseTrackingMode active: xterm's own encoder forwards the wheel to // the PTY (htop/vim with mouse on in a shell pane); // - alternate buffer (direct-PTY fallback running vim/less): xterm's // alt-scroll handling converts the wheel to cursor keys, which is what // those apps expect. container.addEventListener( 'wheel', (ev) => { const trackingMode = this.terminal?.modes?.mouseTrackingMode; if (trackingMode && trackingMode !== 'none') return; if (this.terminal?.buffer?.active?.type === 'alternate') return; ev.preventDefault(); ev.stopPropagation(); if (this._shouldForwardWheelToApp(ev)) { this._logScrollRouting('forward-sgr'); this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev)); return; } // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits // a stream of tiny pixel deltas, and rounding each one to a whole line // (the ±1 fallback) made slow drags scroll faster than the finger. const lines = this._wheelScrollLinesFloat(ev); // …unless there is no local scrollback to scroll, in which case page the // CLI's own transcript instead of doing nothing (_maybePageCliTranscript). if (this._maybePageCliTranscript(ev, lines)) return; this._logScrollRouting('local-scrollback'); this._noteTerminalUserScroll(lines); this._smoothScrollBy(lines); }, { passive: false, capture: true } ); // Touch scrolling — use terminal.scrollLines() for all devices. // xterm.js DOM renderer doesn't populate xterm-viewport's scroll area, // so native CSS scrolling (overflow-y: scroll + touch-action: pan-y) // has nothing to scroll. Instead, convert touch deltas into scrollLines() // calls, matching the wheel handler above, including the forwarding // branch: for the sessions whose wheel goes to the CLI's own transcript // (_shouldForwardWheelToApp), a touch drag must go there too, or every // phone/tablet swipe scrolls the local buffer of stale repaint frames and // drags the CLI's pinned input box off the screen (issue #205's mobile // half). Same gate, so Shift has no touch analog but the local-scrollback // opt-out setting and the CLI-version gate apply to touch exactly as they // do to the wheel — including the PageUp/PageDown fallback the wheel uses // when that gate is false and there is no local scrollback to scroll // (_maybePageCliTranscript), which is what keeps a swipe from being a // complete no-op on a phone. { const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13; let touchLastX = 0; let touchLastY = 0; let velocity = 0; let lastTime = 0; let scrollFrame = null; let isTouching = false; const scrollLoop = (timestamp) => { const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; lastTime = timestamp; if (!isTouching && Math.abs(velocity) > 0.3) { // Momentum phase — convert pixel velocity to lines const lines = Math.round(velocity / cellHeight()); if (lines !== 0) { if (this._shouldForwardWheelToApp({ shiftKey: false })) { // Flick momentum keeps feeding the CLI's transcript from the last // touch point; the 40ms coalescer batches the per-frame reports. this._forwardScrollToApp(touchLastX, touchLastY, lines); } else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) { this.terminal.scrollLines(lines); this._maybeLoadMoreHistoryOnScroll(lines); } } velocity *= 0.92; scrollFrame = requestAnimationFrame(scrollLoop); } else if (!isTouching) { scrollFrame = null; velocity = 0; } else { scrollFrame = requestAnimationFrame(scrollLoop); } }; // Accumulate sub-line pixel deltas so slow swipes still scroll let pixelAccum = 0; let didScroll = false; // track whether touchmove fired (tap vs scroll) let touchStartY = 0; const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll container.addEventListener( 'touchstart', (ev) => { if (ev.touches.length === 1) { touchLastX = ev.touches[0].clientX; touchLastY = ev.touches[0].clientY; touchStartY = touchLastY; velocity = 0; pixelAccum = 0; isTouching = true; didScroll = false; lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null; } } }, { passive: true } ); container.addEventListener( 'touchmove', (ev) => { if (ev.touches.length === 1 && isTouching) { const touchY = ev.touches[0].clientY; if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) { didScroll = true; } // Below the tap threshold, treat the gesture as a potential tap: // don't preventDefault (iOS needs click synthesis to show the // keyboard) and don't accumulate scroll distance or velocity. Without // this guard, sub-threshold micro-drift still scrolls a line and // leaves a non-zero velocity that touchend turns into a momentum // fling, so a jittery tap would both position the cursor AND scroll. if (!didScroll) return; ev.preventDefault(); const delta = touchLastY - touchY; // positive = scroll down pixelAccum += delta; velocity = delta * 1.2; touchLastX = ev.touches[0].clientX; touchLastY = touchY; // Convert accumulated pixels to whole lines const ch = cellHeight(); const lines = Math.trunc(pixelAccum / ch); if (lines !== 0) { if (this._shouldForwardWheelToApp({ shiftKey: false })) { this._logScrollRouting('forward-sgr'); this._forwardScrollToApp(touchLastX, touchLastY, lines); } else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) { this._logScrollRouting('local-scrollback'); this._noteTerminalUserScroll(lines); this.terminal.scrollLines(lines); this._maybeLoadMoreHistoryOnScroll(lines); } pixelAccum -= lines * ch; } } }, { passive: false } ); container.addEventListener( 'touchend', (ev) => { isTouching = false; if (!scrollFrame && Math.abs(velocity) > 0.3) { scrollFrame = requestAnimationFrame(scrollLoop); } if (!didScroll && this.terminal) { // ── Tap-to-position cursor ────────────────────────────────── // Synthesize a click from the real touch point so the foreground app // moves its cursor to the tapped cell (iOS doesn't reliably do this // itself under touch-action:none). CRITICAL: only when mouse tracking // is ON. xterm disables its local SelectionService while mouse events // are active, so the synthetic click is forwarded to the PTY as an SGR // report (cursor moves). But when tracking is OFF, that same click // drives xterm's LOCAL selection (detail 1/2/3 → char/word/line) — a // tap on CJK text would select & copy it instead of positioning. So // gate strictly on the live mouse-tracking mode. const touch = ev.changedTouches && ev.changedTouches[0]; const mouseMode = this.terminal.modes?.mouseTrackingMode; const mouseTrackingOn = !!mouseMode && mouseMode !== 'none'; if (touch) { this._suppressTrustedTapMouseEvents(); } if (touch && mouseTrackingOn) { this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); } else if (touch && this._sessionUsesServerMouseStrip()) { // The server strips mouse-tracking DECSETs from claude/codex/gemini // output (isAltScreenStripMode, session.ts) so the wheel keeps // scrolling scrollback — which leaves THIS xterm permanently at // mouseTrackingMode 'none' even though the TUI on the PTY side has // tracking ON and still understands SGR reports. Encode the report // ourselves and send it straight to the PTY: no DOM click is // dispatched, so xterm's local selection can't trigger either. this._sendSyntheticSgrTap(touch.clientX, touch.clientY); } this._syncMobileHelperTextareaToCursor(); // Route subsequent typing to the right place: keep the CJK input // field focused when Chinese input is on, otherwise the terminal. const cjkInput = document.getElementById('cjkInput'); if (cjkInput?.classList.contains('cjk-input-visible')) { cjkInput.focus(); } else { this.terminal.focus(); } } }, { passive: true } ); container.addEventListener( 'touchcancel', () => { isTouching = false; velocity = 0; pixelAccum = 0; }, { passive: true } ); } // ── Desktop click-to-position cursor ────────────────────────────── // A real mouse click normally reaches the PTY through xterm's own mouse // encoder, but that encoder only runs while mouseTrackingMode is ON — and // the server strips the enabling DECSETs from claude/codex/gemini output // (isAltScreenStripMode, session.ts) so the wheel keeps scrolling // scrollback. Desktop clicks therefore stopped reporting entirely (the // same breakage the mobile touchend tap branch above works around). // Hand-encode the SGR report for plain left-clicks on those sessions. container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev)); // Welcome message this.showWelcome(); // Image paste and drag-and-drop support this.initImageInput(); // Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch this._chunkedWriteGen = 0; this._bufferLoadSeq = 0; this._bufferLoadOwner = null; this._lastUserScrollUpAt = null; // Handle resize with throttling for performance this._resizeTimeout = null; this._lastResizeDims = null; // Minimum terminal dimensions to prevent vertical text wrapping const MIN_COLS = 40; const MIN_ROWS = 10; const throttledResize = () => { // Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens // once after the user stops resizing. During active resize, the terminal // stays at its old dimensions for up to 300ms. // // Why not fit() immediately? Each fitAddon.fit() reflows content at the // new width — lines that were 7 rows become 10, and the overflow gets // pushed into scrollback. With continuous resize events, this creates // dozens of intermediate reflow states in scrollback, appearing as // duplicate/garbled content when the user scrolls up. // // By deferring fit() to the trailing edge, there's exactly ONE reflow // at the final dimensions, ONE viewport clear, and ONE Ink redraw. if (this._resizeTimeout) { clearTimeout(this._resizeTimeout); } this._resizeTimeout = setTimeout(() => { this._resizeTimeout = null; // Fit xterm.js to final container dimensions if (this.fitAddon) { this.fitAddon.fit(); } // Flush any stale flicker buffer before clearing viewport if (this.flickerFilterBuffer) { if (this.flickerFilterTimeout) { clearTimeout(this.flickerFilterTimeout); this.flickerFilterTimeout = null; } this.flushFlickerBuffer(); } // Skip server resize while mobile keyboard is visible — sending SIGWINCH // causes Ink to re-render at the new row count, garbling terminal output. // Local fit() still runs so xterm knows the viewport size for scrolling. const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; if (this.activeSessionId && !keyboardUp) { const dims = this.fitAddon.proposeDimensions(); // Enforce minimum dimensions to prevent layout issues const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS; const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS; // Only send resize if dimensions actually changed if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) { // Clear viewport + scrollback ONLY when dimensions actually change. // fitAddon.fit() reflows content: lines at old width may wrap to more rows, // pushing overflow into scrollback. Ink's cursor-up count is based on the // pre-reflow line count, so ghost renders accumulate in scrollback. // Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris, // then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw. // IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed). // Clearing without a subsequent Ink redraw leaves the terminal blank. const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; if ( activeResizeSession && activeResizeSession.mode !== 'shell' && this.terminal && this.isTerminalAtBottom() ) { this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); } this._lastResizeDims = { cols, rows }; // Typed + WS-first like sendResize: the viewport type feeds resize // arbitration (a phone rotating must not bypass a desktop claim), // and a desktop window narrowing past the tablet breakpoint must // send a typed WS frame so its stale desktop claim is released. const viewportType = typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'desktop'; let sentViaWs = false; if (this._wsReady && this._wsSessionId === this.activeSessionId) { try { this._ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: viewportType })); sentViaWs = true; } catch { // Fall through to HTTP POST } } if (!sentViaWs) { fetch(`/api/sessions/${this.activeSessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols, rows, viewportType }), }).catch(() => {}); } } } // Update subagent connection lines and local echo at new dimensions this.updateConnectionLines(); if (this._localEchoOverlay?.hasPending) { this._localEchoOverlay.rerender(); } }, 300); // Trailing-edge: only fire after 300ms of no resize events }; window.addEventListener('resize', throttledResize); // Store resize observer for cleanup (prevents memory leak on terminal re-init) if (this.terminalResizeObserver) { this.terminalResizeObserver.disconnect(); } this.terminalResizeObserver = new ResizeObserver(throttledResize); this.terminalResizeObserver.observe(container); // Handle keyboard input — send to PTY immediately, no local echo. // PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug). this._pendingInput = ''; this._inputFlushTimeout = null; this._lastKeystrokeTime = 0; const flushInput = () => { this._inputFlushTimeout = null; if (this._pendingInput && this.activeSessionId) { const input = this._pendingInput; const sessionId = this.activeSessionId; this._pendingInput = ''; this._sendInputAsync(sessionId, input); } }; // Local echo mode: buffer keystrokes locally (shown in overlay) and only // send to PTY on Enter. Avoids out-of-order delivery on high-latency // mobile connections. The overlay + localStorage persistence ensure input // survives tab switches and reconnects. this.terminal.onData((data) => { // Mouse SGR reports (tap-to-position) are NOT IME input — they must reach // the PTY even while the CJK input field owns focus. Without this exception // tapping to move the cursor silently does nothing whenever Chinese input // is on, because cjkActive stays true the whole time the field is visible. const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data); // CJK input has focus — block xterm from sending keystrokes to PTY if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) { // Self-heal: if the CJK field is visible but focus drifted to xterm's // hidden textarea (e.g. something called terminal.focus()), everything // typed lands HERE and is swallowed — keyboard shows the IME composing // while both the CJK field and the terminal stay empty. Route focus // back so the very next keystroke lands in the CJK field again. // Only GENUINE typed input qualifies: onData also fires for xterm's // self-generated query replies (DA/DSR/CPR/OSC during Ink redraws), // which arrive no matter what has focus — so require focus to be on // xterm's own textarea and bail on query replies, or this would steal // focus from the rename/search/settings inputs while output streams. const cjkEl = document.getElementById('cjkInput'); if ( cjkEl?.classList.contains('cjk-input-visible') && document.activeElement === this.terminal.textarea && !window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data) ) { _crashDiag.log('CJK regain-focus (onData swallowed input)'); cjkEl.focus(); } return; } if (this.activeSessionId) { // Filter terminal query replies generated by xterm.js itself. // Forwarding them through the WebSocket injects DA/DSR/CPR replies // into the foreground process as typed input (for example "0;276;0c"). if ( window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data) ) { return; } // ── One-shot Ctrl (mobile shell bar, issue #262) ── // A virtual keyboard reports no usable key events, so a keydown hook // would never see the character the modifier applies to: it arrives // here as onData text. Sits AFTER the query-response filter so xterm's // own DA/CPR replies can never spend the modifier, and BEFORE every // send path so the control byte follows the normal control-char route // (immediate flush, local-echo state cleared). // // Mouse and focus reports are skipped rather than suppressed: they are // real bytes the PTY still needs, they just were not typed by anyone. // A shell session passes mouse DECSETs through, so with vim or htop // running, one tap on the terminal used to spend the modifier silently // (measured against a real shell). See isTerminalFocusOrMouseReport. if ( typeof KeyboardAccessoryBar !== 'undefined' && KeyboardAccessoryBar.isCtrlArmed?.() && !window.CodemanTerminalInput?.isTerminalFocusOrMouseReport(data) ) { data = KeyboardAccessoryBar.consumeCtrl(data); } this._lastTerminalData = { data, time: performance.now() }; // ── Local Echo Pass-through ── // After a composer nav key (arrow/Home/End/Delete) the real cursor may // sit mid-text, where the overlay's append-only buffering would corrupt // both the preview and the submitted text. Such sessions are handed // back to plain PTY echo until Enter or Ctrl+C submits/cancels the // composer line (see the nav-key branch below). const echoPassthrough = this._localEchoEnabled && this._echoPassthroughSessions?.has(this.activeSessionId); if (echoPassthrough && (data === '\r' || data === '\x03')) { this._echoPassthroughSessions.delete(this.activeSessionId); } // ── Local Echo Mode ── // When enabled, keystrokes are buffered locally in the overlay for // instant visual feedback. Nothing is sent to the PTY until Enter // (or a control char) is pressed — avoids out-of-order char delivery. if (this._localEchoEnabled && !echoPassthrough) { if (data === '\x7f') { const source = this._localEchoOverlay?.removeChar(); if (source === 'flushed') { // Sync app-level flushed Maps (per-session state for tab switching) const { count, text } = this._localEchoOverlay.getFlushed(); if (this._flushedOffsets?.has(this.activeSessionId)) { if (count === 0) { this._flushedOffsets.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); } else { this._flushedOffsets.set(this.activeSessionId, count); this._flushedTexts?.set(this.activeSessionId, text); } } this._pendingInput += data; flushInput(); } else if (source === false) { // Nothing pending, nothing flushed, nothing detected. The // composer may still hold text the overlay cannot see (buffer // detection is suppressed after a control-char flush), so // forward the backspace instead of swallowing it (issue #218); // an empty composer ignores it. this._pendingInput += data; flushInput(); } // 'pending' = removed unsent text (no PTY backspace needed) return; } if (/^[\r\n]+$/.test(data)) { // Enter: send full buffered text + \r to PTY in one shot const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); // Suppress detection so PTY-echoed text isn't re-detected as user input this._localEchoOverlay?.suppressBufferDetection(); // Clear flushed offset and text — Enter commits all text this._flushedOffsets?.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } if (text) { this._pendingInput += text; flushInput(); } // Send \r after a short delay so text arrives first setTimeout(() => { this._pendingInput += '\r'; flushInput(); }, 80); return; } if (data.length > 1 && data.charCodeAt(0) >= 32) { // Paste: append to overlay only (sent on Enter) this._localEchoOverlay?.appendText(data); return; } if (data.charCodeAt(0) < 32) { // Skip xterm-generated terminal responses. // These arrive via triggerDataEvent when the terminal processes // buffer data (DA responses, OSC color queries, mode reports, etc.). // They are NOT user input and must not clear flushed text state. // Covers: CSI (\x1b[), OSC (\x1b]), DCS (\x1bP), APC (\x1b_), // PM (\x1b^), SOS (\x1bX), and any other multi-byte ESC sequence. // Single-byte ESC (user pressing Escape) still falls through to // the control char handler below. if (data.length > 1 && data.charCodeAt(0) === 27) { // Bracketed paste (terminal.paste() while DECSET 2004 is on): // flush typed-but-unsent overlay text FIRST so the pasted block // lands after it in the composer, not before it (issue #219). // The paste sequence gets its own delayed write: Codex's // paste-burst handling drops keystrokes that arrive in the SAME // PTY read as a bracketed paste (verified against codex 0.147), // mirroring the delayed \r in the Enter branch above. if (data.startsWith(window.CodemanTerminalInput.BRACKETED_PASTE_START)) { const hadPending = !!this._localEchoOverlay?.pendingText; this._flushLocalEchoPending(); if (hadPending) { flushInput(); setTimeout(() => { this._pendingInput += data; flushInput(); }, 80); } else { this._pendingInput += data; flushInput(); } return; } // Composer nav keys (arrows, Home/End, Delete, PgUp/PgDn): // flush unsent text so the key edits the real composer state, // then hand the session to plain PTY echo until Enter/Ctrl+C. // The cursor may now sit mid-text, where append-only buffering // cannot track edits (issue #218). if (window.CodemanTerminalInput.isComposerNavKey(data)) { this._flushLocalEchoPending(); if (!this._echoPassthroughSessions) this._echoPassthroughSessions = new Set(); this._echoPassthroughSessions.add(this.activeSessionId); this._pendingInput += data; flushInput(); return; } // Multi-byte escape sequence — forward to PTY without clearing // overlay/flushed state (terminal response, not user input) this._pendingInput += data; flushInput(); return; } // During buffer load (tab switch), stray control chars from // terminal response processing must not wipe the flushed state // that selectSession() is actively restoring. if (this._restoringFlushedState) { this._pendingInput += data; flushInput(); return; } // Tab key: send pending text + Tab to PTY for tab completion. // Set a flag so flushPendingWrites() re-detects buffer text when // the PTY response arrives (event-driven, no fixed timer). if (data === '\t') { const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); this._flushedOffsets?.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); if (text) { this._pendingInput += text; } this._pendingInput += data; if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } // Snapshot prompt line text BEFORE flushing — used to distinguish // real Tab completions from pre-existing Claude UI text. let baseText = ''; try { const p = this._localEchoOverlay?.findPrompt?.(); if (p) { const buf = this.terminal.buffer.active; const line = buf.getLine(buf.viewportY + p.row); if (line) baseText = line .translateToString(true) .slice(p.col + 2) .trimEnd(); } } catch {} this._tabCompletionBaseText = baseText; flushInput(); this._tabCompletionSessionId = this.activeSessionId; this._tabCompletionRetries = 0; // Fallback: if flushPendingWrites() detection misses the completion // (e.g., flicker filter delays data, or xterm hasn't processed writes // by the time the callback fires), retry detection after a delay. // This ensures the overlay renders even without further terminal data. if (this._tabCompletionFallback) clearTimeout(this._tabCompletionFallback); const selfTab = this; this._tabCompletionFallback = setTimeout(() => { selfTab._tabCompletionFallback = null; if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId) return; const ov = selfTab._localEchoOverlay; if (!ov || ov.pendingText) return; selfTab.terminal.write('', () => { if (!selfTab._tabCompletionSessionId) return; ov.resetBufferDetection(); const detected = ov.detectBufferText(); if (detected && detected !== selfTab._tabCompletionBaseText) { selfTab._tabCompletionSessionId = null; selfTab._tabCompletionRetries = 0; selfTab._tabCompletionBaseText = null; ov.rerender(); } }); }, 300); return; } // Control chars (Ctrl+C, single ESC): send buffered text + control char immediately const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); // Suppress detection so PTY-echoed text isn't re-detected as user input this._localEchoOverlay?.suppressBufferDetection(); // Clear flushed offset and text — control chars (Ctrl+C, Escape) change // cursor position or abort readline, making flushed text tracking invalid. this._flushedOffsets?.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); if (text) { this._pendingInput += text; } this._pendingInput += data; if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } flushInput(); return; } if (data.length === 1 && data.charCodeAt(0) >= 32) { // Printable char: add to overlay only (sent on Enter) this._localEchoOverlay?.addChar(data); return; } } // ── Predictive Echo (codex): visual only. A plain statement, never a // `return`: control ALWAYS falls through into the send path below, // which is the byte-identity guarantee for #218/#219/#220/#222 — // with the predictor active, absent or throwing, the wire sees the // same bytes. Body in _predictHookOnData (vm-testable). this._predictHookOnData(data); // ── Normal Mode (echo disabled) ── this._pendingInput += data; // Control chars (Enter, Ctrl+C, escape sequences) — flush immediately if (data.charCodeAt(0) < 32 || data.length > 1) { if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } flushInput(); return; } // Regular chars — flush immediately if typed after a gap (>50ms), // otherwise batch via microtask to coalesce rapid keystrokes (paste). const now = performance.now(); if (now - this._lastKeystrokeTime > 50) { // Single char after a gap — send immediately, no setTimeout latency if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } this._lastKeystrokeTime = now; flushInput(); } else { // Rapid sequence (paste or fast typing) — coalesce via microtask this._lastKeystrokeTime = now; if (!this._inputFlushTimeout) { this._inputFlushTimeout = setTimeout(flushInput, 0); } } } }); }, /** * Register a custom link provider for xterm.js that detects file paths * in terminal output and makes them clickable. * When clicked, opens a floating log viewer window with live streaming. */ registerFilePathLinkProvider() { const self = this; // Debug: Track if provider is being invoked let lastInvokedLine = -1; this.terminal.registerLinkProvider({ provideLinks(bufferLineNumber, callback) { // Debug logging - only log if line changed to avoid spam if (bufferLineNumber !== lastInvokedLine) { lastInvokedLine = bufferLineNumber; console.debug('[LinkProvider] Checking line:', bufferLineNumber); } const buffer = self.terminal.buffer.active; // provideLinks passes 1-based line number, getLine expects 0-based const line = buffer.getLine(bufferLineNumber - 1); if (!line) { callback(undefined); return; } // Stitch the LOGICAL line back together. // // xterm invokes this provider per visible ROW, and translateToString returns // that row alone (the old comment here claimed otherwise). A URL or path // longer than the terminal is wide therefore matched only as far as the row // boundary, and the link opened a PREFIX of the real target. Walk out to both // ends of the continuation, match against the joined text, and map offsets // back to (x, y) so a link can span rows. // // Two different kinds of continuation, and handling only the first is not // enough: // 1. SOFT wrap: the emulator ran out of columns and flags the next row // `isWrapped`. // 2. HARD wrap: the program did its own wrapping and emitted a real // newline, so nothing is flagged. Ink does this, which is why Claude // Code's own `/login` URL was cut at the window edge, and why the // clickable part grew when the window was widened. // A row that fills the full width is treated as continuing into the next: // that is the signal a hard wrap leaves behind, and a line that genuinely // ended would stop short of the last column. const cols = self.terminal.cols; const rowAt = (r) => buffer.getLine(r - 1); const continuesPrevious = (r) => { if (r <= 1) return false; if (rowAt(r)?.isWrapped) return true; const prev = rowAt(r - 1); return !!prev && prev.translateToString(true).length >= cols; }; // Bounded so a screenful of full-width output (wide tables, box drawing) // cannot make every hover stitch and re-scan the entire viewport. const MAX_STITCHED_ROWS = 12; let startRow = bufferLineNumber; while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) { startRow--; } let endRow = bufferLineNumber; while (endRow < buffer.length && endRow - startRow < MAX_STITCHED_ROWS && continuesPrevious(endRow + 1)) { endRow++; } const rowTexts = []; for (let r = startRow; r <= endRow; r++) { const row = rowAt(r); if (!row) break; // Only the final row may be trimmed. Continuation rows fill the width by // definition, and trimming one would shift every later offset. rowTexts.push(row.translateToString(r === endRow)); } const lineText = rowTexts.join(''); /** Map an offset in the stitched text back to a 1-based terminal cell. */ const coordAt = (index) => { let rest = index; for (let i = 0; i < rowTexts.length - 1; i++) { if (rest < rowTexts[i].length) return { x: rest + 1, y: startRow + i }; rest -= rowTexts[i].length; } return { x: rest + 1, y: startRow + rowTexts.length - 1 }; }; if (!lineText || !lineText.includes('/')) { callback(undefined); return; } const links = []; // Pattern 0: URLs (https://, http://) — matched first so they take priority // // A single `&` is PART of the URL: it separates query parameters, so excluding // it truncated every real query string (`?post=1479&action=edit` linked only // through `1479`, landing on the wrong page). `&&` is still a boundary, since // that is the shell operator and never appears inside a URL. A lone trailing // `&` is trimmed below with the other trailing punctuation. const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g; const addUrlLink = (url, matchIndex) => { // Strip trailing punctuation that's likely not part of the URL const cleaned = url.replace(/[.,;:!?)&]+$/, ''); const startCol = lineText.indexOf(cleaned, matchIndex); if (startCol === -1) return; const start = coordAt(startCol); const end = coordAt(startCol + cleaned.length); if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return; links.push({ text: cleaned, range: { start, end }, decorations: { pointerCursor: true, underline: true }, activate(_event, text) { window.open(text, '_blank', 'noopener,noreferrer'); }, hover() { self._linkHovered = true; }, leave() { self._linkHovered = false; }, }); }; // Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.) // Handles: tail -f /path, grep pattern /path, cat -n /path // ⚠ The arg group must stay linear-time: `(?:[^\s\/]*\s+)*` (empty-matchable // token, unbounded) backtracks exponentially on lines with a trigger word // followed by multi-space runs (e.g. wrapped heredoc/table output) — froze // the whole tab on hover. Non-empty token + bounded reps is O(n). const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g; // Pattern 2: Paths with common extensions. // Image/PDF extensions are included so pasted-attachment paths // (`.claude-images/paste-*.png`) are clickable; they open the file preview // rather than the log viewer (see addLink). const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js|png|jpe?g|gif|webp|bmp|svg|pdf))\b/g; // Pattern 3: Bash() tool output const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; /** Extensions that should open the image/document preview, not the log viewer. */ const PREVIEW_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf']); const addLink = (filePath, matchIndex) => { const startCol = lineText.indexOf(filePath, matchIndex); if (startCol === -1) return; const start = coordAt(startCol); const end = coordAt(startCol + filePath.length); // Skip if already have link at this position if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return; links.push({ text: filePath, range: { start, end }, // 1-based, may span wrapped rows decorations: { pointerCursor: true, underline: true, }, activate(event, text) { // Tailing a PNG in the log viewer shows binary noise; the file preview // already renders images and PDFs inline. const ext = (text.split('.').pop() || '').toLowerCase(); if (PREVIEW_EXTS.has(ext)) { self.openFilePreview(text, self.activeSessionId); return; } self.openLogViewerWindow(text, self.activeSessionId); }, hover() { self._linkHovered = true; }, leave() { self._linkHovered = false; }, }); }; // Match all patterns — URLs first so they take priority let match; urlPattern.lastIndex = 0; while ((match = urlPattern.exec(lineText)) !== null) { addUrlLink(match[0], match.index); } cmdPattern.lastIndex = 0; while ((match = cmdPattern.exec(lineText)) !== null) { addLink(match[2], match.index); } extPattern.lastIndex = 0; while ((match = extPattern.exec(lineText)) !== null) { addLink(match[1], match.index); } bashPattern.lastIndex = 0; while ((match = bashPattern.exec(lineText)) !== null) { addLink(match[1], match.index); } if (links.length > 0) { console.debug( '[LinkProvider] Found links:', links.map((l) => l.text) ); } callback(links.length > 0 ? links : undefined); }, }); console.log('[LinkProvider] File path link provider registered'); }, showWelcome() { // Phones get the session overview instead of the welcome screen: on a small // screen "which session is blocked on me" beats "how do I start one". The // gate lives in mobile-overview.js; every other device falls through // unchanged. Both surfaces are toggled here so a breakpoint change (rotate, // unfold) swaps cleanly instead of showing both. if (this.shouldUseMobileOverview?.()) { const overlay = document.getElementById('welcomeOverlay'); if (overlay) overlay.classList.remove('visible'); this.hideHomeSessions?.(); this.showMobileOverview(); this._updateCjkInputState?.(); return; } this.hideMobileOverview?.(); const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.add('visible'); this.loadTunnelStatus(); this.applyWelcomeCliVisibility(); this.loadHistorySessions(); this.initSearchPanel(); // Open tabs down the left gutter. Self-gating: a window too narrow to hold // the column without overlapping the content leaves it hidden. this.showHomeSessions?.(); } // Home screen has no input target — hide the CJK textarea (activeSessionId // is null by the time we get here). Guarded: defined on the app object. this._updateCjkInputState?.(); }, hideWelcome() { this.hideMobileOverview?.(); this.hideHomeSessions?.(); const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.remove('visible'); } // Collapse expanded QR when leaving welcome screen const qrWrap = document.getElementById('welcomeQr'); if (qrWrap) { clearTimeout(this._welcomeQrShrinkTimer); qrWrap.classList.remove('expanded'); } // Entering a session — restore CJK textarea if the user has it enabled // (activeSessionId is already set by selectSession before this call). this._updateCjkInputState?.(); }, /** * Fetch and deduplicate history sessions (up to 3 per project, sorted by date). * Uses projectKey for grouping because workingDir decoding is lossy. * @returns {Promise} deduplicated session list, most recent first */ async _fetchHistorySessions() { const res = await fetch('/api/history/sessions'); const data = await res.json(); const sessions = data.data?.sessions || []; if (sessions.length === 0) return []; const byProject = new Map(); for (const s of sessions) { const key = s.projectKey || s.workingDir; if (!byProject.has(key)) byProject.set(key, []); byProject.get(key).push(s); } const items = []; for (const [, group] of byProject) { items.push(...group.slice(0, 3)); } items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified)); return items; }, /** * Fetch the unified session list (live + persisted + non-Claude + closed * history), already de-duplicated and sorted newest-first by the backend * (`GET /api/sessions/unified`, COD-121). No client-side grouping needed. * @param {number} [limit=60] max sessions to request * @returns {Promise} unified session items, most recent first */ async _fetchUnifiedSessions(limit = 60) { const res = await fetch('/api/sessions/unified?limit=' + limit); // ApiResponse envelope: { success, data: { sessions } }. Throw on failure so // callers (loadHistorySessions) hit their catch instead of rendering a 5xx as // an empty history. const data = await res.json().catch(() => null); if (!res.ok || !data || data.success === false || !data.data) { throw new Error(data?.error || `unified sessions request failed (HTTP ${res.status})`); } return data.data.sessions || []; }, /** * Resolve workingDir to a case-aware short label. * - Exact case path match → "#caseName" * - workingDir under a case dir → "#caseName/subdir" * - Otherwise → basename (e.g. "Claudeman") */ /** * Badge text for a session's git worktree, or '' when it isn't on one. * `⑂ · `, either half alone if that's all we know. * Branch is truncated: the badge row is a single nowrap line. */ _worktreeLabel(s) { // Worktree name is REQUIRED. gitBranch alone is not worktree information — // every ordinary repo session has one, and badging all of them with `⑂ master` // is noise that buries the rows this badge exists to distinguish. const name = s && s.worktreeName; if (!name) return ''; let branch = s.gitBranch || ''; // A worktree's branch often just restates its name; don't print it twice. if (branch === name || branch === `worktree-${name}`) branch = ''; if (branch.length > 24) branch = branch.slice(0, 23) + '\u2026'; return '⑂ ' + [name, branch].filter(Boolean).join(' · '); }, _resolveCaseLabel(workingDir, cases) { if (!workingDir) return ''; let best = null; for (const c of cases || []) { if (!c || !c.path) continue; if (workingDir === c.path) { return `#${c.name}`; } if (workingDir.startsWith(c.path + '/')) { const len = c.path.length; if (!best || len > best.len) { best = { name: c.name, suffix: workingDir.slice(len), len }; } } } if (best) return `#${best.name}${best.suffix}`; return workingDir.split('/').pop() || workingDir; }, /** * Normalize a home prefix to "~" on both Linux (`/home/`) and macOS * (`/Users/`). The lookahead lets the home directory ITSELF match, so a * path that is exactly `$HOME` renders "~" instead of being left raw. * * This is the only place that pattern belongs. Two hand-rolled copies had * drifted, each broken on the platform its author was not using: the Run * menu's matched `/home/` only, so on macOS nothing was stripped and every * Recent Sessions row spent its first ~19 characters on an identical * `/Users//` prefix (#273); the case-manage list's matched `/Users/` * only, so no Linux path was ever abbreviated there. Route new path labels * through here rather than writing a third copy. */ _shortenHomePath(p) { return (p || '').replace(/^\/(?:home|Users)\/[^/]+(?=\/|$)/, '~'); }, /** * Build a single history item DOM element. * @param {object} s session record * @param {Array} cases linked cases (for #caseName label) * @param {object} [options] * @param {boolean} [options.showViewAll=true] show "View all in folder" button in detail panel * @param {Function} [options.onActivate] main-row click handler override (default: resume the conversation) */ _buildHistoryItem(s, cases, options) { const showViewAll = options?.showViewAll !== false; // Size: only render when a numeric byte count is present (unified items // backed solely by a live/persisted source may omit it). const hasSize = typeof s.sizeBytes === 'number'; const size = !hasSize ? '' : s.sizeBytes < 1024 ? `${s.sizeBytes}B` : s.sizeBytes < 1048576 ? `${(s.sizeBytes / 1024).toFixed(0)}K` : `${(s.sizeBytes / 1048576).toFixed(1)}M`; // Timestamp: unified shape carries lastActivityAt (ms epoch); the older // folder-modal/history shape carries an ISO lastModified string. Prefer ms, // fall back to parsing the string, and omit entirely when neither is valid. const tsMs = typeof s.lastActivityAt === 'number' ? s.lastActivityAt : s.lastModified ? Date.parse(s.lastModified) : NaN; let timeStr = ''; if (!Number.isNaN(tsMs)) { const date = new Date(tsMs); timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); } const shortDir = this._shortenHomePath(s.workingDir); const caseLabel = this._resolveCaseLabel(s.workingDir, cases); const isLive = Array.isArray(s.sources) && s.sources.includes('live'); const isPinned = s.pinned === true; const item = document.createElement('div'); item.className = 'history-item' + (isPinned ? ' is-pinned' : ''); item.title = s.workingDir || ''; // Main row: clickable surface. A caller-supplied onActivate wins (the // Session Manager routes live rows to selectSession and history rows to // resume). Otherwise the default focuses the live tab when the row is a // still-running session, else resumes the conversation — keyed by the Claude // conversation UUID (claudeSessionId) when present, since resumed sessions // carry theirs separately from their Codeman id. const mainRow = document.createElement('div'); mainRow.className = 'history-item-main'; mainRow.addEventListener( 'click', options?.onActivate || (() => { if (isLive && this.sessions.has(s.sessionId)) { this.selectSession(s.sessionId); } else { this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name); } }) ); const textCol = document.createElement('div'); textCol.className = 'history-item-text'; const titleSpan = document.createElement('span'); titleSpan.className = 'history-item-title'; if (isPinned) { // Filled pin glyph indicating the session is pinned to the top (COD-139). const pin = document.createElement('span'); pin.className = 'history-item-pin'; pin.textContent = '📌'; pin.setAttribute('aria-label', 'Pinned'); pin.title = 'Pinned'; titleSpan.appendChild(pin); } titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir))); // Badge row: mode (claude/codex/opencode/gemini/antigravity/shell) + a LIVE pill. const badgeRow = document.createElement('div'); badgeRow.className = 'history-item-badges'; if (s.mode) { const modeBadge = document.createElement('span'); modeBadge.className = 'history-item-badge history-item-badge-mode'; modeBadge.textContent = s.mode; badgeRow.appendChild(modeBadge); } // Worktree pill (#266): distinguishes sessions from different worktrees of the // same repo, which are otherwise identical in this list. Name AND branch when // both are known; a hand-made `git worktree add` yields no recoverable name, // so it degrades to branch-only rather than guessing one. const wtLabel = this._worktreeLabel(s); if (wtLabel) { const wtBadge = document.createElement('span'); wtBadge.className = 'history-item-badge history-item-badge-worktree'; wtBadge.textContent = wtLabel; wtBadge.title = s.worktreeRepo ? `worktree of ${s.worktreeRepo}` : wtLabel; badgeRow.appendChild(wtBadge); } if (isLive) { const liveBadge = document.createElement('span'); liveBadge.className = 'history-item-badge history-item-badge-live'; liveBadge.textContent = 'LIVE'; badgeRow.appendChild(liveBadge); } const subtitleSpan = document.createElement('span'); subtitleSpan.className = 'history-item-subtitle'; if (caseLabel.startsWith('#')) subtitleSpan.classList.add('is-case'); subtitleSpan.textContent = caseLabel; textCol.append(titleSpan); if (badgeRow.childElementCount > 0) textCol.append(badgeRow); textCol.append(subtitleSpan); const metaSpan = document.createElement('span'); metaSpan.className = 'history-item-meta'; metaSpan.textContent = timeStr; const expandBtn = document.createElement('button'); expandBtn.className = 'history-item-expand'; expandBtn.type = 'button'; // COD-130: the ⋯ button now opens a context (kebab) menu rather than // toggling the inline detail panel directly. aria-expanded still tracks // the detail panel (toggled via the menu's "Show details" item). expandBtn.setAttribute('aria-haspopup', 'menu'); expandBtn.setAttribute('aria-label', 'Session actions'); expandBtn.setAttribute('aria-expanded', 'false'); expandBtn.textContent = '⋯'; // ⋯ mainRow.append(textCol, metaSpan, expandBtn); // Detail panel: full prompt + full path, hidden by default const detail = document.createElement('div'); detail.className = 'history-item-detail'; detail.hidden = true; const promptRow = document.createElement('div'); promptRow.className = 'history-detail-row'; const promptLabel = document.createElement('span'); promptLabel.className = 'history-detail-label'; promptLabel.textContent = 'Prompt'; const promptText = document.createElement('span'); promptText.className = 'history-detail-value history-detail-prompt'; promptText.textContent = s.firstPrompt || '(no prompt captured)'; promptRow.append(promptLabel, promptText); // COD-145: show the most recent user prompt too, but collapse single-prompt // sessions (omit when there's no last prompt or it duplicates the first). let lastPromptRow = null; if (s.lastPrompt && s.lastPrompt !== s.firstPrompt) { lastPromptRow = document.createElement('div'); lastPromptRow.className = 'history-detail-row'; const lastPromptLabel = document.createElement('span'); lastPromptLabel.className = 'history-detail-label'; lastPromptLabel.textContent = 'Last prompt'; const lastPromptText = document.createElement('span'); lastPromptText.className = 'history-detail-value history-detail-prompt'; lastPromptText.textContent = s.lastPrompt; lastPromptRow.append(lastPromptLabel, lastPromptText); } const pathRow = document.createElement('div'); pathRow.className = 'history-detail-row'; const pathLabel = document.createElement('span'); pathLabel.className = 'history-detail-label'; pathLabel.textContent = 'Path'; const pathText = document.createElement('span'); pathText.className = 'history-detail-value history-detail-path'; pathText.textContent = shortDir; pathRow.append(pathLabel, pathText); const metaRow = document.createElement('div'); metaRow.className = 'history-detail-row history-detail-meta'; const metaParts = []; if (timeStr) metaParts.push(timeStr); if (hasSize) metaParts.push(size); metaParts.push(s.sessionId.slice(0, 8)); metaRow.textContent = metaParts.join(' · '); detail.append(promptRow); if (lastPromptRow) detail.append(lastPromptRow); detail.append(pathRow, metaRow); if (showViewAll && s.projectKey) { const actionRow = document.createElement('div'); actionRow.className = 'history-detail-row history-detail-actions'; const viewAllBtn = document.createElement('button'); viewAllBtn.type = 'button'; viewAllBtn.className = 'history-view-all-btn'; viewAllBtn.textContent = 'View all in this folder'; viewAllBtn.addEventListener('click', (ev) => { ev.stopPropagation(); this.openFolderHistoryModal(s.projectKey, s.workingDir, cases); }); actionRow.appendChild(viewAllBtn); detail.appendChild(actionRow); } expandBtn.addEventListener('click', (ev) => { // COD-130: stop both the row resume handler and the Session Manager // modal's main-row close listener from firing, then open the kebab menu. ev.stopPropagation(); ev.preventDefault(); this._openSessionRowMenu(ev.currentTarget, s, cases, item, detail); }); item.append(mainRow, detail); return item; }, /** * COD-130: Open a context (kebab) menu anchored to a history item's ⋯ * button. Replaces the old inline detail-toggle so the same control works * both in the history list and inside the Session Manager modal (where a * capture-phase close listener previously swallowed the click). * * The menu is appended to with fixed positioning so it escapes the * modal's overflow/stacking context, and flips above the anchor when it * would overflow the viewport bottom. * * @param {HTMLElement} anchorEl the ⋯ button the menu anchors to * @param {object} s session record * @param {Array} cases linked cases (unused but kept for parity/future) * @param {HTMLElement} item the .history-item element (for detail toggle) * @param {HTMLElement} detail the inline detail panel element */ _openSessionRowMenu(anchorEl, s, cases, item, detail) { // Close any already-open row menu first — call its own close fn so the // previous menu's document/window listeners are detached (a raw .remove() // would leave them dangling until the next event self-cleans). if (this._openRowMenuClose) { try { this._openRowMenuClose(); } catch { /* noop */ } } const isLiveOpen = Array.isArray(s.sources) && s.sources.includes('live') && this.sessions.has(s.sessionId); const menu = document.createElement('div'); menu.className = 'session-row-menu'; menu.setAttribute('role', 'menu'); // closeMenu tears down the menu and all transient listeners. let onDocMouseDown = null; let onKeyDown = null; let onScrollResize = null; const closeMenu = () => { document.removeEventListener('mousedown', onDocMouseDown, true); document.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('scroll', onScrollResize, true); window.removeEventListener('resize', onScrollResize, true); try { menu.remove(); } catch { /* noop */ } if (this._openRowMenuEl === menu) { this._openRowMenuEl = null; this._openRowMenuClose = null; } }; // Helper: build one menu item button. const addItem = (label, onActivate, opts) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'session-row-menu-item'; btn.setAttribute('role', 'menuitem'); const text = document.createElement('span'); text.className = 'session-row-menu-label'; text.textContent = label; btn.appendChild(text); if (opts && opts.sublabel) { const sub = document.createElement('span'); sub.className = 'session-row-menu-sublabel'; sub.textContent = opts.sublabel; btn.appendChild(sub); } btn.addEventListener('click', async (ev) => { // Never let the click bubble to the row resume / modal close handlers. ev.stopPropagation(); ev.preventDefault(); await onActivate(); }); menu.appendChild(btn); }; // Resume / Switch to session (always). addItem( isLiveOpen ? 'Switch to session' : 'Resume session', () => { if (isLiveOpen) { this.selectSession(s.sessionId); } else { // Resume by the Claude conversation UUID when present (resumed sessions // carry theirs separately from their Codeman id). this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name); } this.closeSessionManager?.(); closeMenu(); } ); // Pin / Unpin (COD-139) — floats the session to the top of the list. const isPinned = s.pinned === true; addItem(isPinned ? 'Unpin session' : 'Pin to top', async () => { const ok = await this._setSessionPinned(s.sessionId, !isPinned); if (ok) { // Optimistic local flip so a re-render before the SSE event is consistent. s.pinned = !isPinned; this.showToast(!isPinned ? 'Pinned to top' : 'Unpinned', 'success'); } else { this.showToast('Pin failed', 'error'); } closeMenu(); }); // Open folder (only for a live+open session — file browser is session-scoped). if (isLiveOpen) { addItem('Open folder', () => { this.selectSession(s.sessionId); this.loadFileBrowser?.(s.sessionId); this.closeSessionManager?.(); closeMenu(); }); } // Copy path (only when a workingDir is known). if (s.workingDir) { addItem('Copy path', async () => { const ok = await this._copyText(s.workingDir); this.showToast(ok ? 'Path copied' : 'Copy failed', ok ? 'success' : 'error'); closeMenu(); }); } // Show details (always) — toggles the inline detail panel; keeps modal open. addItem('Show details', () => { const expanded = item.classList.toggle('expanded'); detail.hidden = !expanded; anchorEl.setAttribute('aria-expanded', expanded ? 'true' : 'false'); closeMenu(); }); // Position: fixed, anchored under/over the button; flip up on overflow. document.body.appendChild(menu); const rect = anchorEl.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect(); const gap = 4; let top = rect.bottom + gap; if (top + menuRect.height > window.innerHeight && rect.top - gap - menuRect.height >= 0) { top = rect.top - gap - menuRect.height; // flip above the anchor } // Right-align the menu to the button, clamped into the viewport. let left = rect.right - menuRect.width; if (left < gap) left = gap; if (left + menuRect.width > window.innerWidth - gap) { left = Math.max(gap, window.innerWidth - gap - menuRect.width); } menu.style.top = `${Math.max(gap, top)}px`; menu.style.left = `${left}px`; // Dismissal listeners. onDocMouseDown = (ev) => { if (menu.contains(ev.target) || anchorEl.contains(ev.target)) return; closeMenu(); }; onKeyDown = (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); closeMenu(); } }; onScrollResize = () => closeMenu(); document.addEventListener('mousedown', onDocMouseDown, true); document.addEventListener('keydown', onKeyDown, true); window.addEventListener('scroll', onScrollResize, true); window.addEventListener('resize', onScrollResize, true); this._openRowMenuEl = menu; this._openRowMenuClose = closeMenu; }, /** * COD-139: Toggle a session's pin via POST /api/sessions/:id/pin. * Pinned sessions float to the top of the session manager list. Returns true * on success. The live re-sort happens when the session:pinned SSE event * fires (handled in app.js), so callers don't need to re-render themselves. * @param {string} sessionId * @param {boolean} pinned explicit desired pin state (idempotent) * @returns {Promise} */ async _setSessionPinned(sessionId, pinned) { try { const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ pinned }), }); if (!res.ok) return false; const data = await res.json(); return data?.success === true; } catch (err) { console.error('[_setSessionPinned]', err); return false; } }, /** Number of history items shown before "Show More" */ _HISTORY_INITIAL_COUNT: 10, /** * How many past sessions the home screen loads (also the filter/sort corpus). * 200, not the old 60, so the filter can reach a real backlog, an install with * 35+ conversations would otherwise hit the ceiling before the filter is useful * (raised in @jordan8037310's #263; the endpoint clamps at 500). */ _HISTORY_FETCH_LIMIT: 200, /** localStorage key for the per-device sort choice (#263). */ _HISTORY_SORT_KEY: 'codeman:historySort', async loadHistorySessions() { const container = document.getElementById('historySessions'); const list = document.getElementById('historyList'); if (!container || !list) return; try { // Load cases in parallel so subtitle can show "#caseName" labels. // Prefer already-loaded this.cases to avoid an extra request. const casesPromise = Array.isArray(this.cases) && this.cases.length > 0 ? Promise.resolve(this.cases) : fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []); const [allSessions, cases] = await Promise.all([ this._fetchUnifiedSessions(this._HISTORY_FETCH_LIMIT), casesPromise, ]); if (allSessions.length === 0) { container.style.display = 'none'; return; } // Keep the corpus around: filtering and sorting (issue #260) work on this // array, so a re-render costs no request. Expansion survives the periodic // refresh in panels-ui.js, collapsing the list under the user's cursor // every few seconds would be worse than the original 4-item cap. this._historyAll = allSessions; this._historyCases = cases; this._wireHistoryControls(); this._renderHistoryList(); container.style.display = ''; } catch (err) { console.error('[loadHistorySessions]', err); container.style.display = 'none'; } }, /** * Wire the filter box and sort select once; both re-render from the cached * corpus. The sort choice is restored from (and saved to) localStorage, it is * a per-device display preference, so it stays out of the synced settings * schema, same as `codeman:skin`. */ _wireHistoryControls() { if (this._historyControlsWired) return; const filter = document.getElementById('historyFilter'); const sort = document.getElementById('historySort'); if (!filter && !sort) return; this._historyControlsWired = true; if (sort) { try { const saved = localStorage.getItem(this._HISTORY_SORT_KEY); if (saved && Array.from(sort.options).some((o) => o.value === saved)) sort.value = saved; } catch { /* private mode, the order just won't persist */ } } if (filter) { filter.addEventListener('input', () => this._renderHistoryList()); filter.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && filter.value) { // Swallow it: Escape at the welcome screen otherwise closes overlays. ev.stopPropagation(); filter.value = ''; this._renderHistoryList(); } }); } if (sort) { sort.addEventListener('change', () => { try { localStorage.setItem(this._HISTORY_SORT_KEY, sort.value); } catch { /* private mode, the order just won't persist */ } this._renderHistoryList(); }); } }, /** True when a past-session row matches the filter text (name, folder, case, prompt). */ _historyRowMatches(s, needle, cases) { const fields = [ s.name, s.workingDir, this._resolveCaseLabel(s.workingDir, cases), s.firstPrompt, s.lastPrompt, s.sessionId, ]; return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(needle)); }, /** * The text a history row shows as its title. Most transcript-backed rows have * no session name at all, so this falls through to the first prompt and then * to the path, and the A–Z sort keys off the SAME string, or "sort by name" * would silently do nothing for exactly the rows the list is mostly made of. */ _historyRowLabel(s, fallback) { return s.name || s.firstPrompt || fallback || ''; }, /** * Sort past-session rows. 'recent' keeps the backend order (newest first); * the alphabetical modes sort by the visible title or by folder basename. * Pinned rows stay on top in every mode, pinning is an explicit override and * a sort that buried it would read as the pin having been lost. */ _sortHistoryRows(rows, mode) { const label = (s) => this._historyRowLabel(s, this._shortenHomePath(s.workingDir)).toLowerCase(); const folder = (s) => ((s.workingDir || '').split('/').pop() || '').toLowerCase(); const key = mode === 'name' ? label : folder; // numeric collation so w2-… sorts before w10-…, and base sensitivity so case // does not split a project's rows apart (from @jordan8037310's #263). const sorted = mode === 'recent' ? rows.slice() : rows .slice() .sort((a, b) => key(a).localeCompare(key(b), undefined, { sensitivity: 'base', numeric: true })); const pinned = sorted.filter((s) => s.pinned); return pinned.length === 0 ? sorted : pinned.concat(sorted.filter((s) => !s.pinned)); }, /** * Render the "Resume Conversation" list from the cached corpus, applying the * current filter and sort. Collapsed by default to _HISTORY_INITIAL_COUNT; * "Show more" expands the list AND the box (the CSS cap is class-driven, since * a fixed 240px box made expansion pointless, issue #260). */ _renderHistoryList() { const list = document.getElementById('historyList'); if (!list) return; const all = this._historyAll || []; const cases = this._historyCases || []; const countEl = document.getElementById('historyCount'); const needle = (document.getElementById('historyFilter')?.value || '').trim().toLowerCase(); const mode = document.getElementById('historySort')?.value || 'recent'; const matched = needle ? all.filter((s) => this._historyRowMatches(s, needle, cases)) : all; const rows = this._sortHistoryRows(matched, mode); // Filtering is itself an expansion request: hiding matches behind "Show more" // would defeat the point of typing a filter. const expanded = !!this._historyExpanded || needle.length > 0; const visible = expanded ? rows : rows.slice(0, this._HISTORY_INITIAL_COUNT); list.replaceChildren(); list.classList.toggle('expanded', expanded); if (rows.length === 0) { const empty = document.createElement('div'); empty.className = 'history-empty'; empty.textContent = `No conversations match "${needle}"`; list.appendChild(empty); } for (const s of visible) list.appendChild(this._buildHistoryItem(s, cases)); const hidden = rows.length - visible.length; if (hidden > 0) { const moreBtn = document.createElement('button'); moreBtn.className = 'history-show-more'; moreBtn.textContent = `Show ${hidden} more`; moreBtn.addEventListener('click', () => { this._historyExpanded = true; this._renderHistoryList(); }); list.appendChild(moreBtn); } else if (expanded && !needle && rows.length > this._HISTORY_INITIAL_COUNT) { const lessBtn = document.createElement('button'); lessBtn.className = 'history-show-more'; lessBtn.textContent = 'Show less'; lessBtn.addEventListener('click', () => { this._historyExpanded = false; this._renderHistoryList(); list.scrollTop = 0; }); list.appendChild(lessBtn); } if (countEl) { countEl.textContent = needle ? `${rows.length} of ${all.length}` : rows.length > visible.length ? `${visible.length} of ${rows.length}` : String(rows.length); } }, /** Page size for the folder history modal */ _FOLDER_HISTORY_PAGE_SIZE: 20, /** * Open a modal showing all history sessions in a single folder. * Paginated by FOLDER_HISTORY_PAGE_SIZE; "Show more" loads next page. */ openFolderHistoryModal(projectKey, workingDir, cases) { // Close any existing instance first this._closeFolderHistoryModal(); const modal = document.createElement('div'); modal.className = 'modal active folder-history-modal'; modal.id = 'folderHistoryModal'; const backdrop = document.createElement('div'); backdrop.className = 'modal-backdrop'; backdrop.addEventListener('click', () => this._closeFolderHistoryModal()); const content = document.createElement('div'); content.className = 'modal-content modal-lg'; const header = document.createElement('div'); header.className = 'modal-header'; const title = document.createElement('h3'); title.textContent = 'Folder History'; const subtitle = document.createElement('div'); subtitle.className = 'folder-history-subtitle'; subtitle.textContent = this._shortenHomePath(workingDir); const closeBtn = document.createElement('button'); closeBtn.className = 'modal-close'; closeBtn.setAttribute('aria-label', 'Close'); closeBtn.innerHTML = '×'; closeBtn.addEventListener('click', () => this._closeFolderHistoryModal()); header.append(title, closeBtn); const body = document.createElement('div'); body.className = 'modal-body'; const list = document.createElement('div'); list.className = 'folder-history-list'; list.setAttribute('data-loading', 'true'); list.textContent = 'Loading...'; body.append(subtitle, list); content.append(header, body); modal.append(backdrop, content); document.body.appendChild(modal); // Track state for pagination this._folderHistoryState = { projectKey, workingDir, cases: cases || [], offset: 0, total: null, list, }; // ESC to close this._folderHistoryEscHandler = (ev) => { if (ev.key === 'Escape') this._closeFolderHistoryModal(); }; document.addEventListener('keydown', this._folderHistoryEscHandler); this._loadFolderHistoryPage(); }, async _loadFolderHistoryPage() { const state = this._folderHistoryState; if (!state) return; const { projectKey, cases, list } = state; const limit = this._FOLDER_HISTORY_PAGE_SIZE; const offset = state.offset; // Remove existing "Show more" button while loading const existingMore = list.querySelector('.folder-history-more'); if (existingMore) existingMore.remove(); // First page: clear loading placeholder if (offset === 0) { list.replaceChildren(); list.removeAttribute('data-loading'); } try { const url = `/api/history/sessions?projectKey=${encodeURIComponent(projectKey)}&offset=${offset}&limit=${limit}`; const res = await fetch(url); const data = await res.json(); const sessions = data.data?.sessions || []; state.total = typeof data.data?.total === 'number' ? data.data.total : sessions.length + offset; if (offset === 0 && sessions.length === 0) { const empty = document.createElement('div'); empty.className = 'folder-history-empty'; empty.textContent = 'No conversations found in this folder.'; list.appendChild(empty); return; } for (const s of sessions) { list.appendChild(this._buildHistoryItem(s, cases, { showViewAll: false })); } state.offset = offset + sessions.length; // Add "Show more" if there are more sessions if (state.offset < state.total) { const remaining = state.total - state.offset; const moreBtn = document.createElement('button'); moreBtn.className = 'history-show-more folder-history-more'; moreBtn.textContent = `Show ${Math.min(limit, remaining)} more (${remaining} remaining)`; moreBtn.addEventListener('click', () => this._loadFolderHistoryPage()); list.appendChild(moreBtn); } } catch (err) { console.error('[loadFolderHistoryPage]', err); const errorEl = document.createElement('div'); errorEl.className = 'folder-history-empty'; errorEl.textContent = 'Failed to load folder history.'; list.appendChild(errorEl); } }, _closeFolderHistoryModal() { const modal = document.getElementById('folderHistoryModal'); if (modal) modal.remove(); if (this._folderHistoryEscHandler) { document.removeEventListener('keydown', this._folderHistoryEscHandler); this._folderHistoryEscHandler = null; } this._folderHistoryState = null; }, // Choose the name for a resumed session: keep the session's own name when it // has one, otherwise synthesize a fresh w- name (next free w-number // across open sessions). COD-143 — resume used to always generate a new name. _resolveResumeName(existingName, workingDir) { if (typeof existingName === 'string' && existingName.trim()) return existingName; const dirName = (workingDir || '').split('/').pop() || 'session'; let startNumber = 1; for (const [, session] of this.sessions) { const match = session.name && session.name.match(/^w(\d+)-/); if (match) { const num = parseInt(match[1]); if (num >= startNumber) startNumber = num + 1; } } return `w${startNumber}-${dirName}`; }, async resumeHistorySession(sessionId, workingDir, existingName) { // Close the run mode menu if open document.getElementById('runModeMenu')?.classList.remove('active'); // Close folder history modal if open this._closeFolderHistoryModal(); try { this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`); // Keep the session's own name when resuming; only synthesize a w- // name when the source row had none (COD-143). const name = this._resolveResumeName(existingName, workingDir); // Create session with resumeSessionId — include envOverrides so resumed // conversations inherit current UI settings (effort, agent teams, etc.). // Match by path (not basename) so linked/renamed cases still resolve correctly. const matchingCase = (this.cases || []).find((c) => c.path === workingDir); const caseName = matchingCase?.name || workingDir.split('/').pop() || ''; const globalSettings = this.loadAppSettingsFromStorage(); const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings); const effort = this.getEffortSetting(globalSettings); const createRes = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(effort ? { effort } : {}), }), }); const createData = await createRes.json(); if (!createData.success) throw new Error(createData.error); const newSessionId = createData.data.session.id; // Start interactive await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' }); this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`); await this.selectSession(newSessionId); this.terminal.focus(); } catch (err) { this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); } }, // ═══════════════════════════════════════════════════════════════ // Terminal Rendering // ═══════════════════════════════════════════════════════════════ /** * Check if terminal viewport is at or near the bottom. * Used to implement "sticky scroll" behavior - keep user at bottom if they were there. */ isTerminalAtBottom() { if (!this.terminal) return true; const buffer = this.terminal.buffer.active; // viewportY is the top line of the viewport, baseY is where scrollback starts // If viewportY >= baseY, we're showing the latest content (at bottom) // Allow 2 lines tolerance for edge cases return buffer.viewportY >= buffer.baseY - 2; }, // Record manual scroll gestures so sticky-scroll can give an upward scroll a // short grace window (see _hasRecentUserScrollUp). A downward scroll that // lands back at the bottom clears the suppression immediately. _noteTerminalUserScroll(lines) { if (lines < 0) { this._lastUserScrollUpAt = performance.now(); } else if (this.isTerminalAtBottom()) { this._lastUserScrollUpAt = null; } }, /** * Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the * buffer while scrolling up is the user reaching for history the browser does * not have, so pull the rest of tmux's scrollback (issue #205, see * _maybeRefetchFullHistory). Must be called AFTER scrollLines(), since the * check is on the resulting position, and it is deliberately not folded into * _noteTerminalUserScroll for exactly that reason. Cheap: one integer compare * per scroll event, and the pull itself is cooldown-guarded. */ _maybeLoadMoreHistoryOnScroll(lines) { if (lines >= 0) return; if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.(); }, /** * Rows a `?full=1` capture will occupy once written into xterm. * * tmux joins wrapped rows in that capture (`capture-pane -J`), so a long * logical line re-wraps into several xterm rows on write and a bare newline * count would undershoot; escape sequences occupy no cells and come out * first. Approximate by construction (it ignores double-width glyphs), which * is fine: the only consumer is a coarse size comparison * (_replayWouldShrinkBuffer), and it runs once per cooldown-guarded re-pull. */ _estimateReplayRows(text, cols) { if (typeof text !== 'string' || !text) return 0; const width = cols > 0 ? cols : 80; const plain = text.replace(window.CodemanTerminalInput.REPLAY_ESCAPE_RE, ''); let rows = 0; for (const line of plain.split('\n')) { const cells = line.endsWith('\r') ? line.length - 1 : line.length; rows += cells > width ? Math.ceil(cells / width) : 1; } return rows; }, /** * DOWNGRADE GUARD for the scroll-to-top re-pull (issue #205, round 2). * * `_maybeRefetchFullHistory` resets the terminal and rewrites it from the * capture, which is a straight win when tmux holds more than the browser — * the burst-repaint and tab-switch losses it was built for. But a repaint-mode * CLI pane keeps NO tmux history of its own (`history_size≈0` measured for a * Claude pane), so there the capture is roughly ONE frame while xterm may hold * hundreds of rows of replayed frames. Rewriting then DESTROYS history * mid-scroll: exactly the "goes back a limited amount, repeats blocks, gets * worse when I reach the top" report from the 1.12.0 retest. * * So refuse when the capture is smaller, with a one-screen tolerance because * both sides are estimates: `buffer.active.length` includes the blank rows * below the last line, and _estimateReplayRows can only approximate wrapping. * Only a capture that is worse by more than a full screen counts as a * downgrade, which leaves every genuine recovery case untouched. */ _replayWouldShrinkBuffer(capture) { const term = this.terminal; const rowsNow = term?.buffer?.active?.length || 0; if (!rowsNow) return false; const screen = term?.rows || 24; return this._estimateReplayRows(capture, term?.cols) + screen < rowsNow; }, /** * Ease-out smooth scrolling for the local wheel path. The capture-phase * wheel handler owns local scrolling (xterm's own smooth scroller is * bypassed, see the listener comment), so without this every notch was an * instant multi-line jump. Wheel deltas accumulate into a pending line * count (fractional — see _wheelScrollLinesFloat) and drain ~22% per * animation frame with a one-line floor, so a single notch starts with a * gentle step and glides to an exact landing; more notches mid-glide deepen * the pending count, which reads as natural acceleration. A sub-line * residual stays pending until further input pushes it past a whole line * (that is what makes slow trackpad drags track the finger). Direction * reversals cancel arithmetically. The pending amount is dropped when the * active session changes mid-glide — leftover momentum must never scroll * the tab the user just switched to. */ _smoothScrollBy(lines) { if (!lines) return; this._smoothScrollPending = (this._smoothScrollPending || 0) + lines; this._smoothScrollSession = this.activeSessionId; if (this._smoothScrollFrame) return; const step = () => { this._smoothScrollFrame = null; const pending = this._smoothScrollPending || 0; if (!pending) return; if (this.activeSessionId !== this._smoothScrollSession) { this._smoothScrollPending = 0; return; } if (Math.abs(pending) < 1) return; // sub-line residual: wait for more input const eased = pending * 0.22; const move = pending > 0 ? Math.max(1, Math.floor(eased)) : Math.min(-1, Math.ceil(eased)); this._smoothScrollPending = pending - move; this.terminal.scrollLines(move); this._maybeLoadMoreHistoryOnScroll(move); if (Math.abs(this._smoothScrollPending) >= 1) this._smoothScrollFrame = requestAnimationFrame(step); }; this._smoothScrollFrame = requestAnimationFrame(step); }, /** * Hand a scroll gesture (wheel tick or touch drag, already converted to * lines) to the CLI as synthetic SGR wheel reports. SGR coordinates address * the LIVE screen (the bottom `rows` of the buffer), so a report computed * from a scrolled-up viewport would hit-test a different row entirely, and * forwarding while the user stares at stale scrollback looks like the * gesture is dead. Snap back first: the gesture then always acts on what the * CLI is drawing now. */ _forwardScrollToApp(clientX, clientY, lines) { if (!this._terminalViewportAtBottom()) this.terminal.scrollToBottom(); this._sendSyntheticSgrWheel(clientX, clientY, lines); }, _hasRecentUserScrollUp() { if (typeof this._lastUserScrollUpAt !== 'number') return false; return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS; }, batchTerminalWrite(data) { // If a buffer load (chunkedTerminalWrite) is in progress, queue live events // to prevent interleaving historical buffer data with live SSE data. // This is critical: interleaving causes cursor position chaos with Ink redraws. if (this._isLoadingBuffer) { if (this._loadBufferQueue) this._loadBufferQueue.push(data); return; } // Check if at bottom BEFORE adding data (captures user's scroll position) // Only update if not already scheduled (preserve the first check's result) if (!this.writeFrameScheduled) { this._wasAtBottomBeforeWrite = this.isTerminalAtBottom(); } // Check if flicker filter is enabled for current session const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; const flickerFilterEnabled = session?.flickerFilterEnabled ?? false; // xterm.js 6.0 handles DEC 2026 synchronized output natively — Ink's cursor-up // redraws are wrapped in 2026h/2026l markers and rendered atomically by xterm.js. // No client-side cursor-up detection/buffering needed. The old 50ms flicker filter // was actively harmful: it accumulated multiple resize redraws and flushed them // together, causing stacked ghost renders due to reflow line-count mismatches. // Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it) if (flickerFilterEnabled) { const hasScreenClear = data.includes('\x1b[2J') || data.includes('\x1b[H\x1b[J') || (data.includes('\x1b[H') && data.includes('\x1b[?25l')); if (hasScreenClear) { this.flickerFilterActive = true; this.flickerFilterBuffer += data; if (this.flickerFilterTimeout) { clearTimeout(this.flickerFilterTimeout); } this.flickerFilterTimeout = setTimeout(() => { this.flickerFilterTimeout = null; this.flushFlickerBuffer(); }, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window return; } if (this.flickerFilterActive) { this.flickerFilterBuffer += data; return; } } // Accumulate raw data (may contain DEC 2026 markers) this.pendingWrites.push(data); this._scheduleTerminalWriteFlush(); }, /** * Schedule one render-budgeted terminal flush. * * Clear the scheduled flag before flushing so flushPendingWrites() can queue * another yield when a large final batch leaves bytes behind. Keeping the * flag set through the flush stranded that remainder until unrelated output * arrived, which looked like truncated responses and idle shell commands. */ _scheduleTerminalWriteFlush() { if (this.writeFrameScheduled || this.pendingWrites.length === 0) return; this.writeFrameScheduled = true; this._safeYield(() => { this.writeFrameScheduled = false; // xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers // content between 2026h/2026l and renders atomically. this.flushPendingWrites(); }); }, /** * Flush the flicker filter buffer to the terminal. * Called after the buffer window expires. */ flushFlickerBuffer() { if (!this.flickerFilterBuffer) return; // Transfer buffered data to normal pending writes this.pendingWrites.push(this.flickerFilterBuffer); this.flickerFilterBuffer = ''; this.flickerFilterActive = false; // Trigger a normal flush this._scheduleTerminalWriteFlush(); }, /** * Flush the local-echo overlay's unsent text into `_pendingInput` (no * trailing Enter) and reset overlay + flushed-state tracking. Used before * forwarding sequences that must arrive AFTER the typed text (bracketed * paste, composer nav keys). The caller forwards its own sequence: nav keys * ride the same write, pastes get a delayed second write because codex * drops keys that share a PTY read with a bracketed paste. */ _flushLocalEchoPending() { const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); this._localEchoOverlay?.suppressBufferDetection(); this._flushedOffsets?.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); if (text) this._pendingInput += text; }, /** * Update local echo overlay state based on settings. * Enabled whenever the setting is on — works during idle AND busy. * Position is tracked dynamically by _findPrompt() on every render. */ _updateLocalEchoState() { const settings = this.loadAppSettingsFromStorage(); const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); const shouldEnable = !!(echoEnabled && session); if (this._localEchoEnabled && !shouldEnable) { this._localEchoOverlay?.clear(); } this._localEchoEnabled = shouldEnable; // Swap prompt finder based on session mode if (this._localEchoOverlay && session) { if (session.mode === 'opencode') { // OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row. // The input area is "┃ " — the ┃ is the anchor, offset 3 skips "┃ ". // We use the cursor row (cursorY) to find the right line, then scan for ┃. this._localEchoOverlay.setPrompt({ type: 'custom', offset: 3, find: (terminal) => { try { const buf = terminal.buffer.active; const row = buf.cursorY; const line = buf.getLine(buf.viewportY + row); if (!line) return null; const text = line.translateToString(true); const idx = text.indexOf('\u2503'); // ┃ (BOX DRAWINGS HEAVY VERTICAL) if (idx >= 0) return { row, col: idx }; return null; } catch { return null; } }, }); } else if (session.mode === 'shell' || session.mode === 'codex') { // Shell mode: the shell provides its own PTY echo so the overlay isn't needed. // Codex mode: the composer is fully interactive per keystroke. Typing // "/" pops a live-filtering command picker (issue #222), the composer // grows and rewraps as it fills (#220), pastes are bracketed (#219) // and arrows/history edit server-side state (#218). Buffering // keystrokes until Enter starves all of that, so codex sessions use // plain PTY echo like shell — visually augmented by the predictive // write-through echo (see _localEchoPolicy below and the onData hook). // Disable the buffer overlay by clearing any pending text. this._localEchoOverlay.clear(); this._localEchoEnabled = false; } else { // Codex/Claude-style TUIs usually expose a ❯ prompt. During active // redraws or compact mobile layouts that marker may not be present in // the viewport, while xterm's cursor still marks the editable input // position. Fall back to cursor coordinates so phone typing appears at // the terminal cursor instead of disappearing into pending state. this._localEchoOverlay.setPrompt({ type: 'custom', offset: 0, find: (terminal) => { try { const buf = terminal.buffer.active; for (let row = terminal.rows - 1; row >= 0; row--) { const line = buf.getLine(buf.viewportY + row); if (!line) continue; const text = line.translateToString(true); const idx = text.lastIndexOf('\u276f'); if (idx >= 0) return { row, col: idx + 2 }; } return { row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)), col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)), }; } catch { return null; } }, }); } } // Per-session echo policy: 'buffer' (overlay), 'predict' (codex // write-through, see the onData predict hook), 'off'. _localEchoEnabled // keeps its exact historical values above (false for codex/shell), so // every existing consumer is unchanged; this field is purely additive. let policy = 'off'; if (session && echoEnabled) { if (session.mode === 'codex') policy = 'predict'; else if (session.mode !== 'shell') policy = 'buffer'; } this._localEchoPolicy = policy; if (policy !== 'predict') this._predictiveEcho?.clearPredictions(); }, /** * Predictive-echo onData hook (codex write-through). VISUAL ONLY: paints, * pops or clears prediction spans and never touches _pendingInput, never * sends, never throws into the caller. The onData wire path behaves * byte-identically with this active, absent or broken. */ _predictHookOnData(data) { if (this._localEchoPolicy !== 'predict' || !this._predictiveEcho) return; try { const kind = window.CodemanTerminalInput.classifyPredictInput(data); if (kind === 'char') this._predictiveEcho.predictChar(data); else if (kind === 'backspace') this._predictiveEcho.predictBackspace(); // 'clear' AND 'text' (plain paste, IME word commits) both change the // composer in ways the display has not shown yet: clear the run and let // the addon's anchor hold suppress prediction until the echo catches up else this._predictiveEcho.clearPredictions(); } catch { /* predictions must never block the wire */ } }, // CJK textarea already provides visual feedback — bypass local echo // buffering so each composed word reaches the PTY immediately. _handleCjkInput(text) { if (!this.activeSessionId) { _crashDiag.log(`CJK send DROP no-session len=${text.length}`); return; } // ── One-shot Ctrl (mobile shell bar, issue #262) ── // While the CJK field is visible it OWNS the keyboard: onData returns early // for everything it swallows, and the focus router even redirects // terminal.focus() into it — which is where the accessory bar sends focus // after every key. So the onData hook never sees these keystrokes, and an // armed modifier could neither fire NOR be spent: it survived until a // session switch and then turned an innocent keystroke into a control byte. // This is the module's single choke point to the PTY, so applying it here // covers typed characters, IME flushes, Enter, backspace and arrows at once. // Same policy as the onData hook: the next single character is modified, // anything longer merely spends the modifier. if (typeof KeyboardAccessoryBar !== 'undefined' && KeyboardAccessoryBar.isCtrlArmed?.()) { text = KeyboardAccessoryBar.consumeCtrl(text); } // Bypasses onData (like insertTerminalText): predictions cannot see this if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions(); _crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`); this._sendInputAsync(this.activeSessionId, text); }, /** * Flush pending writes to terminal, processing DEC 2026 sync markers. * Strips markers and writes content atomically within a single frame. */ flushPendingWrites() { if (this.pendingWrites.length === 0 || !this.terminal) return; const _t0 = performance.now(); // xterm.js 6.0+ natively handles DEC 2026 synchronized output markers. // Pass raw data through — xterm.js buffers content between markers and // renders atomically, eliminating split-frame Ink redraws. const joined = this.pendingWrites.join(''); this.pendingWrites = []; const _joinedLen = joined.length; if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen / 1024).toFixed(0)}KB`); // Per-frame byte budget to prevent main thread blocking. // Large writes (141KB+) can freeze Chrome for 2+ minutes. // Codex's TUI emits dense synchronized redraws during thinking/high-effort // phases, so it gets a smaller first frame to keep per-frame xterm/WebGL // stalls short; other modes keep the larger 64KB budget. const activeSession = this.activeSessionId && this.sessions ? this.sessions.get(this.activeSessionId) : null; const MAX_FRAME_BYTES = activeSession?.mode === 'codex' ? 32768 : 65536; let deferred = false; // If the user recently scrolled up, remember the viewport so we can restore // it after the write — Codex status redraws would otherwise jump it. const preserveViewportY = this._hasRecentUserScrollUp() && this.terminal.buffer?.active ? this.terminal.buffer.active.viewportY : null; if (_joinedLen <= MAX_FRAME_BYTES) { this.terminal.write(joined); } else { // Write first chunk now, defer rest to next frame this.terminal.write(joined.slice(0, MAX_FRAME_BYTES)); this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES)); deferred = true; this._scheduleTerminalWriteFlush(); } if ( preserveViewportY !== null && this.terminal.buffer?.active?.viewportY !== preserveViewportY && typeof this.terminal.scrollToLine === 'function' ) { this.terminal.scrollToLine(preserveViewportY); } const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen; const _dt = performance.now() - _t0; if (_dt > 100 || deferred) console.warn( `[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame / 1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)` ); // Sticky scroll: if user was at bottom, keep them there after new output. // Give manual scroll-up gestures a short grace window so high-frequency // Codex status ticks do not snap the viewport back while the user is // trying to inspect earlier output. if (this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) { this.terminal.scrollToBottom(); } // Re-position local echo overlay after terminal writes — Ink redraws can // move the ❯ prompt to a different row, making the overlay invisible. if (this._localEchoOverlay?.hasPending) { this._localEchoOverlay.rerender(); } // After Tab completion: detect the completed text in the overlay. // Use terminal.write('', callback) to defer detection until xterm.js // finishes processing ALL queued writes — direct buffer reads after // terminal.write(data) can miss text if xterm processes asynchronously. if ( this._tabCompletionSessionId && this._tabCompletionSessionId === this.activeSessionId && this._localEchoOverlay && !this._localEchoOverlay.pendingText ) { const overlay = this._localEchoOverlay; const self = this; this.terminal.write('', () => { if (!self._tabCompletionSessionId) return; // already resolved overlay.resetBufferDetection(); const detected = overlay.detectBufferText(); if (detected) { if (detected === self._tabCompletionBaseText) { // Same text as before Tab — no completion yet. Undo and retry. overlay.undoDetection(); self._tabCompletionRetries = (self._tabCompletionRetries || 0) + 1; if (self._tabCompletionRetries > 60) { self._tabCompletionSessionId = null; self._tabCompletionRetries = 0; } } else { // Text changed — real completion happened self._tabCompletionSessionId = null; self._tabCompletionRetries = 0; self._tabCompletionBaseText = null; if (self._tabCompletionFallback) { clearTimeout(self._tabCompletionFallback); self._tabCompletionFallback = null; } overlay.rerender(); } } else { // No text found yet — retry on next flush. self._tabCompletionRetries = (self._tabCompletionRetries || 0) + 1; if (self._tabCompletionRetries > 60) { self._tabCompletionSessionId = null; self._tabCompletionRetries = 0; } } }); } }, /** * Schedule cb via THREE racing primitives so data-pacing makes progress * regardless of which scheduling primitive Chrome is throttling: * 1. requestAnimationFrame — primary, fires at compositor rate * (may be 0Hz when window is occluded / on backgrounded monitor). * 2. setTimeout(50) — fallback for occluded-but-visible windows * (clamped to 1Hz by Chrome's intensive wake-up throttling * after ~5 min of no user interaction). * 3. Worker postMessage — bypasses intensive throttling entirely; * Workers are not subject to background-tab / idle-tab throttling * (the React Scheduler trick). * Whichever fires first wins; the others are no-ops thanks to the * `done` guard. Without all three, chunkedTerminalWrite and the deferred * path of flushPendingWrites stall indefinitely when the substrate is * degraded (visible-but-occluded window, OR idle-throttled tab, OR * background tab on a different monitor). */ _safeYield(cb) { let done = false; const wrapped = () => { if (done) return; done = true; cb(); }; requestAnimationFrame(wrapped); setTimeout(wrapped, 50); this._workerYield(wrapped); }, /** * Lazy-init a tiny "tick" worker whose only job is to postMessage back to * us as fast as possible, escaping main-thread throttling. The worker's * setTimeout(0) is not subject to Chrome's intensive wake-up throttling * even when the parent tab is idle. */ _workerYield(cb) { try { if (this._yieldWorker === undefined) { // First call: build the worker (or mark unavailable). Each // postMessage in produces exactly one postMessage out — we count on // FIFO 1:1 to drain queue entries. const src = "onmessage=()=>setTimeout(()=>postMessage(0),0);"; const blob = new Blob([src], { type: 'application/javascript' }); const url = URL.createObjectURL(blob); this._yieldWorker = new Worker(url); URL.revokeObjectURL(url); this._yieldQueue = []; this._yieldWorker.onmessage = () => { const fn = this._yieldQueue.shift(); if (fn) fn(); }; } if (!this._yieldWorker) return; this._yieldQueue.push(cb); this._yieldWorker.postMessage(0); } catch { this._yieldWorker = null; // mark unavailable, future calls skip } }, scrollToLastNonEmptyLine() { if (!this.terminal?.buffer?.active) { this.terminal?.scrollToBottom?.(); return; } const buffer = this.terminal.buffer.active; const totalLines = buffer.baseY + buffer.length; let lastNonEmptyLine = -1; for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) { const line = buffer.getLine(lineIndex); if (line?.translateToString(true).trim()) { lastNonEmptyLine = lineIndex; break; } } if (lastNonEmptyLine >= 0 && typeof this.terminal.scrollToLine === 'function') { let targetLine = Math.max(0, lastNonEmptyLine - this.terminal.rows + 2); const maxTargetLine = Math.max(0, lastNonEmptyLine); while (targetLine < maxTargetLine) { const line = buffer.getLine(targetLine); if (line?.translateToString(true).trim()) break; targetLine++; } this.terminal.scrollToLine(targetLine); } else { this.terminal.scrollToBottom(); } }, /** * Write large buffer to terminal in chunks to avoid UI jank. * Uses _safeYield to spread work across frames; falls back to setTimeout * and a tick-Worker so progress continues on occluded / idle-throttled tabs. * @param {string} buffer - The full terminal buffer to write * @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps) * @returns {Promise} - Resolves when all chunks written */ chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) { // Generation counter: if a newer chunkedTerminalWrite starts (tab switch), // older writes abort instead of continuing to push stale data into the terminal. const writeGen = ++this._chunkedWriteGen; const bufferLoadOwner = this._beginBufferLoad(loadOwner); return new Promise((resolve) => { if (!buffer || buffer.length === 0) { this._finishBufferLoad(bufferLoadOwner); resolve(); return; } // Strip any DEC 2026 markers that might be in the buffer // (from historical SSE data that was stored with markers) const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, ''); const finish = () => { // Only finish if we're still the active write — a newer write owns buffer load state if (this._chunkedWriteGen === writeGen) { this._finishBufferLoad(bufferLoadOwner); } resolve(); }; // For small buffers, write directly — single-frame render is fast enough if (cleanBuffer.length <= chunkSize) { this.terminal.write(cleanBuffer, finish); return; } // Large buffers: write in chunks across animation frames. // Each 32KB chunk keeps per-frame WebGL render work under ~5ms, // avoiding GPU stalls without needing to toggle the renderer. let offset = 0; const _chunkStart = performance.now(); let _chunkCount = 0; const writeChunk = () => { // Abort if a newer chunked write started (user switched tabs) if (this._chunkedWriteGen !== writeGen) { resolve(); return; } if (offset >= cleanBuffer.length) { const _totalMs = performance.now() - _chunkStart; console.log( `[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total` ); // Wait one more frame for xterm to finish rendering before resolving this._safeYield(finish); return; } const _ct0 = performance.now(); const chunk = cleanBuffer.slice(offset, offset + chunkSize); this.terminal.write(chunk); const _cdt = performance.now() - _ct0; _chunkCount++; if (_cdt > 50) console.warn( `[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})` ); offset += chunkSize; // Schedule next chunk; rAF if possible, else setTimeout/Worker // fallback so progress doesn't stall on occluded/unfocused windows. this._safeYield(writeChunk); }; // Start writing this._safeYield(writeChunk); }); }, /** * Complete a buffer load: unblock live SSE writes. * Called when chunkedTerminalWrite finishes (or is skipped for empty buffers). * * By default queued SSE events are DISCARDED, not flushed. For an established * session the loaded buffer from the API is the source of truth up to the * response timestamp; SSE events queued during the fetch+write overlap already * appear in that buffer, so flushing them writes duplicate data (especially Ink * cursor-up redraws), corrupting the terminal display. * * COD-144: a brand-new session is the exception. Its terminal fetch can resolve * BEFORE the PTY emits its first prompt, so the fetched buffer is empty and the * prompt arrives only as a queued SSE event. Discarding it leaves the terminal * blank until a tab-switch re-fetches a now-populated buffer. When the caller * knows the load painted nothing (empty fetch + no cache), it passes * `{ flushQueued: true }` so the queued events are REPLAYED through * `batchTerminalWrite()` instead of dropped. Replay runs after `_isLoadingBuffer` * is cleared, so the events write through normally and are not re-queued. * * After unblocking, new SSE/WS events deliver subsequent output normally. * * @param {string} [owner] Load token from `_beginBufferLoad`; a stale owner is a no-op. * @param {{ flushQueued?: boolean }} [opts] When `flushQueued` is true, replay any queued events. */ _beginBufferLoad(owner) { if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0; const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner; this._bufferLoadOwner = loadOwner; this._isLoadingBuffer = true; this._loadBufferQueue = []; return loadOwner; }, _finishBufferLoad(owner, opts) { if (owner !== undefined && this._bufferLoadOwner !== owner) { return false; } const queued = this._loadBufferQueue; this._isLoadingBuffer = false; this._loadBufferQueue = null; this._bufferLoadOwner = null; // COD-144: replay (rather than discard) queued live events when the load // painted nothing — the queued prompt is the only content a new session has. if (opts?.flushQueued && queued && queued.length) { for (const data of queued) { this.batchTerminalWrite(data); } } return true; }, // ═══════════════════════════════════════════════════════════════ // Terminal Controls // ═══════════════════════════════════════════════════════════════ clearTerminal() { this.terminal.clear(); }, /** Insert editable text at the active prompt without pressing Enter. */ insertTerminalText(text) { if (!this.activeSessionId || !text) return; // Under predict the text goes out via sendInput (bypasses onData), so the // hook never sees it: clear outstanding predictions here instead. if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions(); if ( this._localEchoEnabled && this._localEchoOverlay && !this._echoPassthroughSessions?.has(this.activeSessionId) ) { this._localEchoOverlay.appendText(text); } else { this.sendInput(text).catch(() => {}); } this.terminal?.focus(); }, /** * Clear only the current editable prompt. This is intentionally distinct * from Ctrl+L (clear display) and the agent's destructive `/clear` command. */ clearTerminalInput() { if (!this.activeSessionId) return; if (typeof CjkInput !== 'undefined') CjkInput.clear(); if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } this._pendingInput = ''; // Composer content is about to change out from under any predictions if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions(); if (this._localEchoEnabled && this._localEchoOverlay) { const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' }; this._localEchoOverlay.clear(); this._localEchoOverlay.suppressBufferDetection(); this._flushedOffsets?.delete(this.activeSessionId); this._flushedTexts?.delete(this.activeSessionId); if (flushed.count > 0) { this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {}); } } else { // In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U // is the conventional kill-line key supported by shells and agent TUIs. this.sendInput('\x15').catch(() => {}); } this.showToast?.('Input cleared', 'success'); this.terminal?.focus(); }, /** * Restore terminal size to match web UI dimensions. * Use this after mobile screen attachment has squeezed the terminal. * Sends only resize — SIGWINCH triggers Ink redraw on real dimension changes. * Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation"). */ async restoreTerminalSize() { if (!this.activeSessionId) { this.showToast('No active session', 'warning'); return; } const dims = this.getTerminalDimensions(); if (!dims) { this.showToast('Could not determine terminal size', 'error'); return; } try { // Force resize even when dimensions match the server's last known state — // another device may have changed the PTY size since this client last sent, // and force guarantees a SIGWINCH → Ink redraw at the current device's size. await this.sendResize(this.activeSessionId, { force: true }); this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success'); } catch (err) { console.error('Failed to restore terminal size:', err); this.showToast('Failed to restore terminal size', 'error'); } }, // Vestigial no-op: this method has no callers today. It's kept (not deleted) // as a documented guard so the Ctrl+L behavior below isn't reintroduced. // // Originally this sent Ctrl+L (\x0c) when a flagged session first reached // idle/working to scrub mux-init junk from the screen. Two problems: // 1. `pendingCtrlL` was never actually populated anywhere (dead path). // 2. Claude Code 2.x interprets Ctrl+L as a two-step "clear conversation" // command — sending it from background flows risked nuking the user's // conversation if it coincided with another Ctrl+L (e.g. from // selectSession on page reload). // If a per-session display-fix is ever needed again, do it via sendResize // or an Ink-safe control sequence, NOT \x0c. sendPendingCtrlL(_sessionId) { // intentionally empty }, // Registry-aware gate for the smart-copy chord (#211). Mirrors // shouldOpenCommandPaletteFromShortcut(): honors a rebound or disabled // 'copy-selection' entry, and falls back to the default chord when the // registry isn't available (isolated test harnesses). // Returning true only means "this chord asked to copy", the CALLER decides // what happens when there is no selection, so the interrupt stays intact. shouldCopyTerminalSelectionFromShortcut(ev) { // The custom key handler also runs for keypress/keyup; only keydown decides. if (!ev || ev.type !== 'keydown') return false; // Hot path: every dispatchable chord needs Ctrl/Cmd/Alt, so plain typing // exits before any registry work. if (!ev.ctrlKey && !ev.metaKey && !ev.altKey) return false; const registryAvailable = typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function'; const entry = registryAvailable ? this.getShortcutRegistry().find((s) => s.id === 'copy-selection') : null; if (entry) return !entry.disabled && this.matchesShortcutEvent(ev, entry); return !ev.altKey && (ev.key || '').toLowerCase() === 'c'; }, // Copy the current terminal selection. Goes through _copyText (Clipboard API, // then a hidden-textarea + execCommand fallback) because install.sh's LAN // option serves plain HTTP, where navigator.clipboard is undefined. async copyTerminalSelection(text) { const selection = text ?? (this.terminal.hasSelection?.() ? this.terminal.getSelection() : ''); if (!selection) return false; const ok = await this._copyText(selection); if (ok) { // Clearing is what makes a second Ctrl+C an interrupt (and xterm already // drops the selection on any keypress, so this matches existing feel). this.terminal.clearSelection?.(); this.showToast('Copied to clipboard', 'success'); } else { this.showToast('Failed to copy', 'error'); } // The execCommand fallback focuses a temp textarea, so hand focus back. This // is the CJK-aware focus router, not xterm's raw focus(). this.terminal.focus(); return ok; }, async copyTerminal() { try { const buffer = this.terminal.buffer.active; let text = ''; for (let i = 0; i < buffer.length; i++) { const line = buffer.getLine(i); if (line) text += line.translateToString(true) + '\n'; } await navigator.clipboard.writeText(text.replace(/\n+$/, '\n')); this.showToast('Copied to clipboard', 'success'); } catch (err) { this.showToast('Failed to copy', 'error'); } }, _syncMobileHelperTextareaToCursor() { if (!MobileDetection.isTouchDevice() || !this.terminal?.element) return; try { const xtermEl = this.terminal.element; const cursor = this.terminal.element.querySelector('.xterm-cursor'); const screen = this.terminal.element.querySelector('.xterm-screen'); if (!(xtermEl instanceof HTMLElement) || !(cursor instanceof HTMLElement) || !(screen instanceof HTMLElement)) return; const cursorRect = cursor.getBoundingClientRect(); const screenRect = screen.getBoundingClientRect(); if (!cursorRect.width && !cursorRect.height) return; const left = Math.max(0, Math.round(cursorRect.left - screenRect.left)); const top = Math.max(0, Math.round(cursorRect.top - screenRect.top)); xtermEl.style.setProperty('--xterm-helper-left', `${left}px`); xtermEl.style.setProperty('--xterm-helper-top', `${top}px`); } catch {} }, // ═══════════════════════════════════════════════════════════════ // Synthetic tap → mouse report // ═══════════════════════════════════════════════════════════════ // Dispatch a mousedown+mouseup pair at viewport coords (clientX/clientY) to // xterm's root element. xterm's mouse-reporting handler reads the event's // client coords, maps them to a terminal cell relative to .xterm-screen, and // — when the foreground app has mouse tracking active (DECSET 1000/1002/1006, // which Claude's input enables) — encodes an SGR mouse report to the PTY. // That is the same path a real desktop click takes; on touch devices the // browser's own compatibility-event synthesis is unreliable (and suppressed // by touch-action:none), so we drive it explicitly. With mouse tracking off // it degrades to a harmless zero-length click (no drag → no text selection). _dispatchSyntheticTerminalClick(clientX, clientY) { const el = this.terminal?.element; if (!el || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return; // xterm registers its mouseup listener on document during mousedown, so a // bubbling mouseup reaches it; dispatch both to the root element in order. const base = { bubbles: true, cancelable: true, view: window, clientX, clientY, screenX: clientX, screenY: clientY, button: 0, detail: 1, }; try { el.dispatchEvent(new MouseEvent('mousedown', { ...base, buttons: 1 })); el.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 })); } catch { /* MouseEvent constructor unavailable — tap-to-position simply no-ops */ } }, // Mirror of the server's isAltScreenStripMode (session.ts): session modes whose // output stream has mouse-tracking DECSET sequences stripped before reaching the // browser. For these, xterm's live mouseTrackingMode is useless as a gate — the // PTY-side TUI keeps tracking enabled, we just never see the enable sequence. _sessionUsesServerMouseStrip() { const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; return mode === 'claude' || mode === 'codex' || mode === 'gemini'; }, // True when xterm's viewport shows the live PTY screen (not scrolled up into // local scrollback). SGR coordinates are only meaningful then: the TUI's // screen is the bottom `rows` of the buffer, so a report computed from a // scrolled-up viewport would hit-test a completely different row. _terminalViewportAtBottom() { const buf = this.terminal?.buffer?.active; return !buf || buf.viewportY >= buf.baseY; }, // Map a viewport point to a 1-based terminal cell the same way xterm maps a // click: offset inside .xterm-screen divided by the rendered cell size, // clamped to the grid. Returns null when the terminal isn't measurable yet. _clientPointToCell(clientX, clientY) { if (!this.terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; const screen = this.terminal.element?.querySelector('.xterm-screen'); const cell = this.terminal._core?._renderService?.dimensions?.css?.cell; if (!screen || !cell?.width || !cell?.height) return null; const rect = screen.getBoundingClientRect(); const col = Math.max(1, Math.min(this.terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1)); const row = Math.max(1, Math.min(this.terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1)); return { col, row }; }, // 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. _sendSyntheticSgrTap(clientX, clientY) { if (!this.activeSessionId) return; if (!this._terminalViewportAtBottom()) return; // scrollback click → misfire, do nothing const pos = this._clientPointToCell(clientX, clientY); if (!pos) return; this._sendInputAsync(this.activeSessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`); }, // True when a parsed CLI version string ('2.1.187' — banner-parsed on the // server, delivered via session:cliInfo / SessionState.cliVersion) is known // AND >= the minimum. Unknown or unparseable versions return false so // callers keep the conservative behavior. _cliVersionAtLeast(version, minimum) { if (typeof version !== 'string') return false; const parts = version.trim().replace(/^v/, '').split('.').map(Number); if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) return false; const min = minimum.split('.').map(Number); for (let i = 0; i < 3; i++) { if (parts[i] !== min[i]) return parts[i] > min[i]; } return true; }, // Wheel forwarding gate for the container wheel handler: no Shift override, // xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to // scroll its transcript on SGR wheel reports — which today is claude 2.1.187+ // and nothing else (older Claude Code captures wheel as select-menu option // navigation; an unknown version is treated as older). Gemini and codex are // strip modes too but keep the local wheel — taps/clicks are still forwarded // for them (harmless no-ops at worst). // // Codex USED to forward here and was the #227 regression (DodgyBadger, Codex // latest / Chrome / Win11: dead wheel in codex, working scrollbar drag). // Measured on codex-cli 0.147.0 in a bare tmux: it never enables mouse // tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change // NOTHING on screen — it runs an inline viewport (`alternate_on=0`) and pushes // its transcript into the terminal's own scrollback (tmux `history_size` // grows), so there is no in-app pager to drive and local scrollback IS the // codex transcript. Forwarding therefore swallowed every tick. // Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger // scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and // Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns // false on Shift). So under Shift, read whichever axis dominates — otherwise // deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through // history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are // unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving. // // `deltaMode` says what UNIT the delta is in, and ignoring it made every // non-pixel browser scroll ~4x too slowly: Firefox reports DOM_DELTA_LINE (1) // with deltaY≈3 per notch, so the pixel math rounded to 0 and fell through to // the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In // Claude mode the same value also capped the forwarded SGR report at one tick. _wheelScrollLines(ev) { const lines = this._wheelScrollLinesFloat(ev); if (!lines) return 0; // pure horizontal swipe: don't fall through to -1 return Math.round(lines) || (lines > 0 ? 1 : -1); }, /** Unrounded variant for the smooth local-scroll path, which accumulates * sub-line fractions across events instead of forcing every tiny trackpad * delta to a whole ±1 line. Same unit handling and Shift-axis trap. */ _wheelScrollLinesFloat(ev) { const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; if (!delta) return 0; return ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel) ? delta : ev.deltaMode === 2 // DOM_DELTA_PAGE ? delta * (this.terminal?.rows || 24) : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) }, _shouldForwardWheelToApp(ev) { if (ev.shiftKey) return false; // Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the // plain wheel to xterm's own scrollback like pre-#144, for users who prefer // it over forwarding the wheel to the CLI's transcript (issue #154). Cheap — // loadAppSettingsFromStorage() is cache-backed. // // FOOTGUN, and why it is handled downstream rather than here: for a // repaint-mode CLI that local scrollback is EMPTY (tmux keeps no history for // the pane), so this setting can silently convert a working wheel into a // dead one — a plausible reading of the #205 retest, where a user whose // scrolling was broken on 1.11.x may well have flipped it while hunting for // a fix. Scoping the setting away from those modes would be the other // option, but it would override an explicit user choice; instead the caller // falls through to _maybePageCliTranscript, so the gesture still pages the // CLI's transcript and the setting keeps meaning exactly what it says. if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false; const mode = this.terminal?.modes?.mouseTrackingMode; if (mode && mode !== 'none') return false; const session = this.sessions?.get(this.activeSessionId); const sessionMode = session?.mode || 'claude'; if (sessionMode !== 'claude') return false; if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; // Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so // that leaving the bottom handed the wheel back to local scrollback and both // histories stayed reachable without a mode switch. In practice that inverted // the behavior users actually want: a repaint-mode CLI keeps NO terminal // scrollback of its own (tmux reports history_size=0 for a Claude pane), so // xterm's buffer holds only Codeman's REPLAYED repaint frames. Scrolling that // locally drags the CLI's own pinned furniture (the prompt box, the status // line) up the screen and shows stale frames underneath, which reads as "the // window scrolled away" rather than "I am reading history". // // And it was easy to fall into: scrollToLastNonEmptyLine() parks the viewport // `rows - 2` above the last non-empty row, so any tab switch onto a session // with trailing blank rows left the viewport off-bottom and every later wheel // went local. Forwarding unconditionally keeps the CLI's transcript as the // plain wheel's target and its input box fixed in place; local scrollback is // still on Shift+wheel and on the "Wheel scrolls local history" opt-out above. return true; }, // Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer // cell. Reports are coalesced into one fire-and-forget write per ~40ms: a // trackpad emits dozens of wheel events per second and each send becomes a // tmux send-keys on the server — unbatched, a single flick would spawn a // process storm. Per-event tick count is capped (Claude applies its own // scroll-speed multiplier and acceleration on top), and the queue is bounded // so a wild scroll can't build a backlog that keeps scrolling after the finger // stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue. _sendSyntheticSgrWheel(clientX, clientY, lines) { if (!this.activeSessionId || !lines) return; const pos = this._clientPointToCell(clientX, clientY); if (!pos) return; const btn = lines < 0 ? 64 : 65; const ticks = Math.min(Math.abs(lines), 5); this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks)); }, /** * Shared 40ms coalescer for every byte a scroll gesture sends to the PTY (SGR * wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a * tmux send-keys server-side, so per-event writes would spawn a process storm * on a single flick; the queue is bounded so a wild scroll can't build a * backlog that keeps scrolling after the finger stops. */ _queueScrollBytes(data) { if (!data || !this.activeSessionId) return; const queued = this._wheelSgrQueue || ''; if (queued.length > 512) return; this._wheelSgrQueue = queued + data; if (this._wheelSgrFlushTimer) return; this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40); }, /** * True when this session's LOCAL scrollback is structurally empty: a Claude * pane in repaint mode, where tmux reports `history_size≈0` and every frame * overwrites the last, so xterm's normal buffer never grows past one screen * (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture * is routed — the "wheel does nothing at all" half of the #205 retest. */ _localScrollbackIsHollow() { const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; if (mode !== 'claude') return false; const buf = this.terminal?.buffer?.active; if (!buf || buf.type === 'alternate') return false; return (buf.baseY || 0) === 0; }, /** * LAST-RESORT scroll for a hollow local buffer: translate gesture lines into * coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript. * * The rescue path for every way `_shouldForwardWheelToApp` can come back false * on a Claude session that has no local history to fall back on: the CLI * version probe failed or is genuinely older than 2.1.187, or the user turned * on "Wheel scrolls local history" (which pins the wheel to a buffer that, * for a repaint-mode CLI, is empty — the setting's footgun). Before this, all * of those produced a completely dead gesture; the #205 reporter proved the * keyboard route works by paging back through intact text with Fn+Up. * * Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with * real local scrollback is never touched. Shift is excluded on purpose: it is * the explicit "give me local scrollback" gesture and must keep that meaning. * * @returns true when the gesture was consumed here (the caller must not also * scroll locally). */ _maybePageCliTranscript(ev, lines) { if (!lines || ev?.shiftKey || !this.activeSessionId) return false; if (!this._localScrollbackIsHollow()) return false; // Leftover travel belongs to the tab it was made on. if (this._pageKeySession !== this.activeSessionId) { this._pageKeySession = this.activeSessionId; this._pageKeyPending = 0; } const tuning = window.CodemanTerminalInput; const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION)); const pending = (this._pageKeyPending || 0) + lines; const pages = Math.trunc(pending / perPage); this._pageKeyPending = pending - pages * perPage; if (pages) { const key = pages < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN; this._queueScrollBytes(key.repeat(Math.min(Math.abs(pages), tuning.PAGE_KEY_MAX_PER_BATCH))); } this._logScrollRouting('page-keys'); return true; }, /** * One line in the console saying WHY a scroll gesture went where it went. * * Issue #205 ran two rounds of remote guesswork — is the CLI version probe * empty, is the opt-out setting on, did a mouse DECSET leak past the strip? — * that this single log answers directly. Logged once per session per distinct * decision, so a steady gesture stays silent and a CHANGE (e.g. the version * arriving late and flipping the route) still prints. */ _logScrollRouting(decision) { const sessionId = this.activeSessionId || '(none)'; const session = this.sessions?.get(sessionId); const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback; const tracking = this.terminal?.modes?.mouseTrackingMode || 'none'; const baseY = this.terminal?.buffer?.active?.baseY ?? -1; const signature = `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${baseY > 0}`; if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map(); if (this._scrollRoutingLogged.get(sessionId) === signature) return; this._scrollRoutingLogged.set(sessionId, signature); console.log( `[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` + `localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, localScrollbackRows=${baseY})` ); }, _flushWheelSgrQueue() { this._wheelSgrFlushTimer = null; const data = this._wheelSgrQueue; this._wheelSgrQueue = ''; // Ephemeral (fire-and-forget): wheel reports are loss-tolerant, so they skip // the durable seq/ACK queue — no localStorage churn, no "Nb queued" flicker // in the connection indicator on every scroll tick. if (data && this.activeSessionId) this._sendInputEphemeral(this.activeSessionId, data); }, // Desktop counterpart of the touchend tap branch: hand-encode an SGR report // for a plain left-click when the server strips mouse DECSETs (see // _sessionUsesServerMouseStrip). Every skip below is a click that already has // a meaning elsewhere: synthetic/compat clicks after a touch tap (touchend // reported already), modified clicks (shift keeps xterm's selection // override), double/triple clicks (word/line selection), drag-selections, // clicks on hovered links (activate() already handles the click — a second // synthetic SGR press could e.g. dismiss a claude permission dialog), // 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). _handleDesktopTerminalClick(ev) { if (!this.terminal || !ev?.isTrusted) return; if (ev.button !== 0 || ev.detail !== 1) return; if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return; const mode = this.terminal.modes?.mouseTrackingMode; if (mode && mode !== 'none') return; if (!this._sessionUsesServerMouseStrip()) return; if (this.terminal.hasSelection?.()) return; if (this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider) if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return; if (!ev.target?.closest?.('.xterm-screen')) return; this._sendSyntheticSgrTap(ev.clientX, ev.clientY); }, _installMobileTapMouseGuard() { const el = this.terminal?.element; if (!el || el._codemanTapMouseGuardInstalled) return; if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice && !MobileDetection.isTouchDevice()) return; el._codemanTapMouseGuardInstalled = true; const suppressTrustedCompatMouse = (ev) => { const suppressUntil = this._trustedTapMouseSuppressUntil || 0; if (!ev.isTrusted || performance.now() > suppressUntil) return; ev.preventDefault(); ev.stopImmediatePropagation(); }; el.addEventListener('mousedown', suppressTrustedCompatMouse, true); el.addEventListener('mouseup', suppressTrustedCompatMouse, true); }, _suppressTrustedTapMouseEvents() { const ms = window.CodemanTerminalInput?.TOUCH_COMPAT_MOUSE_SUPPRESS_MS || 450; this._trustedTapMouseSuppressUntil = performance.now() + ms; }, increaseFontSize() { const current = this.terminal.options.fontSize || 14; this.setFontSize(Math.min(current + 2, 24)); }, decreaseFontSize() { const current = this.terminal.options.fontSize || 14; this.setFontSize(Math.max(current - 2, 10)); }, setFontSize(size) { this.terminal.options.fontSize = size; document.getElementById('fontSizeDisplay').textContent = size; this.fitAddon.fit(); localStorage.setItem('codeman-font-size', size); // Update overlay font cache and re-render at new cell dimensions this._localEchoOverlay?.refreshFont(); this._predictiveEcho?.refreshFont(); }, loadFontSize() { const saved = localStorage.getItem('codeman-font-size'); if (saved) { const size = parseInt(saved, 10); if (size >= 10 && size <= 24) { this.terminal.options.fontSize = size; document.getElementById('fontSizeDisplay').textContent = size; } } }, /** * Get terminal dimensions with minimum enforcement. * Prevents extremely narrow terminals that cause vertical text wrapping. * @returns {{cols: number, rows: number}|null} */ getTerminalDimensions() { const MIN_COLS = 40; const MIN_ROWS = 10; const dims = this.fitAddon?.proposeDimensions(); if (!dims) return null; return { cols: Math.max(dims.cols, MIN_COLS), rows: Math.max(dims.rows, MIN_ROWS), }; }, /** * Send resize to a session with minimum dimension enforcement. * @param {string} sessionId * @param {{ forceHttp?: boolean, force?: boolean }} [options] * @returns {Promise} Whether dimensions changed from the last send */ async sendResize(sessionId, options = {}) { // Fit terminal to container before reading dimensions — ensures local // terminal size matches what we report to the server PTY. if (this.fitAddon) this.fitAddon.fit(); const dims = this.getTerminalDimensions(); if (!dims) return false; // Did the dimensions actually change since the last resize we sent? Callers // use this to skip work (e.g. the post-resize TUI-redraw settle) when no // real SIGWINCH was triggered — switching tabs at the same browser size is // a no-op on the server and needs no redraw grace. const prev = this._lastResizeDims; const changed = !prev || prev.cols !== dims.cols || prev.rows !== dims.rows; // Update _lastResizeDims so the throttledResize handler won't redundantly // clear the terminal for the same dimensions (which would blank the screen // without a subsequent Ink redraw to repaint it). this._lastResizeDims = { cols: dims.cols, rows: dims.rows }; const viewportType = typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : window.innerWidth < 430 ? 'mobile' : window.innerWidth < 768 ? 'tablet' : 'desktop'; // Fast path: WebSocket resize if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) { try { const msg = { t: 'z', c: dims.cols, r: dims.rows, v: viewportType }; if (options.force) msg.f = true; this._ws.send(JSON.stringify(msg)); return changed; } catch { // Fall through to HTTP POST } } const body = { ...dims, viewportType }; if (options.force) body.force = true; await fetch(`/api/sessions/${sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return changed; }, /** * Send input to the active session. * @param {string} input - Text to send (include \r for Enter) * @returns {Promise} */ async sendInput(input) { if (!this.activeSessionId || !input) return; // Route through the durable, exactly-once delivery layer (useMux for the // POST fallback) so voice / keyboard-accessory / paste input also survives a // dropped link instead of being lost in a single best-effort fetch. this._sendInputAsync(this.activeSessionId, input, { useMux: true }); }, // ═══════════════════════════════════════════════════════════════ // Directory Input // ═══════════════════════════════════════════════════════════════ toggleDirInput() { const btn = document.querySelector('#dirDisplay').parentElement; const input = document.getElementById('dirInput'); if (input.classList.contains('hidden')) { input.classList.remove('hidden'); btn.style.display = 'none'; input.focus(); } }, hideDirInput() { const btn = document.querySelector('#dirDisplay').parentElement; const input = document.getElementById('dirInput'); setTimeout(() => { input.classList.add('hidden'); btn.style.display = ''; const value = input.value.trim(); document.getElementById('dirDisplay').textContent = value || 'No directory'; }, 100); }, // Re-theme all live xterm terminals (main + teammate) to the given skin's palette. // Uses the xterm v5+ live setter (full object assignment triggers a repaint for both // DOM and WebGL renderers) plus a belt-and-suspenders refresh(). applyTerminalSkin(skin) { const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) }; const minimumContrastRatio = window.codemanCurrentSkinIsLight(skin) ? 4.5 : 1; if (this.terminal) { this.terminal.options.minimumContrastRatio = minimumContrastRatio; this.terminal.options.theme = theme; // The zero-lag typing overlay caches the xterm foreground/background. // Refresh it on live skin changes so typed text never keeps the prior // theme's dark backing surface or foreground color. this._localEchoOverlay?.refreshFont(); this._predictiveEcho?.refreshFont(); try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {} } if (this.teammateTerminals) { for (const [, entry] of this.teammateTerminals) { if (entry && entry.terminal) { entry.terminal.options.minimumContrastRatio = minimumContrastRatio; entry.terminal.options.theme = { ...theme }; try { entry.terminal.refresh(0, entry.terminal.rows - 1); } catch {} } } } }, }); // ═══════════════════════════════════════════════════════════════ // COD-9 — Cross-session search (folded into the welcome history panel) // Consumes GET /api/search; renders grouped result cards with jump-to actions. // ═══════════════════════════════════════════════════════════════ (function (global) { const SEARCH_DEBOUNCE_MS = 250; const SEARCH_LIMIT = 60; const SOURCE_LABELS = { session: 'Sessions', event: 'Events', file: 'Files' }; /** Human-friendly relative-ish timestamp matching the history panel's style. */ function formatSearchTime(ts) { if (!Number.isFinite(ts)) return ''; const d = new Date(ts); return ( d.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }) ); } global.CodemanSearch = { SEARCH_DEBOUNCE_MS, SEARCH_LIMIT, SOURCE_LABELS, formatSearchTime }; })(window); Object.assign(CodemanApp.prototype, { /** * Wire up the search box, filter chips, and selects inside the welcome * history panel. Idempotent — safe to call every time the overlay opens. */ initSearchPanel() { const input = document.getElementById('searchInput'); if (!input || this._searchPanelWired) { // Even when already wired, refresh the case dropdown (cases may have loaded since). if (this._searchPanelWired) this._populateSearchCaseFilter(); return; } this._searchPanelWired = true; // Active source-type filter set (mirrors the chip .active state → types= param). this._searchTypes = new Set(['session', 'event', 'file']); this._searchSecondary = { caseLabel: '', status: '', days: '' }; this._searchDebounceTimer = null; this._searchSeq = 0; this._searchLastData = null; const clearBtn = document.getElementById('searchClearBtn'); const results = document.getElementById('searchResults'); input.addEventListener('input', () => { if (clearBtn) clearBtn.hidden = input.value.length === 0; this._scheduleSearch(); }); input.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && input.value) { ev.stopPropagation(); this._clearSearch(); } }); if (clearBtn) { clearBtn.addEventListener('click', () => this._clearSearch()); } document.querySelectorAll('#searchFilters .search-filter-chip').forEach((chip) => { chip.addEventListener('click', () => { const t = chip.dataset.typeFilter; // Keep at least one type selected. if (this._searchTypes.has(t) && this._searchTypes.size === 1) return; if (this._searchTypes.has(t)) { this._searchTypes.delete(t); chip.classList.remove('active'); } else { this._searchTypes.add(t); chip.classList.add('active'); } this._runSearch(); }); }); const caseSel = document.getElementById('searchCaseFilter'); const statusSel = document.getElementById('searchStatusFilter'); const dateSel = document.getElementById('searchDateFilter'); if (caseSel) { caseSel.addEventListener('change', () => { this._searchSecondary.caseLabel = caseSel.value; this._renderSearch(this._searchLastData); }); } if (statusSel) { statusSel.addEventListener('change', () => { this._searchSecondary.status = statusSel.value; this._renderSearch(this._searchLastData); }); } if (dateSel) { dateSel.addEventListener('change', () => { this._searchSecondary.days = dateSel.value; this._renderSearch(this._searchLastData); }); } this._populateSearchCaseFilter(); if (results) results.hidden = true; }, /** Fill the case