Skip to content

Commit b5c4f1e

Browse files
committed
feat: Phase 1 xterm.js API parity - essential methods and events
- Add paste(), blur(), input() methods - Add select(), selectLines(), getSelectionPosition() for programmatic selection - Add onKey and onTitleChange events - Add attachCustomKeyEventHandler() for custom keyboard handling - Add convertEol and disableStdin options - Fix programmatic selection rendering - Fix janky mouse selection during drag - Add 30 comprehensive tests (66 total, all passing) - Integrate Phase 1 features into working shell demo with test buttons
1 parent 631144a commit b5c4f1e

9 files changed

Lines changed: 1018 additions & 28 deletions

File tree

bun.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

demo/index.html

Lines changed: 232 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,93 @@
169169
align-items: flex-start;
170170
}
171171
}
172+
173+
/* Phase 1 Feature Panel */
174+
.feature-panel {
175+
background: rgba(255, 255, 255, 0.1);
176+
backdrop-filter: blur(10px);
177+
padding: 15px;
178+
border-radius: 10px;
179+
color: white;
180+
margin-top: 20px;
181+
}
182+
183+
.feature-panel h3 {
184+
margin-bottom: 15px;
185+
font-size: 1.2rem;
186+
}
187+
188+
.button-grid {
189+
display: grid;
190+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
191+
gap: 10px;
192+
margin-bottom: 15px;
193+
}
194+
195+
.test-button {
196+
background: rgba(76, 175, 80, 0.8);
197+
border: none;
198+
padding: 12px 20px;
199+
border-radius: 5px;
200+
color: white;
201+
cursor: pointer;
202+
font-size: 14px;
203+
font-weight: 500;
204+
transition: all 0.2s;
205+
}
206+
207+
.test-button:hover {
208+
background: rgba(76, 175, 80, 1);
209+
transform: translateY(-2px);
210+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
211+
}
212+
213+
.test-button:active {
214+
transform: translateY(0);
215+
}
216+
217+
.event-log {
218+
background: rgba(0, 0, 0, 0.3);
219+
border-radius: 5px;
220+
padding: 10px;
221+
max-height: 150px;
222+
overflow-y: auto;
223+
font-family: 'Monaco', 'Menlo', monospace;
224+
font-size: 12px;
225+
}
226+
227+
.event-log .event {
228+
padding: 4px 0;
229+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
230+
}
231+
232+
.event-log .event:last-child {
233+
border-bottom: none;
234+
}
235+
236+
.event-log .event-type {
237+
color: #4caf50;
238+
font-weight: bold;
239+
}
240+
241+
.event-log .event-data {
242+
color: #e0e0e0;
243+
}
244+
245+
.clear-log {
246+
background: rgba(244, 67, 54, 0.8);
247+
border: none;
248+
padding: 8px 16px;
249+
border-radius: 5px;
250+
color: white;
251+
cursor: pointer;
252+
font-size: 12px;
253+
margin-top: 10px;
254+
}
255+
256+
.clear-log:hover {
257+
background: rgba(244, 67, 54, 1);
258+
}
172259
</style>
173260
</head>
174261
<body>
@@ -193,21 +280,25 @@ <h1>🖥️ Ghostty Terminal</h1>
193280
<div id="terminal-container"></div>
194281
</div>
195282

