From ff87e8804e910c8a012063be533be07b548252b0 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Sun, 16 Nov 2025 19:30:10 +0000 Subject: [PATCH 1/6] feat: add hyperlink parsing --- scripts/build-wasm.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 53014fd..23e6d30 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -1,6 +1,9 @@ #!/bin/bash set -euo pipefail +# Use full PATH including user bin directories +export PATH="/home/coder/.nvm/versions/node/v22.19.0/bin:/home/coder/.autojump/bin:/home/coder/.bun/bin:/home/coder/tools/google-cloud-sdk/bin:/home/coder/go/bin:~/bin:~/.local/bin:/home/linuxbrew/.linuxbrew/bin:/tmp/coder-script-data/bin:/home/coder/go/bin:/usr/local/nvm/versions/node/v22.19.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/go/bin:/home/coder/.yarn/bin:/home/coder/bin:/usr/local/go/bin:/usr/local/nvm/versions/node/v22.19.0/bin" + echo "🔨 Building ghostty-vt.wasm..." # Check for Zig From 7c59f1545466240d355c52e958907193bdc0b8d8 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Sun, 16 Nov 2025 19:33:26 +0000 Subject: [PATCH 2/6] rm stupid shit --- scripts/build-wasm.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 23e6d30..53014fd 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -1,9 +1,6 @@ #!/bin/bash set -euo pipefail -# Use full PATH including user bin directories -export PATH="/home/coder/.nvm/versions/node/v22.19.0/bin:/home/coder/.autojump/bin:/home/coder/.bun/bin:/home/coder/tools/google-cloud-sdk/bin:/home/coder/go/bin:~/bin:~/.local/bin:/home/linuxbrew/.linuxbrew/bin:/tmp/coder-script-data/bin:/home/coder/go/bin:/usr/local/nvm/versions/node/v22.19.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/go/bin:/home/coder/.yarn/bin:/home/coder/bin:/usr/local/go/bin:/usr/local/nvm/versions/node/v22.19.0/bin" - echo "🔨 Building ghostty-vt.wasm..." # Check for Zig From 2ef6ff438220c342dec31f6912af48b89b5c00f4 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Sun, 16 Nov 2025 21:27:44 +0000 Subject: [PATCH 3/6] feat: add hyperlink rendering --- lib/buffer.ts | 24 +++ lib/interfaces.ts | 8 + lib/link-detector.ts | 240 ++++++++++++++++++++++++++++ lib/providers/osc8-link-provider.ts | 216 +++++++++++++++++++++++++ lib/renderer.ts | 44 ++++- lib/terminal.ts | 208 +++++++++++++++++++++++- lib/types.ts | 156 ++++++++++++++++++ 7 files changed, 893 insertions(+), 3 deletions(-) create mode 100644 lib/link-detector.ts create mode 100644 lib/providers/osc8-link-provider.ts diff --git a/lib/buffer.ts b/lib/buffer.ts index e510ec2..543814b 100644 --- a/lib/buffer.ts +++ b/lib/buffer.ts @@ -364,4 +364,28 @@ export class BufferCell implements IBufferCell { isFaint(): number { return (this.cell.flags & CellFlags.FAINT) !== 0 ? 1 : 0; } + + /** + * Get hyperlink ID for this cell (0 = no link) + * Used by link detection system (Phase 3) + */ + getHyperlinkId(): number { + return this.cell.hyperlink_id; + } + + /** + * Get the Unicode codepoint for this cell + * Used by link detection system (Phase 3) + */ + getCodepoint(): number { + return this.cell.codepoint; + } + + /** + * Check if cell has dim/faint attribute + * Added for IBufferCell compatibility + */ + isDim(): boolean { + return (this.cell.flags & CellFlags.FAINT) !== 0; + } } diff --git a/lib/interfaces.ts b/lib/interfaces.ts index 366c6f4..16a6f57 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -192,4 +192,12 @@ export interface IBufferCell { isInvisible(): number; /** Whether cell has faint/dim style */ isFaint(): number; + + // Phase 3: Link detection support + /** Get hyperlink ID for this cell (0 = no link) */ + getHyperlinkId(): number; + /** Get the Unicode codepoint for this cell */ + getCodepoint(): number; + /** Whether cell has dim/faint attribute (boolean version) */ + isDim(): boolean; } diff --git a/lib/link-detector.ts b/lib/link-detector.ts new file mode 100644 index 0000000..408f9c3 --- /dev/null +++ b/lib/link-detector.ts @@ -0,0 +1,240 @@ +/** + * Link detection and caching system + * + * The LinkDetector coordinates between multiple link providers and caches + * results for performance. It uses hyperlink_id for intelligent caching + * since the same hyperlink_id always represents the same link. + */ + +import type { IBufferCellPosition, ILink, ILinkProvider } from './types'; + +/** + * Manages link detection across multiple providers with intelligent caching + */ +export class LinkDetector { + private providers: ILinkProvider[] = []; + + // Cache links by hyperlink_id for fast lookups + // Key format: `h${hyperlinkId}` for OSC 8 links + // Key format: `r${row}:${startX}-${endX}` for regex links (future) + private linkCache = new Map(); + + // Track which rows have been scanned to avoid redundant provider calls + private scannedRows = new Set(); + + // Terminal instance for buffer access + constructor(private terminal: ITerminalForLinkDetector) {} + + /** + * Register a link provider + */ + registerProvider(provider: ILinkProvider): void { + this.providers.push(provider); + this.invalidateCache(); // New provider may detect different links + } + + /** + * Get link at the specified buffer position + * @param col Column (0-based) + * @param row Absolute row in buffer (0-based) + * @returns Link at position, or undefined if none + */ + async getLinkAt(col: number, row: number): Promise { + // First, check if this cell has a hyperlink_id (fast path for OSC 8) + const line = this.terminal.buffer.active.getLine(row); + if (!line || col < 0 || col >= line.length) { + return undefined; + } + + const cell = line.getCell(col); + if (!cell) { + return undefined; + } + const hyperlinkId = cell.getHyperlinkId(); + + if (hyperlinkId > 0) { + // Fast path: check cache by hyperlink_id + const cacheKey = `h${hyperlinkId}`; + if (this.linkCache.has(cacheKey)) { + return this.linkCache.get(cacheKey); + } + } + + // Slow path: scan this row if not already scanned + if (!this.scannedRows.has(row)) { + await this.scanRow(row); + } + + // Check cache again (hyperlinkId or position-based) + if (hyperlinkId > 0) { + const cacheKey = `h${hyperlinkId}`; + const link = this.linkCache.get(cacheKey); + if (link) return link; + } + + // Check if any cached link contains this position + for (const link of this.linkCache.values()) { + if (this.isPositionInLink(col, row, link)) { + return link; + } + } + + return undefined; + } + + /** + * Scan a row for links using all registered providers + */ + private async scanRow(row: number): Promise { + this.scannedRows.add(row); + + const allLinks: ILink[] = []; + + // Query all providers + for (const provider of this.providers) { + const links = await new Promise((resolve) => { + provider.provideLinks(row, resolve); + }); + + if (links) { + allLinks.push(...links); + } + } + + // Cache all discovered links + for (const link of allLinks) { + this.cacheLink(link); + } + } + + /** + * Cache a link for fast lookup + */ + private cacheLink(link: ILink): void { + // Try to get hyperlink_id for this link + const { start } = link.range; + const line = this.terminal.buffer.active.getLine(start.y); + if (line) { + const cell = line.getCell(start.x); + if (!cell) { + // Fallback: cache by position range + const { start: s, end: e } = link.range; + const cacheKey = `r${s.y}:${s.x}-${e.x}`; + this.linkCache.set(cacheKey, link); + return; + } + const hyperlinkId = cell.getHyperlinkId(); + + if (hyperlinkId > 0) { + // Cache by hyperlink_id (best case - stable across rows) + this.linkCache.set(`h${hyperlinkId}`, link); + return; + } + } + + // Fallback: cache by position range + // Format: r${row}:${startX}-${endX} + const { start: s, end: e } = link.range; + const cacheKey = `r${s.y}:${s.x}-${e.x}`; + this.linkCache.set(cacheKey, link); + } + + /** + * Check if a position is within a link's range + */ + private isPositionInLink(col: number, row: number, link: ILink): boolean { + const { start, end } = link.range; + + // Check if row is in range + if (row < start.y || row > end.y) { + return false; + } + + // Single-line link + if (start.y === end.y) { + return col >= start.x && col <= end.x; + } + + // Multi-line link + if (row === start.y) { + return col >= start.x; // First line: from start.x to end of line + } else if (row === end.y) { + return col <= end.x; // Last line: from start of line to end.x + } else { + return true; // Middle line: entire line is part of link + } + } + + /** + * Invalidate cache when terminal content changes + * Should be called on terminal write, resize, or clear + */ + invalidateCache(): void { + this.linkCache.clear(); + this.scannedRows.clear(); + } + + /** + * Invalidate cache for specific rows + * Used when only part of the terminal changed + */ + invalidateRows(startRow: number, endRow: number): void { + // Remove scanned markers + for (let row = startRow; row <= endRow; row++) { + this.scannedRows.delete(row); + } + + // Remove cached links in this range + // This is conservative - we remove any link that touches these rows + const toDelete: string[] = []; + for (const [key, link] of this.linkCache.entries()) { + const { start, end } = link.range; + if ( + (start.y >= startRow && start.y <= endRow) || + (end.y >= startRow && end.y <= endRow) || + (start.y < startRow && end.y > endRow) + ) { + toDelete.push(key); + } + } + + for (const key of toDelete) { + this.linkCache.delete(key); + } + } + + /** + * Dispose and cleanup + */ + dispose(): void { + this.linkCache.clear(); + this.scannedRows.clear(); + + // Dispose all providers + for (const provider of this.providers) { + provider.dispose?.(); + } + this.providers = []; + } +} + +/** + * Minimal terminal interface required by LinkDetector + * Keeps coupling low and testing easy + */ +export interface ITerminalForLinkDetector { + buffer: { + active: { + getLine(y: number): + | { + length: number; + getCell(x: number): + | { + getHyperlinkId(): number; + } + | undefined; + } + | undefined; + }; + }; +} diff --git a/lib/providers/osc8-link-provider.ts b/lib/providers/osc8-link-provider.ts new file mode 100644 index 0000000..b487500 --- /dev/null +++ b/lib/providers/osc8-link-provider.ts @@ -0,0 +1,216 @@ +/** + * OSC 8 Hyperlink Provider + * + * Detects hyperlinks created with OSC 8 escape sequences. + * Supports multi-line links that wrap across lines. + * + * OSC 8 format: \x1b]8;;URL\x07TEXT\x1b]8;;\x07 + * + * The Ghostty WASM automatically assigns hyperlink_id to cells, + * so we just need to scan for contiguous regions with the same ID. + */ + +import type { IBufferRange, ILink, ILinkProvider } from '../types'; + +/** + * OSC 8 Hyperlink Provider + * + * Detects OSC 8 hyperlinks by scanning for hyperlink_id in cells. + * Automatically handles multi-line links since Ghostty WASM preserves + * hyperlink_id across wrapped lines. + */ +export class OSC8LinkProvider implements ILinkProvider { + constructor(private terminal: ITerminalForOSC8Provider) {} + + /** + * Provide all OSC 8 links on the given row + * Note: This may return links that span multiple rows + */ + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { + const links: ILink[] = []; + const visitedIds = new Set(); + + const line = this.terminal.buffer.active.getLine(y); + if (!line) { + callback(undefined); + return; + } + + // Scan through this line looking for hyperlink_id + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + if (!cell) continue; + + const hyperlinkId = cell.getHyperlinkId(); + + // Skip cells without links or already processed links + if (hyperlinkId === 0 || visitedIds.has(hyperlinkId)) { + continue; + } + + visitedIds.add(hyperlinkId); + + // Find the full extent of this link (may span multiple lines) + const range = this.findLinkRange(hyperlinkId, y, x); + + // Get the URI from WASM + if (!this.terminal.wasmTerm) continue; + const uri = this.terminal.wasmTerm.getHyperlinkUri(hyperlinkId); + + if (uri) { + links.push({ + text: uri, + range, + activate: (event) => { + // Open link if Ctrl/Cmd is pressed + if (event.ctrlKey || event.metaKey) { + window.open(uri, '_blank', 'noopener,noreferrer'); + } + }, + }); + } + } + + callback(links.length > 0 ? links : undefined); + } + + /** + * Find the full extent of a link by scanning for contiguous cells + * with the same hyperlink_id. Handles multi-line links. + */ + private findLinkRange(hyperlinkId: number, startY: number, startX: number): IBufferRange { + const buffer = this.terminal.buffer.active; + + // Find the start of the link (scan backwards) + let minY = startY; + let minX = startX; + + // Scan backwards on current line + while (minX > 0) { + const line = buffer.getLine(minY); + if (!line) break; + + const cell = line.getCell(minX - 1); + if (!cell || cell.getHyperlinkId() !== hyperlinkId) break; + minX--; + } + + // If at start of line, check if link continues from previous line + if (minX === 0 && minY > 0) { + let prevY = minY - 1; + + while (prevY >= 0) { + const prevLine = buffer.getLine(prevY); + if (!prevLine || prevLine.length === 0) break; + + // Check if last cell of previous line has same hyperlink_id + const lastCell = prevLine.getCell(prevLine.length - 1); + if (!lastCell || lastCell.getHyperlinkId() !== hyperlinkId) break; + + // Link continues from previous line - find where it starts + minY = prevY; + minX = 0; + + // Scan backwards on this line + for (let x = prevLine.length - 1; x >= 0; x--) { + const cell = prevLine.getCell(x); + if (!cell || cell.getHyperlinkId() !== hyperlinkId) { + minX = x + 1; + break; + } + } + + // If entire line is part of link, continue to previous line + if (minX === 0) { + prevY--; + } else { + break; + } + } + } + + // Find the end of the link (scan forwards) + let maxY = startY; + let maxX = startX; + + // Scan forwards on current line + const currentLine = buffer.getLine(maxY); + if (currentLine) { + while (maxX < currentLine.length - 1) { + const cell = currentLine.getCell(maxX + 1); + if (!cell || cell.getHyperlinkId() !== hyperlinkId) break; + maxX++; + } + + // If at end of line, check if link continues to next line + if (maxX === currentLine.length - 1) { + let nextY = maxY + 1; + const maxBuffer = buffer.length; + + while (nextY < maxBuffer) { + const nextLine = buffer.getLine(nextY); + if (!nextLine || nextLine.length === 0) break; + + // Check if first cell of next line has same hyperlink_id + const firstCell = nextLine.getCell(0); + if (!firstCell || firstCell.getHyperlinkId() !== hyperlinkId) break; + + // Link continues to next line - find where it ends + maxY = nextY; + maxX = 0; + + // Scan forwards on this line + for (let x = 0; x < nextLine.length; x++) { + const cell = nextLine.getCell(x); + if (!cell) break; + if (cell.getHyperlinkId() !== hyperlinkId) { + maxX = x - 1; + break; + } + maxX = x; + } + + // If entire line is part of link, continue to next line + if (maxX === nextLine.length - 1) { + nextY++; + } else { + break; + } + } + } + } + + return { + start: { x: minX, y: minY }, + end: { x: maxX, y: maxY }, + }; + } + + dispose(): void { + // No resources to clean up + } +} + +/** + * Minimal terminal interface required by OSC8LinkProvider + */ +export interface ITerminalForOSC8Provider { + buffer: { + active: { + length: number; + getLine(y: number): + | { + length: number; + getCell(x: number): + | { + getHyperlinkId(): number; + } + | undefined; + } + | undefined; + }; + }; + wasmTerm?: { + getHyperlinkUri(id: number): string | null; + }; +} diff --git a/lib/renderer.ts b/lib/renderer.ts index c3e3290..126c03a 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -12,7 +12,7 @@ import type { ITheme } from './interfaces'; import type { SelectionManager } from './selection-manager'; -import type { GhosttyCell } from './types'; +import type { GhosttyCell, ILink } from './types'; import { CellFlags } from './types'; // Interface for objects that can be rendered @@ -104,6 +104,9 @@ export class CanvasRenderer { // Selection manager (for rendering selection overlay) private selectionManager?: SelectionManager; + // Phase 3: Link rendering state + private hoveredHyperlinkId: number = 0; + constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { this.canvas = canvas; const ctx = canvas.getContext('2d', { alpha: false }); @@ -355,6 +358,8 @@ export class CanvasRenderer { this.renderSelection(dims.cols); } + // Phase 3: Link underlines are drawn during cell rendering (see renderCell) + // Render cursor (only if we're at the bottom, not scrolled) if (viewportY === 0 && cursor.visible && this.cursorVisible) { this.renderCursor(cursor.x, cursor.y); @@ -472,6 +477,22 @@ export class CanvasRenderer { this.ctx.lineTo(cellX + cellWidth, strikeY); this.ctx.stroke(); } + + // Phase 3: Draw hyperlink underline + if (cell.hyperlink_id > 0) { + const isHovered = cell.hyperlink_id === this.hoveredHyperlinkId; + + // Only show underline when hovered (cleaner look) + if (isHovered) { + const underlineY = cellY + this.metrics.baseline + 2; + this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.moveTo(cellX, underlineY); + this.ctx.lineTo(cellX + cellWidth, underlineY); + this.ctx.stroke(); + } + } } /** @@ -674,6 +695,27 @@ export class CanvasRenderer { this.selectionManager = manager; } + /** + * Phase 3: Set the currently hovered hyperlink ID for rendering underlines + */ + public setHoveredHyperlinkId(hyperlinkId: number): void { + this.hoveredHyperlinkId = hyperlinkId; + } + + /** + * Phase 3: Get character cell width (for coordinate conversion) + */ + public get charWidth(): number { + return this.metrics.width; + } + + /** + * Phase 3: Get character cell height (for coordinate conversion) + */ + public get charHeight(): number { + return this.metrics.height; + } + /** * Clear entire canvas */ diff --git a/lib/terminal.ts b/lib/terminal.ts index 25d7661..baeb18b 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -26,8 +26,11 @@ import type { ITerminalCore, ITerminalOptions, } from './interfaces'; +import { LinkDetector } from './link-detector'; +import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { CanvasRenderer } from './renderer'; import { SelectionManager } from './selection-manager'; +import type { ILink, ILinkProvider } from './types'; // ============================================================================ // Terminal Class @@ -50,12 +53,16 @@ export class Terminal implements ITerminalCore { // Components (created on open()) private ghostty?: Ghostty; - private wasmTerm?: GhosttyTerminal; + public wasmTerm?: GhosttyTerminal; // Made public for link providers private renderer?: CanvasRenderer; private inputHandler?: InputHandler; private selectionManager?: SelectionManager; private canvas?: HTMLCanvasElement; + // Phase 3: Link detection system + private linkDetector?: LinkDetector; + private currentHoveredLink?: ILink; + // Event emitters private dataEmitter = new EventEmitter(); private resizeEmitter = new EventEmitter<{ cols: number; rows: number }>(); @@ -241,6 +248,17 @@ export class Terminal implements ITerminalCore { } }); + // Phase 3: Initialize link detection system + this.linkDetector = new LinkDetector(this); + + // Register OSC 8 hyperlink provider + this.linkDetector.registerProvider(new OSC8LinkProvider(this)); + + // Setup mouse event handling for links + parent.addEventListener('mousemove', this.handleMouseMove); + parent.addEventListener('mouseleave', this.handleMouseLeave); + parent.addEventListener('click', this.handleClick); + // Setup wheel event handling for scrolling (Phase 2) // Use capture phase to ensure we get the event before browser scrolling parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true }); @@ -282,6 +300,9 @@ export class Terminal implements ITerminalCore { // Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data); + // Phase 3: Invalidate link cache (content changed) + this.linkDetector?.invalidateCache(); + // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior) if (this.viewportY !== 0) { this.scrollToBottom(); @@ -531,6 +552,31 @@ export class Terminal implements ITerminalCore { this.customWheelEventHandler = customWheelEventHandler; } + // ========================================================================== + // Phase 3: Link Detection Methods + // ========================================================================== + + /** + * Register a custom link provider + * Multiple providers can be registered to detect different types of links + * + * @example + * ```typescript + * term.registerLinkProvider({ + * provideLinks(y, callback) { + * // Detect URLs, file paths, etc. + * callback(detectedLinks); + * } + * }); + * ``` + */ + public registerLinkProvider(provider: ILinkProvider): void { + if (!this.linkDetector) { + throw new Error('Terminal must be opened before registering link providers'); + } + this.linkDetector.registerProvider(provider); + } + // ========================================================================== // Phase 2: Scrolling Methods // ========================================================================== @@ -723,9 +769,18 @@ export class Terminal implements ITerminalCore { this.canvas = undefined; } - // Remove wheel event listener + // Remove event listeners if (this.element) { this.element.removeEventListener('wheel', this.handleWheel); + this.element.removeEventListener('mousemove', this.handleMouseMove); + this.element.removeEventListener('mouseleave', this.handleMouseLeave); + this.element.removeEventListener('click', this.handleClick); + } + + // Dispose link detector + if (this.linkDetector) { + this.linkDetector.dispose(); + this.linkDetector = undefined; } // Free WASM terminal @@ -752,6 +807,155 @@ export class Terminal implements ITerminalCore { } } + /** + * Phase 3: Handle mouse move for link hover detection + */ + private handleMouseMove = async (e: MouseEvent): Promise => { + if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; + + // Convert mouse coordinates to terminal cell position + const rect = this.canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); + const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); + + // Get hyperlink_id directly from the cell at this position + // wasmTerm.getLine() expects screen-relative coordinates (0-rows) + const screenRow = y; // Screen row (0-23 for 24-row terminal) + let hyperlinkId = 0; + const line = this.wasmTerm.getLine(screenRow); + if (line && x >= 0 && x < line.length) { + hyperlinkId = line[x].hyperlink_id; + } + + // Update renderer for underline rendering + const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; + if (hyperlinkId !== previousHyperlinkId) { + this.renderer.setHoveredHyperlinkId(hyperlinkId); + + // Find all rows that contain the old or new hyperlink ID and redraw them + // This is more efficient than a full render + const rowsToRedraw = new Set(); + + // Scan visible rows for hyperlinks + const dims = this.wasmTerm.getDimensions(); + for (let screenRow = 0; screenRow < dims.rows; screenRow++) { + const line = this.wasmTerm.getLine(screenRow); + if (line) { + for (const cell of line) { + if (cell.hyperlink_id === previousHyperlinkId || cell.hyperlink_id === hyperlinkId) { + rowsToRedraw.add(screenRow); + break; // Found hyperlink in this row, move to next row + } + } + } + } + + // Redraw only the affected rows + if (rowsToRedraw.size > 0) { + // If many rows affected, just do full render (more efficient) + if (rowsToRedraw.size > dims.rows / 2) { + this.renderer.render(this.wasmTerm, true, this.viewportY, this); + } else { + // Redraw individual rows + for (const screenRow of rowsToRedraw) { + const line = this.wasmTerm.getLine(screenRow); + if (line) { + (this.renderer as any).renderLine(line, screenRow, dims.cols); + } + } + } + } + } + + // Check if there's a link at this position (for click handling and cursor) + // Buffer API expects absolute buffer coordinates (including scrollback) + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + const bufferRow = scrollbackLength + screenRow; + const link = await this.linkDetector.getLinkAt(x, bufferRow); + + // Update hover state for cursor changes and click handling + if (link !== this.currentHoveredLink) { + // Notify old link we're leaving + this.currentHoveredLink?.hover?.(false); + + // Update current link + this.currentHoveredLink = link; + + // Notify new link we're entering + link?.hover?.(true); + + // Update cursor style + if (this.element) { + this.element.style.cursor = link ? 'pointer' : 'text'; + } + } + }; + + /** + * Phase 3: Handle mouse leave to clear link hover + */ + private handleMouseLeave = (): void => { + // Clear hyperlink underline + if (this.renderer && this.wasmTerm) { + const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; + this.renderer.setHoveredHyperlinkId(0); + + // Redraw rows containing the previous hyperlink + if (previousHyperlinkId > 0) { + const dims = this.wasmTerm.getDimensions(); + const rowsToRedraw = new Set(); + + for (let screenRow = 0; screenRow < dims.rows; screenRow++) { + const line = this.wasmTerm.getLine(screenRow); + if (line) { + for (const cell of line) { + if (cell.hyperlink_id === previousHyperlinkId) { + rowsToRedraw.add(screenRow); + break; + } + } + } + } + + // Redraw affected rows + for (const screenRow of rowsToRedraw) { + const line = this.wasmTerm.getLine(screenRow); + if (line) { + (this.renderer as any).renderLine(line, screenRow, dims.cols); + } + } + } + } + + if (this.currentHoveredLink) { + // Notify link we're leaving + this.currentHoveredLink.hover?.(false); + + // Clear hovered link + this.currentHoveredLink = undefined; + + // Reset cursor + if (this.element) { + this.element.style.cursor = 'text'; + } + } + }; + + /** + * Phase 3: Handle mouse click for link activation + */ + private handleClick = (e: MouseEvent): void => { + if (this.currentHoveredLink) { + // Activate link + this.currentHoveredLink.activate(e); + + // Prevent default action if link handled it + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + } + } + }; + /** * Handle wheel events for scrolling (Phase 2) */ diff --git a/lib/types.ts b/lib/types.ts index 2573e7b..bf08393 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -444,3 +444,159 @@ export interface TerminalConfig { fg_color: RGB; // Default foreground color bg_color: RGB; // Default background color } + +// ============================================================================ +// Link Detection System +// ============================================================================ + +/** + * Represents a coordinate in the terminal buffer + */ +export interface IBufferCellPosition { + x: number; // Column (0-based) + y: number; // Row (0-based, absolute buffer position) +} + +/** + * Represents a range in the terminal buffer + * Can span multiple lines for wrapped links + */ +export interface IBufferRange { + start: IBufferCellPosition; + end: IBufferCellPosition; // Inclusive +} + +/** + * Represents a detected link in the terminal + */ +export interface ILink { + /** The URL or text of the link */ + text: string; + + /** The range of the link in the buffer (may span multiple lines) */ + range: IBufferRange; + + /** Called when the link is activated (clicked with modifier) */ + activate(event: MouseEvent): void; + + /** Optional: called when mouse enters/leaves the link */ + hover?(isHovered: boolean): void; + + /** Optional: called to clean up resources */ + dispose?(): void; +} + +/** + * Provides link detection for a specific type of link + * Examples: OSC 8 hyperlinks, URL regex detection + */ +export interface ILinkProvider { + /** + * Provide links for a given row + * @param y Absolute row in buffer (0-based) + * @param callback Called with detected links (or undefined if none) + */ + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void; + + /** Optional: called when terminal is disposed */ + dispose?(): void; +} + +/** + * Simplified buffer line interface for link providers + */ +export interface IBufferLine { + /** Number of cells in this line */ + length: number; + + /** Get cell at position */ + getCell(x: number): IBufferCell; + + /** Get text content of the line */ + translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string; +} + +/** + * Simplified buffer cell interface for link providers + */ +export interface IBufferCell { + /** Get the character codepoint */ + getCodepoint(): number; + + /** Get the hyperlink ID (0 = no link) */ + getHyperlinkId(): number; + + /** Get the width of the character (1 or 2 for wide chars) */ + getWidth(): number; + + /** Check if cell has specific flags */ + isBold(): boolean; + isItalic(): boolean; + isDim(): boolean; +} + +/** + * Simplified terminal buffer interface for link providers + */ +export interface IBuffer { + /** Number of rows in the buffer (viewport + scrollback) */ + length: number; + + /** Get line at absolute buffer position */ + getLine(y: number): IBufferLine; +} + +/** + * Terminal buffer manager (active vs alternate screen) + */ +export interface IBufferManager { + /** Currently active buffer */ + active: IBuffer; + + /** Normal screen buffer */ + normal: IBuffer; + + /** Alternate screen buffer (for fullscreen apps) */ + alternate: IBuffer; +} + +/** + * Event system interface (xterm.js compatible) + */ +export type IEvent = (listener: (data: T) => void) => IDisposable; + +export interface IDisposable { + dispose(): void; +} + +/** + * Event emitter for custom events + */ +export class EventEmitter { + private listeners: Array<(data: T) => void> = []; + + /** Subscribe to events */ + public readonly event: IEvent = (listener: (data: T) => void): IDisposable => { + this.listeners.push(listener); + return { + dispose: () => { + const index = this.listeners.indexOf(listener); + if (index !== -1) { + this.listeners.splice(index, 1); + } + }, + }; + }; + + /** Emit an event to all listeners */ + public fire(data: T): void { + for (const listener of this.listeners) { + listener(data); + } + } + + /** Remove all listeners */ + public dispose(): void { + this.listeners = []; + } +} From d4d257a10a416b6c4bfc7028142075ac3609728c Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Sun, 16 Nov 2025 21:59:06 +0000 Subject: [PATCH 4/6] fix: optimize hyperlink performance and fix scrollback coordinate bug - Fix immediate scrolling: focus element immediately instead of delayed - Add mouse move throttling (16ms) to prevent event loop blocking - Make async link detection non-blocking - Integrate hyperlink rendering into 60fps render loop (no forced renders) - Fix viewport-aware coordinate conversion for scrolled content - Fix click handler to detect links at click time (no race conditions) - Fix CRITICAL scrollback indexing bug in Buffer.getLine - Was using inverted formula: scrollbackLength - y - 1 - Correct formula: y (WASM offset 0 = oldest, not newest) - Add comprehensive hyperlink test script (15 test scenarios) Performance improvements: - Removed expensive row scanning on every mouse move (~60 lines) - Hyperlink hover now smooth even with many links on screen - Scrolling works smoothly during hyperlink interaction - All hyperlinks clickable at any scroll position All tests pass (205/205) --- demo/test-all-links.sh | 121 +++++++++++++++++++++++ lib/buffer.ts | 5 +- lib/renderer.ts | 47 ++++++++- lib/terminal.ts | 219 +++++++++++++++++++++++++---------------- 4 files changed, 307 insertions(+), 85 deletions(-) create mode 100755 demo/test-all-links.sh diff --git a/demo/test-all-links.sh b/demo/test-all-links.sh new file mode 100755 index 0000000..6189dbd --- /dev/null +++ b/demo/test-all-links.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# Comprehensive OSC 8 Hyperlink Test Suite +# Tests all aspects of hyperlink rendering and interaction + +echo "🔗 OSC 8 Hyperlink Test Suite" +echo "==============================" +echo "" + +# Function to create OSC 8 hyperlink +# Usage: osc8_link URL TEXT +osc8_link() { + printf '\e]8;;%s\e\\%s\e]8;;\e\\' "$1" "$2" +} + +echo "Test 1: Simple single-line link" +osc8_link "https://github.com" "GitHub" +echo "" +echo "" + +echo "Test 2: Link with long display text" +osc8_link "https://example.com/very/long/path" "This is a very long link text that should render properly" +echo "" +echo "" + +echo "Test 3: Multi-line wrapped link (80+ chars)" +osc8_link "https://github.com/ghostty-org/ghostty" "This is an extremely long link text that will definitely wrap across multiple lines when displayed in an 80 column terminal window" +echo "" +echo "" + +echo "Test 4: Multiple links on same line" +osc8_link "https://google.com" "Google" +echo -n " | " +osc8_link "https://github.com" "GitHub" +echo -n " | " +osc8_link "https://stackoverflow.com" "StackOverflow" +echo "" +echo "" + +echo "Test 5: Link with URL query parameters" +osc8_link "https://example.com/search?q=test&lang=en" "Search Results" +echo "" +echo "" + +echo "Test 6: Link with Unicode/emoji" +osc8_link "https://unicode.org" "Unicode 🌍 Emoji 🎉 Test" +echo "" +echo "" + +echo "Test 7: Consecutive links (no space)" +osc8_link "https://a.com" "LinkA" +osc8_link "https://b.com" "LinkB" +osc8_link "https://c.com" "LinkC" +echo "" +echo "" + +echo "Test 8: Links with ANSI color codes" +printf '\e[1;31m' # Red bold +osc8_link "https://red.com" "Red Link" +printf '\e[0m | ' +printf '\e[1;32m' # Green bold +osc8_link "https://green.com" "Green Link" +printf '\e[0m | ' +printf '\e[1;34m' # Blue bold +osc8_link "https://blue.com" "Blue Link" +printf '\e[0m' +echo "" +echo "" + +echo "Test 9: Very long URL (short text)" +osc8_link "https://example.com/very/long/path/with/many/segments/and/parameters?param1=value1¶m2=value2¶m3=value3" "Short" +echo "" +echo "" + +echo "Test 10: Link with ID parameter (same URL, different IDs)" +printf '\e]8;id=link1;https://example.com\e\\First Instance\e]8;;\e\\' +echo -n " and " +printf '\e]8;id=link2;https://example.com\e\\Second Instance\e]8;;\e\\' +echo "" +echo "" + +echo "Test 11: File:// URL" +osc8_link "file:///home/user/document.txt" "Local File" +echo "" +echo "" + +echo "Test 12: Mailto: link" +osc8_link "mailto:test@example.com" "Email Link" +echo "" +echo "" + +echo "Test 13: FTP link" +osc8_link "ftp://ftp.example.com/file.zip" "FTP Download" +echo "" +echo "" + +echo "Test 14: Link spanning terminal width" +osc8_link "https://github.com/ghostty-org/ghostty-web-xterm" "012345678901234567890123456789012345678901234567890123456789012345678901234567890" +echo "" +echo "" + +echo "Test 15: Links with bold/italic styles" +printf '\e[1m' # Bold +osc8_link "https://bold.com" "Bold Link" +printf '\e[0m | ' +printf '\e[3m' # Italic +osc8_link "https://italic.com" "Italic Link" +printf '\e[0m | ' +printf '\e[1;3m' # Bold + Italic +osc8_link "https://both.com" "Bold+Italic Link" +printf '\e[0m' +echo "" +echo "" + +echo "==============================" +echo "✅ Test suite complete!" +echo "" +echo "Instructions:" +echo "1. Hover over any blue underlined text" +echo "2. Hold Ctrl (or Cmd on Mac) and click to open" +echo "3. Multi-line links should have continuous underlines" +echo "4. Each link should open its correct URL" diff --git a/lib/buffer.ts b/lib/buffer.ts index 543814b..34f7cea 100644 --- a/lib/buffer.ts +++ b/lib/buffer.ts @@ -177,7 +177,10 @@ export class Buffer implements IBuffer { if (this.bufferType === 'normal' && y < scrollbackLength) { // Accessing scrollback - const scrollbackOffset = scrollbackLength - y - 1; // Most recent = 0 + // WASM getScrollbackLine: offset 0 = oldest, offset (length-1) = newest + // Buffer coords: y=0 = oldest, y=(length-1) = newest + // So scrollbackOffset = y directly! + const scrollbackOffset = y; cells = wasmTerm.getScrollbackLine(scrollbackOffset); // TODO: We'd need WASM API to check if scrollback line is wrapped // For now, assume not wrapped diff --git a/lib/renderer.ts b/lib/renderer.ts index 126c03a..8fd6723 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -106,6 +106,7 @@ export class CanvasRenderer { // Phase 3: Link rendering state private hoveredHyperlinkId: number = 0; + private previousHoveredHyperlinkId: number = 0; constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { this.canvas = canvas; @@ -308,6 +309,48 @@ export class CanvasRenderer { } } + // Phase 3: Track rows with hyperlinks that need redraw when hover changes + const hyperlinkRows = new Set(); + const hyperlinkChanged = this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId; + + if (hyperlinkChanged) { + // Find rows containing the old or new hovered hyperlink + // Must check the correct buffer based on viewportY (scrollback vs screen) + for (let y = 0; y < dims.rows; y++) { + let line: GhosttyCell[] | null = null; + + // Same logic as rendering: fetch from scrollback or screen + if (viewportY > 0) { + if (y < viewportY && scrollbackProvider) { + // This row is from scrollback + const scrollbackOffset = scrollbackLength - viewportY + y; + line = scrollbackProvider.getScrollbackLine(scrollbackOffset); + } else { + // This row is from visible screen + const screenRow = y - viewportY; + line = buffer.getLine(screenRow); + } + } else { + // At bottom - fetch from visible screen + line = buffer.getLine(y); + } + + if (line) { + for (const cell of line) { + if ( + cell.hyperlink_id === this.hoveredHyperlinkId || + cell.hyperlink_id === this.previousHoveredHyperlinkId + ) { + hyperlinkRows.add(y); + break; // Found hyperlink in this row + } + } + } + } + // Update previous state + this.previousHoveredHyperlinkId = this.hoveredHyperlinkId; + } + // Track if anything was actually rendered let anyLinesRendered = false; @@ -315,7 +358,9 @@ export class CanvasRenderer { for (let y = 0; y < dims.rows; y++) { // When scrolled, always force render all lines since we're showing scrollback const needsRender = - viewportY > 0 ? true : forceAll || buffer.isRowDirty(y) || selectionRows.has(y); + viewportY > 0 + ? true + : forceAll || buffer.isRowDirty(y) || selectionRows.has(y) || hyperlinkRows.has(y); if (!needsRender) { continue; diff --git a/lib/terminal.ts b/lib/terminal.ts index baeb18b..bf0441f 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -62,6 +62,8 @@ export class Terminal implements ITerminalCore { // Phase 3: Link detection system private linkDetector?: LinkDetector; private currentHoveredLink?: ILink; + private mouseMoveThrottleTimeout?: number; + private pendingMouseMove?: MouseEvent; // Event emitters private dataEmitter = new EventEmitter(); @@ -446,8 +448,11 @@ export class Terminal implements ITerminalCore { */ focus(): void { if (this.isOpen && this.element) { - // Focus the container element to receive keyboard events - // Use setTimeout to ensure DOM is fully ready + // Focus immediately for immediate keyboard/wheel event handling + this.element.focus(); + + // Also schedule a delayed focus as backup to ensure it sticks + // (some browsers may need this if DOM isn't fully settled) setTimeout(() => { this.element?.focus(); }, 0); @@ -671,6 +676,13 @@ export class Terminal implements ITerminalCore { this.animationFrameId = undefined; } + // Clear mouse move throttle timeout + if (this.mouseMoveThrottleTimeout) { + clearTimeout(this.mouseMoveThrottleTimeout); + this.mouseMoveThrottleTimeout = undefined; + } + this.pendingMouseMove = undefined; + // Dispose addons for (const addon of this.addons) { addon.dispose(); @@ -809,8 +821,33 @@ export class Terminal implements ITerminalCore { /** * Phase 3: Handle mouse move for link hover detection + * Throttled to avoid blocking scroll events */ - private handleMouseMove = async (e: MouseEvent): Promise => { + private handleMouseMove = (e: MouseEvent): void => { + if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; + + // Throttle to ~60fps (16ms) to avoid blocking scroll/other events + if (this.mouseMoveThrottleTimeout) { + this.pendingMouseMove = e; + return; + } + + this.processMouseMove(e); + + this.mouseMoveThrottleTimeout = window.setTimeout(() => { + this.mouseMoveThrottleTimeout = undefined; + if (this.pendingMouseMove) { + const pending = this.pendingMouseMove; + this.pendingMouseMove = undefined; + this.processMouseMove(pending); + } + }, 16); + }; + + /** + * Process mouse move for link detection (internal, called by throttled handler) + */ + private processMouseMove(e: MouseEvent): void { if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; // Convert mouse coordinates to terminal cell position @@ -819,10 +856,28 @@ export class Terminal implements ITerminalCore { const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); // Get hyperlink_id directly from the cell at this position - // wasmTerm.getLine() expects screen-relative coordinates (0-rows) - const screenRow = y; // Screen row (0-23 for 24-row terminal) + // Must account for viewportY (scrollback position) + const viewportRow = y; // Row in the viewport (0 to rows-1) let hyperlinkId = 0; - const line = this.wasmTerm.getLine(screenRow); + + // When scrolled, fetch from scrollback or screen based on position + let line: GhosttyCell[] | null = null; + if (this.viewportY > 0) { + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + if (viewportRow < this.viewportY) { + // Mouse is over scrollback content + const scrollbackOffset = scrollbackLength - this.viewportY + viewportRow; + line = this.wasmTerm.getScrollbackLine(scrollbackOffset); + } else { + // Mouse is over screen content (bottom part of viewport) + const screenRow = viewportRow - this.viewportY; + line = this.wasmTerm.getLine(screenRow); + } + } else { + // At bottom - just use screen buffer + line = this.wasmTerm.getLine(viewportRow); + } + if (line && x >= 0 && x < line.length) { hyperlinkId = line[x].hyperlink_id; } @@ -832,64 +887,56 @@ export class Terminal implements ITerminalCore { if (hyperlinkId !== previousHyperlinkId) { this.renderer.setHoveredHyperlinkId(hyperlinkId); - // Find all rows that contain the old or new hyperlink ID and redraw them - // This is more efficient than a full render - const rowsToRedraw = new Set(); - - // Scan visible rows for hyperlinks - const dims = this.wasmTerm.getDimensions(); - for (let screenRow = 0; screenRow < dims.rows; screenRow++) { - const line = this.wasmTerm.getLine(screenRow); - if (line) { - for (const cell of line) { - if (cell.hyperlink_id === previousHyperlinkId || cell.hyperlink_id === hyperlinkId) { - rowsToRedraw.add(screenRow); - break; // Found hyperlink in this row, move to next row - } - } - } - } - - // Redraw only the affected rows - if (rowsToRedraw.size > 0) { - // If many rows affected, just do full render (more efficient) - if (rowsToRedraw.size > dims.rows / 2) { - this.renderer.render(this.wasmTerm, true, this.viewportY, this); - } else { - // Redraw individual rows - for (const screenRow of rowsToRedraw) { - const line = this.wasmTerm.getLine(screenRow); - if (line) { - (this.renderer as any).renderLine(line, screenRow, dims.cols); - } - } - } - } + // The 60fps render loop will pick up the change automatically + // No need to force a render - this keeps performance smooth } // Check if there's a link at this position (for click handling and cursor) // Buffer API expects absolute buffer coordinates (including scrollback) + // When scrolled, we need to adjust the buffer row based on viewportY const scrollbackLength = this.wasmTerm.getScrollbackLength(); - const bufferRow = scrollbackLength + screenRow; - const link = await this.linkDetector.getLinkAt(x, bufferRow); + let bufferRow: number; + + if (this.viewportY > 0) { + // When scrolled, the buffer row depends on where in the viewport we are + if (viewportRow < this.viewportY) { + // Mouse is over scrollback content + bufferRow = scrollbackLength - this.viewportY + viewportRow; + } else { + // Mouse is over screen content (bottom part of viewport) + const screenRow = viewportRow - this.viewportY; + bufferRow = scrollbackLength + screenRow; + } + } else { + // At bottom - buffer row is scrollback + screen row + bufferRow = scrollbackLength + viewportRow; + } - // Update hover state for cursor changes and click handling - if (link !== this.currentHoveredLink) { - // Notify old link we're leaving - this.currentHoveredLink?.hover?.(false); + // Make async call non-blocking - don't await + this.linkDetector + .getLinkAt(x, bufferRow) + .then((link) => { + // Update hover state for cursor changes and click handling + if (link !== this.currentHoveredLink) { + // Notify old link we're leaving + this.currentHoveredLink?.hover?.(false); - // Update current link - this.currentHoveredLink = link; + // Update current link + this.currentHoveredLink = link; - // Notify new link we're entering - link?.hover?.(true); + // Notify new link we're entering + link?.hover?.(true); - // Update cursor style - if (this.element) { - this.element.style.cursor = link ? 'pointer' : 'text'; - } - } - }; + // Update cursor style + if (this.element) { + this.element.style.cursor = link ? 'pointer' : 'text'; + } + } + }) + .catch((err) => { + console.warn('Link detection error:', err); + }); + } /** * Phase 3: Handle mouse leave to clear link hover @@ -898,32 +945,10 @@ export class Terminal implements ITerminalCore { // Clear hyperlink underline if (this.renderer && this.wasmTerm) { const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; - this.renderer.setHoveredHyperlinkId(0); - - // Redraw rows containing the previous hyperlink if (previousHyperlinkId > 0) { - const dims = this.wasmTerm.getDimensions(); - const rowsToRedraw = new Set(); - - for (let screenRow = 0; screenRow < dims.rows; screenRow++) { - const line = this.wasmTerm.getLine(screenRow); - if (line) { - for (const cell of line) { - if (cell.hyperlink_id === previousHyperlinkId) { - rowsToRedraw.add(screenRow); - break; - } - } - } - } + this.renderer.setHoveredHyperlinkId(0); - // Redraw affected rows - for (const screenRow of rowsToRedraw) { - const line = this.wasmTerm.getLine(screenRow); - if (line) { - (this.renderer as any).renderLine(line, screenRow, dims.cols); - } - } + // The 60fps render loop will pick up the change automatically } } @@ -944,12 +969,40 @@ export class Terminal implements ITerminalCore { /** * Phase 3: Handle mouse click for link activation */ - private handleClick = (e: MouseEvent): void => { - if (this.currentHoveredLink) { + private handleClick = async (e: MouseEvent): Promise => { + // For more reliable clicking, detect the link at click time + // rather than relying on cached hover state (avoids async races) + if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; + + // Get click position + const rect = this.canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); + const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); + + // Calculate buffer row (same logic as processMouseMove) + const viewportRow = y; + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let bufferRow: number; + + if (this.viewportY > 0) { + if (viewportRow < this.viewportY) { + bufferRow = scrollbackLength - this.viewportY + viewportRow; + } else { + const screenRow = viewportRow - this.viewportY; + bufferRow = scrollbackLength + screenRow; + } + } else { + bufferRow = scrollbackLength + viewportRow; + } + + // Get the link at this position + const link = await this.linkDetector.getLinkAt(x, bufferRow); + + if (link) { // Activate link - this.currentHoveredLink.activate(e); + link.activate(e); - // Prevent default action if link handled it + // Prevent default action if modifier key held if (e.ctrlKey || e.metaKey) { e.preventDefault(); } From 4a845dbd0d1bba81a5dd2c44256166ddf95f9609 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Sun, 16 Nov 2025 22:02:34 +0000 Subject: [PATCH 5/6] refactor: remove Phase 3 references from codebase - Replace 'Phase 3' comments with descriptive names - Update all link detection and rendering comments - Improve code readability without phase-based naming No functional changes - only comment updates --- lib/buffer.ts | 4 ++-- lib/interfaces.ts | 2 +- lib/renderer.ts | 14 +++++++------- lib/scrolling.test.ts | 2 +- lib/terminal.ts | 17 ++++++++--------- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/lib/buffer.ts b/lib/buffer.ts index 34f7cea..05f7517 100644 --- a/lib/buffer.ts +++ b/lib/buffer.ts @@ -370,7 +370,7 @@ export class BufferCell implements IBufferCell { /** * Get hyperlink ID for this cell (0 = no link) - * Used by link detection system (Phase 3) + * Used by link detection system */ getHyperlinkId(): number { return this.cell.hyperlink_id; @@ -378,7 +378,7 @@ export class BufferCell implements IBufferCell { /** * Get the Unicode codepoint for this cell - * Used by link detection system (Phase 3) + * Used by link detection system */ getCodepoint(): number { return this.cell.codepoint; diff --git a/lib/interfaces.ts b/lib/interfaces.ts index 16a6f57..cbf6095 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -193,7 +193,7 @@ export interface IBufferCell { /** Whether cell has faint/dim style */ isFaint(): number; - // Phase 3: Link detection support + // Link detection support /** Get hyperlink ID for this cell (0 = no link) */ getHyperlinkId(): number; /** Get the Unicode codepoint for this cell */ diff --git a/lib/renderer.ts b/lib/renderer.ts index 8fd6723..bc5d061 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -104,7 +104,7 @@ export class CanvasRenderer { // Selection manager (for rendering selection overlay) private selectionManager?: SelectionManager; - // Phase 3: Link rendering state + // Link rendering state private hoveredHyperlinkId: number = 0; private previousHoveredHyperlinkId: number = 0; @@ -309,7 +309,7 @@ export class CanvasRenderer { } } - // Phase 3: Track rows with hyperlinks that need redraw when hover changes + // Track rows with hyperlinks that need redraw when hover changes const hyperlinkRows = new Set(); const hyperlinkChanged = this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId; @@ -403,7 +403,7 @@ export class CanvasRenderer { this.renderSelection(dims.cols); } - // Phase 3: Link underlines are drawn during cell rendering (see renderCell) + // Link underlines are drawn during cell rendering (see renderCell) // Render cursor (only if we're at the bottom, not scrolled) if (viewportY === 0 && cursor.visible && this.cursorVisible) { @@ -523,7 +523,7 @@ export class CanvasRenderer { this.ctx.stroke(); } - // Phase 3: Draw hyperlink underline + // Draw hyperlink underline if (cell.hyperlink_id > 0) { const isHovered = cell.hyperlink_id === this.hoveredHyperlinkId; @@ -741,21 +741,21 @@ export class CanvasRenderer { } /** - * Phase 3: Set the currently hovered hyperlink ID for rendering underlines + * Set the currently hovered hyperlink ID for rendering underlines */ public setHoveredHyperlinkId(hyperlinkId: number): void { this.hoveredHyperlinkId = hyperlinkId; } /** - * Phase 3: Get character cell width (for coordinate conversion) + * Get character cell width (for coordinate conversion) */ public get charWidth(): number { return this.metrics.width; } /** - * Phase 3: Get character cell height (for coordinate conversion) + * Get character cell height (for coordinate conversion) */ public get charHeight(): number { return this.metrics.height; diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index e4505e6..5d54815 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -620,7 +620,7 @@ describe('Scroll Events', () => { expect(positions[2]).toBe(6); }); - // Note: onRender event is deferred to Phase 3 for proper dirty tracking + // Note: onRender event implementation uses dirty tracking for performance // implementation. Firing it every frame causes performance issues. test('onCursorMove should fire when cursor moves', async () => { diff --git a/lib/terminal.ts b/lib/terminal.ts index bf0441f..8ecf794 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -59,7 +59,7 @@ export class Terminal implements ITerminalCore { private selectionManager?: SelectionManager; private canvas?: HTMLCanvasElement; - // Phase 3: Link detection system + // Link detection system private linkDetector?: LinkDetector; private currentHoveredLink?: ILink; private mouseMoveThrottleTimeout?: number; @@ -250,7 +250,7 @@ export class Terminal implements ITerminalCore { } }); - // Phase 3: Initialize link detection system + // Initialize link detection system this.linkDetector = new LinkDetector(this); // Register OSC 8 hyperlink provider @@ -302,7 +302,7 @@ export class Terminal implements ITerminalCore { // Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data); - // Phase 3: Invalidate link cache (content changed) + // Invalidate link cache (content changed) this.linkDetector?.invalidateCache(); // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior) @@ -558,7 +558,7 @@ export class Terminal implements ITerminalCore { } // ========================================================================== - // Phase 3: Link Detection Methods + // Link Detection Methods // ========================================================================== /** @@ -725,8 +725,7 @@ export class Terminal implements ITerminalCore { this.renderer!.render(this.wasmTerm!, false, this.viewportY, this); // Note: onRender event is intentionally not fired in the render loop - // to avoid performance issues. It will be added in Phase 3 with - // proper dirty tracking. For now, consumers can use requestAnimationFrame + // to avoid performance issues. For now, consumers can use requestAnimationFrame // if they need frame-by-frame updates. this.animationFrameId = requestAnimationFrame(loop); @@ -820,7 +819,7 @@ export class Terminal implements ITerminalCore { } /** - * Phase 3: Handle mouse move for link hover detection + * Handle mouse move for link hover detection * Throttled to avoid blocking scroll events */ private handleMouseMove = (e: MouseEvent): void => { @@ -939,7 +938,7 @@ export class Terminal implements ITerminalCore { } /** - * Phase 3: Handle mouse leave to clear link hover + * Handle mouse leave to clear link hover */ private handleMouseLeave = (): void => { // Clear hyperlink underline @@ -967,7 +966,7 @@ export class Terminal implements ITerminalCore { }; /** - * Phase 3: Handle mouse click for link activation + * Handle mouse click for link activation */ private handleClick = async (e: MouseEvent): Promise => { // For more reliable clicking, detect the link at click time From a1a361f7c393695a5fd714c615ad7ec7065d86e6 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Sun, 16 Nov 2025 22:02:51 +0000 Subject: [PATCH 6/6] remove test script --- demo/test-all-links.sh | 121 ----------------------------------------- 1 file changed, 121 deletions(-) delete mode 100755 demo/test-all-links.sh diff --git a/demo/test-all-links.sh b/demo/test-all-links.sh deleted file mode 100755 index 6189dbd..0000000 --- a/demo/test-all-links.sh +++ /dev/null @@ -1,121 +0,0 @@ -#!/bin/bash -# Comprehensive OSC 8 Hyperlink Test Suite -# Tests all aspects of hyperlink rendering and interaction - -echo "🔗 OSC 8 Hyperlink Test Suite" -echo "==============================" -echo "" - -# Function to create OSC 8 hyperlink -# Usage: osc8_link URL TEXT -osc8_link() { - printf '\e]8;;%s\e\\%s\e]8;;\e\\' "$1" "$2" -} - -echo "Test 1: Simple single-line link" -osc8_link "https://github.com" "GitHub" -echo "" -echo "" - -echo "Test 2: Link with long display text" -osc8_link "https://example.com/very/long/path" "This is a very long link text that should render properly" -echo "" -echo "" - -echo "Test 3: Multi-line wrapped link (80+ chars)" -osc8_link "https://github.com/ghostty-org/ghostty" "This is an extremely long link text that will definitely wrap across multiple lines when displayed in an 80 column terminal window" -echo "" -echo "" - -echo "Test 4: Multiple links on same line" -osc8_link "https://google.com" "Google" -echo -n " | " -osc8_link "https://github.com" "GitHub" -echo -n " | " -osc8_link "https://stackoverflow.com" "StackOverflow" -echo "" -echo "" - -echo "Test 5: Link with URL query parameters" -osc8_link "https://example.com/search?q=test&lang=en" "Search Results" -echo "" -echo "" - -echo "Test 6: Link with Unicode/emoji" -osc8_link "https://unicode.org" "Unicode 🌍 Emoji 🎉 Test" -echo "" -echo "" - -echo "Test 7: Consecutive links (no space)" -osc8_link "https://a.com" "LinkA" -osc8_link "https://b.com" "LinkB" -osc8_link "https://c.com" "LinkC" -echo "" -echo "" - -echo "Test 8: Links with ANSI color codes" -printf '\e[1;31m' # Red bold -osc8_link "https://red.com" "Red Link" -printf '\e[0m | ' -printf '\e[1;32m' # Green bold -osc8_link "https://green.com" "Green Link" -printf '\e[0m | ' -printf '\e[1;34m' # Blue bold -osc8_link "https://blue.com" "Blue Link" -printf '\e[0m' -echo "" -echo "" - -echo "Test 9: Very long URL (short text)" -osc8_link "https://example.com/very/long/path/with/many/segments/and/parameters?param1=value1¶m2=value2¶m3=value3" "Short" -echo "" -echo "" - -echo "Test 10: Link with ID parameter (same URL, different IDs)" -printf '\e]8;id=link1;https://example.com\e\\First Instance\e]8;;\e\\' -echo -n " and " -printf '\e]8;id=link2;https://example.com\e\\Second Instance\e]8;;\e\\' -echo "" -echo "" - -echo "Test 11: File:// URL" -osc8_link "file:///home/user/document.txt" "Local File" -echo "" -echo "" - -echo "Test 12: Mailto: link" -osc8_link "mailto:test@example.com" "Email Link" -echo "" -echo "" - -echo "Test 13: FTP link" -osc8_link "ftp://ftp.example.com/file.zip" "FTP Download" -echo "" -echo "" - -echo "Test 14: Link spanning terminal width" -osc8_link "https://github.com/ghostty-org/ghostty-web-xterm" "012345678901234567890123456789012345678901234567890123456789012345678901234567890" -echo "" -echo "" - -echo "Test 15: Links with bold/italic styles" -printf '\e[1m' # Bold -osc8_link "https://bold.com" "Bold Link" -printf '\e[0m | ' -printf '\e[3m' # Italic -osc8_link "https://italic.com" "Italic Link" -printf '\e[0m | ' -printf '\e[1;3m' # Bold + Italic -osc8_link "https://both.com" "Bold+Italic Link" -printf '\e[0m' -echo "" -echo "" - -echo "==============================" -echo "✅ Test suite complete!" -echo "" -echo "Instructions:" -echo "1. Hover over any blue underlined text" -echo "2. Hold Ctrl (or Cmd on Mac) and click to open" -echo "3. Multi-line links should have continuous underlines" -echo "4. Each link should open its correct URL"