Skip to content

Commit 9791146

Browse files
authored
feat: improve xterm parity (#27)
- 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 9791146

9 files changed

Lines changed: 1189 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: 241 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,93 @@
169169
align-items: flex-start;
170170
}
171171
}
172+
173+
/* 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,27 @@ <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>🎯 xterm.js API Features</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"
297+
>Event Log (onKey, onTitleChange):</strong
298+
>
299+
<div class="event-log" id="event-log">
300+
<div style="opacity: 0.5; text-align: center">Events will appear here...</div>
301+
</div>
302+
<button class="clear-log" id="btn-clear-log">Clear Log</button>
303+
</div>
211304
</div>
212305

213306
<div class="warning">
@@ -532,6 +625,136 @@ <h3>📚 Available Commands</h3>
532625
// - Auto-copy to clipboard on selection
533626
// - Ctrl+C / Cmd+C to copy (via browser context menu)
534627

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

829+
// Hook up event listeners and test buttons
830+
setupEventHandlers();
831+
606832
// Connect to WebSocket server
607833
connect();
608834
} 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

0 commit comments

Comments
 (0)