196-
<div class="info-box">
197-
<h3>📚 Available Commands</h3>
198-
<ul>
199-
<li><code>ls</code>, <code>ls -la</code> - List directory contents</li>
200-
<li>
201-
<code>cd &lt;path&gt;</code> - Change directory (supports <code>~</code>,
202-
<code>/</code>, <code>..</code>)
203-
</li>
204-
<li><code>pwd</code> - Print working directory</li>
205-
<li><code>cat &lt;file&gt;</code> - Display file contents</li>
206-
<li><code>grep</code>, <code>find</code>, <code>tree</code> - Search and explore</li>
207-
<li><code>whoami</code>, <code>hostname</code>, <code>date</code> - System info</li>
208-
<li><code>clear</code> - Clear screen</li>
209-
<li><code>exit</code> - Close connection</li>
210-
</ul>
283+
<div class="feature-panel">
284+
<h3>🎯 Phase 1 Features - xterm.js API Parity</h3>
285+
<div class="button-grid">
286+
<button class="test-button" id="btn-paste">📋 Test paste()</button>
287+
<button class="test-button" id="btn-blur">👁️ Test blur()</button>
288+
<button class="test-button" id="btn-input">⌨️ Test input()</button>
289+
<button class="test-button" id="btn-select">🔤 Test select()</button>
290+
<button class="test-button" id="btn-selectLines">📄 Test selectLines()</button>
291+
<button class="test-button" id="btn-getSelectionPos">📍 Get Selection Position</button>
292+
<button class="test-button" id="btn-setTitle">🏷️ Set Title (OSC)</button>
293+
<button class="test-button" id="btn-customHandler">🔧 Toggle Custom Handler</button>
294+
</div>
295+
<div>
296+
<strong style="display: block; margin-bottom: 8px;">Event Log (onKey, onTitleChange):</strong>
297+
<div class="event-log" id="event-log">
298+
<div style="opacity: 0.5; text-align: center;">Events will appear here...</div>
299+
</div>
300+
<button class="clear-log" id="btn-clear-log">Clear Log</button>
301+
</div>
211302
</div>
212303

213304
<div class="warning">
@@ -532,6 +623,129 @@ <h3>📚 Available Commands</h3>
532623
// - Auto-copy to clipboard on selection
533624
// - Ctrl+C / Cmd+C to copy (via browser context menu)
534625

