mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix CJK IME issues: language detection, visible input, performance optimizations (#495)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
ab2e57ab05
commit
31e48b6674
15 changed files with 953 additions and 50 deletions
|
|
@ -34,6 +34,46 @@ SessionView
|
||||||
|
|
||||||
## Implementation Details
|
## Implementation Details
|
||||||
|
|
||||||
|
### Cursor Position Tracking
|
||||||
|
|
||||||
|
**File**: `cursor-position.ts`
|
||||||
|
|
||||||
|
The cursor position tracking system uses a shared utility function that works consistently across both terminal types (XTerm.js and binary buffer modes):
|
||||||
|
|
||||||
|
#### Coordinate System
|
||||||
|
```typescript
|
||||||
|
export function calculateCursorPosition(
|
||||||
|
cursorX: number, // 0-based column position
|
||||||
|
cursorY: number, // 0-based row position
|
||||||
|
fontSize: number, // Terminal font size in pixels
|
||||||
|
container: Element, // Terminal container element
|
||||||
|
sessionStatus: string // Session status for validation
|
||||||
|
): { x: number; y: number } | null
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Position Calculation Process
|
||||||
|
1. **Character Measurement**: Dynamically measures actual character width using font metrics
|
||||||
|
2. **Absolute Positioning**: Calculates page-absolute cursor coordinates
|
||||||
|
3. **Container Relative**: Converts to position relative to `#session-terminal` container
|
||||||
|
4. **IME Positioning**: Returns coordinates suitable for IME input placement
|
||||||
|
|
||||||
|
#### Terminal Type Support
|
||||||
|
- **XTerm Terminal (`vibe-terminal`)**: Uses `terminal.buffer.active.cursorX/Y` from XTerm.js
|
||||||
|
- **Binary Terminal (`vibe-terminal-binary`)**: Uses `buffer.cursorX/Y` from WebSocket buffer data
|
||||||
|
|
||||||
|
#### Key Features
|
||||||
|
- **Precise Alignment**: Accounts for exact character width and line height
|
||||||
|
- **Container Aware**: Handles side panels and complex layouts
|
||||||
|
- **Font Responsive**: Adapts to different font sizes and families
|
||||||
|
- **Platform Consistent**: Same calculation logic across all terminal types
|
||||||
|
|
||||||
|
#### Error Handling
|
||||||
|
The function includes comprehensive error handling and graceful fallbacks:
|
||||||
|
- Returns `null` when session is not running
|
||||||
|
- Returns `null` when container element is not found
|
||||||
|
- Returns `null` when character measurement fails
|
||||||
|
- Falls back to absolute coordinates if session container is missing
|
||||||
|
|
||||||
### Platform Detection
|
### Platform Detection
|
||||||
**File**: `mobile-utils.ts`
|
**File**: `mobile-utils.ts`
|
||||||
|
|
||||||
|
|
@ -244,6 +284,11 @@ OS shows IME candidates → User selects → Text appears in terminal
|
||||||
## Code Reference
|
## Code Reference
|
||||||
|
|
||||||
### Primary Files
|
### Primary Files
|
||||||
|
- `cursor-position.ts` - **Shared cursor position calculation**
|
||||||
|
- `14-20` - Main `calculateCursorPosition()` function signature
|
||||||
|
- `32-46` - Character width measurement using test elements
|
||||||
|
- `48-69` - Coordinate conversion (absolute → container-relative)
|
||||||
|
- `70-72` - Error handling and cleanup
|
||||||
- `ime-input.ts` - Desktop IME component implementation
|
- `ime-input.ts` - Desktop IME component implementation
|
||||||
- `32-48` - DesktopIMEInput class definition
|
- `32-48` - DesktopIMEInput class definition
|
||||||
- `50-80` - Invisible input element creation
|
- `50-80` - Invisible input element creation
|
||||||
|
|
@ -259,11 +304,13 @@ OS shows IME candidates → User selects → Text appears in terminal
|
||||||
- `mobile-utils.ts` - Mobile detection utilities
|
- `mobile-utils.ts` - Mobile detection utilities
|
||||||
|
|
||||||
### Supporting Files
|
### Supporting Files
|
||||||
- `terminal.ts` - XTerm cursor position API via `getCursorInfo()`
|
- `cursor-position.ts` - **Shared cursor position calculation utility**
|
||||||
- `vibe-terminal-binary.ts` - Binary terminal cursor position API
|
- `terminal.ts` - XTerm cursor position API via `getCursorInfo()` (uses shared utility)
|
||||||
|
- `vibe-terminal-binary.ts` - Binary terminal cursor position API (uses shared utility)
|
||||||
- `session-view.ts` - Container element and terminal integration
|
- `session-view.ts` - Container element and terminal integration
|
||||||
- `lifecycle-event-manager.ts` - Event coordination and interception
|
- `lifecycle-event-manager.ts` - Event coordination and interception
|
||||||
- `ime-constants.ts` - IME-related key filtering utilities
|
- `ime-constants.ts` - IME-related key filtering utilities
|
||||||
|
- `terminal-constants.ts` - **Centralized terminal element IDs and selectors**
|
||||||
|
|
||||||
## Browser Compatibility
|
## Browser Compatibility
|
||||||
|
|
||||||
|
|
@ -309,7 +356,29 @@ Comprehensive logging available in browser console:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Recent Improvements (v1.0.0-beta.16+)
|
||||||
|
|
||||||
|
### Unified Cursor Position Tracking
|
||||||
|
- **Shared Utility**: Created `cursor-position.ts` for consistent cursor calculation across all terminal types
|
||||||
|
- **Container-Aware Positioning**: Fixed IME positioning issues with side panels and complex layouts
|
||||||
|
- **Precise Alignment**: Improved character width measurement for pixel-perfect cursor alignment
|
||||||
|
- **Debug Logging**: Enhanced debug output with comprehensive coordinate information
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
- **Code Deduplication**: Eliminated ~120 lines of duplicate cursor calculation code
|
||||||
|
- **Maintainability**: Single source of truth for cursor positioning logic
|
||||||
|
- **Type Safety**: Improved TypeScript interfaces and error handling
|
||||||
|
- **Performance**: More efficient coordinate conversion with optimized calculations
|
||||||
|
|
||||||
|
### Element ID Centralization
|
||||||
|
- **Constants File**: Created `terminal-constants.ts` to centralize all critical terminal element IDs
|
||||||
|
- **Prevention of Breakage**: Changes to IDs like `session-terminal`, `buffer-container`, or `terminal-container` now only require updates in one location
|
||||||
|
- **Consistent References**: All components now import `TERMINAL_IDS` constants instead of using hardcoded strings
|
||||||
|
- **Type Safety**: Constants are strongly typed to prevent typos and ensure consistent usage across the codebase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**Status**: ✅ Production Ready
|
**Status**: ✅ Production Ready
|
||||||
**Platforms**: Desktop (Windows, macOS, Linux) and Mobile (iOS, Android)
|
**Platforms**: Desktop (Windows, macOS, Linux) and Mobile (iOS, Android)
|
||||||
**Version**: VibeTunnel Web v1.0.0-beta.15+
|
**Version**: VibeTunnel Web v1.0.0-beta.16+
|
||||||
**Last Updated**: 2025-01-22
|
**Last Updated**: 2025-08-02
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
import { Z_INDEX } from '../utils/constants.js';
|
import { Z_INDEX } from '../utils/constants.js';
|
||||||
import { createLogger } from '../utils/logger.js';
|
import { createLogger } from '../utils/logger.js';
|
||||||
|
import { IME_VERTICAL_OFFSET_PX, TERMINAL_FONT_FAMILY } from '../utils/terminal-constants.js';
|
||||||
|
|
||||||
const logger = createLogger('ime-input');
|
const logger = createLogger('ime-input');
|
||||||
|
|
||||||
|
|
@ -23,6 +24,8 @@ export interface DesktopIMEInputOptions {
|
||||||
onSpecialKey?: (key: string) => void;
|
onSpecialKey?: (key: string) => void;
|
||||||
/** Optional callback to get cursor position for positioning the input */
|
/** Optional callback to get cursor position for positioning the input */
|
||||||
getCursorInfo?: () => { x: number; y: number } | null;
|
getCursorInfo?: () => { x: number; y: number } | null;
|
||||||
|
/** Optional callback to get font size from terminal */
|
||||||
|
getFontSize?: () => number;
|
||||||
/** Whether to auto-focus the input on creation */
|
/** Whether to auto-focus the input on creation */
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** Additional class name for the input element */
|
/** Additional class name for the input element */
|
||||||
|
|
@ -52,22 +55,28 @@ export class DesktopIMEInput {
|
||||||
private createInput(): HTMLInputElement {
|
private createInput(): HTMLInputElement {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
|
// Use a more standard IME input approach - always visible but positioned
|
||||||
input.style.position = 'absolute';
|
input.style.position = 'absolute';
|
||||||
input.style.top = '0px';
|
input.style.top = '-9999px'; // Start off-screen
|
||||||
input.style.left = '0px';
|
input.style.left = '-9999px';
|
||||||
input.style.transform = 'none';
|
input.style.transform = 'none';
|
||||||
input.style.width = '1px';
|
input.style.width = '200px'; // Fixed width for better IME compatibility
|
||||||
input.style.height = '1px';
|
input.style.height = '24px';
|
||||||
input.style.fontSize = '16px';
|
// Use terminal font size if available, otherwise default to 14px
|
||||||
input.style.padding = '0';
|
const fontSize = this.options.getFontSize?.() || 14;
|
||||||
|
input.style.fontSize = `${fontSize}px`;
|
||||||
|
input.style.padding = '2px 4px';
|
||||||
input.style.border = 'none';
|
input.style.border = 'none';
|
||||||
input.style.borderRadius = '0';
|
input.style.borderRadius = '0';
|
||||||
input.style.backgroundColor = 'transparent';
|
input.style.backgroundColor = 'transparent';
|
||||||
input.style.color = 'transparent';
|
input.style.color = '#e2e8f0';
|
||||||
input.style.zIndex = String(this.options.zIndex || Z_INDEX.IME_INPUT);
|
input.style.zIndex = String(this.options.zIndex || Z_INDEX.IME_INPUT);
|
||||||
input.style.opacity = '0';
|
input.style.opacity = '1';
|
||||||
input.style.pointerEvents = 'none';
|
input.style.visibility = 'visible';
|
||||||
input.placeholder = 'CJK Input';
|
input.style.pointerEvents = 'auto';
|
||||||
|
input.style.fontFamily = TERMINAL_FONT_FAMILY;
|
||||||
|
input.style.outline = 'none';
|
||||||
|
input.style.caretColor = 'transparent'; // Hide the blinking cursor
|
||||||
input.autocapitalize = 'off';
|
input.autocapitalize = 'off';
|
||||||
input.setAttribute('autocorrect', 'off');
|
input.setAttribute('autocorrect', 'off');
|
||||||
input.autocomplete = 'off';
|
input.autocomplete = 'off';
|
||||||
|
|
@ -136,12 +145,16 @@ export class DesktopIMEInput {
|
||||||
private handleCompositionStart = () => {
|
private handleCompositionStart = () => {
|
||||||
this.isComposing = true;
|
this.isComposing = true;
|
||||||
document.body.setAttribute('data-ime-composing', 'true');
|
document.body.setAttribute('data-ime-composing', 'true');
|
||||||
|
// Keep input visible during composition
|
||||||
|
this.showInput();
|
||||||
this.updatePosition();
|
this.updatePosition();
|
||||||
logger.log('IME composition started');
|
logger.log('IME composition started');
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleCompositionUpdate = (e: CompositionEvent) => {
|
private handleCompositionUpdate = (e: CompositionEvent) => {
|
||||||
logger.log('IME composition update:', e.data);
|
logger.log('IME composition update:', e.data);
|
||||||
|
// Update position during composition as well
|
||||||
|
this.updatePosition();
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleCompositionEnd = (e: CompositionEvent) => {
|
private handleCompositionEnd = (e: CompositionEvent) => {
|
||||||
|
|
@ -155,6 +168,14 @@ export class DesktopIMEInput {
|
||||||
|
|
||||||
this.input.value = '';
|
this.input.value = '';
|
||||||
logger.log('IME composition ended:', finalText);
|
logger.log('IME composition ended:', finalText);
|
||||||
|
|
||||||
|
// Hide input after composition if not focused
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.activeElement !== this.input) {
|
||||||
|
this.hideInput();
|
||||||
|
}
|
||||||
|
this.updatePosition();
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleInput = (e: Event) => {
|
private handleInput = (e: Event) => {
|
||||||
|
|
@ -170,6 +191,12 @@ export class DesktopIMEInput {
|
||||||
if (text) {
|
if (text) {
|
||||||
this.options.onTextInput(text);
|
this.options.onTextInput(text);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
// Hide input after sending text if not focused
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.activeElement !== this.input) {
|
||||||
|
this.hideInput();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -179,7 +206,7 @@ export class DesktopIMEInput {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// During IME composition, let the browser handle ALL keys
|
// During IME composition, let the browser handle ALL keys including Enter
|
||||||
if (this.isComposing) {
|
if (this.isComposing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -188,12 +215,16 @@ export class DesktopIMEInput {
|
||||||
if (this.options.onSpecialKey) {
|
if (this.options.onSpecialKey) {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault();
|
|
||||||
if (this.input.value.trim()) {
|
if (this.input.value.trim()) {
|
||||||
|
// Send the text content and clear input
|
||||||
|
e.preventDefault();
|
||||||
this.options.onTextInput(this.input.value);
|
this.options.onTextInput(this.input.value);
|
||||||
this.input.value = '';
|
this.input.value = '';
|
||||||
|
} else {
|
||||||
|
// Send Enter key to terminal only if input is empty
|
||||||
|
e.preventDefault();
|
||||||
|
this.options.onSpecialKey('enter');
|
||||||
}
|
}
|
||||||
this.options.onSpecialKey('enter');
|
|
||||||
break;
|
break;
|
||||||
case 'Backspace':
|
case 'Backspace':
|
||||||
if (!this.input.value) {
|
if (!this.input.value) {
|
||||||
|
|
@ -251,6 +282,9 @@ export class DesktopIMEInput {
|
||||||
document.body.setAttribute('data-ime-input-focused', 'true');
|
document.body.setAttribute('data-ime-input-focused', 'true');
|
||||||
logger.log('IME input focused');
|
logger.log('IME input focused');
|
||||||
|
|
||||||
|
// Show the input when focused
|
||||||
|
this.showInput();
|
||||||
|
|
||||||
// Start focus retention to prevent losing focus
|
// Start focus retention to prevent losing focus
|
||||||
this.startFocusRetention();
|
this.startFocusRetention();
|
||||||
};
|
};
|
||||||
|
|
@ -264,13 +298,30 @@ export class DesktopIMEInput {
|
||||||
if (document.activeElement !== this.input) {
|
if (document.activeElement !== this.input) {
|
||||||
document.body.removeAttribute('data-ime-input-focused');
|
document.body.removeAttribute('data-ime-input-focused');
|
||||||
this.stopFocusRetention();
|
this.stopFocusRetention();
|
||||||
|
// Hide the input when not focused and not composing
|
||||||
|
if (!this.isComposing) {
|
||||||
|
this.hideInput();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private showInput(): void {
|
||||||
|
// Position will be updated by updatePosition()
|
||||||
|
logger.log('IME input shown');
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideInput(): void {
|
||||||
|
// Move input off-screen instead of hiding
|
||||||
|
this.input.style.top = '-9999px';
|
||||||
|
this.input.style.left = '-9999px';
|
||||||
|
logger.log('IME input hidden');
|
||||||
|
}
|
||||||
|
|
||||||
private updatePosition(): void {
|
private updatePosition(): void {
|
||||||
if (!this.options.getCursorInfo) {
|
if (!this.options.getCursorInfo) {
|
||||||
// Fallback to safe positioning when no cursor info provider
|
// Fallback to safe positioning when no cursor info provider
|
||||||
|
logger.warn('No getCursorInfo callback provided, using fallback position');
|
||||||
this.input.style.left = '10px';
|
this.input.style.left = '10px';
|
||||||
this.input.style.top = '10px';
|
this.input.style.top = '10px';
|
||||||
return;
|
return;
|
||||||
|
|
@ -279,21 +330,31 @@ export class DesktopIMEInput {
|
||||||
const cursorInfo = this.options.getCursorInfo();
|
const cursorInfo = this.options.getCursorInfo();
|
||||||
if (!cursorInfo) {
|
if (!cursorInfo) {
|
||||||
// Fallback to safe positioning when cursor info unavailable
|
// Fallback to safe positioning when cursor info unavailable
|
||||||
|
logger.warn('getCursorInfo returned null, using fallback position');
|
||||||
this.input.style.left = '10px';
|
this.input.style.left = '10px';
|
||||||
this.input.style.top = '10px';
|
this.input.style.top = '10px';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position IME input at cursor location
|
// Position IME input at cursor location with upward adjustment for better alignment
|
||||||
this.input.style.left = `${Math.max(10, cursorInfo.x)}px`;
|
const x = Math.max(10, cursorInfo.x);
|
||||||
this.input.style.top = `${Math.max(10, cursorInfo.y)}px`;
|
const y = Math.max(10, cursorInfo.y - IME_VERTICAL_OFFSET_PX);
|
||||||
|
|
||||||
|
logger.log(`Positioning CJK input at x=${x}, y=${y}`);
|
||||||
|
this.input.style.left = `${x}px`;
|
||||||
|
this.input.style.top = `${y}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
focus(): void {
|
focus(): void {
|
||||||
|
// Update position first to bring input into view
|
||||||
this.updatePosition();
|
this.updatePosition();
|
||||||
|
this.showInput();
|
||||||
|
|
||||||
|
// Use immediate focus
|
||||||
|
this.input.focus();
|
||||||
|
|
||||||
|
// Verify focus worked
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this.input.focus();
|
|
||||||
// If focus didn't work, try once more
|
|
||||||
if (document.activeElement !== this.input) {
|
if (document.activeElement !== this.input) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (document.activeElement !== this.input) {
|
if (document.activeElement !== this.input) {
|
||||||
|
|
@ -304,6 +365,24 @@ export class DesktopIMEInput {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the IME input position based on cursor location
|
||||||
|
* Can be called externally when cursor moves
|
||||||
|
*/
|
||||||
|
refreshPosition(): void {
|
||||||
|
this.updatePosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the font size of the IME input
|
||||||
|
* Should be called when terminal font size changes
|
||||||
|
*/
|
||||||
|
updateFontSize(): void {
|
||||||
|
const fontSize = this.options.getFontSize?.() || 14;
|
||||||
|
this.input.style.fontSize = `${fontSize}px`;
|
||||||
|
logger.log(`Updated IME input font size to ${fontSize}px`);
|
||||||
|
}
|
||||||
|
|
||||||
blur(): void {
|
blur(): void {
|
||||||
this.input.blur();
|
this.input.blur();
|
||||||
}
|
}
|
||||||
|
|
@ -326,15 +405,11 @@ export class DesktopIMEInput {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't use aggressive focus retention - it interferes with IME
|
||||||
|
// Just ensure focus stays during composition
|
||||||
if (this.focusRetentionInterval) {
|
if (this.focusRetentionInterval) {
|
||||||
clearInterval(this.focusRetentionInterval);
|
clearInterval(this.focusRetentionInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.focusRetentionInterval = setInterval(() => {
|
|
||||||
if (document.activeElement !== this.input) {
|
|
||||||
this.input.focus();
|
|
||||||
}
|
|
||||||
}, 100) as unknown as number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopFocusRetention(): void {
|
private stopFocusRetention(): void {
|
||||||
|
|
|
||||||
|
|
@ -503,6 +503,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
||||||
getAsFile: () => file,
|
getAsFile: () => file,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
getData: () => '', // Return empty string for text data
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteEvent = new ClipboardEvent('paste', {
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
|
@ -535,6 +536,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
||||||
getAsFile: () => file2,
|
getAsFile: () => file2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
getData: () => '', // Return empty string for text data
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteEvent = new ClipboardEvent('paste', {
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
|
@ -567,6 +569,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
||||||
getAsFile: () => file,
|
getAsFile: () => file,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
getData: () => '', // Return empty string for text data
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteEvent = new ClipboardEvent('paste', {
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
|
@ -596,6 +599,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
||||||
getAsFile: () => file,
|
getAsFile: () => file,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
getData: () => '', // Return empty string for text data
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteEvent = new ClipboardEvent('paste', {
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
|
@ -625,6 +629,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
||||||
getAsFile: () => file,
|
getAsFile: () => file,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
getData: () => '', // Return empty string for text data
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteEvent = new ClipboardEvent('paste', {
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
|
@ -647,6 +652,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
getData: () => '', // Return empty string for text data
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteEvent = new ClipboardEvent('paste', {
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
|
@ -673,6 +679,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
||||||
getAsFile: () => file,
|
getAsFile: () => file,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
getData: () => '', // Return empty string for text data
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteEvent = new ClipboardEvent('paste', {
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
|
@ -707,6 +714,7 @@ describe('SessionView Drag & Drop and Paste', () => {
|
||||||
{ kind: 'file', getAsFile: () => file2 },
|
{ kind: 'file', getAsFile: () => file2 },
|
||||||
{ kind: 'file', getAsFile: () => file3 },
|
{ kind: 'file', getAsFile: () => file3 },
|
||||||
],
|
],
|
||||||
|
getData: () => '', // Return empty string for text data
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteEvent = new ClipboardEvent('paste', {
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { fixture, html } from '@open-wc/testing';
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
clickElement,
|
clickElement,
|
||||||
pressKey,
|
|
||||||
resetViewport,
|
resetViewport,
|
||||||
setupFetchMock,
|
setupFetchMock,
|
||||||
setViewport,
|
setViewport,
|
||||||
|
|
@ -304,8 +303,10 @@ describe('SessionView', () => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Simulate typing
|
// Use the input manager directly instead of simulating keyboard events
|
||||||
await pressKey(element, 'a');
|
// biome-ignore lint/suspicious/noExplicitAny: need to access private property for testing
|
||||||
|
const inputManager = (element as any).inputManager;
|
||||||
|
await inputManager.sendInputText('a');
|
||||||
|
|
||||||
// Wait for async operation
|
// Wait for async operation
|
||||||
await waitForAsync();
|
await waitForAsync();
|
||||||
|
|
@ -325,8 +326,12 @@ describe('SessionView', () => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Use the input manager directly instead of simulating keyboard events
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: need to access private property for testing
|
||||||
|
const inputManager = (element as any).inputManager;
|
||||||
|
|
||||||
// Test Enter key
|
// Test Enter key
|
||||||
await pressKey(element, 'Enter');
|
await inputManager.sendInput('enter');
|
||||||
await waitForAsync();
|
await waitForAsync();
|
||||||
expect(inputCapture).toHaveBeenCalledWith({ key: 'enter' });
|
expect(inputCapture).toHaveBeenCalledWith({ key: 'enter' });
|
||||||
|
|
||||||
|
|
@ -334,7 +339,7 @@ describe('SessionView', () => {
|
||||||
inputCapture.mockClear();
|
inputCapture.mockClear();
|
||||||
|
|
||||||
// Test Escape key
|
// Test Escape key
|
||||||
await pressKey(element, 'Escape');
|
await inputManager.sendInput('escape');
|
||||||
await waitForAsync();
|
await waitForAsync();
|
||||||
expect(inputCapture).toHaveBeenCalledWith({ key: 'escape' });
|
expect(inputCapture).toHaveBeenCalledWith({ key: 'escape' });
|
||||||
});
|
});
|
||||||
|
|
@ -870,8 +875,21 @@ describe('SessionView', () => {
|
||||||
element.session = mockSession;
|
element.session = mockSession;
|
||||||
await element.updateComplete;
|
await element.updateComplete;
|
||||||
|
|
||||||
// Press escape on exited session
|
// Ensure we're in desktop mode by setting localStorage preference
|
||||||
await pressKey(element, 'Escape');
|
localStorage.setItem('touchKeyboardPreference', 'never');
|
||||||
|
|
||||||
|
// Force the lifecycle manager to re-evaluate mobile status
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
await waitForAsync();
|
||||||
|
|
||||||
|
// Press escape on exited session - dispatch on document since lifecycle manager listens there
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Escape',
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
await waitForAsync();
|
||||||
|
|
||||||
expect(navigateHandler).toHaveBeenCalled();
|
expect(navigateHandler).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import './worktree-manager.js';
|
||||||
import { authClient } from '../services/auth-client.js';
|
import { authClient } from '../services/auth-client.js';
|
||||||
import { GitService } from '../services/git-service.js';
|
import { GitService } from '../services/git-service.js';
|
||||||
import { createLogger } from '../utils/logger.js';
|
import { createLogger } from '../utils/logger.js';
|
||||||
|
import { TERMINAL_IDS } from '../utils/terminal-constants.js';
|
||||||
import type { TerminalThemeId } from '../utils/terminal-themes.js';
|
import type { TerminalThemeId } from '../utils/terminal-themes.js';
|
||||||
// Manager imports
|
// Manager imports
|
||||||
import { ConnectionManager } from './session-view/connection-manager.js';
|
import { ConnectionManager } from './session-view/connection-manager.js';
|
||||||
|
|
@ -1185,7 +1186,7 @@ export class SessionView extends LitElement {
|
||||||
? html`
|
? html`
|
||||||
<!-- Enhanced Terminal Component -->
|
<!-- Enhanced Terminal Component -->
|
||||||
<terminal-renderer
|
<terminal-renderer
|
||||||
id="session-terminal"
|
id="${TERMINAL_IDS.SESSION_TERMINAL}"
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
.useBinaryMode=${uiState.useBinaryMode}
|
.useBinaryMode=${uiState.useBinaryMode}
|
||||||
.terminalFontSize=${uiState.terminalFontSize}
|
.terminalFontSize=${uiState.terminalFontSize}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { consumeEvent } from '../../utils/event-utils.js';
|
||||||
import { isIMEAllowedKey } from '../../utils/ime-constants.js';
|
import { isIMEAllowedKey } from '../../utils/ime-constants.js';
|
||||||
import { createLogger } from '../../utils/logger.js';
|
import { createLogger } from '../../utils/logger.js';
|
||||||
import { detectMobile } from '../../utils/mobile-utils.js';
|
import { detectMobile } from '../../utils/mobile-utils.js';
|
||||||
|
import { CJK_LANGUAGE_CODES, TERMINAL_IDS } from '../../utils/terminal-constants.js';
|
||||||
import { DesktopIMEInput } from '../ime-input.js';
|
import { DesktopIMEInput } from '../ime-input.js';
|
||||||
import type { Terminal } from '../terminal.js';
|
import type { Terminal } from '../terminal.js';
|
||||||
import type { VibeTerminalBinary } from '../vibe-terminal-binary.js';
|
import type { VibeTerminalBinary } from '../vibe-terminal-binary.js';
|
||||||
|
|
@ -32,6 +33,7 @@ export class InputManager {
|
||||||
private lastEscapeTime = 0;
|
private lastEscapeTime = 0;
|
||||||
private readonly DOUBLE_ESCAPE_THRESHOLD = 500; // ms
|
private readonly DOUBLE_ESCAPE_THRESHOLD = 500; // ms
|
||||||
private imeInput: DesktopIMEInput | null = null;
|
private imeInput: DesktopIMEInput | null = null;
|
||||||
|
private globalCompositionListener: ((e: CompositionEvent) => void) | null = null;
|
||||||
|
|
||||||
setSession(session: Session | null): void {
|
setSession(session: Session | null): void {
|
||||||
// Clean up IME input when session is null
|
// Clean up IME input when session is null
|
||||||
|
|
@ -41,11 +43,16 @@ export class InputManager {
|
||||||
|
|
||||||
this.session = session;
|
this.session = session;
|
||||||
|
|
||||||
// Setup IME input when session is available
|
// Setup IME input when session is available and CJK language is active
|
||||||
if (session && !this.imeInput) {
|
if (session && !this.imeInput) {
|
||||||
this.setupIMEInput();
|
this.setupIMEInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up global composition event listener to detect CJK input dynamically
|
||||||
|
if (session && !detectMobile()) {
|
||||||
|
this.setupGlobalCompositionListener();
|
||||||
|
}
|
||||||
|
|
||||||
// Check URL parameter for WebSocket input feature flag
|
// Check URL parameter for WebSocket input feature flag
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const socketInputParam = urlParams.get('socket_input');
|
const socketInputParam = urlParams.get('socket_input');
|
||||||
|
|
@ -68,19 +75,114 @@ export class InputManager {
|
||||||
this.callbacks = callbacks;
|
this.callbacks = callbacks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupIMEInput(): void {
|
/**
|
||||||
|
* Check if a CJK (Chinese, Japanese, Korean) language is currently active
|
||||||
|
* This detects both system language and input method editor (IME) state
|
||||||
|
*/
|
||||||
|
private isCJKLanguageActive(): boolean {
|
||||||
|
// Check system/browser language first
|
||||||
|
const languages = [navigator.language, ...(navigator.languages || [])];
|
||||||
|
|
||||||
|
// Check if any of the user's languages are CJK
|
||||||
|
const hasCJKLanguage = languages.some((lang) =>
|
||||||
|
CJK_LANGUAGE_CODES.some((cjkLang) => lang.toLowerCase().startsWith(cjkLang.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasCJKLanguage) {
|
||||||
|
logger.log('CJK language detected in browser languages:', languages);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional check: look for common CJK input method indicators
|
||||||
|
// This is more of a heuristic since there's no direct IME detection API
|
||||||
|
const hasIMEKeyboard = this.hasIMEKeyboard();
|
||||||
|
if (hasIMEKeyboard) {
|
||||||
|
logger.log('IME keyboard detected, likely CJK input method');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('No CJK language or IME detected', { languages, hasIMEKeyboard });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic check for IME keyboard presence
|
||||||
|
* This is not 100% reliable but provides a reasonable fallback
|
||||||
|
*/
|
||||||
|
private hasIMEKeyboard(): boolean {
|
||||||
|
// Check for composition events support (indicates IME capability)
|
||||||
|
if (!('CompositionEvent' in window)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the virtual keyboard API indicates composition support
|
||||||
|
if ('virtualKeyboard' in navigator) {
|
||||||
|
try {
|
||||||
|
const nav = navigator as Navigator & { virtualKeyboard?: { overlaysContent?: boolean } };
|
||||||
|
const vk = nav.virtualKeyboard;
|
||||||
|
// Some IME keyboards set overlaysContent to true
|
||||||
|
if (vk && vk.overlaysContent !== undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// Ignore errors accessing virtual keyboard API
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: assume IME is possible if composition events are supported
|
||||||
|
// and we're on a platform that commonly uses IME
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
const isCommonIMEPlatform =
|
||||||
|
userAgent.includes('windows') || userAgent.includes('mac') || userAgent.includes('linux');
|
||||||
|
|
||||||
|
return isCommonIMEPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupIMEInput(retryCount = 0): void {
|
||||||
|
const MAX_RETRIES = 10;
|
||||||
|
const IME_SETUP_RETRY_DELAY_MS = 100;
|
||||||
|
|
||||||
// Skip IME input setup on mobile devices (they have their own IME handling)
|
// Skip IME input setup on mobile devices (they have their own IME handling)
|
||||||
if (detectMobile()) {
|
if (detectMobile()) {
|
||||||
console.log('🔍 Skipping IME input setup on mobile device');
|
|
||||||
logger.log('Skipping IME input setup on mobile device');
|
logger.log('Skipping IME input setup on mobile device');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('🔍 Setting up IME input on desktop device');
|
|
||||||
|
// Skip if IME input already exists
|
||||||
|
if (this.imeInput) {
|
||||||
|
logger.log('IME input already exists, skipping setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only enable IME input for CJK languages
|
||||||
|
if (!this.isCJKLanguageActive()) {
|
||||||
|
logger.log('Skipping IME input setup - no CJK language detected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('Setting up IME input on desktop device for CJK language');
|
||||||
|
|
||||||
|
// Check if terminal element exists first - if not, defer setup
|
||||||
|
const terminalElement = this.callbacks?.getTerminalElement?.();
|
||||||
|
if (!terminalElement) {
|
||||||
|
if (retryCount >= MAX_RETRIES) {
|
||||||
|
logger.error('Failed to setup IME after maximum retries');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.log(
|
||||||
|
`Terminal element not ready yet, deferring IME setup (retry ${retryCount + 1}/${MAX_RETRIES})`
|
||||||
|
);
|
||||||
|
// Retry after a short delay when terminal should be ready
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setupIMEInput(retryCount + 1);
|
||||||
|
}, IME_SETUP_RETRY_DELAY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Find the terminal container to position the IME input correctly
|
// Find the terminal container to position the IME input correctly
|
||||||
const terminalContainer = document.getElementById('terminal-container');
|
const terminalContainer = document.getElementById(TERMINAL_IDS.SESSION_TERMINAL);
|
||||||
if (!terminalContainer) {
|
if (!terminalContainer) {
|
||||||
console.warn('🌏 InputManager: Terminal container not found, cannot setup IME input');
|
logger.warn('Terminal container not found, cannot setup IME input');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,10 +196,36 @@ export class InputManager {
|
||||||
this.sendInput(key);
|
this.sendInput(key);
|
||||||
},
|
},
|
||||||
getCursorInfo: () => {
|
getCursorInfo: () => {
|
||||||
// For now, return null to use fallback positioning
|
// Get cursor position from the terminal element
|
||||||
// TODO: Implement cursor position tracking when Terminal/VibeTerminalBinary support it
|
const terminalElement = this.callbacks?.getTerminalElement?.();
|
||||||
|
if (!terminalElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the terminal element has getCursorInfo method
|
||||||
|
if (
|
||||||
|
'getCursorInfo' in terminalElement &&
|
||||||
|
typeof terminalElement.getCursorInfo === 'function'
|
||||||
|
) {
|
||||||
|
return terminalElement.getCursorInfo();
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
getFontSize: () => {
|
||||||
|
// Get font size from the terminal element
|
||||||
|
const terminalElement = this.callbacks?.getTerminalElement?.();
|
||||||
|
if (!terminalElement) {
|
||||||
|
return 14; // Default font size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the terminal element has fontSize property
|
||||||
|
if ('fontSize' in terminalElement && typeof terminalElement.fontSize === 'number') {
|
||||||
|
return terminalElement.fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 14; // Default font size
|
||||||
|
},
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -307,12 +435,18 @@ export class InputManager {
|
||||||
// sendInputText is used for pasted content - always treat as literal text
|
// sendInputText is used for pasted content - always treat as literal text
|
||||||
// Never interpret pasted text as special keys to avoid ambiguity
|
// Never interpret pasted text as special keys to avoid ambiguity
|
||||||
await this.sendInputInternal({ text }, 'send input to session');
|
await this.sendInputInternal({ text }, 'send input to session');
|
||||||
|
|
||||||
|
// Update IME input position after sending text
|
||||||
|
this.refreshIMEPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendControlSequence(controlChar: string): Promise<void> {
|
async sendControlSequence(controlChar: string): Promise<void> {
|
||||||
// sendControlSequence is for control characters - always send as literal text
|
// sendControlSequence is for control characters - always send as literal text
|
||||||
// Control characters like '\x12' (Ctrl+R) should be sent directly
|
// Control characters like '\x12' (Ctrl+R) should be sent directly
|
||||||
await this.sendInputInternal({ text: controlChar }, 'send control sequence to session');
|
await this.sendInputInternal({ text: controlChar }, 'send control sequence to session');
|
||||||
|
|
||||||
|
// Update IME input position after sending control sequence
|
||||||
|
this.refreshIMEPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendInput(inputText: string): Promise<void> {
|
async sendInput(inputText: string): Promise<void> {
|
||||||
|
|
@ -350,6 +484,23 @@ export class InputManager {
|
||||||
|
|
||||||
const input = specialKeys.includes(inputText) ? { key: inputText } : { text: inputText };
|
const input = specialKeys.includes(inputText) ? { key: inputText } : { text: inputText };
|
||||||
await this.sendInputInternal(input, 'send input to session');
|
await this.sendInputInternal(input, 'send input to session');
|
||||||
|
|
||||||
|
// Update IME input position after sending input
|
||||||
|
this.refreshIMEPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshIMEPosition(): void {
|
||||||
|
// Update IME input position if it exists
|
||||||
|
if (this.imeInput?.isFocused()) {
|
||||||
|
// Update immediately first
|
||||||
|
this.imeInput?.refreshPosition();
|
||||||
|
|
||||||
|
// Debounced update after allowing terminal to update cursor position
|
||||||
|
// Use a single setTimeout to avoid race conditions
|
||||||
|
setTimeout(() => {
|
||||||
|
this.imeInput?.refreshPosition();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isKeyboardShortcut(e: KeyboardEvent): boolean {
|
isKeyboardShortcut(e: KeyboardEvent): boolean {
|
||||||
|
|
@ -434,6 +585,12 @@ export class InputManager {
|
||||||
this.imeInput = null;
|
this.imeInput = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove global composition listener
|
||||||
|
if (this.globalCompositionListener) {
|
||||||
|
document.removeEventListener('compositionstart', this.globalCompositionListener);
|
||||||
|
this.globalCompositionListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Disconnect WebSocket if feature was enabled
|
// Disconnect WebSocket if feature was enabled
|
||||||
if (this.useWebSocketInput) {
|
if (this.useWebSocketInput) {
|
||||||
websocketInputClient.disconnect();
|
websocketInputClient.disconnect();
|
||||||
|
|
@ -444,6 +601,116 @@ export class InputManager {
|
||||||
this.callbacks = null;
|
this.callbacks = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry IME setup - useful when terminal becomes ready after initial setup attempt
|
||||||
|
*/
|
||||||
|
retryIMESetup(): void {
|
||||||
|
if (!this.imeInput && !detectMobile()) {
|
||||||
|
logger.log('Retrying IME setup after terminal became ready');
|
||||||
|
this.setupIMEInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a global composition event listener to detect CJK input dynamically
|
||||||
|
* This allows enabling IME input when the user starts composing CJK text
|
||||||
|
*/
|
||||||
|
private setupGlobalCompositionListener(): void {
|
||||||
|
if (this.globalCompositionListener) {
|
||||||
|
return; // Already set up
|
||||||
|
}
|
||||||
|
|
||||||
|
this.globalCompositionListener = (e: CompositionEvent) => {
|
||||||
|
// Only enable IME input if it's not already set up
|
||||||
|
if (!this.imeInput && this.session && !detectMobile()) {
|
||||||
|
logger.log('Composition event detected, enabling IME input:', e.type, e.data);
|
||||||
|
this.enableIMEInput();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for composition start events globally
|
||||||
|
document.addEventListener('compositionstart', this.globalCompositionListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable IME input dynamically when CJK input is detected
|
||||||
|
* This can be called when composition events are detected or user explicitly enables CJK input
|
||||||
|
*/
|
||||||
|
enableIMEInput(): void {
|
||||||
|
if (detectMobile()) {
|
||||||
|
logger.log('Skipping IME input enable on mobile device');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.imeInput) {
|
||||||
|
logger.log('IME input already enabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.session) {
|
||||||
|
logger.log('Cannot enable IME input - no session available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('Dynamically enabling IME input for CJK composition');
|
||||||
|
// Force enable by skipping the language check since composition was detected
|
||||||
|
this.forceSetupIMEInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force setup IME input without language checks (used when composition is detected)
|
||||||
|
*/
|
||||||
|
private forceSetupIMEInput(): void {
|
||||||
|
const terminalContainer = document.getElementById(TERMINAL_IDS.SESSION_TERMINAL);
|
||||||
|
if (!terminalContainer) {
|
||||||
|
logger.warn('Terminal container not found, cannot setup IME input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create IME input component
|
||||||
|
this.imeInput = new DesktopIMEInput({
|
||||||
|
container: terminalContainer,
|
||||||
|
onTextInput: (text: string) => {
|
||||||
|
this.sendInputText(text);
|
||||||
|
},
|
||||||
|
onSpecialKey: (key: string) => {
|
||||||
|
this.sendInput(key);
|
||||||
|
},
|
||||||
|
getCursorInfo: () => {
|
||||||
|
// Get cursor position from the terminal element
|
||||||
|
const terminalElement = this.callbacks?.getTerminalElement?.();
|
||||||
|
if (!terminalElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the terminal element has getCursorInfo method
|
||||||
|
if (
|
||||||
|
'getCursorInfo' in terminalElement &&
|
||||||
|
typeof terminalElement.getCursorInfo === 'function'
|
||||||
|
) {
|
||||||
|
return terminalElement.getCursorInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getFontSize: () => {
|
||||||
|
// Get font size from the terminal element
|
||||||
|
const terminalElement = this.callbacks?.getTerminalElement?.();
|
||||||
|
if (!terminalElement) {
|
||||||
|
return 14; // Default font size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the terminal element has fontSize property
|
||||||
|
if ('fontSize' in terminalElement && typeof terminalElement.fontSize === 'number') {
|
||||||
|
return terminalElement.fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 14; // Default font size
|
||||||
|
},
|
||||||
|
autoFocus: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// For testing purposes only
|
// For testing purposes only
|
||||||
getIMEInputForTesting(): DesktopIMEInput | null {
|
getIMEInputForTesting(): DesktopIMEInput | null {
|
||||||
return this.imeInput;
|
return this.imeInput;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Session } from '../../../shared/types.js';
|
import type { Session } from '../../../shared/types.js';
|
||||||
|
import { clearCharacterWidthCache } from '../../utils/cursor-position.js';
|
||||||
import { consumeEvent } from '../../utils/event-utils.js';
|
import { consumeEvent } from '../../utils/event-utils.js';
|
||||||
import { isIMEAllowedKey } from '../../utils/ime-constants.js';
|
import { isIMEAllowedKey } from '../../utils/ime-constants.js';
|
||||||
import { createLogger } from '../../utils/logger.js';
|
import { createLogger } from '../../utils/logger.js';
|
||||||
|
|
@ -198,6 +199,9 @@ export class LifecycleEventManager extends ManagerEventEmitter {
|
||||||
handleWindowResize = (): void => {
|
handleWindowResize = (): void => {
|
||||||
if (!this.callbacks) return;
|
if (!this.callbacks) return;
|
||||||
|
|
||||||
|
// Clear character width cache when window is resized (may affect font rendering)
|
||||||
|
clearCharacterWidthCache();
|
||||||
|
|
||||||
// Clear cache to re-evaluate capabilities (in case of device mode changes in dev tools)
|
// Clear cache to re-evaluate capabilities (in case of device mode changes in dev tools)
|
||||||
this.touchCapabilityCache = null;
|
this.touchCapabilityCache = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
* - Settings persistence via TerminalPreferencesManager
|
* - Settings persistence via TerminalPreferencesManager
|
||||||
*/
|
*/
|
||||||
import type { Session } from '../../../shared/types.js';
|
import type { Session } from '../../../shared/types.js';
|
||||||
|
import { clearCharacterWidthCache } from '../../utils/cursor-position.js';
|
||||||
import { createLogger } from '../../utils/logger.js';
|
import { createLogger } from '../../utils/logger.js';
|
||||||
import {
|
import {
|
||||||
COMMON_TERMINAL_WIDTHS,
|
COMMON_TERMINAL_WIDTHS,
|
||||||
|
|
@ -170,6 +171,9 @@ export class TerminalSettingsManager {
|
||||||
this.preferencesManager.setFontSize(clampedSize);
|
this.preferencesManager.setFontSize(clampedSize);
|
||||||
this.callbacks.setTerminalFontSize(clampedSize);
|
this.callbacks.setTerminalFontSize(clampedSize);
|
||||||
|
|
||||||
|
// Clear character width cache when font size changes
|
||||||
|
clearCharacterWidthCache();
|
||||||
|
|
||||||
// Update the terminal lifecycle manager
|
// Update the terminal lifecycle manager
|
||||||
const lifecycleManager = this.callbacks.getTerminalLifecycleManager();
|
const lifecycleManager = this.callbacks.getTerminalLifecycleManager();
|
||||||
if (lifecycleManager) {
|
if (lifecycleManager) {
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@
|
||||||
import { type IBufferCell, type IBufferLine, Terminal as XtermTerminal } from '@xterm/headless';
|
import { type IBufferCell, type IBufferLine, Terminal as XtermTerminal } from '@xterm/headless';
|
||||||
import { html, LitElement, type PropertyValues } from 'lit';
|
import { html, LitElement, type PropertyValues } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
import { calculateCursorPosition } from '../utils/cursor-position.js';
|
||||||
import { processKeyboardShortcuts } from '../utils/keyboard-shortcut-highlighter.js';
|
import { processKeyboardShortcuts } from '../utils/keyboard-shortcut-highlighter.js';
|
||||||
import { createLogger } from '../utils/logger.js';
|
import { createLogger } from '../utils/logger.js';
|
||||||
|
import { TERMINAL_IDS } from '../utils/terminal-constants.js';
|
||||||
import { TerminalPreferencesManager } from '../utils/terminal-preferences.js';
|
import { TerminalPreferencesManager } from '../utils/terminal-preferences.js';
|
||||||
import { TERMINAL_THEMES, type TerminalThemeId } from '../utils/terminal-themes.js';
|
import { TERMINAL_THEMES, type TerminalThemeId } from '../utils/terminal-themes.js';
|
||||||
import { getCurrentTheme } from '../utils/theme-utils.js';
|
import { getCurrentTheme } from '../utils/theme-utils.js';
|
||||||
|
|
@ -458,7 +460,7 @@ export class Terminal extends LitElement {
|
||||||
logger.debug('initializeTerminal starting');
|
logger.debug('initializeTerminal starting');
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|
||||||
this.container = this.querySelector('#terminal-container') as HTMLElement;
|
this.container = this.querySelector(`#${TERMINAL_IDS.TERMINAL_CONTAINER}`) as HTMLElement;
|
||||||
|
|
||||||
if (!this.container) {
|
if (!this.container) {
|
||||||
const error = new Error('Terminal container not found');
|
const error = new Error('Terminal container not found');
|
||||||
|
|
@ -1690,7 +1692,7 @@ export class Terminal extends LitElement {
|
||||||
</style>
|
</style>
|
||||||
<div class="relative w-full h-full p-0 m-0">
|
<div class="relative w-full h-full p-0 m-0">
|
||||||
<div
|
<div
|
||||||
id="terminal-container"
|
id="${TERMINAL_IDS.TERMINAL_CONTAINER}"
|
||||||
class="terminal-container w-full h-full overflow-hidden p-0 m-0"
|
class="terminal-container w-full h-full overflow-hidden p-0 m-0"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
contenteditable="false"
|
contenteditable="false"
|
||||||
|
|
@ -1741,4 +1743,28 @@ export class Terminal extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cursor position information for IME input positioning
|
||||||
|
* Returns null if terminal is not available or cursor is not visible
|
||||||
|
*/
|
||||||
|
getCursorInfo(): { x: number; y: number } | null {
|
||||||
|
if (!this.terminal) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cursor position from xterm.js
|
||||||
|
const buffer = this.terminal.buffer.active;
|
||||||
|
const cursorX = buffer.cursorX;
|
||||||
|
const cursorY = buffer.cursorY;
|
||||||
|
|
||||||
|
// Find the terminal container element
|
||||||
|
const container = this.querySelector(`#${TERMINAL_IDS.TERMINAL_CONTAINER}`);
|
||||||
|
if (!container) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use shared cursor position calculation
|
||||||
|
return calculateCursorPosition(cursorX, cursorY, this.fontSize, container, this.sessionStatus);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ import { html, type PropertyValues } from 'lit';
|
||||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||||
import { HttpMethod } from '../../shared/types.js';
|
import { HttpMethod } from '../../shared/types.js';
|
||||||
import { authClient } from '../services/auth-client.js';
|
import { authClient } from '../services/auth-client.js';
|
||||||
|
import { calculateCursorPosition } from '../utils/cursor-position.js';
|
||||||
import { consumeEvent } from '../utils/event-utils.js';
|
import { consumeEvent } from '../utils/event-utils.js';
|
||||||
import { createLogger } from '../utils/logger.js';
|
import { createLogger } from '../utils/logger.js';
|
||||||
|
import { TERMINAL_IDS } from '../utils/terminal-constants.js';
|
||||||
import { TerminalPreferencesManager } from '../utils/terminal-preferences.js';
|
import { TerminalPreferencesManager } from '../utils/terminal-preferences.js';
|
||||||
import type { TerminalThemeId } from '../utils/terminal-themes.js';
|
import type { TerminalThemeId } from '../utils/terminal-themes.js';
|
||||||
import { getCurrentTheme } from '../utils/theme-utils.js';
|
import { getCurrentTheme } from '../utils/theme-utils.js';
|
||||||
|
|
@ -399,4 +401,27 @@ export class VibeTerminalBinary extends VibeTerminalBuffer {
|
||||||
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
|
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cursor position information for IME input positioning
|
||||||
|
* Returns null if buffer is not available or session is not running
|
||||||
|
*/
|
||||||
|
getCursorInfo(): { x: number; y: number } | null {
|
||||||
|
if (!this.buffer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cursor position from buffer data
|
||||||
|
const cursorX = this.buffer.cursorX;
|
||||||
|
const cursorY = this.buffer.cursorY;
|
||||||
|
|
||||||
|
// Find the terminal container element
|
||||||
|
const container = this.querySelector(`#${TERMINAL_IDS.BUFFER_CONTAINER}`);
|
||||||
|
if (!container) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use shared cursor position calculation
|
||||||
|
return calculateCursorPosition(cursorX, cursorY, this.fontSize, container, this.sessionStatus);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { html, LitElement } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { cellsToText } from '../../shared/terminal-text-formatter.js';
|
import { cellsToText } from '../../shared/terminal-text-formatter.js';
|
||||||
import { bufferSubscriptionService } from '../services/buffer-subscription-service.js';
|
import { bufferSubscriptionService } from '../services/buffer-subscription-service.js';
|
||||||
|
import { TERMINAL_IDS } from '../utils/terminal-constants.js';
|
||||||
import { type BufferCell, TerminalRenderer } from '../utils/terminal-renderer.js';
|
import { type BufferCell, TerminalRenderer } from '../utils/terminal-renderer.js';
|
||||||
import { TERMINAL_THEMES, type TerminalThemeId } from '../utils/terminal-themes.js';
|
import { TERMINAL_THEMES, type TerminalThemeId } from '../utils/terminal-themes.js';
|
||||||
import { getCurrentTheme } from '../utils/theme-utils.js';
|
import { getCurrentTheme } from '../utils/theme-utils.js';
|
||||||
|
|
@ -35,7 +36,7 @@ export class VibeTerminalBuffer extends LitElement {
|
||||||
@property({ type: String }) theme: TerminalThemeId = 'auto';
|
@property({ type: String }) theme: TerminalThemeId = 'auto';
|
||||||
@property({ type: String }) sessionStatus = 'running'; // Track session status for cursor control
|
@property({ type: String }) sessionStatus = 'running'; // Track session status for cursor control
|
||||||
|
|
||||||
@state() private buffer: BufferSnapshot | null = null;
|
@state() protected buffer: BufferSnapshot | null = null;
|
||||||
@state() private error: string | null = null;
|
@state() private error: string | null = null;
|
||||||
@state() private displayedFontSize = 16;
|
@state() private displayedFontSize = 16;
|
||||||
@state() private visibleRows = 0;
|
@state() private visibleRows = 0;
|
||||||
|
|
@ -70,7 +71,7 @@ export class VibeTerminalBuffer extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
firstUpdated() {
|
firstUpdated() {
|
||||||
this.container = this.querySelector('#buffer-container') as HTMLElement;
|
this.container = this.querySelector(`#${TERMINAL_IDS.BUFFER_CONTAINER}`) as HTMLElement;
|
||||||
if (this.container) {
|
if (this.container) {
|
||||||
this.setupResize();
|
this.setupResize();
|
||||||
if (this.sessionId) {
|
if (this.sessionId) {
|
||||||
|
|
@ -250,7 +251,7 @@ export class VibeTerminalBuffer extends LitElement {
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<div
|
<div
|
||||||
id="buffer-container"
|
id="${TERMINAL_IDS.BUFFER_CONTAINER}"
|
||||||
class="terminal-container w-full h-full overflow-x-auto overflow-y-hidden font-mono antialiased"
|
class="terminal-container w-full h-full overflow-x-auto overflow-y-hidden font-mono antialiased"
|
||||||
></div>
|
></div>
|
||||||
`
|
`
|
||||||
|
|
|
||||||
251
web/src/client/utils/cursor-position.test.ts
Normal file
251
web/src/client/utils/cursor-position.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { calculateCursorPosition, clearCharacterWidthCache } from './cursor-position.js';
|
||||||
|
import { TERMINAL_IDS } from './terminal-constants.js';
|
||||||
|
|
||||||
|
describe('cursor-position', () => {
|
||||||
|
let mockContainer: HTMLElement;
|
||||||
|
let mockSessionTerminal: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear the cache before each test
|
||||||
|
clearCharacterWidthCache();
|
||||||
|
|
||||||
|
// Reset any existing mocks
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Create mock DOM elements
|
||||||
|
mockContainer = {
|
||||||
|
style: {},
|
||||||
|
appendChild: vi.fn(),
|
||||||
|
removeChild: vi.fn(),
|
||||||
|
getBoundingClientRect: vi.fn().mockReturnValue({
|
||||||
|
left: 100,
|
||||||
|
top: 50,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
right: 900,
|
||||||
|
bottom: 650,
|
||||||
|
x: 100,
|
||||||
|
y: 50,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
mockSessionTerminal = {
|
||||||
|
id: TERMINAL_IDS.SESSION_TERMINAL,
|
||||||
|
style: {},
|
||||||
|
remove: vi.fn(),
|
||||||
|
getBoundingClientRect: vi.fn().mockReturnValue({
|
||||||
|
left: 20,
|
||||||
|
top: 10,
|
||||||
|
width: 1000,
|
||||||
|
height: 700,
|
||||||
|
right: 1020,
|
||||||
|
bottom: 710,
|
||||||
|
x: 20,
|
||||||
|
y: 10,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Mock getElementById to return our mock session terminal
|
||||||
|
vi.spyOn(document, 'getElementById').mockImplementation((id) => {
|
||||||
|
if (id === TERMINAL_IDS.SESSION_TERMINAL) {
|
||||||
|
return mockSessionTerminal;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateCursorPosition', () => {
|
||||||
|
it('should calculate correct position for given cursor coordinates', () => {
|
||||||
|
const fontSize = 14;
|
||||||
|
const cursorX = 5; // 5 characters from left
|
||||||
|
const cursorY = 3; // 3 lines from top
|
||||||
|
|
||||||
|
// Mock getBoundingClientRect for the test element to provide consistent char width
|
||||||
|
const mockTestElement = {
|
||||||
|
getBoundingClientRect: vi.fn().mockReturnValue({ width: 8.4 }), // Mock char width
|
||||||
|
style: {},
|
||||||
|
textContent: '',
|
||||||
|
};
|
||||||
|
vi.spyOn(document, 'createElement').mockReturnValue(mockTestElement as any);
|
||||||
|
|
||||||
|
const result = calculateCursorPosition(cursorX, cursorY, fontSize, mockContainer, 'running');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.x).toBeGreaterThan(0);
|
||||||
|
expect(result?.y).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The position should be relative to the session terminal container
|
||||||
|
// x = (containerLeft + cursorX * charWidth) - sessionTerminalLeft
|
||||||
|
// y = (containerTop + cursorY * lineHeight) - sessionTerminalTop
|
||||||
|
const expectedRelativeX = 100 + cursorX * 8.4 - 20; // Using mocked char width
|
||||||
|
const expectedRelativeY = 50 + cursorY * (fontSize * 1.2) - 10; // Using actual line height calculation
|
||||||
|
|
||||||
|
expect(result?.x).toBeCloseTo(expectedRelativeX, 1);
|
||||||
|
expect(result?.y).toBeCloseTo(expectedRelativeY, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when session is not running', () => {
|
||||||
|
const result = calculateCursorPosition(5, 3, 14, mockContainer, 'exited');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing container gracefully', () => {
|
||||||
|
// Mock a container that throws an error during getBoundingClientRect
|
||||||
|
const errorContainer = {
|
||||||
|
getBoundingClientRect: vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Container error');
|
||||||
|
}),
|
||||||
|
appendChild: vi.fn(),
|
||||||
|
removeChild: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = calculateCursorPosition(5, 3, 14, errorContainer as any, 'running');
|
||||||
|
|
||||||
|
// Should return null when calculation fails
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cache character width measurements', () => {
|
||||||
|
const fontSize = 14;
|
||||||
|
|
||||||
|
// Mock a test element with consistent measurement
|
||||||
|
const mockTestElement = {
|
||||||
|
getBoundingClientRect: vi.fn().mockReturnValue({ width: 8.4 }),
|
||||||
|
style: {},
|
||||||
|
textContent: '',
|
||||||
|
};
|
||||||
|
const createElementSpy = vi
|
||||||
|
.spyOn(document, 'createElement')
|
||||||
|
.mockReturnValue(mockTestElement as any);
|
||||||
|
|
||||||
|
// Mock appendChild and removeChild to track element creation
|
||||||
|
const appendChildSpy = vi.spyOn(mockContainer, 'appendChild');
|
||||||
|
const removeChildSpy = vi.spyOn(mockContainer, 'removeChild');
|
||||||
|
|
||||||
|
// First call should create a test element and cache the result
|
||||||
|
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||||
|
expect(createElementSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(appendChildSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(removeChildSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Reset spies
|
||||||
|
createElementSpy.mockClear();
|
||||||
|
appendChildSpy.mockClear();
|
||||||
|
removeChildSpy.mockClear();
|
||||||
|
|
||||||
|
// Second call with same font size should use cached value (no new element creation)
|
||||||
|
calculateCursorPosition(2, 2, fontSize, mockContainer, 'running');
|
||||||
|
expect(createElementSpy).toHaveBeenCalledTimes(0); // Cached value, no new element
|
||||||
|
expect(appendChildSpy).toHaveBeenCalledTimes(0); // No new appendChild
|
||||||
|
expect(removeChildSpy).toHaveBeenCalledTimes(0); // No new removeChild
|
||||||
|
|
||||||
|
// Different font size should create new measurement
|
||||||
|
calculateCursorPosition(1, 1, 16, mockContainer, 'running');
|
||||||
|
expect(createElementSpy).toHaveBeenCalledTimes(1); // New font size, new element
|
||||||
|
expect(appendChildSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(removeChildSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up test elements even on error', () => {
|
||||||
|
const fontSize = 14;
|
||||||
|
|
||||||
|
// Mock a test element that will throw during getBoundingClientRect
|
||||||
|
const testElement = {
|
||||||
|
style: {},
|
||||||
|
textContent: '',
|
||||||
|
getBoundingClientRect: vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Test error');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.spyOn(document, 'createElement').mockReturnValue(testElement as any);
|
||||||
|
|
||||||
|
// This should not throw and should still clean up
|
||||||
|
expect(() => {
|
||||||
|
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
// Verify cleanup was called even though getBoundingClientRect failed
|
||||||
|
expect(mockContainer.removeChild).toHaveBeenCalledWith(testElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing session terminal element', () => {
|
||||||
|
// Mock getElementById to return null (session terminal not found)
|
||||||
|
vi.spyOn(document, 'getElementById').mockImplementation(() => null);
|
||||||
|
|
||||||
|
// Mock a test element for char width measurement
|
||||||
|
const mockTestElement = {
|
||||||
|
getBoundingClientRect: vi.fn().mockReturnValue({ width: 8.4 }),
|
||||||
|
style: {},
|
||||||
|
textContent: '',
|
||||||
|
};
|
||||||
|
vi.spyOn(document, 'createElement').mockReturnValue(mockTestElement as any);
|
||||||
|
|
||||||
|
const result = calculateCursorPosition(5, 3, 14, mockContainer, 'running');
|
||||||
|
|
||||||
|
// Should still return a position (absolute coordinates)
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.x).toBeGreaterThan(0);
|
||||||
|
expect(result?.y).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use correct font family for measurements', () => {
|
||||||
|
const fontSize = 14;
|
||||||
|
|
||||||
|
// Mock a test element that tracks style assignments
|
||||||
|
const testElement = {
|
||||||
|
style: {} as any,
|
||||||
|
textContent: '',
|
||||||
|
getBoundingClientRect: vi.fn().mockReturnValue({ width: 8.4 }),
|
||||||
|
};
|
||||||
|
const createElementSpy = vi
|
||||||
|
.spyOn(document, 'createElement')
|
||||||
|
.mockReturnValue(testElement as any);
|
||||||
|
|
||||||
|
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||||
|
|
||||||
|
expect(createElementSpy).toHaveBeenCalledWith('span');
|
||||||
|
expect(testElement.style.fontFamily).toBe(
|
||||||
|
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace'
|
||||||
|
);
|
||||||
|
expect(testElement.style.fontSize).toBe('14px');
|
||||||
|
expect(testElement.textContent).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearCharacterWidthCache', () => {
|
||||||
|
it('should clear the character width cache', () => {
|
||||||
|
const fontSize = 14;
|
||||||
|
|
||||||
|
// Mock a test element with consistent measurement
|
||||||
|
const mockTestElement = {
|
||||||
|
getBoundingClientRect: vi.fn().mockReturnValue({ width: 8.4 }),
|
||||||
|
style: {},
|
||||||
|
textContent: '',
|
||||||
|
};
|
||||||
|
const createElementSpy = vi
|
||||||
|
.spyOn(document, 'createElement')
|
||||||
|
.mockReturnValue(mockTestElement as any);
|
||||||
|
|
||||||
|
// Make a call to populate the cache
|
||||||
|
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||||
|
expect(createElementSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
createElementSpy.mockClear();
|
||||||
|
|
||||||
|
// This should use cached value (no new element creation)
|
||||||
|
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||||
|
expect(createElementSpy).toHaveBeenCalledTimes(0); // Uses cached value
|
||||||
|
|
||||||
|
// Clear the cache
|
||||||
|
clearCharacterWidthCache();
|
||||||
|
|
||||||
|
// This should create a new measurement after cache clear
|
||||||
|
calculateCursorPosition(1, 1, fontSize, mockContainer, 'running');
|
||||||
|
expect(createElementSpy).toHaveBeenCalledTimes(1); // Cache cleared, new measurement needed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
111
web/src/client/utils/cursor-position.ts
Normal file
111
web/src/client/utils/cursor-position.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* Shared cursor position calculation utility for terminal components
|
||||||
|
*/
|
||||||
|
import { TERMINAL_FONT_FAMILY, TERMINAL_IDS } from './terminal-constants.js';
|
||||||
|
|
||||||
|
// Cache for character width measurements per font size
|
||||||
|
const charWidthCache = new Map<number, number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure character width for a given font size, with caching
|
||||||
|
* @param fontSize - Font size in pixels
|
||||||
|
* @param container - Container element to append test element to
|
||||||
|
* @returns Character width in pixels
|
||||||
|
*/
|
||||||
|
function measureCharacterWidth(fontSize: number, container: Element): number {
|
||||||
|
// Return cached value if available
|
||||||
|
if (charWidthCache.has(fontSize)) {
|
||||||
|
const cachedWidth = charWidthCache.get(fontSize);
|
||||||
|
if (cachedWidth !== undefined) {
|
||||||
|
return cachedWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test element to measure character width
|
||||||
|
const testElement = document.createElement('span');
|
||||||
|
testElement.style.position = 'absolute';
|
||||||
|
testElement.style.visibility = 'hidden';
|
||||||
|
testElement.style.fontSize = `${fontSize}px`;
|
||||||
|
testElement.style.fontFamily = TERMINAL_FONT_FAMILY;
|
||||||
|
testElement.textContent = '0';
|
||||||
|
|
||||||
|
try {
|
||||||
|
container.appendChild(testElement);
|
||||||
|
const charWidth = testElement.getBoundingClientRect().width;
|
||||||
|
|
||||||
|
// Cache the measurement
|
||||||
|
charWidthCache.set(fontSize, charWidth);
|
||||||
|
return charWidth;
|
||||||
|
} finally {
|
||||||
|
// Ensure cleanup even if measurement fails
|
||||||
|
container.removeChild(testElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the character width cache
|
||||||
|
* Call when font size changes or on window resize/zoom
|
||||||
|
*/
|
||||||
|
export function clearCharacterWidthCache(): void {
|
||||||
|
charWidthCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cursor position for IME input positioning
|
||||||
|
* @param cursorX - Cursor column position (0-based)
|
||||||
|
* @param cursorY - Cursor row position (0-based)
|
||||||
|
* @param fontSize - Terminal font size in pixels
|
||||||
|
* @param container - Terminal container element
|
||||||
|
* @param sessionStatus - Session status ('running' or other)
|
||||||
|
* @returns Cursor position relative to #session-terminal container, or null if unavailable
|
||||||
|
*/
|
||||||
|
export function calculateCursorPosition(
|
||||||
|
cursorX: number,
|
||||||
|
cursorY: number,
|
||||||
|
fontSize: number,
|
||||||
|
container: Element,
|
||||||
|
sessionStatus: string
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
if (sessionStatus !== 'running') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Calculate character dimensions based on font size
|
||||||
|
const lineHeight = fontSize * 1.2;
|
||||||
|
|
||||||
|
// Get character width with caching
|
||||||
|
const charWidth = measureCharacterWidth(fontSize, container);
|
||||||
|
|
||||||
|
// Calculate cursor position within the terminal container
|
||||||
|
const terminalRect = container.getBoundingClientRect();
|
||||||
|
const cursorOffsetX = cursorX * charWidth;
|
||||||
|
const cursorOffsetY = cursorY * lineHeight;
|
||||||
|
|
||||||
|
// Calculate absolute position on the page
|
||||||
|
const absoluteX = terminalRect.left + cursorOffsetX;
|
||||||
|
const absoluteY = terminalRect.top + cursorOffsetY;
|
||||||
|
|
||||||
|
// Convert to position relative to #session-terminal container
|
||||||
|
// (The IME input is positioned relative to this container)
|
||||||
|
const sessionTerminal = document.getElementById(TERMINAL_IDS.SESSION_TERMINAL);
|
||||||
|
if (!sessionTerminal) {
|
||||||
|
return { x: absoluteX, y: absoluteY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionRect = sessionTerminal.getBoundingClientRect();
|
||||||
|
const relativeX = absoluteX - sessionRect.left;
|
||||||
|
const relativeY = absoluteY - sessionRect.top;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: relativeX,
|
||||||
|
y: relativeY,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
/**
|
/**
|
||||||
* Keys that are allowed to be processed even when IME input is focused
|
* Keys that are allowed to be processed even when IME input is focused
|
||||||
*/
|
*/
|
||||||
export const IME_ALLOWED_KEYS = ['ArrowLeft', 'ArrowRight', 'Home', 'End'] as const;
|
export const IME_ALLOWED_KEYS = ['Home', 'End', 'Escape'] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a keyboard event is allowed during IME input focus
|
* Check if a keyboard event is allowed during IME input focus
|
||||||
|
|
|
||||||
43
web/src/client/utils/terminal-constants.ts
Normal file
43
web/src/client/utils/terminal-constants.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* Terminal component constants and selectors
|
||||||
|
*
|
||||||
|
* Centralized definitions to prevent breaking changes when IDs or classes are modified
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML element IDs used across terminal components
|
||||||
|
*/
|
||||||
|
export const TERMINAL_IDS = {
|
||||||
|
/** Main session container element */
|
||||||
|
SESSION_TERMINAL: 'session-terminal',
|
||||||
|
/** Buffer container for vibe-terminal-buffer component */
|
||||||
|
BUFFER_CONTAINER: 'buffer-container',
|
||||||
|
/** Terminal container for terminal.ts component */
|
||||||
|
TERMINAL_CONTAINER: 'terminal-container',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard terminal font family used across the application
|
||||||
|
*/
|
||||||
|
export const TERMINAL_FONT_FAMILY =
|
||||||
|
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IME input vertical offset in pixels for better alignment
|
||||||
|
*/
|
||||||
|
export const IME_VERTICAL_OFFSET_PX = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CJK (Chinese, Japanese, Korean) language codes for IME detection
|
||||||
|
*/
|
||||||
|
export const CJK_LANGUAGE_CODES = [
|
||||||
|
'zh',
|
||||||
|
'zh-CN',
|
||||||
|
'zh-TW',
|
||||||
|
'zh-HK',
|
||||||
|
'zh-SG', // Chinese variants
|
||||||
|
'ja',
|
||||||
|
'ja-JP', // Japanese
|
||||||
|
'ko',
|
||||||
|
'ko-KR', // Korean
|
||||||
|
] as const;
|
||||||
Loading…
Reference in a new issue