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:
File diff suppressed because it is too large
Load Diff
491
frontend/src/composables/useTerminalRenderer.ts
Normal file
491
frontend/src/composables/useTerminalRenderer.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
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 { endpoints } from '../config/endpoints'
|
||||||
|
import { useTerminalRenderer } from '../composables/useTerminalRenderer'
|
||||||
|
|
||||||
const terminalContainer = ref<HTMLElement | null>(null)
|
const terminalContainer = ref<HTMLElement | null>(null)
|
||||||
const connected = ref(false)
|
const connected = ref(false)
|
||||||
@@ -13,22 +10,12 @@ const error = ref<string | null>(null)
|
|||||||
const sessionId = ref<string | null>(null)
|
const sessionId = ref<string | null>(null)
|
||||||
const isResumedSession = ref(false)
|
const isResumedSession = ref(false)
|
||||||
|
|
||||||
let terminal: Terminal | null = null
|
|
||||||
let fitAddon: FitAddon | null = null
|
|
||||||
let socket: WebSocket | null = null
|
let socket: WebSocket | null = null
|
||||||
let resizeObserver: ResizeObserver | null = null
|
|
||||||
|
|
||||||
const WS_URL = endpoints.terminal
|
const WS_URL = endpoints.terminal
|
||||||
|
|
||||||
function initTerminal() {
|
// Terminal theme for TerminalPage (different from FloatingTerminal)
|
||||||
if (!terminalContainer.value) return
|
const TERMINAL_PAGE_THEME = {
|
||||||
|
|
||||||
terminal = new Terminal({
|
|
||||||
cursorBlink: true,
|
|
||||||
cursorStyle: 'block',
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
|
||||||
theme: {
|
|
||||||
background: '#0f0f14',
|
background: '#0f0f14',
|
||||||
foreground: '#e4e4e7',
|
foreground: '#e4e4e7',
|
||||||
cursor: '#6366f1',
|
cursor: '#6366f1',
|
||||||
@@ -50,39 +37,25 @@ function initTerminal() {
|
|||||||
brightMagenta: '#c084fc',
|
brightMagenta: '#c084fc',
|
||||||
brightCyan: '#22d3ee',
|
brightCyan: '#22d3ee',
|
||||||
brightWhite: '#ffffff'
|
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
|
// Use the composable
|
||||||
terminal.onData((data) => {
|
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) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify({ type: 'input', data }))
|
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() {
|
async function connect() {
|
||||||
if (connecting.value) return
|
if (connecting.value) return
|
||||||
@@ -96,37 +69,37 @@ async function connect() {
|
|||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
connected.value = true
|
connected.value = true
|
||||||
connecting.value = false
|
connecting.value = false
|
||||||
terminal?.focus()
|
renderer.focus()
|
||||||
|
|
||||||
// Send initial resize
|
const term = renderer.terminal.value
|
||||||
if (terminal) {
|
if (term) {
|
||||||
socket?.send(JSON.stringify({
|
socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||||
type: 'resize',
|
|
||||||
cols: terminal.cols,
|
|
||||||
rows: terminal.rows
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = async (event) => {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
|
|
||||||
if (msg.type === 'connected') {
|
if (msg.type === 'connected') {
|
||||||
sessionId.value = msg.sessionId
|
sessionId.value = msg.sessionId
|
||||||
isResumedSession.value = !msg.isNew
|
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') {
|
} else if (msg.type === 'replay') {
|
||||||
// Replay buffer from persistent session
|
await renderer.handleReplay(msg.data || '')
|
||||||
terminal?.write(msg.data)
|
|
||||||
} else if (msg.type === 'output') {
|
} else if (msg.type === 'output') {
|
||||||
terminal?.write(msg.data)
|
renderer.write(msg.data)
|
||||||
} else if (msg.type === 'exit') {
|
} else if (msg.type === 'exit') {
|
||||||
terminal?.write(msg.data)
|
renderer.write(msg.data)
|
||||||
sessionId.value = null
|
sessionId.value = null
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
error.value = msg.message
|
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
|
connected.value = false
|
||||||
connecting.value = false
|
connecting.value = false
|
||||||
if (sessionId.value) {
|
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 {
|
} 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() {
|
function clearTerminal() {
|
||||||
terminal?.clear()
|
renderer.clear()
|
||||||
terminal?.reset()
|
renderer.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyContent() {
|
async function copyContent() {
|
||||||
if (!terminal) return
|
const content = renderer.getBufferContent()
|
||||||
|
if (!content) 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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(content.trim())
|
await navigator.clipboard.writeText(content)
|
||||||
// Visual feedback
|
|
||||||
const btn = document.querySelector('.btn-copy') as HTMLButtonElement
|
const btn = document.querySelector('.btn-copy') as HTMLButtonElement
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.classList.add('copied')
|
btn.classList.add('copied')
|
||||||
@@ -198,15 +160,13 @@ function runClaudeCode() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
initTerminal()
|
renderer.init()
|
||||||
// Auto-connect
|
|
||||||
connect()
|
connect()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
resizeObserver?.disconnect()
|
|
||||||
socket?.close()
|
socket?.close()
|
||||||
terminal?.dispose()
|
renderer.dispose()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -228,28 +188,14 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<button
|
<button v-if="!connected" class="btn-primary" @click="connect" :disabled="connecting">
|
||||||
v-if="!connected"
|
|
||||||
class="btn-primary"
|
|
||||||
@click="connect"
|
|
||||||
:disabled="connecting"
|
|
||||||
>
|
|
||||||
Connect
|
Connect
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button v-else class="btn-secondary" @click="disconnect">
|
||||||
v-else
|
|
||||||
class="btn-secondary"
|
|
||||||
@click="disconnect"
|
|
||||||
>
|
|
||||||
Disconnect
|
Disconnect
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button class="btn-accent" @click="runClaudeCode" :disabled="!connected" title="Run Claude Code">
|
||||||
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">
|
<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="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
@@ -258,22 +204,14 @@ onBeforeUnmount(() => {
|
|||||||
Claude
|
Claude
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button class="btn-icon btn-copy" @click="copyContent" title="Copy terminal content">
|
||||||
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">
|
<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"/>
|
<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"/>
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button class="btn-icon" @click="clearTerminal" title="Clear terminal">
|
||||||
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">
|
<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="M3 6h18"/>
|
||||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
||||||
@@ -331,20 +269,9 @@ onBeforeUnmount(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.connected {
|
.status.connected { background: var(--success-bg); color: var(--success); }
|
||||||
background: var(--success-bg);
|
.status.connecting { background: var(--warning-bg); color: var(--warning); }
|
||||||
color: var(--success);
|
.status.disconnected { background: var(--error-bg); color: var(--error); }
|
||||||
}
|
|
||||||
|
|
||||||
.status.connecting {
|
|
||||||
background: var(--warning-bg);
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.disconnected {
|
|
||||||
background: var(--error-bg);
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-badge {
|
.session-badge {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
@@ -378,15 +305,8 @@ onBeforeUnmount(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
background: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
@@ -398,10 +318,7 @@ onBeforeUnmount(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.btn-secondary:hover { background: var(--bg-tertiary); }
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-accent {
|
.btn-accent {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -417,15 +334,8 @@ onBeforeUnmount(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
.btn-accent:hover:not(:disabled) { opacity: 0.9; }
|
||||||
.btn-accent:hover:not(:disabled) {
|
.btn-accent:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-accent:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@@ -435,16 +345,8 @@ onBeforeUnmount(() => {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
.btn-icon:hover {
|
.btn-icon.btn-copy.copied { color: var(--success); background: var(--success-bg); }
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon.btn-copy.copied {
|
|
||||||
color: var(--success);
|
|
||||||
background: var(--success-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner {
|
.error-banner {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -474,29 +376,10 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* xterm overrides */
|
/* xterm overrides */
|
||||||
.terminal-container :deep(.xterm) {
|
.terminal-container :deep(.xterm) { height: 100%; padding: 0.5rem; }
|
||||||
height: 100%;
|
.terminal-container :deep(.xterm-viewport) { overflow-y: auto !important; }
|
||||||
padding: 0.5rem;
|
.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) {
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) { background: #3a3a4a; }
|
||||||
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>
|
</style>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const WORKING_DIR = process.cwd().replace(/[\\\/]server$/, '')
|
|||||||
export const SHELL = process.platform === 'win32' ? 'powershell.exe' : 'bash'
|
export const SHELL = process.platform === 'win32' ? 'powershell.exe' : 'bash'
|
||||||
export const SHELL_ARGS = process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : []
|
export const SHELL_ARGS = process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : []
|
||||||
export const DEFAULT_SESSION_ID = 'main'
|
export const DEFAULT_SESSION_ID = 'main'
|
||||||
export const MAX_BUFFER_LINES = 1000
|
export const MAX_BUFFER_LINES = 10000
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
export const DB_PATH = 'agent-ui.db'
|
export const DB_PATH = 'agent-ui.db'
|
||||||
|
|||||||
@@ -155,21 +155,19 @@ export function startTerminalServer() {
|
|||||||
session.clients.add(ws)
|
session.clients.add(ws)
|
||||||
wsToSession.set(ws, sessionId)
|
wsToSession.set(ws, sessionId)
|
||||||
|
|
||||||
// Send connection info
|
// Send connection info (include buffer size so client knows if replay is needed)
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'connected',
|
type: 'connected',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
isNew: session.outputBuffer.length === 0
|
isNew: session.outputBuffer.length === 0,
|
||||||
|
hasHistory: session.outputBuffer.length > 0,
|
||||||
|
bufferSize: session.outputBuffer.length
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Replay buffer if there's history
|
// DON'T auto-replay here!
|
||||||
if (session.outputBuffer.length > 0) {
|
// Client will request replay when terminal is visible and ready.
|
||||||
console.log(`[Terminal] Replaying ${session.outputBuffer.length} buffer entries`)
|
// This fixes xterm.js rendering issues with hidden containers.
|
||||||
ws.send(JSON.stringify({
|
console.log(`[Terminal] Client connected, buffer has ${session.outputBuffer.length} chunks (client will request replay)`)
|
||||||
type: 'replay',
|
|
||||||
data: session.outputBuffer.join('')
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`)
|
console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -191,6 +189,33 @@ export function startTerminalServer() {
|
|||||||
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
||||||
session.pty.resize(msg.cols, msg.rows)
|
session.pty.resize(msg.cols, msg.rows)
|
||||||
console.log(`[Terminal] Session ${sessionId} resized to ${msg.cols}x${msg.rows}`)
|
console.log(`[Terminal] Session ${sessionId} resized to ${msg.cols}x${msg.rows}`)
|
||||||
|
} else if (msg.type === 'request-replay') {
|
||||||
|
// Client requests fresh replay (used when terminal becomes visible)
|
||||||
|
console.log(`[Terminal] Replay requested, buffer has ${session.outputBuffer.length} chunks`)
|
||||||
|
|
||||||
|
if (session.outputBuffer.length > 0) {
|
||||||
|
// If tailOnly specified, only send last N chunks (enough for a few screens)
|
||||||
|
const tailOnly = msg.tailOnly === true
|
||||||
|
const tailChunks = msg.chunks || 500 // Default ~500 chunks for tail
|
||||||
|
|
||||||
|
let data: string
|
||||||
|
if (tailOnly && session.outputBuffer.length > tailChunks) {
|
||||||
|
// Send only the tail - more efficient for large buffers
|
||||||
|
data = session.outputBuffer.slice(-tailChunks).join('')
|
||||||
|
console.log(`[Terminal] Replaying tail (${tailChunks}/${session.outputBuffer.length} chunks), ${data.length} bytes`)
|
||||||
|
} else {
|
||||||
|
data = session.outputBuffer.join('')
|
||||||
|
console.log(`[Terminal] Replaying full buffer (${session.outputBuffer.length} chunks), ${data.length} bytes`)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'replay',
|
||||||
|
data,
|
||||||
|
isTail: tailOnly && session.outputBuffer.length > tailChunks
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
console.log('[Terminal] No buffer to replay')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[Terminal] Error:', e)
|
console.error('[Terminal] Error:', e)
|
||||||
|
|||||||
Reference in New Issue
Block a user