626+
// =========================================================================
627+
// Phase 1 Features - Event Logging & Handlers
628+
// =========================================================================
629+
630+
let customHandlerEnabled = false;
631+
632+
function logEvent(type, data) {
633+
const eventLog = document.getElementById('event-log');
634+
const eventDiv = document.createElement('div');
635+
eventDiv.className = 'event';
636+
637+
const timestamp = new Date().toLocaleTimeString();
638+
const dataStr = typeof data === 'object' ? JSON.stringify(data) : String(data);
639+
640+
eventDiv.innerHTML = `
641+
<span style="opacity: 0.6;">[${timestamp}]</span>
642+
<span class="event-type">${type}:</span>
643+
<span class="event-data">${dataStr}</span>
644+
`;
645+
646+
eventLog.appendChild(eventDiv);
647+
eventLog.scrollTop = eventLog.scrollHeight;
648+
649+
// Keep only last 50 events
650+
while (eventLog.children.length > 50) {
651+
eventLog.removeChild(eventLog.firstChild);
652+
}
653+
}
654+
655+
function setupPhase1Events() {
656+
// Event: onKey - fires on every keypress
657+
term.onKey((e) => {
658+
logEvent('onKey', `key="${e.key}" ctrl=${e.domEvent.ctrlKey} alt=${e.domEvent.altKey}`);
659+
});
660+
661+
// Event: onTitleChange - fires when terminal title changes via OSC sequences
662+
term.onTitleChange((title) => {
663+
logEvent('onTitleChange', title);
664+
document.title = `${title} - Ghostty Terminal`;
665+
});
666+
667+
// Button handlers
668+
document.getElementById('btn-paste').addEventListener('click', () => {
669+
term.paste('Pasted text from paste() method!\n');
670+
logEvent('Action', 'Called paste()');
671+
});
672+
673+
document.getElementById('btn-blur').addEventListener('click', () => {
674+
term.blur();
675+
logEvent('Action', 'Called blur() - terminal lost focus');
676+
});
677+
678+
document.getElementById('btn-input').addEventListener('click', () => {
679+
term.input('echo "Input from input() method"\n', true);
680+
logEvent('Action', 'Called input() with wasUserInput=true');
681+
});
682+
683+
document.getElementById('btn-select').addEventListener('click', () => {
684+
// Select 20 characters starting at column 0, row 0
685+
// This selects from the current cursor position backwards 20 chars
686+
const cursor = term.wasmTerm.getCursor();
687+
const startRow = Math.max(0, cursor.y - 1);
688+
term.select(0, startRow, 30);
689+
logEvent('Action', `Called select(0, ${startRow}, 30) - selects 30 chars from row ${startRow}`);
690+
});
691+
692+
document.getElementById('btn-selectLines').addEventListener('click', () => {
693+
// Select last 3 visible lines
694+
const cursor = term.wasmTerm.getCursor();
695+
const endRow = cursor.y;
696+
const startRow = Math.max(0, endRow - 2);
697+
term.selectLines(startRow, endRow);
698+
logEvent('Action', `Called selectLines(${startRow}, ${endRow}) - selects 3 lines`);
699+
});
700+
701+
document.getElementById('btn-getSelectionPos').addEventListener('click', () => {
702+
const pos = term.getSelectionPosition();
703+
if (pos) {
704+
logEvent('getSelectionPosition',
705+
`start=(${pos.start.x},${pos.start.y}) end=(${pos.end.x},${pos.end.y})`);
706+
} else {
707+
logEvent('getSelectionPosition', 'No selection');
708+
}
709+
});
710+
711+
document.getElementById('btn-setTitle').addEventListener('click', () => {
712+
// Send OSC 2 sequence to change title
713+
const newTitle = `Ghostty Terminal ${new Date().toLocaleTimeString()}`;
714+
term.write(`\x1b]2;${newTitle}\x07`);
715+
logEvent('Action', `Sent OSC 2 sequence with title: ${newTitle}`);
716+
});
717+
718+
document.getElementById('btn-customHandler').addEventListener('click', () => {
719+
customHandlerEnabled = !customHandlerEnabled;
720+
721+
if (customHandlerEnabled) {
722+
// Custom handler that blocks Ctrl+K
723+
term.attachCustomKeyEventHandler((event) => {
724+
if (event.ctrlKey && event.key === 'k') {
725+
logEvent('CustomHandler', 'Blocked Ctrl+K');
726+
return true; // Block default handling
727+
}
728+
return false; // Allow default handling
729+
});
730+
logEvent('Action', 'Custom handler enabled (blocks Ctrl+K)');
731+
document.getElementById('btn-customHandler').style.background = 'rgba(33, 150, 243, 0.8)';
732+
} else {
733+
term.attachCustomKeyEventHandler(undefined);
734+
logEvent('Action', 'Custom handler disabled');
735+
document.getElementById('btn-customHandler').style.background = 'rgba(76, 175, 80, 0.8)';
736+
}
737+
});
738+
739+
document.getElementById('btn-clear-log').addEventListener('click', () => {
740+
document.getElementById('event-log').innerHTML =
741+
'<div style="opacity: 0.5; text-align: center;">Log cleared</div>';
742+
});
743+
744+
// Expose terminal to console for debugging
745+
window.term = term;
746+
console.log('Terminal exposed to window.term for debugging');
747+
}
748+
535749
// =========================================================================
536750
// Initialization
537751
// =========================================================================
@@ -603,6 +817,9 @@ <h3>📚 Available Commands</h3>
603817
// Text selection is now built-in - no need to enable it!
604818
// You can use term.getSelection(), term.selectAll(), etc.
605819

