Fix the stupid terminal scaling calculations, we properly measure glyph width/height and apply linear scaling plus conservative row clamping. Good enough. Scroll could be reset to bottom after fit, but might also kill user scroll position so shrug.

This commit is contained in:
Mario Zechner 2025-06-16 22:55:54 +02:00
parent afa48c67d5
commit ddb342bdec
6 changed files with 174 additions and 280 deletions

View file

@ -1,180 +0,0 @@
/* Terminal-style CSS with 1em character grid */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--char-width: 1em;
--char-height: 1em;
--terminal-bg: #000000;
--terminal-fg: #ffffff;
--terminal-green: #00ff00;
--terminal-blue: #0080ff;
--terminal-cyan: #00ffff;
--terminal-yellow: #ffff00;
--terminal-red: #ff0000;
--terminal-gray: #808080;
}
body {
font-family: 'Courier New', monospace;
font-size: 16px;
line-height: var(--char-height);
background: var(--terminal-bg);
color: var(--terminal-fg);
padding: var(--char-height);
}
/* Terminal box styling */
.terminal-box {
border: 1px solid var(--terminal-gray);
background: var(--terminal-bg);
padding: var(--char-height);
margin: var(--char-height) 0;
}
/* Input styling */
input, textarea, select {
font-family: 'Courier New', monospace;
font-size: 16px;
line-height: var(--char-height);
background: var(--terminal-bg);
color: var(--terminal-fg);
border: 1px solid var(--terminal-gray);
padding: 0 var(--char-width);
height: calc(var(--char-height) * 2);
}
input:focus, textarea:focus, select:focus {
outline: 1px solid var(--terminal-green);
border-color: var(--terminal-green);
}
/* Button styling */
button {
font-family: 'Courier New', monospace;
font-size: 16px;
line-height: var(--char-height);
background: var(--terminal-gray);
color: var(--terminal-bg);
border: 1px solid var(--terminal-gray);
padding: 0 var(--char-width);
height: calc(var(--char-height) * 2);
cursor: pointer;
min-width: calc(var(--char-width) * 8);
}
button:hover {
background: var(--terminal-fg);
}
button.primary {
background: var(--terminal-green);
border-color: var(--terminal-green);
}
button.danger {
background: var(--terminal-red);
border-color: var(--terminal-red);
color: var(--terminal-fg);
}
/* Typography */
h1, h2, h3 {
line-height: var(--char-height);
margin: var(--char-height) 0;
}
h1 { color: var(--terminal-green); }
h2 { color: var(--terminal-cyan); }
h3 { color: var(--terminal-yellow); }
p {
margin: var(--char-height) 0;
}
/* Layout helpers */
.row {
display: flex;
gap: var(--char-width);
margin: var(--char-height) 0;
}
.col {
flex: 1;
}
.hidden {
display: none !important;
}
/* Terminal colors */
.text-green { color: var(--terminal-green); }
.text-blue { color: var(--terminal-blue); }
.text-cyan { color: var(--terminal-cyan); }
.text-yellow { color: var(--terminal-yellow); }
.text-red { color: var(--terminal-red); }
.text-gray { color: var(--terminal-gray); }
/* Session grid */
.session-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(40ch, 1fr));
gap: var(--char-height);
margin: var(--char-height) 0;
}
/* Asciinema player styling */
.asciinema-player {
width: 100% !important;
max-width: 100% !important;
}
.asciinema-player .control-bar {
display: none !important;
}
/* Modal styling */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
padding: var(--char-height);
}
.modal-content {
background: var(--terminal-bg);
border: 1px solid var(--terminal-gray);
padding: var(--char-height);
max-width: 80ch;
max-height: 80vh;
overflow-y: auto;
}
/* File browser */
.file-item {
padding: 0 var(--char-width);
cursor: pointer;
line-height: var(--char-height);
}
.file-item:hover {
background: var(--terminal-gray);
color: var(--terminal-bg);
}
.file-item.directory {
color: var(--terminal-blue);
}
.file-item.file {
color: var(--terminal-fg);
}

View file

