Files
Codeman/src/web/public/terminal-ui.js
T

1423 lines
55 KiB
JavaScript
Raw Normal View History

/**
* @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';
2026-03-25 00:18:14 +01:00
} 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
2026-03-25 00:18:14 +01:00
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.
2026-03-25 00:18:14 +01:00
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 — 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.
{
2026-03-25 00:18:14 +01:00
const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13;
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) this.terminal.scrollLines(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)
2026-03-25 00:18:14 +01:00
container.addEventListener(
'touchstart',
(ev) => {
if (ev.touches.length === 1) {
touchLastY = ev.touches[0].clientY;
velocity = 0;
pixelAccum = 0;
isTouching = true;
didScroll = false;
2026-03-25 00:18:14 +01:00
lastTime = 0;
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
scrollFrame = null;
}
}
},
{ passive: true }
);
container.addEventListener(
'touchmove',
(ev) => {
if (ev.touches.length === 1 && isTouching) {
didScroll = true;
2026-03-25 00:18:14 +01:00
const touchY = ev.touches[0].clientY;
const delta = touchLastY - touchY; // positive = scroll down
pixelAccum += delta;
velocity = delta * 1.2;
touchLastY = touchY;
// Convert accumulated pixels to whole lines
const ch = cellHeight();
const lines = Math.trunc(pixelAccum / ch);
if (lines !== 0) {
this.terminal.scrollLines(lines);
pixelAccum -= lines * ch;
}
}
},
{ passive: true }
);
container.addEventListener(
'touchend',
() => {
isTouching = false;
if (!scrollFrame && Math.abs(velocity) > 0.3) {
scrollFrame = requestAnimationFrame(scrollLoop);
}
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
// routes back to the terminal. Without this, a tap on the terminal area
// consumes the touch event but xterm's textarea never regains focus.
if (!didScroll && this.terminal) {
this.terminal.focus();
}
2026-03-25 00:18:14 +01:00
},
{ passive: true }
);
container.addEventListener(
'touchcancel',
() => {
isTouching = false;
velocity = 0;
pixelAccum = 0;
2026-03-25 00:18:14 +01:00
},
{ passive: true }
);
}
// Welcome message
this.showWelcome();
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
this._chunkedWriteGen = 0;
// 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
2026-03-25 00:18:14 +01:00
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' &&
!activeResizeSession._ended &&
this.terminal &&
this.isTerminalAtBottom()
) {
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
}
this._lastResizeDims = { cols, rows };
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
2026-03-25 00:18:14 +01:00
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);
2026-03-25 00:18:14 +01:00
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;
2026-03-25 00:18:14 +01:00
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;
2026-03-25 00:18:14 +01:00
// provideLinks passes 1-based line number, getLine expects 0-based
const line = buffer.getLine(bufferLineNumber - 1);
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 = [];
2026-03-25 00:18:14 +01:00
// Pattern 0: URLs (https://, http://) — matched first so they take priority
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;
if (links.some((l) => l.range.start.x === startCol + 1)) return;
links.push({
text: cleaned,
range: {
start: { x: startCol + 1, y: bufferLineNumber },
end: { x: startCol + cleaned.length + 1, y: bufferLineNumber },
},
decorations: { pointerCursor: true, underline: true },
activate(_event, text) {
window.open(text, '_blank', 'noopener,noreferrer');
},
});
};
// 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
2026-03-25 00:18:14 +01:00
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
2026-03-25 00:18:14 +01:00
if (links.some((l) => l.range.start.x === startCol + 1)) return;
links.push({
text: filePath,
range: {
2026-03-25 00:18:14 +01:00
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
},
decorations: {
pointerCursor: true,
2026-03-25 00:18:14 +01:00
underline: true,
},
activate(event, text) {
self.openLogViewerWindow(text, self.activeSessionId);
2026-03-25 00:18:14 +01:00
},
});
};
2026-03-25 00:18:14 +01:00
// Match all patterns — URLs first so they take priority
let match;
2026-03-25 00:18:14 +01:00
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) {
2026-03-25 00:18:14 +01:00
console.debug(
'[LinkProvider] Found links:',
links.map((l) => l.text)
);
}
callback(links.length > 0 ? links : undefined);
2026-03-25 00:18:14 +01:00
},
});
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) {
2026-03-25 00:18:14 +01:00
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);
2026-03-25 00:18:14 +01:00
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 textCol = document.createElement('div');
textCol.className = 'history-item-text';
const titleSpan = document.createElement('span');
titleSpan.className = 'history-item-title';
titleSpan.textContent = s.firstPrompt || shortDir;
const subtitleSpan = document.createElement('span');
subtitleSpan.className = 'history-item-subtitle';
subtitleSpan.textContent = shortDir;
textCol.append(titleSpan, subtitleSpan);
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(textCol, 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' },
2026-03-25 00:18:14 +01:00
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) {
2026-03-25 00:18:14 +01:00
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() {
2026-03-25 00:18:14 +01:00
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;
2026-03-25 00:18:14 +01:00
// 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;
}
2026-03-25 00:18:14 +01:00
},
});
} 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 });
}
2026-03-25 00:18:14 +01:00
}
},
/**
* 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;
2026-03-25 00:18:14 +01:00
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;
2026-03-25 00:18:14 +01:00
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.
2026-03-25 00:18:14 +01:00
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;
2026-03-25 00:18:14 +01:00
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) {
// 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;
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 = () => {
// Only finish if we're still the active write — a newer write owns buffer load state
if (this._chunkedWriteGen === writeGen) {
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 = () => {
// 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;
2026-03-25 00:18:14 +01:00
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++;
2026-03-25 00:18:14 +01:00
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.
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
*
* Queued SSE events are DISCARDED, not flushed. The loaded buffer from the API
* is the source of truth up to the response timestamp. SSE events queued during
* the fetch+write overlap with the buffer — flushing them writes duplicate data
* (especially Ink cursor-up redraws), corrupting the terminal display.
* After unblocking, new SSE/WS events deliver subsequent output normally.
*/
_finishBufferLoad() {
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
},
// ═══════════════════════════════════════════════════════════════
// 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' },
2026-03-25 00:18:14 +01:00
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' },
2026-03-25 00:18:14 +01:00
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),
2026-03-25 00:18:14 +01:00
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) {
// 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;
// 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 };
// 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' },
2026-03-25 00:18:14 +01:00
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' },
2026-03-25 00:18:14 +01:00
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);
},
});