820+
// Phase 1: Hook up new event listeners
821+
setupPhase1Events();
822+
606823
// Connect to WebSocket server
607824
connect();
608825
} catch (error) {

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export type {
1515
ITerminalCore,
1616
IDisposable,
1717
IEvent,
18+
IBufferRange,
19+
IKeyEvent,
1820
} from './interfaces';
1921

2022
// Ghostty WASM components (for advanced usage)

lib/input-handler.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import type { Ghostty } from './ghostty';
1717
import type { KeyEncoder } from './ghostty';
18+
import type { IKeyEvent } from './interfaces';
1819
import { Key, KeyAction, Mods } from './types';
1920

2021
/**
@@ -162,6 +163,8 @@ export class InputHandler {
162163
private container: HTMLElement;
163164
private onDataCallback: (data: string) => void;
164165
private onBellCallback: () => void;
166+
private onKeyCallback?: (keyEvent: IKeyEvent) => void;
167+
private customKeyEventHandler?: (event: KeyboardEvent) => boolean;
165168
private keydownListener: ((e: KeyboardEvent) => void) | null = null;
166169
private keypressListener: ((e: KeyboardEvent) => void) | null = null;
167170
private pasteListener: ((e: ClipboardEvent) => void) | null = null;
@@ -173,22 +176,35 @@ export class InputHandler {
173176
* @param container - DOM element to attach listeners to
174177
* @param onData - Callback for terminal data (escape sequences to send to PTY)
175178
* @param onBell - Callback for bell/beep event
179+
* @param onKey - Optional callback for raw key events
180+
* @param customKeyEventHandler - Optional custom key event handler
176181
*/
177182
constructor(
178183
ghostty: Ghostty,
179184
container: HTMLElement,
180185
onData: (data: string) => void,
181-
onBell: () => void
186+
onBell: () => void,
187+
onKey?: (keyEvent: IKeyEvent) => void,
188+
customKeyEventHandler?: (event: KeyboardEvent) => boolean
182189
) {
183190
this.encoder = ghostty.createKeyEncoder();
184191
this.container = container;
185192
this.onDataCallback = onData;
186193
this.onBellCallback = onBell;
194+
this.onKeyCallback = onKey;
195+
this.customKeyEventHandler = customKeyEventHandler;
187196

188197
// Attach event listeners
189198
this.attach();
190199
}
191200

201+
/**
202+
* Set custom key event handler (for runtime updates)
203+
*/
204+
setCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void {
205+
this.customKeyEventHandler = handler;
206+
}
207+
192208
/**
193209
* Attach keyboard event listeners to container
194210
*/
@@ -267,6 +283,21 @@ export class InputHandler {
267283
private handleKeyDown(event: KeyboardEvent): void {
268284
if (this.isDisposed) return;
269285

286+
// Emit onKey event first (before any processing)
287+
if (this.onKeyCallback) {
288+
this.onKeyCallback({ key: event.key, domEvent: event });
289+
}
290+
291+
// Check custom key event handler
292+
if (this.customKeyEventHandler) {
293+
const handled = this.customKeyEventHandler(event);
294+
if (handled) {
295+
// Custom handler consumed the event
296+
event.preventDefault();
297+
return;
298+
}
299+
}
300+
270301
// Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault)
271302
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') {
272303
// Let the browser's native paste event fire

lib/interfaces.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export interface ITerminalOptions {
1313
fontFamily?: string; // Default: 'monospace'
1414
allowTransparency?: boolean;
1515
wasmPath?: string; // Optional: custom WASM path (auto-detected by default)
16+
17+
// Phase 1 additions
18+
convertEol?: boolean; // Convert \n to \r\n (default: false)
19+
disableStdin?: boolean; // Disable keyboard input (default: false)
1620
}
1721

1822
export interface ITheme {
@@ -59,3 +63,19 @@ export interface ITerminalCore {
5963
element?: HTMLElement;
6064
textarea?: HTMLTextAreaElement;
6165
}
66+
67+
/**
68+
* Buffer range for selection coordinates
69+
*/
70+
export interface IBufferRange {
71+
start: { x: number; y: number };
72+
end: { x: number; y: number };
73+
}
74+
75+
/**
76+
* Keyboard event with key and DOM event
77+
*/
78+
export interface IKeyEvent {
79+
key: string;
80+
domEvent: KeyboardEvent;
81+
}

0 commit comments

Comments
 (0)