refactor: Extract terminal rendering logic to useTerminalRenderer composable

- Create useTerminalRenderer.ts with all xterm.js logic
- Support custom theme, fontSize, fontFamily options
- Add handleReplay() for proper visibility handling
- Add getBufferContent() for copying terminal content
- Refactor FloatingTerminal.vue to use composable
- Refactor TerminalPage.vue to use composable
- Server: Add request-replay message type for on-demand replay
- Server: Remove auto-replay on connect (client requests when ready)
- Fix xterm.js rendering issues with hidden containers (v-show)
This commit is contained in:
2026-02-14 12:16:34 -06:00
parent e3ce3712b5
commit 303755437d
5 changed files with 877 additions and 770 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,491 @@
/**
* useTerminalRenderer
*
* Composable dedicado al renderizado de xterm.js para Claude Code.
* TODA la lógica de renderizado del terminal debe estar aquí.
*
* Problemas conocidos de xterm.js que este composable resuelve:
* - xterm.js no puede calcular dimensiones cuando el contenedor está oculto
* - fit() no funciona correctamente con display:none
* - El contenido se corrompe al cambiar visibilidad
*
* Referencias:
* - https://github.com/xtermjs/xterm.js/issues/3029
* - https://github.com/xtermjs/xterm.js/issues/664
* - https://github.com/xtermjs/xterm.js/issues/3653
*/
import { ref, type Ref, nextTick } from 'vue'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
// ============================================================================
// TYPES
// ============================================================================
export interface TerminalTheme {
background?: string
foreground?: string
cursor?: string
cursorAccent?: string
selectionBackground?: string
black?: string
red?: string
green?: string
yellow?: string
blue?: string
magenta?: string
cyan?: string
white?: string
brightBlack?: string
brightRed?: string
brightGreen?: string
brightYellow?: string
brightBlue?: string
brightMagenta?: string
brightCyan?: string
brightWhite?: string
}
export interface TerminalRendererOptions {
container: Ref<HTMLElement | null>
onData?: (data: string) => void
onResize?: (cols: number, rows: number) => void
onKeyEvent?: (e: KeyboardEvent) => boolean | void
theme?: TerminalTheme
fontSize?: number
fontFamily?: string
}
export interface TerminalRenderer {
// State
terminal: Ref<Terminal | null>
fitAddon: Ref<FitAddon | null>
isReady: Ref<boolean>
// Lifecycle
init: () => boolean
dispose: () => void
// Writing
write: (data: string) => Promise<void>
writeln: (data: string) => void
// Buffer management
clear: () => void
reset: () => void
// Display
fit: () => void
refresh: () => void
scrollToBottom: () => void
scrollLines: (lines: number) => void
// Focus
focus: () => void
blur: () => void
// Selection
getSelection: () => string
// Buffer content
getBufferContent: () => string
// Visibility handling - THE MAIN PROBLEM WE'RE SOLVING
onBecameVisible: () => Promise<void>
// Replay handling
handleReplay: (data: string) => Promise<void>
}
// ============================================================================
// TERMINAL THEME
// ============================================================================
const TERMINAL_THEME = {
background: 'rgba(12, 12, 12, 0.95)',
foreground: '#ffffff',
cursor: '#ffffff',
cursorAccent: '#000000',
selectionBackground: 'rgba(100, 150, 255, 0.4)',
black: '#0c0c0c',
red: '#c50f1f',
green: '#13a10e',
yellow: '#c19c00',
blue: '#0037da',
magenta: '#881798',
cyan: '#3a96dd',
white: '#cccccc',
brightBlack: '#767676',
brightRed: '#e74856',
brightGreen: '#16c60c',
brightYellow: '#f9f1a5',
brightBlue: '#3b78ff',
brightMagenta: '#b4009e',
brightCyan: '#61d6d6',
brightWhite: '#f2f2f2'
}
function getTerminalConfig(options: TerminalRendererOptions) {
return {
cursorBlink: true,
cursorStyle: 'block' as const,
fontSize: options.fontSize || 12,
fontFamily: options.fontFamily || "'Consolas', 'Lucida Console', monospace",
scrollback: 10000,
theme: options.theme || TERMINAL_THEME,
allowProposedApi: true
}
}
// ============================================================================
// COMPOSABLE
// ============================================================================
export function useTerminalRenderer(options: TerminalRendererOptions): TerminalRenderer {
const terminal = ref<Terminal | null>(null)
const fitAddon = ref<FitAddon | null>(null)
const isReady = ref(false)
let resizeObserver: ResizeObserver | null = null
// ==========================================================================
// LIFECYCLE
// ==========================================================================
/**
* Initialize the terminal.
* IMPORTANT: Only call this when the container is VISIBLE!
* Returns true if initialization succeeded.
*/
function init(): boolean {
if (!options.container.value) {
console.warn('[TerminalRenderer] Init failed - no container')
return false
}
if (terminal.value) {
console.log('[TerminalRenderer] Already initialized')
return true
}
console.log('[TerminalRenderer] Initializing terminal...')
// Create terminal with options
terminal.value = new Terminal(getTerminalConfig(options))
// Load addons
fitAddon.value = new FitAddon()
terminal.value.loadAddon(fitAddon.value)
terminal.value.loadAddon(new WebLinksAddon())
// Open terminal in container
terminal.value.open(options.container.value)
// Initial fit (use nextTick to ensure DOM is ready)
nextTick(() => {
fit()
isReady.value = true
console.log('[TerminalRenderer] Ready, cols:', terminal.value?.cols, 'rows:', terminal.value?.rows)
})
// Setup resize observer
resizeObserver = new ResizeObserver(() => {
if (fitAddon.value && terminal.value) {
fitAddon.value.fit()
options.onResize?.(terminal.value.cols, terminal.value.rows)
}
})
resizeObserver.observe(options.container.value)
// Handle user input
if (options.onData) {
terminal.value.onData(options.onData)
}
// Handle custom key events
if (options.onKeyEvent) {
terminal.value.attachCustomKeyEventHandler((e) => {
const result = options.onKeyEvent!(e)
return result === undefined ? true : result
})
}
return true
}
/**
* Dispose the terminal and cleanup resources.
*/
function dispose(): void {
console.log('[TerminalRenderer] Disposing...')
resizeObserver?.disconnect()
resizeObserver = null
terminal.value?.dispose()
terminal.value = null
fitAddon.value = null
isReady.value = false
}
// ==========================================================================
// WRITING
// ==========================================================================
/**
* Write data to terminal with Promise-based completion.
* The promise resolves when the data has been rendered.
*/
function write(data: string): Promise<void> {
return new Promise((resolve) => {
if (!terminal.value) {
console.warn('[TerminalRenderer] Write failed - no terminal')
resolve()
return
}
terminal.value.write(data, () => {
resolve()
})
})
}
/**
* Write a line to the terminal (adds \r\n).
*/
function writeln(data: string): void {
terminal.value?.writeln(data)
}
// ==========================================================================
// BUFFER MANAGEMENT
// ==========================================================================
/**
* Clear the terminal scrollback buffer.
*/
function clear(): void {
console.log('[TerminalRenderer] Clearing buffer')
terminal.value?.clear()
}
/**
* Reset the terminal completely (more aggressive than clear).
*/
function reset(): void {
console.log('[TerminalRenderer] Resetting terminal')
terminal.value?.reset()
}
// ==========================================================================
// DISPLAY
// ==========================================================================
/**
* Fit terminal dimensions to container size.
*/
function fit(): void {
if (!fitAddon.value) return
fitAddon.value.fit()
}
/**
* Refresh all visible rows.
*/
function refresh(): void {
if (!terminal.value) return
const rows = terminal.value.rows || 24
console.log('[TerminalRenderer] Refreshing rows 0 to', rows - 1)
terminal.value.refresh(0, rows - 1)
}
/**
* Scroll to the bottom of the terminal.
*/
function scrollToBottom(): void {
terminal.value?.scrollToBottom()
}
/**
* Scroll by a number of lines (positive = down, negative = up).
*/
function scrollLines(lines: number): void {
terminal.value?.scrollLines(lines)
}
// ==========================================================================
// FOCUS
// ==========================================================================
function focus(): void {
terminal.value?.focus()
}
function blur(): void {
terminal.value?.blur()
}
// ==========================================================================
// SELECTION
// ==========================================================================
function getSelection(): string {
return terminal.value?.getSelection() || ''
}
/**
* Get all content from the terminal buffer.
* Useful for copying terminal content.
*/
function getBufferContent(): string {
if (!terminal.value) return ''
const buffer = terminal.value.buffer.active
let content = ''
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i)
if (line) {
content += line.translateToString(true) + '\n'
}
}
return content.trim()
}
// ==========================================================================
// VISIBILITY HANDLING - THE MAIN PROBLEM WE'RE SOLVING
// ==========================================================================
/**
* Handle terminal becoming visible after being hidden.
*
* When terminal container goes from hidden to visible, xterm.js has issues:
* 1. Dimensions were calculated as 0x0 when hidden
* 2. Content may not render correctly
* 3. Scroll position may be wrong
*
* This function should be called when the terminal becomes visible.
* It uses multiple requestAnimationFrame to ensure browser has painted.
*/
async function onBecameVisible(): Promise<void> {
console.log('[TerminalRenderer] onBecameVisible called')
if (!terminal.value || !fitAddon.value) {
console.warn('[TerminalRenderer] Terminal not initialized')
return
}
return new Promise((resolve) => {
// Triple RAF to ensure browser has fully painted
requestAnimationFrame(() => {
console.log('[TerminalRenderer] RAF 1: Fitting...')
fit()
requestAnimationFrame(() => {
console.log('[TerminalRenderer] RAF 2: Dimensions:', terminal.value?.cols, 'x', terminal.value?.rows)
requestAnimationFrame(() => {
console.log('[TerminalRenderer] RAF 3: Ready for content')
focus()
resolve()
})
})
})
})
}
// ==========================================================================
// REPLAY HANDLING
// ==========================================================================
/**
* Handle replay data from server.
* This is the critical function for solving the rendering issue.
*
* Steps:
* 1. Reset terminal to clean state
* 2. Fit to ensure correct dimensions
* 3. Write data
* 4. Wait for write to complete
* 5. Scroll to bottom using multiple RAF
*/
async function handleReplay(data: string): Promise<void> {
console.log('[TerminalRenderer] handleReplay, bytes:', data.length)
if (!terminal.value || !fitAddon.value) {
console.warn('[TerminalRenderer] Cannot handle replay - terminal not ready')
return
}
// Step 1: Reset terminal completely
reset()
// Step 2: Fit to get correct dimensions
fit()
console.log('[TerminalRenderer] Pre-write dimensions:', terminal.value.cols, 'x', terminal.value.rows)
// Step 3 & 4: Write data and wait for completion
await write(data)
console.log('[TerminalRenderer] Write completed')
// Step 5: Scroll to bottom with multiple RAF to ensure rendering
return new Promise((resolve) => {
requestAnimationFrame(() => {
fit()
requestAnimationFrame(() => {
scrollToBottom()
console.log('[TerminalRenderer] Scroll completed')
resolve()
})
})
})
}
// ==========================================================================
// RETURN
// ==========================================================================
return {
// State
terminal,
fitAddon,
isReady,
// Lifecycle
init,
dispose,
// Writing
write,
writeln,
// Buffer management
clear,
reset,
// Display
fit,
refresh,
scrollToBottom,
scrollLines,
// Focus
focus,
blur,
// Selection
getSelection,
// Buffer content
getBufferContent,
// Visibility handling
onBecameVisible,
// Replay handling
handleReplay
}
}

