Skip to content

Commit aebbb20

Browse files
authored
Test Terminal Follows Scrolling (#75)
1 parent 963b930 commit aebbb20

6 files changed

Lines changed: 344 additions & 9 deletions

File tree

components/terminal/terminal-display.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33
*
44
* Pure display component for terminal iframe
55
* VSCode Dark Modern theme style
6+
*
7+
* Auto-scroll implementation:
8+
* 1. ttyd serves custom HTML with injected autoscroll script
9+
* 2. Script monitors xterm.js write events and forces scroll to bottom
10+
* 3. Script sends status updates via postMessage
11+
* 4. This component listens to postMessage for debugging/monitoring
612
*/
713

814
'use client';
915

10-
import { useState } from 'react';
11-
import { AlertCircle, Terminal as TerminalIcon } from 'lucide-react';
16+
import { useEffect, useState } from 'react';
17+
import { Activity, AlertCircle, Terminal as TerminalIcon } from 'lucide-react';
1218

1319
import { Spinner } from '@/components/ui/spinner';
1420
import {
@@ -34,6 +40,29 @@ export interface TerminalDisplayProps {
3440
*/
3541
export function TerminalDisplay({ ttydUrl, status, tabId }: TerminalDisplayProps) {
3642
const [iframeLoaded, setIframeLoaded] = useState(false);
43+
const [scrollStatus, setScrollStatus] = useState<'ready' | 'streaming' | 'idle'>('idle');
44+
45+
// Listen to postMessage from ttyd iframe (autoscroll status updates)
46+
useEffect(() => {
47+
const handleMessage = (event: MessageEvent) => {
48+
// Security: Verify message format
49+
if (typeof event.data !== 'object' || !event.data) return;
50+
51+
// Handle autoscroll status updates
52+
if (event.data.type === 'ttyd-scroll-status') {
53+
const newStatus = event.data.status;
54+
setScrollStatus(newStatus);
55+
56+
// Debug logging (disable in production)
57+
if (process.env.NODE_ENV === 'development') {
58+
console.log(`[Terminal ${tabId}] Auto-scroll status:`, newStatus);
59+
}
60+
}
61+
};
62+
63+
window.addEventListener('message', handleMessage);
64+
return () => window.removeEventListener('message', handleMessage);
65+
}, [tabId]);
3766

3867
// Only show terminal iframe if status is RUNNING and URL is available
3968
if (status === 'RUNNING' && ttydUrl) {
@@ -49,6 +78,16 @@ export function TerminalDisplay({ ttydUrl, status, tabId }: TerminalDisplayProps
4978
</div>
5079
)}
5180

81+
{/* Auto-scroll status indicator (optional, only in development) */}
82+
{process.env.NODE_ENV === 'development' && scrollStatus === 'streaming' && (
83+
<div className="absolute top-3 right-3 z-20">
84+
<div className="flex items-center gap-2 bg-[#3794ff]/20 text-[#3794ff] px-2 py-1 rounded text-xs backdrop-blur-sm">
85+
<Activity className="h-3 w-3 animate-pulse" />
86+
<span>Auto-scrolling</span>
87+
</div>
88+
</div>
89+
)}
90+
5291
{/* Terminal iframe - unique key per tab ensures separate WebSocket connection */}
5392
<iframe
5493
key={`terminal-${tabId}`}

lib/k8s/sandbox-manager.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -788,16 +788,17 @@ if [ ! -d /opt/next-template ]; then
788788
exit 1
789789
fi
790790
791-
# Copy Next.js project template
791+
# Copy Next.js project template (excluding node_modules to prevent pnpm store conflicts)
792792
echo "→ Copying Next.js project template from /opt/next-template..."
793793
echo " Source: /opt/next-template (agent:agent)"
794794
echo " Target: /home/agent/next"
795+
echo " Excluding: node_modules, .pnpm-store"
795796
echo " This may take 10-30 seconds..."
796797
mkdir -p /home/agent/next
797798
798-
# Copy with progress indicator and preserve timestamps
799-
# Using cp instead of rsync for simplicity (rsync is available but cp is sufficient)
800-
cp -rp /opt/next-template/. /home/agent/next 2>&1 || {
799+
# Copy all files except node_modules and .pnpm-store
800+
# Using rsync with exclude to prevent pnpm store path conflicts
801+
rsync -av --exclude='node_modules' --exclude='.pnpm-store' /opt/next-template/ /home/agent/next/ 2>&1 || {
801802
echo "✗ ERROR: Failed to copy template"
802803
exit 1
803804
}

sandbox/Dockerfile

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,14 @@ WORKDIR /home/agent
195195
# Copy configuration files (placed before user switch for better caching)
196196
# entrypoint.sh: Container startup script
197197
# ttyd-auth.sh: Authentication script for ttyd terminal access
198+
# ttyd-index.html: Custom HTML template for ttyd
199+
# ttyd-autoscroll.js: Auto-scroll script injected into ttyd HTML
198200
# .bashrc: Shell configuration with custom prompt and Claude CLI auto-start
199201
# -----------------------------------------------------------------------------
200202
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
201203
COPY --chmod=755 ttyd-auth.sh /usr/local/bin/ttyd-auth.sh
204+
COPY --chmod=644 ttyd-index.html /usr/local/share/ttyd/index.html
205+
COPY --chmod=644 ttyd-autoscroll.js /usr/local/share/ttyd/ttyd-autoscroll.js
202206
COPY --chmod=644 .bashrc /etc/skel/.bashrc
203207

204208
# =============================================================================
@@ -249,10 +253,14 @@ RUN set -eux; \
249253

250254
# -----------------------------------------------------------------------------
251255
# Step 3: Clean up and set ownership
256+
# IMPORTANT: Remove node_modules to avoid pnpm store path conflicts
257+
# Users will run pnpm install manually when needed
252258
# -----------------------------------------------------------------------------
253259
RUN set -eux; \
254260
TEMPLATE_DIR="/opt/next-template"; \
255261
cd "$TEMPLATE_DIR"; \
262+
echo "=== Removing node_modules to prevent store conflicts ==="; \
263+
rm -rf node_modules .pnpm-store; \
256264
echo "=== Cleaning up pnpm cache ==="; \
257265
pnpm store prune; \
258266
echo "=== Setting ownership to agent user (1001:1001) ==="; \
@@ -263,7 +271,11 @@ RUN set -eux; \
263271
echo "ERROR: Template verification failed"; \
264272
exit 1; \
265273
fi; \
266-
echo "✓ Template ready at $TEMPLATE_DIR (owned by agent:agent)"
274+
if [ -d "$TEMPLATE_DIR/node_modules" ]; then \
275+
echo "ERROR: node_modules should have been removed"; \
276+
exit 1; \
277+
fi; \
278+
echo "✓ Template ready at $TEMPLATE_DIR (owned by agent:agent, no node_modules)"
267279

268280
# =============================================================================
269281
# Container Runtime Configuration

sandbox/entrypoint.sh

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,9 @@ THEME='theme={
3535
"brightWhite":"#FFFFFF"
3636
}'
3737

38-
# Start ttyd with authentication wrapper and theme
39-
ttyd -T xterm-256color -W -a -t "$THEME" /usr/local/bin/ttyd-auth.sh
38+
# Start ttyd with authentication wrapper, theme, and custom HTML for auto-scroll injection
39+
# -b: Set base path for serving static files (index.html and autoscroll script)
40+
# -I: Custom index.html path
41+
ttyd -T xterm-256color -W -a -t "$THEME" \
42+
-b /usr/local/share/ttyd \
43+
/usr/local/bin/ttyd-auth.sh

sandbox/ttyd-autoscroll.js

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* Force Auto-Scroll for ttyd/xterm.js
3+
*
4+
* This script is injected into ttyd's HTML page to override xterm.js scroll behavior.
5+
* It ensures the terminal always scrolls to bottom when new content arrives,
6+
* even if the user has scrolled up to view history.
7+
*
8+
* Strategy:
9+
* 1. Wait for xterm.js instance (window.term)
10+
* 2. Hook into data/write events
11+
* 3. Force scroll to bottom during active streaming
12+
* 4. Report status to parent window via postMessage
13+
*/
14+
15+
(function() {
16+
'use strict';
17+
18+
const DEBUG = true;
19+
const log = (...args) => DEBUG && console.log('[AutoScroll]', ...args);
20+
21+
log('Initializing...');
22+
23+
// Configuration
24+
const CONFIG = {
25+
// Time window to consider "active streaming" (ms)
26+
STREAMING_WINDOW: 800,
27+
// Scroll check interval during streaming (ms)
28+
SCROLL_INTERVAL: 100,
29+
// Idle timeout before stopping scroll checks (ms)
30+
IDLE_TIMEOUT: 2000,
31+
};
32+
33+
/**
34+
* Wait for xterm.js instance to be available
35+
*/
36+
function waitForTerminal() {
37+
return new Promise((resolve) => {
38+
const startTime = Date.now();
39+
const checkInterval = setInterval(() => {
40+
if (window.term && window.term.element) {
41+
clearInterval(checkInterval);
42+
log(`✓ Terminal found after ${Date.now() - startTime}ms`);
43+
resolve(window.term);
44+
}
45+
46+
// Timeout after 10 seconds
47+
if (Date.now() - startTime > 10000) {
48+
clearInterval(checkInterval);
49+
log('✗ Terminal not found (timeout)');
50+
resolve(null);
51+
}
52+
}, 50);
53+
});
54+
}
55+
56+
/**
57+
* Check if terminal is at bottom
58+
*/
59+
function isAtBottom(term) {
60+
try {
61+
const viewport = term.element.querySelector('.xterm-viewport');
62+
if (!viewport) return true;
63+
64+
const scrollTop = viewport.scrollTop;
65+
const scrollHeight = viewport.scrollHeight;
66+
const clientHeight = viewport.clientHeight;
67+
68+
// Consider "at bottom" if within 50px
69+
return scrollTop + clientHeight >= scrollHeight - 50;
70+
} catch (e) {
71+
return true;
72+
}
73+
}
74+
75+
/**
76+
* Force scroll to bottom
77+
*/
78+
function forceScrollToBottom(term) {
79+
try {
80+
term.scrollToBottom();
81+
} catch (e) {
82+
log('Error scrolling:', e);
83+
}
84+
}
85+
86+
/**
87+
* Notify parent window about scroll status
88+
*/
89+
function notifyParent(status) {
90+
try {
91+
window.parent.postMessage({
92+
type: 'ttyd-scroll-status',
93+
status: status,
94+
timestamp: Date.now(),
95+
}, '*');
96+
} catch (e) {
97+
// Ignore postMessage errors
98+
}
99+
}
100+
101+
/**
102+
* Main auto-scroll logic
103+
*/
104+
async function initAutoScroll() {
105+
const term = await waitForTerminal();
106+
if (!term) {
107+
log('✗ Failed to initialize - terminal not found');
108+
return;
109+
}
110+
111+
log('✓ Terminal instance detected');
112+
113+
let lastActivityTime = 0;
114+
let scrollInterval = null;
115+
let isStreaming = false;
116+
117+
/**
118+
* Start aggressive auto-scroll during streaming
119+
*/
120+
function startScrolling() {
121+
if (scrollInterval) return;
122+
123+
isStreaming = true;
124+
notifyParent('streaming');
125+
log('→ Streaming detected, starting auto-scroll');
126+
127+
scrollInterval = setInterval(() => {
128+
const timeSinceActivity = Date.now() - lastActivityTime;
129+
130+
// Active streaming: force scroll
131+
if (timeSinceActivity < CONFIG.STREAMING_WINDOW) {
132+
forceScrollToBottom(term);
133+
}
134+
// Idle for too long: stop scrolling
135+
else if (timeSinceActivity > CONFIG.IDLE_TIMEOUT) {
136+
stopScrolling();
137+
}
138+
}, CONFIG.SCROLL_INTERVAL);
139+
}
140+
141+
/**
142+
* Stop auto-scroll when idle
143+
*/
144+
function stopScrolling() {
145+
if (!scrollInterval) return;
146+
147+
clearInterval(scrollInterval);
148+
scrollInterval = null;
149+
isStreaming = false;
150+
notifyParent('idle');
151+
log('→ Streaming stopped, auto-scroll disabled');
152+
}
153+
154+
/**
155+
* Record activity and trigger scrolling
156+
*/
157+
function recordActivity() {
158+
lastActivityTime = Date.now();
159+
160+
// Start scrolling if not already active
161+
if (!isStreaming) {
162+
startScrolling();
163+
}
164+
}
165+
166+
// Hook 1: Monitor data events (keyboard input, etc.)
167+
try {
168+
term.onData(() => {
169+
recordActivity();
170+
});
171+
log('✓ Hooked into onData');
172+
} catch (e) {
173+
log('⚠ Failed to hook onData:', e);
174+
}
175+
176+
// Hook 2: Override write method (terminal output)
177+
try {
178+
const originalWrite = term.write.bind(term);
179+
const originalWriteln = term.writeln.bind(term);
180+
181+
term.write = function(...args) {
182+
recordActivity();
183+
return originalWrite(...args);
184+
};
185+
186+
term.writeln = function(...args) {
187+
recordActivity();
188+
return originalWriteln(...args);
189+
};
190+
191+
log('✓ Hooked into write/writeln');
192+
} catch (e) {
193+
log('⚠ Failed to hook write methods:', e);
194+
}
195+
196+
// Hook 3: Monitor terminal buffer changes (fallback)
197+
try {
198+
let lastBufferLength = term.buffer.active.length;
199+
200+
setInterval(() => {
201+
const currentBufferLength = term.buffer.active.length;
202+
if (currentBufferLength !== lastBufferLength) {
203+
recordActivity();
204+
lastBufferLength = currentBufferLength;
205+
}
206+
}, 200);
207+
208+
log('✓ Monitoring buffer changes');
209+
} catch (e) {
210+
log('⚠ Failed to monitor buffer:', e);
211+
}
212+
213+
log('✓✓✓ Auto-scroll fully initialized ✓✓✓');
214+
notifyParent('ready');
215+
216+
// Test: Trigger initial scroll
217+
setTimeout(() => {
218+
forceScrollToBottom(term);
219+
}, 500);
220+
}
221+
222+
// Start initialization
223+
if (document.readyState === 'loading') {
224+
document.addEventListener('DOMContentLoaded', initAutoScroll);
225+
} else {
226+
initAutoScroll();
227+
}
228+
229+
})();

0 commit comments

Comments
 (0)