From 4f12284a44238ca7f96f3261a2dd7a967fd0e99b Mon Sep 17 00:00:00 2001 From: lim Date: Tue, 11 Nov 2025 14:30:52 +0000 Subject: [PATCH 1/2] chore --- components/terminal/terminal-display.tsx | 17 +- sandbox/entrypoint.sh | 3 +- sandbox/ttyd-autoscroll.js | 428 +++++++++++++---------- 3 files changed, 260 insertions(+), 188 deletions(-) diff --git a/components/terminal/terminal-display.tsx b/components/terminal/terminal-display.tsx index fade889..a81605e 100644 --- a/components/terminal/terminal-display.tsx +++ b/components/terminal/terminal-display.tsx @@ -45,9 +45,22 @@ export function TerminalDisplay({ ttydUrl, status, tabId }: TerminalDisplayProps // Listen to postMessage from ttyd iframe (autoscroll status updates) useEffect(() => { const handleMessage = (event: MessageEvent) => { - // Security: Verify message format + // Security: Verify message format and origin if (typeof event.data !== 'object' || !event.data) return; + // Verify message comes from ttyd iframe (check if origin matches ttydUrl) + if (ttydUrl) { + try { + const ttydOrigin = new URL(ttydUrl).origin; + if (event.origin !== ttydOrigin) { + // Silently ignore messages from other origins + return; + } + } catch { + // Invalid URL, skip origin check + } + } + // Handle autoscroll status updates if (event.data.type === 'ttyd-scroll-status') { const newStatus = event.data.status; @@ -62,7 +75,7 @@ export function TerminalDisplay({ ttydUrl, status, tabId }: TerminalDisplayProps window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); - }, [tabId]); + }, [tabId, ttydUrl]); // Only show terminal iframe if status is RUNNING and URL is available if (status === 'RUNNING' && ttydUrl) { diff --git a/sandbox/entrypoint.sh b/sandbox/entrypoint.sh index c0bc674..03fc7ea 100755 --- a/sandbox/entrypoint.sh +++ b/sandbox/entrypoint.sh @@ -37,7 +37,8 @@ THEME='theme={ # Start ttyd with authentication wrapper, theme, and custom HTML for auto-scroll injection # -b: Set base path for serving static files (index.html and autoscroll script) -# -I: Custom index.html path +# -I: Custom index.html path (required for autoscroll to work) ttyd -T xterm-256color -W -a -t "$THEME" \ -b /usr/local/share/ttyd \ + -I /usr/local/share/ttyd/index.html \ /usr/local/bin/ttyd-auth.sh \ No newline at end of file diff --git a/sandbox/ttyd-autoscroll.js b/sandbox/ttyd-autoscroll.js index 1854ce4..a1b10be 100644 --- a/sandbox/ttyd-autoscroll.js +++ b/sandbox/ttyd-autoscroll.js @@ -2,228 +2,286 @@ * Force Auto-Scroll for ttyd/xterm.js * * This script is injected into ttyd's HTML page to override xterm.js scroll behavior. - * It ensures the terminal always scrolls to bottom when new content arrives, - * even if the user has scrolled up to view history. + * It ensures the terminal scrolls to bottom when new content arrives, + * but respects user manual scrolling to view history. * * Strategy: * 1. Wait for xterm.js instance (window.term) * 2. Hook into data/write events - * 3. Force scroll to bottom during active streaming - * 4. Report status to parent window via postMessage + * 3. Detect user manual scrolling + * 4. Smart scroll: only auto-scroll if user hasn't manually scrolled up + * 5. Report status to parent window via postMessage */ -(function() { - 'use strict'; +(function () { + 'use strict' + + const DEBUG = true + const log = (...args) => DEBUG && console.log('[AutoScroll]', ...args) + + log('Initializing...') + + // Configuration + const CONFIG = { + // Time window to consider "active streaming" (ms) + STREAMING_WINDOW: 800, + // Scroll check interval during streaming (ms) + SCROLL_INTERVAL: 100, + // Idle timeout before stopping scroll checks (ms) + IDLE_TIMEOUT: 2000, + } + + /** + * Wait for xterm.js instance to be available with retry logic + */ + function waitForTerminal() { + return new Promise((resolve) => { + const startTime = Date.now() + const TIMEOUT = 15000 // Increased timeout to 15 seconds + const checkInterval = setInterval(() => { + if (window.term && window.term.element && window.term.buffer) { + clearInterval(checkInterval) + log(`✓ Terminal found after ${Date.now() - startTime}ms`) + resolve(window.term) + return + } - const DEBUG = true; - const log = (...args) => DEBUG && console.log('[AutoScroll]', ...args); + // Timeout after TIMEOUT + if (Date.now() - startTime > TIMEOUT) { + clearInterval(checkInterval) + log('✗ Terminal not found (timeout)') + notifyParent('error') + resolve(null) + } + }, 50) + }) + } + + /** + * Check if terminal is at bottom + */ + function isAtBottom(term) { + try { + const viewport = term.element.querySelector('.xterm-viewport') + if (!viewport) return true + + const scrollTop = viewport.scrollTop + const scrollHeight = viewport.scrollHeight + const clientHeight = viewport.clientHeight + + // Consider "at bottom" if within 50px + return scrollTop + clientHeight >= scrollHeight - 50 + } catch { + return true + } + } + + /** + * Force scroll to bottom + */ + function forceScrollToBottom(term) { + try { + term.scrollToBottom() + } catch { + log('Error scrolling') + } + } + + /** + * Notify parent window about scroll status + */ + function notifyParent(status) { + try { + window.parent.postMessage({ + type: 'ttyd-scroll-status', + status: status, + timestamp: Date.now(), + }, '*') + } catch { + // Ignore postMessage errors + } + } + + /** + * Main auto-scroll logic + */ + async function initAutoScroll() { + const term = await waitForTerminal() + if (!term) { + log('✗ Failed to initialize - terminal not found') + return + } - log('Initializing...'); + log('✓ Terminal instance detected') - // Configuration - const CONFIG = { - // Time window to consider "active streaming" (ms) - STREAMING_WINDOW: 800, - // Scroll check interval during streaming (ms) - SCROLL_INTERVAL: 100, - // Idle timeout before stopping scroll checks (ms) - IDLE_TIMEOUT: 2000, - }; + let lastActivityTime = 0 + let scrollInterval = null + let isStreaming = false + let userManuallyScrolled = false + let lastScrollTime = 0 /** - * Wait for xterm.js instance to be available + * Detect user manual scroll */ - function waitForTerminal() { - return new Promise((resolve) => { - const startTime = Date.now(); - const checkInterval = setInterval(() => { - if (window.term && window.term.element) { - clearInterval(checkInterval); - log(`✓ Terminal found after ${Date.now() - startTime}ms`); - resolve(window.term); - } - - // Timeout after 10 seconds - if (Date.now() - startTime > 10000) { - clearInterval(checkInterval); - log('✗ Terminal not found (timeout)'); - resolve(null); - } - }, 50); - }); + function setupScrollDetection() { + try { + const viewport = term.element.querySelector('.xterm-viewport') + if (!viewport) return + + let lastScrollTop = viewport.scrollTop + + viewport.addEventListener('scroll', () => { + const currentScrollTop = viewport.scrollTop + const wasAtBottom = isAtBottom(term) + + // User scrolled manually (not at bottom) + if (!wasAtBottom && currentScrollTop !== lastScrollTop) { + userManuallyScrolled = true + lastScrollTime = Date.now() + log('→ User manually scrolled up') + } + // User scrolled back to bottom + else if (wasAtBottom && userManuallyScrolled) { + userManuallyScrolled = false + log('→ User scrolled back to bottom, resuming auto-scroll') + } + + lastScrollTop = currentScrollTop + }, { passive: true }) + + log('✓ Scroll detection enabled') + } catch (e) { + log('⚠ Failed to setup scroll detection:', e) + } } /** - * Check if terminal is at bottom + * Start smart auto-scroll during streaming + * Only scrolls if user hasn't manually scrolled up */ - function isAtBottom(term) { - try { - const viewport = term.element.querySelector('.xterm-viewport'); - if (!viewport) return true; - - const scrollTop = viewport.scrollTop; - const scrollHeight = viewport.scrollHeight; - const clientHeight = viewport.clientHeight; - - // Consider "at bottom" if within 50px - return scrollTop + clientHeight >= scrollHeight - 50; - } catch (e) { - return true; + function startScrolling() { + if (scrollInterval) return + + isStreaming = true + notifyParent('streaming') + log('→ Streaming detected, starting smart auto-scroll') + + scrollInterval = setInterval(() => { + const timeSinceActivity = Date.now() - lastActivityTime + const timeSinceManualScroll = Date.now() - lastScrollTime + + // Active streaming: smart scroll (only if user hasn't manually scrolled) + if (timeSinceActivity < CONFIG.STREAMING_WINDOW) { + // If user manually scrolled recently (within 2 seconds), don't force scroll + if (userManuallyScrolled && timeSinceManualScroll < 2000) { + // User is viewing history, don't interrupt + return + } + // User is at bottom or hasn't manually scrolled, auto-scroll + if (!userManuallyScrolled || isAtBottom(term)) { + forceScrollToBottom(term) + // Reset manual scroll flag if we successfully scrolled to bottom + if (isAtBottom(term)) { + userManuallyScrolled = false + } + } } - } - - /** - * Force scroll to bottom - */ - function forceScrollToBottom(term) { - try { - term.scrollToBottom(); - } catch (e) { - log('Error scrolling:', e); + // Idle for too long: stop scrolling + else if (timeSinceActivity > CONFIG.IDLE_TIMEOUT) { + stopScrolling() } + }, CONFIG.SCROLL_INTERVAL) } /** - * Notify parent window about scroll status + * Stop auto-scroll when idle */ - function notifyParent(status) { - try { - window.parent.postMessage({ - type: 'ttyd-scroll-status', - status: status, - timestamp: Date.now(), - }, '*'); - } catch (e) { - // Ignore postMessage errors - } + function stopScrolling() { + if (!scrollInterval) return + + clearInterval(scrollInterval) + scrollInterval = null + isStreaming = false + notifyParent('idle') + log('→ Streaming stopped, auto-scroll disabled') } /** - * Main auto-scroll logic + * Record activity and trigger scrolling */ - async function initAutoScroll() { - const term = await waitForTerminal(); - if (!term) { - log('✗ Failed to initialize - terminal not found'); - return; - } + function recordActivity() { + lastActivityTime = Date.now() - log('✓ Terminal instance detected'); - - let lastActivityTime = 0; - let scrollInterval = null; - let isStreaming = false; - - /** - * Start aggressive auto-scroll during streaming - */ - function startScrolling() { - if (scrollInterval) return; - - isStreaming = true; - notifyParent('streaming'); - log('→ Streaming detected, starting auto-scroll'); - - scrollInterval = setInterval(() => { - const timeSinceActivity = Date.now() - lastActivityTime; - - // Active streaming: force scroll - if (timeSinceActivity < CONFIG.STREAMING_WINDOW) { - forceScrollToBottom(term); - } - // Idle for too long: stop scrolling - else if (timeSinceActivity > CONFIG.IDLE_TIMEOUT) { - stopScrolling(); - } - }, CONFIG.SCROLL_INTERVAL); - } + // Start scrolling if not already active + if (!isStreaming) { + startScrolling() + } + } - /** - * Stop auto-scroll when idle - */ - function stopScrolling() { - if (!scrollInterval) return; - - clearInterval(scrollInterval); - scrollInterval = null; - isStreaming = false; - notifyParent('idle'); - log('→ Streaming stopped, auto-scroll disabled'); - } + // Hook 1: Monitor data events (keyboard input, etc.) + try { + term.onData(() => { + recordActivity() + }) + log('✓ Hooked into onData') + } catch (e) { + log('⚠ Failed to hook onData:', e) + } - /** - * Record activity and trigger scrolling - */ - function recordActivity() { - lastActivityTime = Date.now(); + // Hook 2: Override write method (terminal output) + try { + const originalWrite = term.write.bind(term) + const originalWriteln = term.writeln.bind(term) - // Start scrolling if not already active - if (!isStreaming) { - startScrolling(); - } - } - - // Hook 1: Monitor data events (keyboard input, etc.) - try { - term.onData(() => { - recordActivity(); - }); - log('✓ Hooked into onData'); - } catch (e) { - log('⚠ Failed to hook onData:', e); - } + term.write = function (...args) { + recordActivity() + return originalWrite(...args) + } - // Hook 2: Override write method (terminal output) - try { - const originalWrite = term.write.bind(term); - const originalWriteln = term.writeln.bind(term); + term.writeln = function (...args) { + recordActivity() + return originalWriteln(...args) + } - term.write = function(...args) { - recordActivity(); - return originalWrite(...args); - }; + log('✓ Hooked into write/writeln') + } catch (e) { + log('⚠ Failed to hook write methods:', e) + } - term.writeln = function(...args) { - recordActivity(); - return originalWriteln(...args); - }; + // Hook 3: Monitor terminal buffer changes (fallback, less frequent to reduce overhead) + try { + let lastBufferLength = term.buffer.active.length - log('✓ Hooked into write/writeln'); - } catch (e) { - log('⚠ Failed to hook write methods:', e); + setInterval(() => { + const currentBufferLength = term.buffer.active.length + if (currentBufferLength !== lastBufferLength) { + recordActivity() + lastBufferLength = currentBufferLength } + }, 500) // Increased interval from 200ms to 500ms to reduce overhead - // Hook 3: Monitor terminal buffer changes (fallback) - try { - let lastBufferLength = term.buffer.active.length; - - setInterval(() => { - const currentBufferLength = term.buffer.active.length; - if (currentBufferLength !== lastBufferLength) { - recordActivity(); - lastBufferLength = currentBufferLength; - } - }, 200); - - log('✓ Monitoring buffer changes'); - } catch (e) { - log('⚠ Failed to monitor buffer:', e); - } + log('✓ Monitoring buffer changes') + } catch (e) { + log('⚠ Failed to monitor buffer:', e) + } - log('✓✓✓ Auto-scroll fully initialized ✓✓✓'); - notifyParent('ready'); + // Setup scroll detection before starting auto-scroll + setupScrollDetection() - // Test: Trigger initial scroll - setTimeout(() => { - forceScrollToBottom(term); - }, 500); - } + log('✓✓✓ Auto-scroll fully initialized ✓✓✓') + notifyParent('ready') - // Start initialization - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initAutoScroll); - } else { - initAutoScroll(); - } + // Test: Trigger initial scroll + setTimeout(() => { + forceScrollToBottom(term) + }, 500) + } + + // Start initialization + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initAutoScroll) + } else { + initAutoScroll() + } -})(); +})() From a299b69d6dfd780d6f39e3edc2e7e148dee1c34f Mon Sep 17 00:00:00 2001 From: lim Date: Tue, 11 Nov 2025 14:32:07 +0000 Subject: [PATCH 2/2] chore --- sandbox/ttyd-autoscroll.js | 14 +++++++------ sandbox/ttyd-index.html | 43 ++++++++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/sandbox/ttyd-autoscroll.js b/sandbox/ttyd-autoscroll.js index a1b10be..48351d7 100644 --- a/sandbox/ttyd-autoscroll.js +++ b/sandbox/ttyd-autoscroll.js @@ -132,16 +132,17 @@ viewport.addEventListener('scroll', () => { const currentScrollTop = viewport.scrollTop - const wasAtBottom = isAtBottom(term) + const isAtBottomNow = isAtBottom(term) + const scrolledUp = currentScrollTop < lastScrollTop - // User scrolled manually (not at bottom) - if (!wasAtBottom && currentScrollTop !== lastScrollTop) { + // User scrolled up manually (not at bottom) + if (scrolledUp && !isAtBottomNow) { userManuallyScrolled = true lastScrollTime = Date.now() log('→ User manually scrolled up') } // User scrolled back to bottom - else if (wasAtBottom && userManuallyScrolled) { + else if (isAtBottomNow && userManuallyScrolled) { userManuallyScrolled = false log('→ User scrolled back to bottom, resuming auto-scroll') } @@ -172,8 +173,9 @@ // Active streaming: smart scroll (only if user hasn't manually scrolled) if (timeSinceActivity < CONFIG.STREAMING_WINDOW) { - // If user manually scrolled recently (within 2 seconds), don't force scroll - if (userManuallyScrolled && timeSinceManualScroll < 2000) { + // If user manually scrolled recently (within 5 seconds), don't force scroll + // Increased from 2 seconds to 5 seconds to give users more time to read history + if (userManuallyScrolled && timeSinceManualScroll < 5000) { // User is viewing history, don't interrupt return } diff --git a/sandbox/ttyd-index.html b/sandbox/ttyd-index.html index f46cbdc..6d6c111 100644 --- a/sandbox/ttyd-index.html +++ b/sandbox/ttyd-index.html @@ -1,4 +1,4 @@ - + @@ -29,22 +29,39 @@ - \ No newline at end of file +