View File

@@ -1,10 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import { endpoints } from '../config/endpoints'
import { useTerminalRenderer } from '../composables/useTerminalRenderer'
const terminalContainer = ref<HTMLElement | null>(null)
const connected = ref(false)
@@ -13,76 +10,52 @@ const error = ref<string | null>(null)
const sessionId = ref<string | null>(null)
const isResumedSession = ref(false)
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
let socket: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null
const WS_URL = endpoints.terminal
function initTerminal() {
if (!terminalContainer.value) return
// Terminal theme for TerminalPage (different from FloatingTerminal)
const TERMINAL_PAGE_THEME = {
background: '#0f0f14',
foreground: '#e4e4e7',
cursor: '#6366f1',
cursorAccent: '#0f0f14',
selectionBackground: 'rgba(99, 102, 241, 0.3)',
black: '#16161d',
red: '#ef4444',
green: '#22c55e',
yellow: '#eab308',
blue: '#3b82f6',
magenta: '#a855f7',
cyan: '#06b6d4',
white: '#e4e4e7',
brightBlack: '#52525b',
brightRed: '#f87171',
brightGreen: '#4ade80',
brightYellow: '#facc15',
brightBlue: '#60a5fa',
brightMagenta: '#c084fc',
brightCyan: '#22d3ee',
brightWhite: '#ffffff'
}
terminal = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
theme: {
background: '#0f0f14',
foreground: '#e4e4e7',
cursor: '#6366f1',
cursorAccent: '#0f0f14',
selectionBackground: 'rgba(99, 102, 241, 0.3)',
black: '#16161d',
red: '#ef4444',
green: '#22c55e',
yellow: '#eab308',
blue: '#3b82f6',
magenta: '#a855f7',
cyan: '#06b6d4',
white: '#e4e4e7',
brightBlack: '#52525b',
brightRed: '#f87171',
brightGreen: '#4ade80',
brightYellow: '#facc15',
brightBlue: '#60a5fa',
brightMagenta: '#c084fc',
brightCyan: '#22d3ee',
brightWhite: '#ffffff'
},
allowProposedApi: true
})
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebLinksAddon())
terminal.open(terminalContainer.value)
fitAddon.fit()
// Handle resize
resizeObserver = new ResizeObserver(() => {
if (fitAddon && terminal) {
fitAddon.fit()
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'resize',
cols: terminal.cols,
rows: terminal.rows
}))
}
}
})
resizeObserver.observe(terminalContainer.value)
// Handle terminal input
terminal.onData((data) => {
// Use the composable
const renderer = useTerminalRenderer({
container: terminalContainer,
theme: TERMINAL_PAGE_THEME,
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
onData: (data) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data }))
}
})
}
},
onResize: (cols, rows) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'resize', cols, rows }))
}
}
})
async function connect() {
if (connecting.value) return
@@ -96,37 +69,37 @@ async function connect() {
socket.onopen = () => {
connected.value = true
connecting.value = false
terminal?.focus()
renderer.focus()
// Send initial resize
if (terminal) {
socket?.send(JSON.stringify({
type: 'resize',
cols: terminal.cols,
rows: terminal.rows
}))
const term = renderer.terminal.value
if (term) {
socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
}
socket.onmessage = (event) => {
socket.onmessage = async (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
sessionId.value = msg.sessionId
isResumedSession.value = !msg.isNew
if (!msg.isNew) {
terminal?.write('\x1b[36m[Reconnected to existing session]\x1b[0m\r\n')
if (msg.hasHistory) {
// Request replay
socket?.send(JSON.stringify({ type: 'request-replay', tailOnly: false }))
} else if (!msg.isNew) {
renderer.writeln('\x1b[36m[Reconnected to existing session]\x1b[0m')
}
} else if (msg.type === 'replay') {
// Replay buffer from persistent session
terminal?.write(msg.data)
await renderer.handleReplay(msg.data || '')
} else if (msg.type === 'output') {
terminal?.write(msg.data)
renderer.write(msg.data)
} else if (msg.type === 'exit') {
terminal?.write(msg.data)
renderer.write(msg.data)
sessionId.value = null
} else if (msg.type === 'error') {
error.value = msg.message
terminal?.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`)
}
}
@@ -134,9 +107,9 @@ async function connect() {
connected.value = false
connecting.value = false
if (sessionId.value) {
terminal?.write('\r\n\x1b[33mDisconnected - session preserved on server\x1b[0m\r\n')
renderer.writeln('\x1b[33mDisconnected - session preserved on server\x1b[0m')
} else {
terminal?.write('\r\n\x1b[33mConnection closed\x1b[0m\r\n')
renderer.writeln('\x1b[33mConnection closed\x1b[0m')
}
}
@@ -159,27 +132,16 @@ function disconnect() {
}
function clearTerminal() {
terminal?.clear()
terminal?.reset()
renderer.clear()
renderer.reset()
}
async function copyContent() {
if (!terminal) return
// Get all terminal content
const buffer = terminal.buffer.active
let content = ''
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i)
if (line) {
content += line.translateToString(true) + '\n'
}
}
const content = renderer.getBufferContent()
if (!content) return
try {
await navigator.clipboard.writeText(content.trim())
// Visual feedback
await navigator.clipboard.writeText(content)
const btn = document.querySelector('.btn-copy') as HTMLButtonElement
if (btn) {
btn.classList.add('copied')
@@ -198,15 +160,13 @@ function runClaudeCode() {
onMounted(async () => {
await nextTick()
initTerminal()
// Auto-connect
renderer.init()
connect()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
socket?.close()
terminal?.dispose()
renderer.dispose()
})
</script>
@@ -228,28 +188,14 @@ onBeforeUnmount(() => {
</div>
<div class="toolbar-actions">
<button
v-if="!connected"
class="btn-primary"
@click="connect"
:disabled="connecting"
>
<button v-if="!connected" class="btn-primary" @click="connect" :disabled="connecting">
Connect
</button>
<button
v-else
class="btn-secondary"
@click="disconnect"
>
<button v-else class="btn-secondary" @click="disconnect">
Disconnect
</button>
<button
class="btn-accent"
@click="runClaudeCode"
:disabled="!connected"
title="Run Claude Code"
>
<button class="btn-accent" @click="runClaudeCode" :disabled="!connected" title="Run Claude Code">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
@@ -258,22 +204,14 @@ onBeforeUnmount(() => {
Claude
</button>
<button
class="btn-icon btn-copy"
@click="copyContent"
title="Copy terminal content"
>
<button class="btn-icon btn-copy" @click="copyContent" title="Copy terminal content">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button
class="btn-icon"
@click="clearTerminal"
title="Clear terminal"
>
<button class="btn-icon" @click="clearTerminal" title="Clear terminal">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
@@ -331,20 +269,9 @@ onBeforeUnmount(() => {
font-weight: 500;
}
.status.connected {
background: var(--success-bg);
color: var(--success);
}
.status.connecting {
background: var(--warning-bg);
color: var(--warning);
}
.status.disconnected {
background: var(--error-bg);
color: var(--error);
}
.status.connected { background: var(--success-bg); color: var(--success); }
.status.connecting { background: var(--warning-bg); color: var(--warning); }
.status.disconnected { background: var(--error-bg); color: var(--error); }
.session-badge {
margin-left: 0.5rem;
@@ -378,15 +305,8 @@ onBeforeUnmount(() => {
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary {
padding: 0.5rem 1rem;
@@ -398,10 +318,7 @@ onBeforeUnmount(() => {
font-weight: 500;
cursor: pointer;
}
.btn-secondary:hover {
background: var(--bg-tertiary);
}
.btn-secondary:hover { background: var(--bg-tertiary); }
.btn-accent {
display: flex;
@@ -417,15 +334,8 @@ onBeforeUnmount(() => {
cursor: pointer;
transition: opacity 0.15s;
}
.btn-accent:hover:not(:disabled) {
opacity: 0.9;
}
.btn-accent:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-accent:hover:not(:disabled) { opacity: 0.9; }
.btn-accent:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-icon {
padding: 0.5rem;
@@ -435,16 +345,8 @@ onBeforeUnmount(() => {
color: var(--text-secondary);
cursor: pointer;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-icon.btn-copy.copied {
color: var(--success);
background: var(--success-bg);
}
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
.btn-icon.btn-copy.copied { color: var(--success); background: var(--success-bg); }
.error-banner {
padding: 1rem;
@@ -474,29 +376,10 @@ onBeforeUnmount(() => {
}
/* xterm overrides */
.terminal-container :deep(.xterm) {
height: 100%;
padding: 0.5rem;
}
.terminal-container :deep(.xterm-viewport) {
overflow-y: auto !important;
}
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
width: 10px;
}
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
background: #16161d;
}
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
background: #2a2a3a;
border-radius: 5px;
}
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
background: #3a3a4a;
}
.terminal-container :deep(.xterm) { height: 100%; padding: 0.5rem; }
.terminal-container :deep(.xterm-viewport) { overflow-y: auto !important; }
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) { width: 10px; }
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) { background: #16161d; }
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: #2a2a3a; border-radius: 5px; }
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) { background: #3a3a4a; }
</style>