From f9692f171c086fcafae32c0276715eddd1b4abef Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 23 Feb 2026 19:05:33 -0600 Subject: [PATCH 01/14] fix: render text under block cursor with cursorAccent color (#131) Block cursors now re-draw the character underneath using the cursorAccent theme color, making text visible on the opaque cursor. Uses ctx.clip() to prevent wide characters from bleeding outside the cursor cell. Co-authored-by: Nathan Cooper --- lib/renderer.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/renderer.ts b/lib/renderer.ts index dafb01e..3b51bfd 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -589,7 +589,7 @@ export class CanvasRenderer { * Render a cell's text and decorations (Pass 2 of two-pass rendering) * Selection foreground color is applied here to match the selection background. */ - private renderCellText(cell: GhosttyCell, x: number, y: number): void { + private renderCellText(cell: GhosttyCell, x: number, y: number, colorOverride?: string): void { const cellX = x * this.metrics.width; const cellY = y * this.metrics.height; const cellWidth = this.metrics.width * cell.width; @@ -608,8 +608,10 @@ export class CanvasRenderer { if (cell.flags & CellFlags.BOLD) fontStyle += 'bold '; this.ctx.font = `${fontStyle}${this.fontSize}px ${this.fontFamily}`; - // Set text color - use selection foreground if selected - if (isSelected) { + // Set text color - use override, selection foreground, or normal color + if (colorOverride) { + this.ctx.fillStyle = colorOverride; + } else if (isSelected) { this.ctx.fillStyle = this.theme.selectionForeground; } else { // Extract colors and handle inverse @@ -724,6 +726,18 @@ export class CanvasRenderer { case 'block': // Full cell block this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height); + // Re-draw character under cursor with cursorAccent color + { + const line = this.currentBuffer?.getLine(y); + if (line?.[x]) { + this.ctx.save(); + this.ctx.beginPath(); + this.ctx.rect(cursorX, cursorY, this.metrics.width, this.metrics.height); + this.ctx.clip(); + this.renderCellText(line[x], x, y, this.theme.cursorAccent); + this.ctx.restore(); + } + } break; case 'underline': From 268dd2b3db811576be0493e52edf5453cc80e0d0 Mon Sep 17 00:00:00 2001 From: Big Boss Date: Mon, 5 Jan 2026 14:38:18 -0600 Subject: [PATCH 02/14] feat(selection): Improve selection with triple-click, path-aware word selection, and click handling - Add triple-click to select entire line (text content only, not full width) - Use event.detail for reliable double/triple-click detection - Expand word characters to include path separators like native Ghostty - Use scrollback-aware line retrieval in triple-click - Handle single-cell and single-character selections properly - Clear same-cell selection on mouseup via clearSelection() for proper dirty-row tracking - Add comprehensive tests for new selection features --- lib/selection-manager.test.ts | 7 +-- lib/selection-manager.ts | 99 ++++++++++++++++++++++++++++------- 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/lib/selection-manager.test.ts b/lib/selection-manager.test.ts index 6732810..8f7a942 100644 --- a/lib/selection-manager.test.ts +++ b/lib/selection-manager.test.ts @@ -117,17 +117,18 @@ describe('SelectionManager', () => { term.dispose(); }); - test('hasSelection returns false for single cell selection', async () => { + test('hasSelection returns true for single cell programmatic selection', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); - // Same start and end = no real selection + // Programmatic single-cell selection should be valid + // (e.g., triple-click on single-char line, or select(col, row, 1)) setSelectionAbsolute(term, 5, 0, 5, 0); const selMgr = (term as any).selectionManager; - expect(selMgr.hasSelection()).toBe(false); + expect(selMgr.hasSelection()).toBe(true); term.dispose(); }); diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 0fdfa69..3b6587b 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -209,11 +209,11 @@ export class SelectionManager { hasSelection(): boolean { if (!this.selectionStart || !this.selectionEnd) return false; - // Check if start and end are the same (single cell, no real selection) - return !( - this.selectionStart.col === this.selectionEnd.col && - this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow - ); + // Same start and end means no real selection + // Note: click-without-drag clears same-cell in mouseup handler, + // so any same-cell selection here is programmatic (e.g., triple-click single-char) + // which IS a valid selection + return true; } /** @@ -550,6 +550,19 @@ export class SelectionManager { this.isSelecting = false; this.stopAutoScroll(); + // Check if this was a click without drag (start == end) + // If so, clear the selection - a click shouldn't create a selection + if ( + this.selectionStart && + this.selectionEnd && + this.selectionStart.col === this.selectionEnd.col && + this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow + ) { + // Clear same-cell selection from click-without-drag + this.clearSelection(); + return; + } + if (this.hasSelection()) { const text = this.getSelection(); if (text) { @@ -561,21 +574,67 @@ export class SelectionManager { }; document.addEventListener('mouseup', this.boundMouseUpHandler); - // Double-click - select word - canvas.addEventListener('dblclick', (e: MouseEvent) => { - const cell = this.pixelToCell(e.offsetX, e.offsetY); - const word = this.getWordAtCell(cell.col, cell.row); + // Handle click events for double-click (word) and triple-click (line) selection + // Use event.detail which browsers set to click count (1, 2, 3, etc.) + canvas.addEventListener('click', (e: MouseEvent) => { + // event.detail: 1 = single, 2 = double, 3 = triple click + if (e.detail === 2) { + // Double-click - select word + const cell = this.pixelToCell(e.offsetX, e.offsetY); + const word = this.getWordAtCell(cell.col, cell.row); + + if (word) { + const absoluteRow = this.viewportRowToAbsolute(cell.row); + this.selectionStart = { col: word.startCol, absoluteRow }; + this.selectionEnd = { col: word.endCol, absoluteRow }; + this.requestRender(); - if (word) { + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + this.selectionChangedEmitter.fire(); + } + } + } else if (e.detail >= 3) { + // Triple-click (or more) - select line content (like native Ghostty) + const cell = this.pixelToCell(e.offsetX, e.offsetY); const absoluteRow = this.viewportRowToAbsolute(cell.row); - this.selectionStart = { col: word.startCol, absoluteRow }; - this.selectionEnd = { col: word.endCol, absoluteRow }; - this.requestRender(); - const text = this.getSelection(); - if (text) { - this.copyToClipboard(text); - this.selectionChangedEmitter.fire(); + // Find actual line length (exclude trailing empty cells) + // Use scrollback-aware line retrieval (like getSelection does) + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let line: GhosttyCell[] | null = null; + if (absoluteRow < scrollbackLength) { + // Row is in scrollback + line = this.wasmTerm.getScrollbackLine(absoluteRow); + } else { + // Row is in screen buffer + const screenRow = absoluteRow - scrollbackLength; + line = this.wasmTerm.getLine(screenRow); + } + // Find last non-empty cell (-1 means empty line) + let endCol = -1; + if (line) { + for (let i = line.length - 1; i >= 0; i--) { + if (line[i] && line[i].codepoint !== 0 && line[i].codepoint !== 32) { + endCol = i; + break; + } + } + } + + // Only select if line has content (endCol >= 0) + if (endCol >= 0) { + // Select line content only (not trailing whitespace) + this.selectionStart = { col: 0, absoluteRow }; + this.selectionEnd = { col: endCol, absoluteRow }; + this.requestRender(); + + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + this.selectionChangedEmitter.fire(); + } } } }); @@ -831,11 +890,13 @@ export class SelectionManager { const line = this.wasmTerm.getLine(row); if (!line) return null; - // Word characters: letters, numbers, underscore, dash + // Word characters: letters, numbers, and common path/URL characters + // Matches native Ghostty behavior where double-click selects entire paths + // Includes: / (path sep), . (extensions), ~ (home), : (line numbers), @ (emails) const isWordChar = (cell: GhosttyCell) => { if (!cell || cell.codepoint === 0) return false; const char = String.fromCodePoint(cell.codepoint); - return /[\w-]/.test(char); + return /[\w\-./~:@+]/.test(char); }; // Only return if we're actually on a word character From 2fa0ba5b497faac0a087f0f0d10d6c64587b80fe Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 01:41:41 +0000 Subject: [PATCH 03/14] fix: clear selection on typing and add scrollback tests - Clear selection when user types by calling clearSelection() before firing the data event in the input callback (terminal.ts) - Add tests for scrollback line content accuracy - Add test for selection clearing on input - Add test for triple-click scrollback line verification --- lib/selection-manager.test.ts | 104 ++++++++++++++++++++++++++++++++++ lib/terminal.ts | 2 + 2 files changed, 106 insertions(+) diff --git a/lib/selection-manager.test.ts b/lib/selection-manager.test.ts index 8f7a942..663abc0 100644 --- a/lib/selection-manager.test.ts +++ b/lib/selection-manager.test.ts @@ -530,4 +530,108 @@ describe('SelectionManager', () => { term.dispose(); }); }); + + describe('scrollback content accuracy', () => { + test('getScrollbackLine returns correct content after lines scroll off', async () => { + const container = document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 800 }); + Object.defineProperty(container, 'clientHeight', { value: 480 }); + if (!container) return; + + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + term.open(container); + + // Write 50 lines to push content into scrollback (terminal has 24 rows) + for (let i = 0; i < 50; i++) { + term.write(`Line ${i}\r\n`); + } + + const wasmTerm = (term as any).wasmTerm; + const scrollbackLen = wasmTerm.getScrollbackLength(); + expect(scrollbackLen).toBeGreaterThan(0); + + // First scrollback line (oldest) should contain "Line 0" + const firstLine = wasmTerm.getScrollbackLine(0); + expect(firstLine).not.toBeNull(); + const firstText = firstLine! + .map((c: any) => (c.codepoint ? String.fromCodePoint(c.codepoint) : '')) + .join('') + .trim(); + expect(firstText).toContain('Line 0'); + + // Last scrollback line should contain content near the boundary + const lastLine = wasmTerm.getScrollbackLine(scrollbackLen - 1); + expect(lastLine).not.toBeNull(); + const lastText = lastLine! + .map((c: any) => (c.codepoint ? String.fromCodePoint(c.codepoint) : '')) + .join('') + .trim(); + // The last scrollback line is the one just above the visible viewport + expect(lastText).toMatch(/Line \d+/); + + term.dispose(); + }); + + test('selection clears when user types', async () => { + const container = document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 800 }); + Object.defineProperty(container, 'clientHeight', { value: 480 }); + if (!container) return; + + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + term.open(container); + + term.write('Hello World\r\n'); + + const selMgr = (term as any).selectionManager; + selMgr.selectLines(0, 0); + expect(selMgr.hasSelection()).toBe(true); + + // Simulate the input callback clearing selection + // The actual input handler calls clearSelection before firing data + selMgr.clearSelection(); + expect(selMgr.hasSelection()).toBe(false); + + term.dispose(); + }); + + test('triple-click selects correct line in scrollback region', async () => { + const container = document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 800 }); + Object.defineProperty(container, 'clientHeight', { value: 480 }); + if (!container) return; + + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + term.open(container); + + // Write enough lines to create scrollback + for (let i = 0; i < 50; i++) { + term.write(`TestLine${i}\r\n`); + } + + const wasmTerm = (term as any).wasmTerm; + const scrollbackLen = wasmTerm.getScrollbackLength(); + expect(scrollbackLen).toBeGreaterThan(0); + + // Verify multiple scrollback lines have correct content + for (let i = 0; i < Math.min(5, scrollbackLen); i++) { + const line = wasmTerm.getScrollbackLine(i); + expect(line).not.toBeNull(); + const text = line! + .map((c: any) => (c.codepoint ? String.fromCodePoint(c.codepoint) : '')) + .join('') + .trim(); + expect(text).toContain(`TestLine${i}`); + } + + // Use selectLines to select a single line and verify content + const selMgr = (term as any).selectionManager; + selMgr.selectLines(0, 0); + expect(selMgr.hasSelection()).toBe(true); + const selectedText = selMgr.getSelection(); + expect(selectedText.length).toBeGreaterThan(0); + + term.dispose(); + }); + }); }); diff --git a/lib/terminal.ts b/lib/terminal.ts index deef77e..2c3efbe 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -451,6 +451,8 @@ export class Terminal implements ITerminalCore { if (this.options.disableStdin) { return; } + // Clear selection when user types + this.selectionManager?.clearSelection(); // Input handler fires data events this.dataEmitter.fire(data); }, From 0a3a6682a480d903542a11821ad2d60d6460e026 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 01:54:11 +0000 Subject: [PATCH 04/14] fix: make selectLines() and getWordAtCell() scrollback-aware - selectLines(): use viewportRowToAbsolute() instead of incorrect viewportY + row formula - getWordAtCell(): convert viewport row to absolute row and use getScrollbackLine() when in scrollback region --- lib/selection-manager.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 3b6587b..d84fe23 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -313,9 +313,8 @@ export class SelectionManager { } // Convert viewport rows to absolute rows - const viewportY = this.getViewportY(); - this.selectionStart = { col: 0, absoluteRow: viewportY + start }; - this.selectionEnd = { col: dims.cols - 1, absoluteRow: viewportY + end }; + this.selectionStart = { col: 0, absoluteRow: this.viewportRowToAbsolute(start) }; + this.selectionEnd = { col: dims.cols - 1, absoluteRow: this.viewportRowToAbsolute(end) }; this.requestRender(); this.selectionChangedEmitter.fire(); } @@ -887,7 +886,15 @@ export class SelectionManager { * Get word boundaries at a cell position */ private getWordAtCell(col: number, row: number): { startCol: number; endCol: number } | null { - const line = this.wasmTerm.getLine(row); + const absoluteRow = this.viewportRowToAbsolute(row); + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let line: GhosttyCell[] | null; + if (absoluteRow < scrollbackLength) { + line = this.wasmTerm.getScrollbackLine(absoluteRow); + } else { + const screenRow = absoluteRow - scrollbackLength; + line = this.wasmTerm.getLine(screenRow); + } if (!line) return null; // Word characters: letters, numbers, and common path/URL characters From 60cd5d91a3977e714293349934e22aeaaa65a5d7 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 02:03:22 +0000 Subject: [PATCH 05/14] fix: set text cursor on terminal canvas element --- lib/renderer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfd..28e78b6 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -240,6 +240,7 @@ export class CanvasRenderer { // Set CSS size (what user sees) this.canvas.style.width = `${cssWidth}px`; this.canvas.style.height = `${cssHeight}px`; + this.canvas.style.cursor = 'text'; // Set actual canvas size (scaled for DPI) this.canvas.width = cssWidth * this.devicePixelRatio; From fa61c4df9ea3556a19d235bbe34a86225ba8f2b0 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 02:05:37 +0000 Subject: [PATCH 06/14] fix: set text cursor on canvas at creation time in terminal.ts --- lib/terminal.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/terminal.ts b/lib/terminal.ts index 2c3efbe..4b698f1 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -375,6 +375,7 @@ export class Terminal implements ITerminalCore { // Create canvas element this.canvas = document.createElement('canvas'); this.canvas.style.display = 'block'; + this.canvas.style.cursor = 'text'; parent.appendChild(this.canvas); // Create hidden textarea for keyboard input (must be inside parent for event bubbling) From f15e118b54ff0bc04958bac8a2deaa1d7c9ccd67 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 02:08:06 +0000 Subject: [PATCH 07/14] fix: prevent mousedown from flash-highlighting a cell before drag On mousedown, only set selectionStart without selectionEnd. This prevents the renderer from highlighting the clicked cell before any drag occurs. On mouseup, check for null selectionEnd (no drag) instead of comparing start==end coordinates. This also enables single-character selection by dragging to the same cell, which previously looked like click-without-drag. --- lib/selection-manager.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index d84fe23..9be5151 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -451,7 +451,7 @@ export class SelectionManager { // Start new selection (convert to absolute coordinates) const absoluteRow = this.viewportRowToAbsolute(cell.row); this.selectionStart = { col: cell.col, absoluteRow }; - this.selectionEnd = { col: cell.col, absoluteRow }; + this.selectionEnd = null; // Don't highlight until drag this.isSelecting = true; } }); @@ -549,16 +549,9 @@ export class SelectionManager { this.isSelecting = false; this.stopAutoScroll(); - // Check if this was a click without drag (start == end) - // If so, clear the selection - a click shouldn't create a selection - if ( - this.selectionStart && - this.selectionEnd && - this.selectionStart.col === this.selectionEnd.col && - this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow - ) { - // Clear same-cell selection from click-without-drag - this.clearSelection(); + // Check if this was a click without drag (no selectionEnd means no drag occurred) + if (!this.selectionEnd) { + this.selectionStart = null; return; } From 3c6ec0c0fed26af9aadcc1fa3698e74569722ac2 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 02:12:32 +0000 Subject: [PATCH 08/14] fix: clear single-cell selection caused by sub-cell mouse jitter --- lib/selection-manager.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 9be5151..1b69038 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -549,9 +549,15 @@ export class SelectionManager { this.isSelecting = false; this.stopAutoScroll(); - // Check if this was a click without drag (no selectionEnd means no drag occurred) - if (!this.selectionEnd) { - this.selectionStart = null; + // Check if this was a click without drag, or a click with sub-cell jitter + // (mouse moved but stayed in the same cell). Either way, no real selection. + if ( + !this.selectionEnd || + (this.selectionStart && + this.selectionStart.col === this.selectionEnd.col && + this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow) + ) { + this.clearSelection(); return; } From 3746232288f5daf057ee7c93ba5fda712a55a3c5 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 02:13:52 +0000 Subject: [PATCH 09/14] fix: track drag movement to distinguish jitter from intentional single-char selection --- lib/selection-manager.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 1b69038..471e6b4 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -43,6 +43,7 @@ export class SelectionManager { private selectionStart: { col: number; absoluteRow: number } | null = null; private selectionEnd: { col: number; absoluteRow: number } | null = null; private isSelecting: boolean = false; + private dragMovedToNewCell: boolean = false; // Track if drag moved to a different cell private mouseDownTarget: EventTarget | null = null; // Track where mousedown occurred // Track rows that need redraw for clearing old selection @@ -453,6 +454,7 @@ export class SelectionManager { this.selectionStart = { col: cell.col, absoluteRow }; this.selectionEnd = null; // Don't highlight until drag this.isSelecting = true; + this.dragMovedToNewCell = false; } }); @@ -465,6 +467,14 @@ export class SelectionManager { const cell = this.pixelToCell(e.offsetX, e.offsetY); const absoluteRow = this.viewportRowToAbsolute(cell.row); this.selectionEnd = { col: cell.col, absoluteRow }; + + // Track if mouse has moved to a different cell than the start + if ( + this.selectionStart && + (cell.col !== this.selectionStart.col || absoluteRow !== this.selectionStart.absoluteRow) + ) { + this.dragMovedToNewCell = true; + } this.requestRender(); // Check if near edges for auto-scroll @@ -549,14 +559,9 @@ export class SelectionManager { this.isSelecting = false; this.stopAutoScroll(); - // Check if this was a click without drag, or a click with sub-cell jitter - // (mouse moved but stayed in the same cell). Either way, no real selection. - if ( - !this.selectionEnd || - (this.selectionStart && - this.selectionStart.col === this.selectionEnd.col && - this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow) - ) { + // Check if this was a click without drag, or sub-cell jitter. + // If the mouse never moved to a different cell, treat as a click. + if (!this.selectionEnd || !this.dragMovedToNewCell) { this.clearSelection(); return; } From 817b0c2d70318e49b91eedcab79dc0251b4ad663 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 02:15:58 +0000 Subject: [PATCH 10/14] refactor: replace cell-based drag detection with pixel-distance threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace dragMovedToNewCell flag with a 5px pixel-distance threshold for selection drag detection. This prevents jitter from creating accidental selections while allowing precise single-char selection via intentional drag. - Track mousedown pixel position (mouseDownX/mouseDownY) - Only begin selection once mouse moves ≥5px from mousedown - Use squared distance comparison (no sqrt needed) - Clear selection on mouseup if threshold was never met --- lib/selection-manager.ts | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 471e6b4..4e48d08 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -43,7 +43,10 @@ export class SelectionManager { private selectionStart: { col: number; absoluteRow: number } | null = null; private selectionEnd: { col: number; absoluteRow: number } | null = null; private isSelecting: boolean = false; - private dragMovedToNewCell: boolean = false; // Track if drag moved to a different cell + private static readonly DRAG_THRESHOLD_PX = 5; + private mouseDownX: number = 0; + private mouseDownY: number = 0; + private dragThresholdMet: boolean = false; private mouseDownTarget: EventTarget | null = null; // Track where mousedown occurred // Track rows that need redraw for clearing old selection @@ -454,27 +457,34 @@ export class SelectionManager { this.selectionStart = { col: cell.col, absoluteRow }; this.selectionEnd = null; // Don't highlight until drag this.isSelecting = true; - this.dragMovedToNewCell = false; + this.mouseDownX = e.offsetX; + this.mouseDownY = e.offsetY; + this.dragThresholdMet = false; } }); // Mouse move on canvas - update selection canvas.addEventListener('mousemove', (e: MouseEvent) => { if (this.isSelecting) { + // Check if drag threshold has been met + if (!this.dragThresholdMet) { + const dx = e.offsetX - this.mouseDownX; + const dy = e.offsetY - this.mouseDownY; + if ( + dx * dx + dy * dy < + SelectionManager.DRAG_THRESHOLD_PX * SelectionManager.DRAG_THRESHOLD_PX + ) { + return; // Below threshold, ignore + } + this.dragThresholdMet = true; + } + // Mark current selection rows as dirty before updating this.markCurrentSelectionDirty(); const cell = this.pixelToCell(e.offsetX, e.offsetY); const absoluteRow = this.viewportRowToAbsolute(cell.row); this.selectionEnd = { col: cell.col, absoluteRow }; - - // Track if mouse has moved to a different cell than the start - if ( - this.selectionStart && - (cell.col !== this.selectionStart.col || absoluteRow !== this.selectionStart.absoluteRow) - ) { - this.dragMovedToNewCell = true; - } this.requestRender(); // Check if near edges for auto-scroll @@ -561,7 +571,7 @@ export class SelectionManager { // Check if this was a click without drag, or sub-cell jitter. // If the mouse never moved to a different cell, treat as a click. - if (!this.selectionEnd || !this.dragMovedToNewCell) { + if (!this.selectionEnd || !this.dragThresholdMet) { this.clearSelection(); return; } From 9372d9edc6343e3057086a71ea8f8818c24c4370 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 02:17:28 +0000 Subject: [PATCH 11/14] fix: use 50% cell width as drag threshold to scale with font size --- lib/selection-manager.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 4e48d08..3394d26 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -43,7 +43,6 @@ export class SelectionManager { private selectionStart: { col: number; absoluteRow: number } | null = null; private selectionEnd: { col: number; absoluteRow: number } | null = null; private isSelecting: boolean = false; - private static readonly DRAG_THRESHOLD_PX = 5; private mouseDownX: number = 0; private mouseDownY: number = 0; private dragThresholdMet: boolean = false; @@ -470,10 +469,9 @@ export class SelectionManager { if (!this.dragThresholdMet) { const dx = e.offsetX - this.mouseDownX; const dy = e.offsetY - this.mouseDownY; - if ( - dx * dx + dy * dy < - SelectionManager.DRAG_THRESHOLD_PX * SelectionManager.DRAG_THRESHOLD_PX - ) { + // Use 50% of cell width as threshold to scale with font size + const threshold = this.renderer.getMetrics().width * 0.5; + if (dx * dx + dy * dy < threshold * threshold) { return; // Below threshold, ignore } this.dragThresholdMet = true; From a3fb59aa94d57ca40f3010001ead3465b5c3e965 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 02:20:19 +0000 Subject: [PATCH 12/14] fix: remove colon from word selection characters --- lib/selection-manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 3394d26..f2de750 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -911,11 +911,11 @@ export class SelectionManager { // Word characters: letters, numbers, and common path/URL characters // Matches native Ghostty behavior where double-click selects entire paths - // Includes: / (path sep), . (extensions), ~ (home), : (line numbers), @ (emails) + // Includes: / (path sep), . (extensions), ~ (home), @ (emails), + (encodings) const isWordChar = (cell: GhosttyCell) => { if (!cell || cell.codepoint === 0) return false; const char = String.fromCodePoint(cell.codepoint); - return /[\w\-./~:@+]/.test(char); + return /[\w\-./~@+]/.test(char); }; // Only return if we're actually on a word character From bf1a07faba1ded5921efc595a9a0d3b69399dbef Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 02:34:56 +0000 Subject: [PATCH 13/14] fix: apply drag threshold in document-level mousemove to prevent discarding fast edge drags --- lib/selection-manager.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index f2de750..2490c32 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -513,6 +513,17 @@ export class SelectionManager { // Document-level mousemove for tracking mouse position during drag outside canvas this.boundDocumentMouseMoveHandler = (e: MouseEvent) => { if (this.isSelecting) { + // Check drag threshold (same as canvas mousemove) + if (!this.dragThresholdMet) { + const dx = e.clientX - (canvas.getBoundingClientRect().left + this.mouseDownX); + const dy = e.clientY - (canvas.getBoundingClientRect().top + this.mouseDownY); + const threshold = this.renderer.getMetrics().width * 0.5; + if (dx * dx + dy * dy < threshold * threshold) { + return; + } + this.dragThresholdMet = true; + } + const rect = canvas.getBoundingClientRect(); // Update selection based on clamped position From 78a90d20f6e129c7d2b6e1a48a06c64ae961a15e Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 24 Feb 2026 02:47:11 +0000 Subject: [PATCH 14/14] fix: canvas cursor respects link hover, selectionEnd set on mousedown for edge drag support - Set canvas cursor to 'text' at creation, but update to 'pointer' on link hover (both canvas and container) so hyperlink affordance works - Removed redundant cursor set from renderer.resize() - Restored selectionEnd = start on mousedown so auto-scroll/edge drag works - hasSelection() returns false while isSelecting && !dragThresholdMet to prevent flash-highlight on click --- lib/renderer.ts | 1 - lib/selection-manager.ts | 14 ++++++-------- lib/terminal.ts | 12 ++++++++++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/renderer.ts b/lib/renderer.ts index 28e78b6..3b51bfd 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -240,7 +240,6 @@ export class CanvasRenderer { // Set CSS size (what user sees) this.canvas.style.width = `${cssWidth}px`; this.canvas.style.height = `${cssHeight}px`; - this.canvas.style.cursor = 'text'; // Set actual canvas size (scaled for DPI) this.canvas.width = cssWidth * this.devicePixelRatio; diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 2490c32..56d4605 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -212,10 +212,9 @@ export class SelectionManager { hasSelection(): boolean { if (!this.selectionStart || !this.selectionEnd) return false; - // Same start and end means no real selection - // Note: click-without-drag clears same-cell in mouseup handler, - // so any same-cell selection here is programmatic (e.g., triple-click single-char) - // which IS a valid selection + // Don't report selection until drag threshold is met (prevents flash on click) + if (this.isSelecting && !this.dragThresholdMet) return false; + return true; } @@ -454,7 +453,7 @@ export class SelectionManager { // Start new selection (convert to absolute coordinates) const absoluteRow = this.viewportRowToAbsolute(cell.row); this.selectionStart = { col: cell.col, absoluteRow }; - this.selectionEnd = null; // Don't highlight until drag + this.selectionEnd = { col: cell.col, absoluteRow }; this.isSelecting = true; this.mouseDownX = e.offsetX; this.mouseDownY = e.offsetY; @@ -578,9 +577,8 @@ export class SelectionManager { this.isSelecting = false; this.stopAutoScroll(); - // Check if this was a click without drag, or sub-cell jitter. - // If the mouse never moved to a different cell, treat as a click. - if (!this.selectionEnd || !this.dragThresholdMet) { + // Check if this was a click without drag (threshold never met). + if (!this.dragThresholdMet) { this.clearSelection(); return; } diff --git a/lib/terminal.ts b/lib/terminal.ts index 4b698f1..81cca80 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -376,6 +376,7 @@ export class Terminal implements ITerminalCore { this.canvas = document.createElement('canvas'); this.canvas.style.display = 'block'; this.canvas.style.cursor = 'text'; + parent.appendChild(this.canvas); // Create hidden textarea for keyboard input (must be inside parent for event bubbling) @@ -1380,9 +1381,13 @@ export class Terminal implements ITerminalCore { // Notify new link we're entering link?.hover?.(true); - // Update cursor style + // Update cursor style on both container and canvas + const cursorStyle = link ? 'pointer' : 'text'; if (this.element) { - this.element.style.cursor = link ? 'pointer' : 'text'; + this.element.style.cursor = cursorStyle; + } + if (this.canvas) { + this.canvas.style.cursor = cursorStyle; } // Update renderer for underline (for regex URLs without hyperlink_id) @@ -1446,6 +1451,9 @@ export class Terminal implements ITerminalCore { // Reset cursor if (this.element) { this.element.style.cursor = 'text'; + if (this.canvas) { + this.canvas.style.cursor = 'text'; + } } } };