@ -25,7 +25,7 @@ export class SessionCard extends LitElement {
@state() private renderer: Renderer | null = null;
@state() private killing = false;
@state() private killingFrame = 0;
private refreshInterval: number | null = null;
private killingInterval: number | null = null;
@ -55,7 +55,7 @@ export class SessionCard extends LitElement {
if (!playerElement) return;
// Create single renderer for this card
this.renderer = new Renderer(playerElement, 80, 24, 10000, 4, true);
this.renderer = new Renderer(playerElement, 80, 24, 10000, 4, false);
// Always use snapshot endpoint for cards
const url = `/api/sessions/${this.session.id}/snapshot`;
@ -107,7 +107,7 @@ export class SessionCard extends LitElement {
private async handleKillClick(e: Event) {
e.stopPropagation();
e.preventDefault();
// Start killing animation
this.killing = true;
this.killingFrame = 0;
@ -151,7 +151,7 @@ export class SessionCard extends LitElement {
private async handlePidClick(e: Event) {
e.stopPropagation();
e.preventDefault();
if (this.session.pid) {
try {
await navigator.clipboard.writeText(this.session.pid.toString());
@ -181,7 +181,7 @@ export class SessionCard extends LitElement {
render() {
const isRunning = this.session.status === 'running';
return html`
<div class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden ${this.killing ? 'opacity-60' : ''}"
@click=${this.handleCardClick}>
@ -221,7 +221,7 @@ export class SessionCard extends LitElement {
${this.getStatusText()}
</span>
${this.session.pid ? html`
<span
<span
class="cursor-pointer hover:text-vs-accent transition-colors"
@click=${this.handlePidClick}
title="Click to copy PID"

View file

@ -143,8 +143,19 @@ export class SessionView extends LitElement {
private createInteractiveTerminal() {
if (!this.session) return;
const terminalElement = this.querySelector('#interactive-terminal') as HTMLElement;
if (!terminalElement) return;
// Look for existing interactive terminal div or create one
let terminalElement = this.querySelector('#interactive-terminal') as HTMLElement;
if (!terminalElement) {
// Create the interactive terminal div inside the container
const container = this.querySelector('#terminal-container') as HTMLElement;
if (!container) return;
terminalElement = document.createElement('div');
terminalElement.id = 'interactive-terminal';
terminalElement.className = 'w-full h-full';
terminalElement.style.cssText = 'max-width: 100%; height: 100%;';
container.appendChild(terminalElement);
}
// Create renderer once and connect to current session
this.renderer = new Renderer(terminalElement);
@ -610,7 +621,7 @@ export class SessionView extends LitElement {
box-shadow: none !important;
}
</style>
<div class="flex flex-col bg-vs-bg font-mono" style="height: 100vh; outline: none !important; box-shadow: none !important;">
<div class="flex flex-col bg-vs-bg font-mono" style="height: 100vh; height: 100dvh; outline: none !important; box-shadow: none !important;">
<!-- Compact Header -->
<div class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm">
<div class="flex items-center gap-3">
@ -633,9 +644,7 @@ export class SessionView extends LitElement {
</div>
<!-- Terminal Container -->
<div class="flex-1 bg-black overflow-hidden min-h-0 relative" id="terminal-container" style="max-width: 100vw;">
<div id="interactive-terminal" class="w-full h-full" style="max-width: 100%;"></div>
<div class="flex-1 bg-black overflow-hidden min-h-0 relative" id="terminal-container" style="max-width: 100vw; height: 100%;">
${this.loading ? html`
<!-- Loading overlay -->
<div class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center">

View file

@ -2,7 +2,6 @@
// Professional-grade terminal emulation with full VT compatibility
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { ScaleFitAddon } from './scale-fit-addon.js';
@ -22,19 +21,16 @@ interface CastEvent {
export class Renderer {
private static activeCount: number = 0;
private container: HTMLElement;
private terminal: Terminal;
private fitAddon: FitAddon;
private scaleFitAddon: ScaleFitAddon;
private webLinksAddon: WebLinksAddon;
private isPreview: boolean;
constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14, isPreview: boolean = false) {
constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14) {
Renderer.activeCount++;
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
this.container = container;
this.isPreview = isPreview;
// Create terminal with options similar to the custom renderer
this.terminal = new Terminal({
@ -74,17 +70,13 @@ export class Renderer {
altClickMovesCursor: false,
rightClickSelectsWord: false,
disableStdin: true, // We handle input separately
cursorStyle: 'block',
cursorInactiveStyle: 'block',
cursorWidth: 1,
});
// Add addons
this.fitAddon = new FitAddon();
this.scaleFitAddon = new ScaleFitAddon(isPreview);
this.scaleFitAddon = new ScaleFitAddon();
this.webLinksAddon = new WebLinksAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.loadAddon(this.scaleFitAddon);
this.terminal.loadAddon(this.webLinksAddon);
@ -94,23 +86,13 @@ export class Renderer {
private setupDOM(): void {
// Clear container and add CSS
this.container.innerHTML = '';
// Different styling for preview vs full terminals
if (this.isPreview) {
// No padding for previews, let container control sizing
this.container.style.padding = '0';
this.container.style.backgroundColor = '#1e1e1e';
this.container.style.overflow = 'hidden';
this.container.style.maxWidth = '100%';
this.container.style.boxSizing = 'border-box';
} else {
// Full terminals get padding
this.container.style.padding = '10px';
this.container.style.backgroundColor = '#1e1e1e';
this.container.style.overflow = 'hidden';
this.container.style.maxWidth = '100%';
this.container.style.boxSizing = 'border-box';
}
// Full terminals get padding
this.container.style.padding = '10px';
this.container.style.backgroundColor = '#1e1e1e';
this.container.style.overflow = 'hidden';
this.container.style.maxWidth = '100%';
this.container.style.boxSizing = 'border-box';
// Create terminal wrapper
const terminalWrapper = document.createElement('div');
@ -127,21 +109,6 @@ export class Renderer {
// Apply to both previews and full terminals
const containerId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
this.container.id = containerId;
const style = document.createElement('style');
style.textContent = `
#${containerId} .xterm-screen {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
}
#${containerId} .xterm-viewport {
width: 100% !important;
max-width: 100% !important;
}
`;
document.head.appendChild(style);
// Always use ScaleFitAddon for better scaling
this.scaleFitAddon.fit();
@ -260,14 +227,14 @@ export class Renderer {
const exitCode = data[1];
const sessionId = data[2];
console.log(`Session ${sessionId} exited with code ${exitCode}`);
// Close the SSE connection immediately
if (this.eventSource) {
console.log('Closing SSE connection due to session exit');
this.eventSource.close();
this.eventSource = null;
}
// Dispatch custom event that session-view can listen to
const exitEvent = new CustomEvent('session-exit', {
detail: { sessionId, exitCode }
@ -275,7 +242,7 @@ export class Renderer {
this.container.dispatchEvent(exitEvent);
return;
}
// Regular cast event
const castEvent: CastEvent = {
timestamp: data[0],
@ -352,7 +319,7 @@ export class Renderer {
// Method to fit terminal to container (useful for responsive layouts)
fit(): void {
this.fitAddon.fit();
this.scaleFitAddon.fit();
}
// Get terminal dimensions

View file

@ -16,10 +16,8 @@ const MAX_FONT_SIZE = 16;
export class ScaleFitAddon implements ITerminalAddon {
private _terminal: Terminal | undefined;
private _isPreview: boolean;
constructor(isPreview: boolean = false) {
this._isPreview = isPreview;
constructor() {
}
public activate(terminal: Terminal): void {
@ -29,10 +27,6 @@ export class ScaleFitAddon implements ITerminalAddon {
public dispose(): void {}
public fit(): void {
if (this._isPreview) {
// For previews, only scale font size, don't change terminal dimensions
this.scaleFontOnly();
} else {
// For full terminals, resize both font and dimensions
const dims = this.proposeDimensions();
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
@ -43,7 +37,9 @@ export class ScaleFitAddon implements ITerminalAddon {
if (this._terminal.rows !== dims.rows) {
this._terminal.resize(this._terminal.cols, dims.rows);
}
}
// Force responsive sizing by overriding XTerm's fixed dimensions
this.forceResponsiveSizing();
}
public proposeDimensions(): ITerminalDimensions | undefined {
@ -75,35 +71,60 @@ export class ScaleFitAddon implements ITerminalAddon {
// Current terminal dimensions
const currentCols = this._terminal.cols;
// Calculate optimal font size to fit current cols in available width
// Character width is approximately 0.6 * fontSize for monospace fonts
// For 80 cols exactly, we need to be more conservative to prevent wrapping
const charWidthRatio = 0.63;
// Calculate font size and round down for precision
const calculatedFontSize = Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
// Get exact character dimensions from XTerm's measurement system first
const charDimensions = this.getXTermCharacterDimensions();
// Apply the calculated font size (outside of proposeDimensions to avoid recursion)
requestAnimationFrame(() => this.applyFontSize(optimalFontSize));
if (charDimensions) {
// Use actual measured dimensions for linear scaling calculation
const { charWidth, lineHeight } = charDimensions;
const currentFontSize = this._terminal.options.fontSize || 14;
// Get the actual line height from the rendered XTerm element
const xtermElement = this._terminal.element;
const currentStyle = window.getComputedStyle(xtermElement);
const actualLineHeight = parseFloat(currentStyle.lineHeight);
// Calculate current total rendered width for all columns
const currentRenderedWidth = currentCols * charWidth;
// XTerm typically uses a line height of around 1.0 for the character cell height
// Use a more accurate fallback based on XTerm's actual behavior
const lineHeight = (actualLineHeight && !isNaN(actualLineHeight)) ?
actualLineHeight :
(optimalFontSize * (this._terminal.options.lineHeight || 1.0));
// Calculate scale factor needed to fit exactly in available width
const scaleFactor = availableWidth / currentRenderedWidth;
// Calculate how many rows fit with this line height
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
// Apply linear scaling to font size
const newFontSize = currentFontSize * scaleFactor;
const clampedFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, newFontSize));
return {
cols: currentCols, // Keep existing cols
rows: optimalRows // Fit as many rows as possible
};
// Calculate actual font scaling that was applied (accounting for clamping)
const actualFontScaling = clampedFontSize / currentFontSize;
// Apply the actual font scaling to line height
const newLineHeight = lineHeight * actualFontScaling;
// Calculate how many rows fit with the scaled line height
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / newLineHeight));
// Apply the new font size
requestAnimationFrame(() => this.applyFontSize(clampedFontSize));
// Log all calculations for debugging
console.log(`ScaleFitAddon: ${availableWidth}×${availableHeight}px available, ${currentCols}×${this._terminal.rows} terminal, charWidth=${charWidth.toFixed(2)}px, lineHeight=${lineHeight.toFixed(2)}px, currentRenderedWidth=${currentRenderedWidth.toFixed(2)}px, scaleFactor=${scaleFactor.toFixed(3)}, actualFontScaling=${actualFontScaling.toFixed(3)}, fontSize ${currentFontSize}px→${clampedFontSize.toFixed(2)}px, lineHeight ${lineHeight.toFixed(2)}px→${newLineHeight.toFixed(2)}px, rows ${this._terminal.rows}${optimalRows}`);
return {
cols: currentCols, // ALWAYS keep exact column count
rows: optimalRows // Maximize rows that fit
};
} else {
// Fallback: estimate font size and dimensions if measurements aren't available
const charWidthRatio = 0.63;
const calculatedFontSize = Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
// Apply the calculated font size
requestAnimationFrame(() => this.applyFontSize(optimalFontSize));
const lineHeight = optimalFontSize * (this._terminal.options.lineHeight || 1.2);
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
return {
cols: currentCols,
rows: optimalRows
};
}
}
private applyFontSize(fontSize: number): void {
@ -121,10 +142,12 @@ export class ScaleFitAddon implements ITerminalAddon {
// Apply CSS font size to the element
terminalElement.style.fontSize = `${fontSize}px`;
// Force a refresh to apply the new font size
// Force a refresh to apply the new font size and ensure responsive sizing
requestAnimationFrame(() => {
if (this._terminal) {
this._terminal.refresh(0, this._terminal.rows - 1);
// Force responsive sizing after refresh since XTerm might reset dimensions
this.forceResponsiveSizing();
}
});
}
@ -158,6 +181,9 @@ export class ScaleFitAddon implements ITerminalAddon {
// Apply the font size without changing terminal dimensions
this.applyFontSize(optimalFontSize);
// Also force responsive sizing for previews
this.forceResponsiveSizing();
}
public getOptimalFontSize(): number {
@ -179,4 +205,84 @@ export class ScaleFitAddon implements ITerminalAddon {
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
}
/**
* Get exact character dimensions from XTerm's built-in measurement system
*/
private getXTermCharacterDimensions(): { charWidth: number; lineHeight: number } | null {
if (!this._terminal?.element) return null;
// XTerm has a built-in character measurement system with multiple font styles
const measureContainer = this._terminal.element.querySelector('.xterm-width-cache-measure-container');
// Find the first measurement element (normal weight, usually 'm' characters)
// This is what XTerm uses for baseline character width calculations
const firstMeasureElement = measureContainer?.querySelector('.xterm-char-measure-element');
if (firstMeasureElement) {
const measureRect = firstMeasureElement.getBoundingClientRect();
const measureText = firstMeasureElement.textContent || '';
if (measureText.length > 0 && measureRect.width > 0) {
// Calculate actual character width from the primary measurement element
const actualCharWidth = measureRect.width / measureText.length;
// Get line height from the first row in .xterm-rows
const xtermRows = this._terminal.element.querySelector('.xterm-rows');
const firstRow = xtermRows?.querySelector('div');
let lineHeight = 21.5; // fallback
if (firstRow) {
const rowStyle = window.getComputedStyle(firstRow);
const rowLineHeight = parseFloat(rowStyle.lineHeight);
if (!isNaN(rowLineHeight) && rowLineHeight > 0) {
lineHeight = rowLineHeight;
}
}
return {
charWidth: actualCharWidth,
lineHeight: lineHeight
};
}
}
// Fallback: try to measure from the xterm-screen dimensions and terminal cols/rows
const xtermScreen = this._terminal.element.querySelector('.xterm-screen') as HTMLElement;
if (xtermScreen) {
const screenRect = xtermScreen.getBoundingClientRect();
const charWidth = screenRect.width / this._terminal.cols;
const lineHeight = screenRect.height / this._terminal.rows;
if (charWidth > 0 && lineHeight > 0) {
return { charWidth, lineHeight };
}
}
return null;
}
/**
* Force XTerm elements to use responsive sizing instead of fixed dimensions
*/
private forceResponsiveSizing(): void {
if (!this._terminal?.element) return;
// Find the xterm-screen element within the terminal
const xtermScreen = this._terminal.element.querySelector('.xterm-screen') as HTMLElement;
const xtermViewport = this._terminal.element.querySelector('.xterm-viewport') as HTMLElement;
if (xtermScreen) {
// Remove any fixed width/height styles and force responsive sizing
xtermScreen.style.width = '100%';
xtermScreen.style.height = '100%';
xtermScreen.style.maxWidth = '100%';
xtermScreen.style.maxHeight = '100%';
}
if (xtermViewport) {
xtermViewport.style.width = '100%';
xtermViewport.style.maxWidth = '100%';
}
}
}

View file

@ -60,16 +60,8 @@ session-view .xterm-helper-textarea {
.session-preview .xterm .xterm-screen {
max-width: 100% !important;
max-height: 100% !important;
/* Scaling now handled by ScaleFitAddon */
}
.session-preview .xterm .xterm-viewport {
overflow: hidden !important;
}
/* Hide the helper textarea in session previews too */
.session-preview .xterm-helper-textarea {
display: none !important;
opacity: 0 !important;
pointer-events: none !important;
}