mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
Split the monolithic app.js (~12.5K lines) into 6 focused mixin modules that extend CodemanApp.prototype via Object.assign: - terminal-ui.js — terminal setup, rendering pipeline, controls - respawn-ui.js — respawn banner, countdown, presets, run summary - ralph-panel.js — Ralph state panel, fix_plan, plan versioning - settings-ui.js — app settings, visibility, web push, tunnel/QR, help - panels-ui.js — subagent panel, teams, insights, file browser, log viewer - session-ui.js — quick start, session options, case settings Fix deferred script init ordering: wrap CodemanApp instantiation in DOMContentLoaded so all defer'd mixin modules execute their Object.assign before the constructor runs. Without this, init() calls methods like applyHeaderVisibilitySettings() that don't exist yet. Guard missing cleanupWizardDragging() call in subagent-windows.js. Update build.mjs to minify/hash all new modules. Update CLAUDE.md with new frontend architecture and load order. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1293 lines
50 KiB
JavaScript
1293 lines
50 KiB
JavaScript
/**
|
||
* @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
|
||
*/
|
||
|
||
Object.assign(CodemanApp.prototype, {
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Terminal Setup — xterm.js config and input handling
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
initTerminal() {
|
||
// Load scrollback setting from localStorage (default 5000)
|
||
const scrollback = parseInt(localStorage.getItem('codeman-scrollback')) || DEFAULT_SCROLLBACK;
|
||
|
||
this.terminal = new Terminal({
|
||
theme: {
|
||
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',
|
||
},
|
||
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',
|
||
scrollback: scrollback,
|
||
allowTransparency: true,
|
||
allowProposedApi: true,
|
||
});
|
||
|
||
this.fitAddon = new FitAddon.FitAddon();
|
||
this.terminal.loadAddon(this.fitAddon);
|
||
|
||
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);
|
||
|
||
// 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;
|
||
return true;
|
||
});
|
||
|
||
// WebGL renderer for GPU-accelerated terminal rendering.
|
||
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
|
||
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
|
||
// oversized terminal.write() calls that triggered the stalls.
|
||
// Disable with ?nowebgl URL param if GPU issues return.
|
||
// Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile).
|
||
this._webglAddon = null;
|
||
const skipWebGL = MobileDetection.getDeviceType() !== 'desktop';
|
||
if (!skipWebGL && !new URLSearchParams(location.search).has('nowebgl')) {
|
||
if (typeof WebglAddon !== 'undefined') {
|
||
this._initWebGL();
|
||
} else {
|
||
// Lazy-load WebGL addon — not bundled in <head> 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);
|
||
|
||
// CJK IME input — textarea in index.html, just wire up send
|
||
this._cjkInput = null;
|
||
if (typeof CjkInput !== 'undefined') {
|
||
this._cjkInput = CjkInput.init({
|
||
send: (text) => {
|
||
if (this.activeSessionId) {
|
||
this._sendInputAsync(this.activeSessionId, text);
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
// 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();
|
||
|
||
// Always use mouse wheel for terminal scrollback, never forward to application.
|
||
// Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation.
|
||
container.addEventListener('wheel', (ev) => {
|
||
ev.preventDefault();
|
||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||
this.terminal.scrollLines(lines);
|
||
}, { passive: false });
|
||
|
||
// Touch scrolling - only use custom JS scrolling on desktop
|
||
// Mobile uses native browser scrolling via CSS touch-action: pan-y
|
||
const isMobileDevice = MobileDetection.isTouchDevice() && window.innerWidth < 1024;
|
||
|
||
if (!isMobileDevice) {
|
||
// Desktop touch scrolling with custom momentum
|
||
let touchLastY = 0;
|
||
let pendingDelta = 0;
|
||
let velocity = 0;
|
||
let lastTime = 0;
|
||
let scrollFrame = null;
|
||
let isTouching = false;
|
||
|
||
const viewport = container.querySelector('.xterm-viewport');
|
||
|
||
// Single RAF loop handles both touch and momentum
|
||
const scrollLoop = (timestamp) => {
|
||
if (!viewport) return;
|
||
|
||
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; // Normalize to 60fps
|
||
lastTime = timestamp;
|
||
|
||
if (isTouching) {
|
||
// During touch: apply pending delta
|
||
if (pendingDelta !== 0) {
|
||
viewport.scrollTop += pendingDelta;
|
||
pendingDelta = 0;
|
||
}
|
||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||
} else if (Math.abs(velocity) > 0.1) {
|
||
// Momentum phase
|
||
viewport.scrollTop += velocity * dt;
|
||
velocity *= 0.94; // Smooth deceleration
|
||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||
} else {
|
||
scrollFrame = null;
|
||
velocity = 0;
|
||
}
|
||
};
|
||
|
||
container.addEventListener('touchstart', (ev) => {
|
||
if (ev.touches.length === 1) {
|
||
touchLastY = ev.touches[0].clientY;
|
||
pendingDelta = 0;
|
||
velocity = 0;
|
||
isTouching = true;
|
||
lastTime = 0;
|
||
if (!scrollFrame) {
|
||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||
}
|
||
}
|
||
}, { passive: true });
|
||
|
||
container.addEventListener('touchmove', (ev) => {
|
||
if (ev.touches.length === 1 && isTouching) {
|
||
const touchY = ev.touches[0].clientY;
|
||
const delta = touchLastY - touchY;
|
||
pendingDelta += delta;
|
||
velocity = delta * 1.2; // Track for momentum
|
||
touchLastY = touchY;
|
||
}
|
||
}, { passive: true });
|
||
|
||
container.addEventListener('touchend', () => {
|
||
isTouching = false;
|
||
// Momentum continues in scrollLoop
|
||
}, { passive: true });
|
||
|
||
container.addEventListener('touchcancel', () => {
|
||
isTouching = false;
|
||
velocity = 0;
|
||
}, { passive: true });
|
||
}
|
||
// Mobile: native scrolling handles touch via CSS
|
||
|
||
// Welcome message
|
||
this.showWelcome();
|
||
|
||
// 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();
|
||
}
|
||
// Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH.
|
||
// 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.
|
||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||
if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended
|
||
&& this.terminal && this.isTerminalAtBottom()) {
|
||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||
}
|
||
// 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) {
|
||
this._lastResizeDims = { cols, rows };
|
||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ cols, rows })
|
||
}).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) => {
|
||
// CJK input has focus — block xterm from sending to PTY
|
||
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
|
||
if (this.activeSessionId) {
|
||
// Filter out terminal query responses that xterm.js generates automatically.
|
||
// These are responses to DA (Device Attributes), DSR (Device Status Report), etc.
|
||
// sent by tmux when attaching. Without this filter, they appear as typed text.
|
||
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
|
||
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
|
||
|
||
// ── 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) {
|
||
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();
|
||
}
|
||
// 'pending' = removed unsent text (no PTY backspace needed)
|
||
// false = nothing to remove (swallow the backspace)
|
||
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) {
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
// ── 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;
|
||
const line = buffer.getLine(bufferLineNumber);
|
||
|
||
if (!line) {
|
||
callback(undefined);
|
||
return;
|
||
}
|
||
|
||
// Get line text - translateToString handles wrapped lines
|
||
const lineText = line.translateToString(true);
|
||
|
||
if (!lineText || !lineText.includes('/')) {
|
||
callback(undefined);
|
||
return;
|
||
}
|
||
|
||
const links = [];
|
||
|
||
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
|
||
// Handles: tail -f /path, grep pattern /path, cat -n /path
|
||
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||
|
||
// Pattern 2: Paths with common extensions
|
||
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||
|
||
// Pattern 3: Bash() tool output
|
||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||
|
||
const addLink = (filePath, matchIndex) => {
|
||
const startCol = lineText.indexOf(filePath, matchIndex);
|
||
if (startCol === -1) return;
|
||
|
||
// Skip if already have link at this position
|
||
if (links.some(l => l.range.start.x === startCol + 1)) return;
|
||
|
||
links.push({
|
||
text: filePath,
|
||
range: {
|
||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber }
|
||
},
|
||
decorations: {
|
||
pointerCursor: true,
|
||
underline: true
|
||
},
|
||
activate(event, text) {
|
||
self.openLogViewerWindow(text, self.activeSessionId);
|
||
}
|
||
});
|
||
};
|
||
|
||
// Match all patterns
|
||
let match;
|
||
|
||
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() {
|
||
const overlay = document.getElementById('welcomeOverlay');
|
||
if (overlay) {
|
||
overlay.classList.add('visible');
|
||
this.loadTunnelStatus();
|
||
this.loadHistorySessions();
|
||
}
|
||
},
|
||
|
||
hideWelcome() {
|
||
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');
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total).
|
||
* @returns {Promise<Array>} deduplicated session list, sorted by lastModified desc
|
||
*/
|
||
async _fetchHistorySessions(limit = 12) {
|
||
const res = await fetch('/api/history/sessions');
|
||
const data = await res.json();
|
||
const sessions = data.sessions || [];
|
||
if (sessions.length === 0) return [];
|
||
|
||
const byDir = new Map();
|
||
for (const s of sessions) {
|
||
if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []);
|
||
byDir.get(s.workingDir).push(s);
|
||
}
|
||
const items = [];
|
||
for (const [, group] of byDir) {
|
||
items.push(...group.slice(0, 2));
|
||
}
|
||
items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
||
return items.slice(0, limit);
|
||
},
|
||
|
||
async loadHistorySessions() {
|
||
const container = document.getElementById('historySessions');
|
||
const list = document.getElementById('historyList');
|
||
if (!container || !list) return;
|
||
|
||
try {
|
||
const display = await this._fetchHistorySessions(12);
|
||
if (display.length === 0) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// Build DOM safely (no innerHTML with user data)
|
||
list.replaceChildren();
|
||
for (const s of display) {
|
||
const size = s.sizeBytes < 1024 ? `${s.sizeBytes}B`
|
||
: s.sizeBytes < 1048576 ? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||
const date = new Date(s.lastModified);
|
||
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'history-item';
|
||
item.title = s.workingDir;
|
||
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
|
||
|
||
const dirSpan = document.createElement('span');
|
||
dirSpan.className = 'history-item-dir';
|
||
dirSpan.textContent = shortDir;
|
||
|
||
const metaSpan = document.createElement('span');
|
||
metaSpan.className = 'history-item-meta';
|
||
metaSpan.textContent = timeStr;
|
||
|
||
const sizeSpan = document.createElement('span');
|
||
sizeSpan.className = 'history-item-size';
|
||
sizeSpan.textContent = size;
|
||
|
||
item.append(dirSpan, metaSpan, sizeSpan);
|
||
list.appendChild(item);
|
||
}
|
||
|
||
container.style.display = '';
|
||
} catch (err) {
|
||
console.error('[loadHistorySessions]', err);
|
||
container.style.display = 'none';
|
||
}
|
||
},
|
||
|
||
async resumeHistorySession(sessionId, workingDir) {
|
||
// Close the run mode menu if open
|
||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||
try {
|
||
this.terminal.clear();
|
||
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
|
||
|
||
// Generate a session name from the working dir
|
||
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;
|
||
}
|
||
}
|
||
const name = `w${startNumber}-${dirName}`;
|
||
|
||
// Create session with resumeSessionId
|
||
const createRes = await fetch('/api/sessions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId })
|
||
});
|
||
const createData = await createRes.json();
|
||
if (!createData.success) throw new Error(createData.error);
|
||
|
||
const newSessionId = createData.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;
|
||
},
|
||
|
||
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);
|
||
|
||
if (!this.writeFrameScheduled) {
|
||
this.writeFrameScheduled = true;
|
||
requestAnimationFrame(() => {
|
||
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
|
||
// content between 2026h/2026l and renders atomically. No need for
|
||
// client-side incomplete-block detection; just flush every frame.
|
||
this.flushPendingWrites();
|
||
this.writeFrameScheduled = false;
|
||
});
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 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
|
||
if (!this.writeFrameScheduled) {
|
||
this.writeFrameScheduled = true;
|
||
requestAnimationFrame(() => {
|
||
this.flushPendingWrites();
|
||
this.writeFrameScheduled = false;
|
||
});
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 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 "┃ <text>" — 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') {
|
||
// Shell mode: the shell provides its own PTY echo so the overlay isn't needed.
|
||
// Disable it by clearing any pending text.
|
||
this._localEchoOverlay.clear();
|
||
this._localEchoEnabled = false;
|
||
} else {
|
||
// Claude Code: scan for ❯ prompt character
|
||
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
|
||
}
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 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.
|
||
const MAX_FRAME_BYTES = 65536; // 64KB budget per frame
|
||
let deferred = false;
|
||
|
||
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;
|
||
if (!this.writeFrameScheduled) {
|
||
this.writeFrameScheduled = true;
|
||
requestAnimationFrame(() => {
|
||
this.flushPendingWrites();
|
||
this.writeFrameScheduled = false;
|
||
});
|
||
}
|
||
}
|
||
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
|
||
if (this._wasAtBottomBeforeWrite) {
|
||
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;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Write large buffer to terminal in chunks to avoid UI jank.
|
||
* Uses requestAnimationFrame to spread work across frames.
|
||
* @param {string} buffer - The full terminal buffer to write
|
||
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
|
||
* @returns {Promise<void>} - Resolves when all chunks written
|
||
*/
|
||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
|
||
return new Promise((resolve) => {
|
||
if (!buffer || buffer.length === 0) {
|
||
this._finishBufferLoad();
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
// Block live SSE writes during buffer load to prevent interleaving
|
||
this._isLoadingBuffer = true;
|
||
this._loadBufferQueue = [];
|
||
|
||
// 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 = () => {
|
||
this._finishBufferLoad();
|
||
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 = () => {
|
||
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
|
||
requestAnimationFrame(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 on next frame
|
||
requestAnimationFrame(writeChunk);
|
||
};
|
||
|
||
// Start writing
|
||
requestAnimationFrame(writeChunk);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Complete a buffer load: unblock live SSE writes and flush any queued events.
|
||
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
|
||
*/
|
||
_finishBufferLoad() {
|
||
const queue = this._loadBufferQueue;
|
||
this._isLoadingBuffer = false;
|
||
this._loadBufferQueue = null;
|
||
if (queue && queue.length > 0) {
|
||
for (const data of queue) {
|
||
this.batchTerminalWrite(data);
|
||
}
|
||
}
|
||
},
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Terminal Controls
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
clearTerminal() {
|
||
this.terminal.clear();
|
||
},
|
||
|
||
/**
|
||
* Restore terminal size to match web UI dimensions.
|
||
* Use this after mobile screen attachment has squeezed the terminal.
|
||
* Sends resize to PTY and Ctrl+L to trigger Claude to redraw.
|
||
*/
|
||
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 {
|
||
// Send resize to restore proper dimensions (with minimum enforcement)
|
||
await this.sendResize(this.activeSessionId);
|
||
|
||
// Send Ctrl+L to trigger Claude to redraw at new size
|
||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ input: '\x0c' })
|
||
});
|
||
|
||
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');
|
||
}
|
||
},
|
||
|
||
// Send Ctrl+L to fix display for newly created sessions once Claude is running
|
||
sendPendingCtrlL(sessionId) {
|
||
if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) {
|
||
return;
|
||
}
|
||
this.pendingCtrlL.delete(sessionId);
|
||
|
||
// Only send if this is the active session
|
||
if (sessionId !== this.activeSessionId) {
|
||
return;
|
||
}
|
||
|
||
// Send resize + Ctrl+L to fix the display (with minimum dimension enforcement)
|
||
this.sendResize(sessionId).then(() => {
|
||
fetch(`/api/sessions/${sessionId}/input`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ input: '\x0c' })
|
||
});
|
||
});
|
||
},
|
||
|
||
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');
|
||
}
|
||
},
|
||
|
||
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();
|
||
},
|
||
|
||
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
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async sendResize(sessionId) {
|
||
const dims = this.getTerminalDimensions();
|
||
if (!dims) return;
|
||
// Fast path: WebSocket resize
|
||
if (this._wsReady && this._wsSessionId === sessionId) {
|
||
try {
|
||
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows }));
|
||
return;
|
||
} catch {
|
||
// Fall through to HTTP POST
|
||
}
|
||
}
|
||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(dims)
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Send input to the active session.
|
||
* @param {string} input - Text to send (include \r for Enter)
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async sendInput(input) {
|
||
if (!this.activeSessionId) return;
|
||
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ 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);
|
||
},
|
||
});
|