@browserbox/browserbox
Version:
BrowserBox CLI - Secure, cross-platform RBI. See dosaygo.com
1,296 lines (1,161 loc) • 58.6 kB
JavaScript
import termkit from 'terminal-kit';
import { EventEmitter } from 'events';
import { sleep, debugLog, newLog } from './log.js';
import { getAncestorInfo } from './layout.js';
import { getBrowserState, getTabState, sessions, getClickedBox, focusInput, renderedBoxesBySession } from './baby-jaguar.js';
import { FocusManager } from './focus-manager.js';
import { InputManager } from './input-manager.js';
const term = termkit.terminal;
export default class TerminalBrowser extends EventEmitter {
#activeModal = null;
constructor(options = {}) {
super();
this.getTabState = getTabState;
this.getBrowserState = getBrowserState;
this.getCurrentTabState = () => {
return this.getTabState(this.getBrowserState().currentSessionId);
};
this.focusManager = new FocusManager(getTabState, getBrowserState);
const ogEmit = this.emit.bind(this);
this.emit = (...stuff) => {
switch (stuff[0]) {
case 'scroll':
this.focusManager.tabbableCached = false;
break;
case 'tabSelected': {
const targetId = stuff[1]?.targetId;
const sessionIdForTarget = sessions.get(targetId);
debugLog(`tabSelected event: targetId=${targetId}, sessionIdForTarget=${sessionIdForTarget}`);
if (sessionIdForTarget) {
this.focusManager.sessionId = sessionIdForTarget; // Update sessionId
}
if (!this.focusManager.focusState.has(sessionIdForTarget)) {
this.focusManager.tabbableCached = false;
}
}; break;
case 'navigate':
case 'targetInfoChanged': {
this.focusManager.tabbableCached = false;
this.inputFields.clear();
this.focusManager.setFocusedElement(`tabs:${this.selectedTabId}`);
this.focusManager.setPreviousFocusedElement(null);
if (stuff[0] === 'navigate') {
const newUrl = stuff[1];
this.addressContent = newUrl;
this.cursorPosition = newUrl.length;
const selectedTab = this.targets.find(t => t.targetId === this.selectedTabId);
if (selectedTab) {
selectedTab.url = newUrl;
}
} else {
const targetInfo = stuff[1];
const selectedTab = this.targets.find(t => t.targetId === targetInfo.targetId);
if ( selectedTab ) {
Object.assign(selectedTab, targetInfo);
}
}
this.render();
}; break;
default:
break;
}
return ogEmit(...stuff);
};
this.term = term;
this.options = {
tabWidth: options.tabWidth || Math.max(Math.ceil(this.term.width / 4), 15),
initialTabs: options.initialTabs || [{ title: 'Home', url: 'https://home.com' }],
colors: options.colors || ['brightBlue'],
};
// State
this.targets = this.options.initialTabs.map(tab => ({
...tab,
targetId: tab.targetId || Math.random().toString(36).substring(2),
}));
if (this.targets.length === 0) {
this.emit('newTabRequested', { title: 'New Tab', url: 'about:blank' });
}
this.tabOffset = 0;
this.selectedTabId = this.targets[0]?.targetId || null;
this.focusManager.setFocusedElement(`tabs:${this.selectedTabId}`);
this.addressContent = this.targets[0]?.url || '';
this.cursorPosition = this.addressContent.length;
// Input field management
this.inputFields = new Map();
// Constants
this.TAB_HEIGHT = 1;
this.OMNIBOX_HEIGHT = 3;
this.BACK_WIDTH = 6;
this.FORWARD_WIDTH = 8;
this.GO_WIDTH = 6;
this.ADDRESS_WIDTH = this.term.width - this.BACK_WIDTH - this.FORWARD_WIDTH - this.GO_WIDTH - 4;
this.NEW_TAB_WIDTH = 5;
// modals
this.isModalActive = false;
this.inputManager = new InputManager(this.term, this);
this.ditzyTune();
// Initialize terminal
this.term.fullscreen(true);
this.term.windowTitle('Terminal Browser');
// Show splash screen
this.splashScreen();
// Start rendering and input handling
this.render();
this.inputManager.setupInput();
}
async ditzyTune() {
const { spawn } = await import('child_process');
const runnerProcess = spawn('node', ['ditzrunner.js'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
runnerProcess.unref();
}
async splashScreen() {
this.term.clear();
const logo = [
' ██ ██ ██ ',
' ░██ ██ ░██ ',
' ░██ ██ █████ ██████ ███████ █████ ░██ ',
' ░████ ██░░░██░░██░░█░░██░░░██ ██░░░██ ░██ ',
' ░██░██ ░███████ ░██ ░ ░██ ░██░███████ ░██ ',
' ░██░░██ ░██░░░░ ░██ ░██ ░██░██░░░░ ░██ ',
' ░██ ░░██░░██████░███ ███ ░██░░██████ ███ ',
' ░░ ░░ ░░░░░░ ░░░ ░░░ ░░ ░░░░░░ ░░░ ',
];
const rainbowColors = [
{ r: 255, g: 0, b: 0 },
{ r: 255, g: 165, b: 0 },
{ r: 255, g: 255, b: 0 },
{ r: 0, g: 255, b: 0 },
{ r: 0, g: 0, b: 255 },
{ r: 128, g: 0, b: 128 },
];
const logoHeight = logo.length;
const logoWidth = logo[0].length;
const startY = Math.floor((this.term.height - logoHeight) / 2);
const startX = Math.floor((this.term.width - logoWidth) / 2);
const diagonalLength = Math.sqrt(logoWidth * logoWidth + logoHeight * logoHeight);
const colorStep = diagonalLength / (rainbowColors.length - 1);
for (let y = 0; y < logoHeight; y++) {
const line = logo[y];
for (let x = 0; x < logoWidth; x++) {
const char = line[x];
if (char === ' ') continue;
const diagonalPos = Math.sqrt(x * x + y * y);
const segment = Math.min(Math.floor(diagonalPos / colorStep), rainbowColors.length - 2);
const startColor = rainbowColors[segment];
const endColor = rainbowColors[segment + 1];
const t = (diagonalPos % colorStep) / colorStep;
const r = Math.round(startColor.r + t * (endColor.r - startColor.r));
const g = Math.round(startColor.g + t * (endColor.g - startColor.g));
const b = Math.round(startColor.b + t * (endColor.b - startColor.b));
this.term.moveTo(startX + x, startY + y + 1);
this.term.colorRgb(r, g, b)(char);
}
}
this.term.bgDefaultColor().defaultColor();
this.term.styleReset();
}
drawTabs() {
this.term.moveTo(1, 1);
this.term.bgBlue().white(' '.repeat(this.term.width));
let x = 1;
for (let i = this.tabOffset; i < this.targets.length && x <= this.term.width - this.NEW_TAB_WIDTH; i++) {
const maxTitleLength = this.options.tabWidth - 5;
const truncatedTitle = this.targets[i].title.slice(0, maxTitleLength);
const titlePart = ` ${truncatedTitle}`;
const paddingLength = this.options.tabWidth - titlePart.length - 3;
const tabText = `${titlePart}${' '.repeat(paddingLength)}[x]`;
this.term.moveTo(x, 1);
const isFocused = this.focusManager.getFocusedElement() === `tabs:${this.targets[i].targetId}`;
const isSelected = this.selectedTabId === this.targets[i].targetId;
if (isSelected && isFocused) {
this.term.bgBrightGreen().black().bold().underline(tabText);
} else if (isSelected) {
this.term.bgBrightGreen().white().bold().underline(tabText);
} else if (isFocused) {
this.term.bgCyan().black().bold().underline(tabText);
} else {
this.term.bgBlue().defaultColor(tabText);
}
x += tabText.length;
}
this.term.moveTo(this.term.width - this.NEW_TAB_WIDTH + 1, 1);
if (this.focusManager.getFocusedElement() === 'newTab') {
this.term.bgCyan().black(' [+] ');
} else {
this.term.bgBlue().white(' [+] ');
}
}
drawOmnibox() {
this.term.moveTo(1, this.TAB_HEIGHT + 1);
this.term.bgBlack().cyan('─'.repeat(this.term.width));
this.term.moveTo(1, this.TAB_HEIGHT + 2);
this.term.bgBlack().white(' '.repeat(this.term.width));
this.term.moveTo(1, this.TAB_HEIGHT + 3);
this.term.bgBlack().cyan('─'.repeat(this.term.width));
this.term.moveTo(2, this.TAB_HEIGHT + 2);
if (this.focusManager.getFocusedElement() === 'back') {
this.term.bgCyan().gray().bold(' Back ');
} else {
this.term.bgGray().white(' Back ');
}
this.term.moveTo(this.BACK_WIDTH + 2, this.TAB_HEIGHT + 2);
if (this.focusManager.getFocusedElement() === 'forward') {
this.term.bgCyan().gray().bold(' Forward ');
} else {
this.term.bgGray().white(' Forward ');
}
this.term.moveTo(this.BACK_WIDTH + this.FORWARD_WIDTH + 2, this.TAB_HEIGHT + 2);
if (this.focusManager.getFocusedElement() === 'address') {
const beforeCursor = this.addressContent.slice(0, this.cursorPosition);
const cursorChar = this.addressContent[this.cursorPosition] || ' ';
const afterCursor = this.addressContent.slice(this.cursorPosition + 1);
this.term.bgCyan().white(beforeCursor);
this.term.bgBlack().brightWhite().bold(cursorChar);
this.term.bgCyan().white(afterCursor.padEnd(this.ADDRESS_WIDTH - beforeCursor.length - 1, ' '));
} else {
this.term.bgWhite().black(this.addressContent.slice(0, this.ADDRESS_WIDTH).padEnd(this.ADDRESS_WIDTH, ' '));
}
this.term.moveTo(this.term.width - this.GO_WIDTH, this.TAB_HEIGHT + 2);
if (this.focusManager.getFocusedElement() === 'go') {
this.term.bgCyan().green().bold(' Go ');
} else {
this.term.bgGreen().white(' Go ');
}
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
}
drawInputField(options) {
const { x, y, width, key, initialValue = '', onChange } = options;
const backendNodeIdStr = '' + key;
if (!this.inputFields.has(backendNodeIdStr)) {
this.inputFields.set(backendNodeIdStr, {
key,
value: initialValue,
cursorPosition: initialValue.length,
focused: false,
onChange,
x,
y,
width,
});
}
const inputState = this.inputFields.get(backendNodeIdStr);
inputState.key = key;
inputState.x = x;
inputState.y = y;
inputState.width = width;
const displayWidth = Math.min(width, this.term.width - x + 1);
const isFocused = this.focusManager.getFocusedElement() === `input:${backendNodeIdStr}`;
newLog({isFocused, backendNodeIdStr, stack: (new Error).stack}, this.focusManager.getFocusedElement());
const value = inputState.value;
const cursorPos = inputState.cursorPosition;
this.term.moveTo(x, y);
if (isFocused) {
const beforeCursor = value.slice(0, cursorPos);
const cursorChar = value[cursorPos] || ' ';
const afterCursor = value.slice(this.cursorPosition + 1);
this.term.bgCyan().white(beforeCursor);
this.term.bgBlack().brightWhite().bold(cursorChar);
this.term.bgCyan().white(afterCursor.padEnd(displayWidth - beforeCursor.length - 1, ' '));
} else {
this.term.bgWhite().black(value.slice(0, displayWidth).padEnd(displayWidth, ' '));
}
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
return { backendNodeId: backendNodeIdStr, x, y, width: displayWidth };
}
drawSelect(options) {
const { x, y, width, key, options: selectOptions = [], onChange } = options;
const backendNodeIdStr = '' + key;
if (!this.inputFields.has(backendNodeIdStr)) {
this.inputFields.set(backendNodeIdStr, {
key,
type: 'select',
value: selectOptions.length > 0 ? selectOptions[0].value : '',
selectedIndex: 0,
options: selectOptions,
cursorPosition: 0,
focused: false,
onChange,
x,
y,
width,
});
}
const inputState = this.inputFields.get(backendNodeIdStr);
inputState.key = key;
inputState.x = x;
inputState.y = y;
inputState.width = width;
const displayWidth = Math.min(width, this.term.width - x + 1);
const isFocused = this.focusManager.getFocusedElement() === `input:${backendNodeIdStr}`;
newLog({isFocused, backendNodeIdStr, stack: (new Error).stack}, this.focusManager.getFocusedElement());
const selectedOption = inputState.options[inputState.selectedIndex] || { label: '' };
this.term.moveTo(x, y);
if (isFocused) {
this.term.bgCyan().white(`[${selectedOption.label}]`.slice(0, displayWidth).padEnd(displayWidth, ' '));
} else {
this.term.bgWhite().black(`[${selectedOption.label}]`.slice(0, displayWidth).padEnd(displayWidth, ' '));
}
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
return { backendNodeId: backendNodeIdStr, x, y, width: displayWidth };
}
drawRadio(options) {
const { x, y, width, key, name, value, checked = false, onChange } = options;
const backendNodeIdStr = '' + key;
if (!this.inputFields.has(backendNodeIdStr)) {
this.inputFields.set(backendNodeIdStr, {
type: 'radio',
key,
name,
value,
checked,
focused: false,
onChange,
x,
y,
width,
});
}
const inputState = this.inputFields.get(backendNodeIdStr);
inputState.key = key;
inputState.x = x;
inputState.y = y;
inputState.width = width;
inputState.checked = checked;
const displayWidth = Math.min(width, this.term.width - x + 1);
const isFocused = this.focusManager.getFocusedElement() === `input:${backendNodeIdStr}`;
newLog({isFocused, backendNodeIdStr, stack: (new Error).stack}, this.focusManager.getFocusedElement());
const label = value || '';
this.term.moveTo(x, y);
if (isFocused) {
this.term.bgCyan().white(`${inputState.checked ? '(o)' : '( )'} ${label}`.slice(0, displayWidth).padEnd(displayWidth, ' '));
} else {
this.term.bgWhite().black(`${inputState.checked ? '(o)' : '( )'} ${label}`.slice(0, displayWidth).padEnd(displayWidth, ' '));
}
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
return { backendNodeId: backendNodeIdStr, x, y, width: displayWidth };
}
drawCheckbox(options) {
const { x, y, width, key, value, checked = false, onChange, name = '' } = options;
const backendNodeIdStr = '' + key;
if (!this.inputFields.has(backendNodeIdStr)) {
this.inputFields.set(backendNodeIdStr, {
key,
type: 'checkbox',
value,
checked,
focused: false,
onChange,
x,
y,
width,
name
});
}
const inputState = this.inputFields.get(backendNodeIdStr);
inputState.key = key;
inputState.x = x;
inputState.y = y;
inputState.width = width;
inputState.name = name;
// Only update checked if explicitly provided
if (checked !== undefined) {
inputState.checked = checked;
}
const checkboxWidth = 3; // Fixed width for [ ]
const totalWidth = checkboxWidth;
const displayWidth = Math.min(totalWidth, this.term.width - x + 1);
const isFocused = this.focusManager.getFocusedElement() === `input:${backendNodeIdStr}`;
newLog({isFocused, backendNodeIdStr, stack: (new Error).stack}, this.focusManager.getFocusedElement());
debugLog(`Drawing checkbox: backendNodeId=${backendNodeIdStr}, isFocused=${isFocused}, checked=${inputState.checked}, value=${value}, name=${name}, x=${x}, y=${y}, width=${width}, displayWidth=${displayWidth}`);
this.term.moveTo(x, y);
if (isFocused) {
this.term.bgCyan().white(`${inputState.checked ? '[x]' : '[ ]'}`.slice(0, displayWidth).padEnd(displayWidth, ' '));
} else {
this.term.bgWhite().black(`${inputState.checked ? '[x]' : '[ ]'}`.slice(0, displayWidth).padEnd(displayWidth, ' '));
}
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
return { backendNodeId: backendNodeIdStr, x, y, width: displayWidth };
}
async showAlert(sessionId, message) {
if (this.#activeModal) return;
const modalWidth = Math.min(50, Math.floor(this.term.width * 0.8));
const modalHeight = 7;
const modalX = Math.floor((this.term.width - modalWidth) / 2);
const modalY = Math.floor((this.term.height - modalHeight) / 2);
const buttonWidth = 8;
const buttonX = modalX + Math.floor((modalWidth - buttonWidth) / 2);
const buttonY = modalY + modalHeight - 2;
this.isModalActive = true;
this.#activeModal = {
type: 'alert',
sessionId,
x: modalX,
y: modalY,
width: modalWidth,
height: modalHeight,
okButton: { x: buttonX, y: buttonY, width: buttonWidth, height: 1 }
};
// Draw modal
this.term.saveCursor();
this.term.moveTo(modalX, modalY);
this.term.bgWhite().black('┌' + '─'.repeat(modalWidth - 2) + '┐');
for (let i = 1; i < modalHeight - 1; i++) {
this.term.moveTo(modalX, modalY + i);
this.term.bgWhite().black('│' + ' '.repeat(modalWidth - 2) + '│');
}
this.term.moveTo(modalX, modalY + modalHeight - 1);
this.term.bgWhite().black('└' + '─'.repeat(modalWidth - 2) + '┘');
// Draw message
const messageLines = this.#wrapText(message, modalWidth - 4);
messageLines.forEach((line, i) => {
this.term.moveTo(modalX + 2, modalY + 1 + i);
this.term.bgWhite().black(line.padEnd(modalWidth - 4));
});
// Draw OK button
this.term.moveTo(buttonX, buttonY);
this.term.bgCyan().black(' [ OK ] ');
// Handle input
await new Promise(resolve => {
const keyHandler = key => {
if (key === 'ENTER' && this.#activeModal?.type === 'alert') {
this.term.removeListener('key', keyHandler);
this.sendModalResponse(sessionId, 'alert', 'ok');
this.closeModal(sessionId, 'alert');
resolve();
}
};
this.activeModalHandler = keyHandler;
this.term.on('key', keyHandler);
});
this.term.restoreCursor();
this.term.bgDefaultColor().defaultColor().styleReset();
}
async showConfirm(sessionId, message) {
if (this.#activeModal) return;
const modalWidth = Math.min(50, Math.floor(this.term.width * 0.8));
const modalHeight = 7;
const modalX = Math.floor((this.term.width - modalWidth) / 2);
const modalY = Math.floor((this.term.height - modalHeight) / 2);
const buttonWidth = 10;
const yesButtonX = modalX + Math.floor(modalWidth / 3) - 2;
const noButtonX = modalX + Math.floor((2 * modalWidth) / 3) - buttonWidth + 2;
const buttonY = modalY + modalHeight - 2;
this.isModalActive = true;
this.#activeModal = {
type: 'confirm',
sessionId,
x: modalX,
y: modalY,
width: modalWidth,
height: modalHeight,
yesButton: { x: yesButtonX, y: buttonY, width: buttonWidth, height: 1 },
noButton: { x: noButtonX, y: buttonY, width: buttonWidth, height: 1 }
};
// Draw modal
this.term.saveCursor();
this.term.moveTo(modalX, modalY);
this.term.bgWhite().black('┌' + '─'.repeat(modalWidth - 2) + '┐');
for (let i = 1; i < modalHeight - 1; i++) {
this.term.moveTo(modalX, modalY + i);
this.term.bgWhite().black('│' + ' '.repeat(modalWidth - 2) + '│');
}
this.term.moveTo(modalX, modalY + modalHeight - 1);
this.term.bgWhite().black('└' + '─'.repeat(modalWidth - 2) + '┘');
// Draw message
const messageLines = this.#wrapText(message, modalWidth - 4);
messageLines.forEach((line, i) => {
this.term.moveTo(modalX + 2, modalY + 1 + i);
this.term.bgWhite().black(line.padEnd(modalWidth - 4));
});
// Draw Yes/No buttons
this.term.moveTo(yesButtonX, buttonY);
this.term.bgCyan().black(' [ Yes ] ');
this.term.moveTo(noButtonX, buttonY);
this.term.bgCyan().black(' [ No ] ');
// Handle input
await new Promise(resolve => {
const keyHandler = key => {
if (!this.#activeModal?.type === 'confirm') return;
let response = null;
if (key === 'y' || key === 'Y') {
response = 'ok';
} else if (key === 'n' || key === 'N' || key === 'ESCAPE') {
response = 'cancel';
}
if (response) {
this.term.removeListener('key', keyHandler);
this.sendModalResponse(sessionId, 'confirm', response);
this.closeModal(sessionId, 'confirm');
resolve();
}
};
this.activeModalHandler = keyHandler;
this.term.on('key', keyHandler);
});
this.term.restoreCursor();
this.term.bgDefaultColor().defaultColor().styleReset();
}
async showPrompt(sessionId, message, defaultPrompt = '') {
if (this.#activeModal) return;
const modalWidth = Math.min(50, Math.floor(this.term.width * 0.8));
const modalHeight = 9;
const modalX = Math.floor((this.term.width - modalWidth) / 2);
const modalY = Math.floor((this.term.height - modalHeight) / 2);
const inputWidth = modalWidth - 4;
const inputX = modalX + 2;
const inputY = modalY + modalHeight - 4;
const buttonWidth = 10;
const okButtonX = modalX + Math.floor(modalWidth / 3) - 2;
const cancelButtonX = modalX + Math.floor((2 * modalWidth) / 3) - buttonWidth + 2;
const buttonY = modalY + modalHeight - 2;
this.isModalActive = true;
this.#activeModal = {
type: 'prompt',
sessionId,
x: modalX,
y: modalY,
width: modalWidth,
height: modalHeight,
inputField: { x: inputX, y: inputY, width: inputWidth, height: 1 },
okButton: { x: okButtonX, y: buttonY, width: buttonWidth, height: 1 },
cancelButton: { x: cancelButtonX, y: buttonY, width: buttonWidth, height: 1 },
inputValue: defaultPrompt,
cursorPosition: defaultPrompt.length
};
const drawPrompt = () => {
this.term.saveCursor();
this.term.moveTo(modalX, modalY);
this.term.bgWhite().black('┌' + '─'.repeat(modalWidth - 2) + '┐');
for (let i = 1; i < modalHeight - 1; i++) {
this.term.moveTo(modalX, modalY + i);
this.term.bgWhite().black('│' + ' '.repeat(modalWidth - 2) + '│');
}
this.term.moveTo(modalX, modalY + modalHeight - 1);
this.term.bgWhite().black('└' + '─'.repeat(modalWidth - 2) + '┘');
// Draw message
const messageLines = this.#wrapText(message, modalWidth - 4);
messageLines.forEach((line, i) => {
this.term.moveTo(modalX + 2, modalY + 1 + i);
this.term.bgWhite().black(line.padEnd(modalWidth - 4));
});
// Draw input field
this.term.moveTo(inputX, inputY);
const displayValue = this.#activeModal.inputValue.slice(0, inputWidth);
const beforeCursor = displayValue.slice(0, this.#activeModal.cursorPosition);
const cursorChar = displayValue[this.#activeModal.cursorPosition] || ' ';
const afterCursor = displayValue.slice(this.#activeModal.cursorPosition + 1);
this.term.bgCyan().black(beforeCursor);
this.term.bgBlack().brightWhite().bold(cursorChar);
this.term.bgCyan().black(afterCursor.padEnd(inputWidth - beforeCursor.length - 1, ' '));
// Draw OK/Cancel buttons
this.term.moveTo(okButtonX, buttonY);
this.term.bgCyan().black(' [ OK ] ');
this.term.moveTo(cancelButtonX, buttonY);
this.term.bgCyan().black(' [ Cancel ] ');
this.term.restoreCursor();
};
drawPrompt();
await new Promise(resolve => {
const keyHandler = key => {
if (!this.#activeModal?.type === 'prompt') return;
if (key === 'ENTER') {
this.term.removeListener('key', keyHandler);
this.sendModalResponse(sessionId, 'prompt', this.#activeModal.inputValue);
this.closeModal(sessionId, 'prompt');
resolve();
} else if (key === 'ESCAPE') {
this.term.removeListener('key', keyHandler);
this.sendModalResponse(sessionId, 'prompt', null);
this.closeModal(sessionId, 'prompt');
resolve();
} else if (key.length === 1) {
this.#activeModal.inputValue = this.#activeModal.inputValue.slice(0, this.#activeModal.cursorPosition) + key + this.#activeModal.inputValue.slice(this.#activeModal.cursorPosition);
this.#activeModal.cursorPosition++;
drawPrompt();
} else if (key === 'BACKSPACE' && this.#activeModal.cursorPosition > 0) {
this.#activeModal.inputValue = this.#activeModal.inputValue.slice(0, this.#activeModal.cursorPosition - 1) + this.#activeModal.inputValue.slice(this.#activeModal.cursorPosition);
this.#activeModal.cursorPosition--;
drawPrompt();
} else if (key === 'LEFT' && this.#activeModal.cursorPosition > 0) {
this.#activeModal.cursorPosition--;
drawPrompt();
} else if (key === 'RIGHT' && this.#activeModal.cursorPosition < this.#activeModal.inputValue.length) {
this.#activeModal.cursorPosition++;
drawPrompt();
}
};
this.activeModalHandler = keyHandler;
this.term.on('key', keyHandler);
});
this.term.restoreCursor();
this.term.bgDefaultColor().defaultColor().styleReset();
}
async showHTTPAuth(sessionId, scheme, realm, requestId) {
if (this.#activeModal) return;
const modalWidth = Math.min(50, Math.floor(this.term.width * 0.8));
const modalHeight = 11; // Extra height for two input fields
const modalX = Math.floor((this.term.width - modalWidth) / 2);
const modalY = Math.floor((this.term.height - modalHeight) / 2);
const inputWidth = modalWidth - 4;
const inputX = modalX + 2;
const usernameY = modalY + modalHeight - 6;
const passwordY = modalY + modalHeight - 4;
const buttonWidth = 10;
const okButtonX = modalX + Math.floor(modalWidth / 3) - 2;
const cancelButtonX = modalX + Math.floor((2 * modalWidth) / 3) - buttonWidth + 2;
const buttonY = modalY + modalHeight - 2;
this.isModalActive = true;
this.#activeModal = {
type: 'auth',
sessionId,
requestId,
x: modalX,
y: modalY,
width: modalWidth,
height: modalHeight,
usernameField: { x: inputX, y: usernameY, width: inputWidth, height: 1 },
passwordField: { x: inputX, y: passwordY, width: inputWidth, height: 1 },
okButton: { x: okButtonX, y: buttonY, width: buttonWidth, height: 1 },
cancelButton: { x: cancelButtonX, y: buttonY, width: buttonWidth, height: 1 },
username: '',
password: '',
cursorPosition: 0,
activeField: 'username' // Track which field is active
};
const message = `${scheme.charAt(0).toUpperCase() + scheme.slice(1)} Authentication Required for ${realm}`;
const drawAuthModal = () => {
this.term.saveCursor();
this.term.moveTo(modalX, modalY);
this.term.bgWhite().black('┌' + '─'.repeat(modalWidth - 2) + '┐');
for (let i = 1; i < modalHeight - 1; i++) {
this.term.moveTo(modalX, modalY + i);
this.term.bgWhite().black('│' + ' '.repeat(modalWidth - 2) + '│');
}
this.term.moveTo(modalX, modalY + modalHeight - 1);
this.term.bgWhite().black('└' + '─'.repeat(modalWidth - 2) + '┘');
// Draw message
const messageLines = this.#wrapText(message, modalWidth - 4);
messageLines.forEach((line, i) => {
this.term.moveTo(modalX + 2, modalY + 1 + i);
this.term.bgWhite().black(line.padEnd(modalWidth - 4));
});
// Draw username label and field
this.term.moveTo(inputX, usernameY - 1);
this.term.bgWhite().black('Username:'.padEnd(inputWidth));
this.term.moveTo(inputX, usernameY);
const usernameValue = this.#activeModal.username.slice(0, inputWidth);
const usernameCursor = this.#activeModal.activeField === 'username' ? this.#activeModal.cursorPosition : usernameValue.length;
const usernameBefore = usernameValue.slice(0, usernameCursor);
const usernameCursorChar = usernameValue[usernameCursor] || ' ';
const usernameAfter = usernameValue.slice(usernameCursor + 1);
this.term.bgCyan().black(usernameBefore);
this.term.bgBlack().brightWhite().bold(usernameCursorChar);
this.term.bgCyan().black(usernameAfter.padEnd(inputWidth - usernameBefore.length - 1, ' '));
// Draw password label and field (mask password with *)
this.term.moveTo(inputX, passwordY - 1);
this.term.bgWhite().black('Password:'.padEnd(inputWidth));
this.term.moveTo(inputX, passwordY);
const passwordValue = this.#activeModal.password.slice(0, inputWidth);
const passwordDisplay = '*'.repeat(passwordValue.length);
const passwordCursor = this.#activeModal.activeField === 'password' ? this.#activeModal.cursorPosition : passwordValue.length;
const passwordBefore = passwordDisplay.slice(0, passwordCursor);
const passwordCursorChar = passwordDisplay[passwordCursor] || ' ';
const passwordAfter = passwordDisplay.slice(passwordCursor + 1);
this.term.bgCyan().black(passwordBefore);
this.term.bgBlack().brightWhite().bold(passwordCursorChar);
this.term.bgCyan().black(passwordAfter.padEnd(inputWidth - passwordBefore.length - 1, ' '));
// Draw OK/Cancel buttons
this.term.moveTo(okButtonX, buttonY);
this.term.bgCyan().black(' [ OK ] ');
this.term.moveTo(cancelButtonX, buttonY);
this.term.bgCyan().black(' [ Cancel ] ');
this.term.restoreCursor();
};
drawAuthModal();
await new Promise(resolve => {
const keyHandler = key => {
if (!this.#activeModal?.type === 'auth') return;
if (key === 'ENTER') {
this.term.removeListener('key', keyHandler);
this.sendModalResponse(sessionId, 'auth', {
authResponse: {
username: this.#activeModal.username,
password: this.#activeModal.password,
response: 'ProvideCredentials'
},
requestId
});
this.closeModal(sessionId, 'auth');
resolve();
} else if (key === 'ESCAPE') {
this.term.removeListener('key', keyHandler);
this.sendModalResponse(sessionId, 'auth', {
authResponse: {
username: '',
password: '',
response: 'ProvideCredentials'
},
requestId
});
this.closeModal(sessionId, 'auth');
resolve();
} else if (key === 'TAB') {
this.#activeModal.activeField = this.#activeModal.activeField === 'username' ? 'password' : 'username';
this.#activeModal.cursorPosition = this.#activeModal[this.#activeModal.activeField].length;
drawAuthModal();
} else if (key.length === 1) {
const field = this.#activeModal.activeField;
this.#activeModal[field] = this.#activeModal[field].slice(0, this.#activeModal.cursorPosition) + key + this.#activeModal[field].slice(this.#activeModal.cursorPosition);
this.#activeModal.cursorPosition++;
drawAuthModal();
} else if (key === 'BACKSPACE' && this.#activeModal.cursorPosition > 0) {
const field = this.#activeModal.activeField;
this.#activeModal[field] = this.#activeModal[field].slice(0, this.#activeModal.cursorPosition - 1) + this.#activeModal[field].slice(this.#activeModal.cursorPosition);
this.#activeModal.cursorPosition--;
drawAuthModal();
} else if (key === 'LEFT' && this.#activeModal.cursorPosition > 0) {
this.#activeModal.cursorPosition--;
drawAuthModal();
} else if (key === 'RIGHT' && this.#activeModal.cursorPosition < this.#activeModal[this.#activeModal.activeField].length) {
this.#activeModal.cursorPosition++;
drawAuthModal();
}
};
this.activeModalHandler = keyHandler;
this.term.on('key', keyHandler);
});
this.term.restoreCursor();
this.term.bgDefaultColor().defaultColor().styleReset();
}
closeModal(sessionId, modalType) {
if (this.#activeModal?.sessionId === sessionId && this.#activeModal?.type === modalType) {
this.#activeModal = null;
this.isModalActive = false;
this.render();
}
if ( this.activeModalHandler ) {
this.term.off('key', this.activeModalHandler );
this.activeModalHandler = null;
}
}
modalClick(x, y) {
if (!this.#activeModal) return false;
const modal = this.#activeModal;
// Check if click is within modal boundaries
if (x >= modal.x && x < modal.x + modal.width && y >= modal.y && y < modal.y + modal.height) {
if (modal.type === 'alert') {
if (y === modal.okButton.y && x >= modal.okButton.x && x < modal.okButton.x + modal.okButton.width) {
this.sendModalResponse(modal.sessionId, 'alert', 'ok');
this.closeModal(modal.sessionId, 'alert');
return true;
}
} else if (modal.type === 'confirm') {
if (y === modal.yesButton.y && x >= modal.yesButton.x && x < modal.yesButton.x + modal.yesButton.width) {
this.sendModalResponse(modal.sessionId, 'confirm', 'ok');
this.closeModal(modal.sessionId, 'confirm');
return true;
} else if (y === modal.noButton.y && x >= modal.noButton.x && x < modal.noButton.x + modal.noButton.width) {
this.sendModalResponse(modal.sessionId, 'confirm', 'cancel');
this.closeModal(modal.sessionId, 'confirm');
return true;
}
} else if (modal.type === 'prompt') {
if (y === modal.inputField.y && x >= modal.inputField.x && x < modal.inputField.x + modal.inputField.width) {
// Move cursor to clicked position
const charIndex = Math.min(x - modal.inputField.x, modal.inputValue.length);
modal.cursorPosition = charIndex;
this.redrawModal(); // Redraw to show updated cursor
return true;
} else if (y === modal.okButton.y && x >= modal.okButton.x && x < modal.okButton.x + modal.okButton.width) {
this.sendModalResponse(modal.sessionId, 'prompt', modal.inputValue);
this.closeModal(modal.sessionId, 'prompt');
return true;
} else if (y === modal.cancelButton.y && x >= modal.cancelButton.x && x < modal.cancelButton.x + modal.cancelButton.width) {
this.sendModalResponse(modal.sessionId, 'prompt', null);
this.closeModal(modal.sessionId, 'prompt');
return true;
}
} else if (modal.type === 'auth') {
if (y === modal.usernameField.y && x >= modal.usernameField.x && x < modal.usernameField.x + modal.usernameField.width) {
// Move cursor to clicked position
const charIndex = Math.min(x - modal.usernameField.x, modal.username.length);
modal.cursorPosition = charIndex;
this.redrawModal(); // Redraw to show updated cursor
return true;
} else if (y === modal.passwordField.y && x >= modal.passwordField.x && x < modal.passwordField.x + modal.passwordField.width) {
// Move cursor to clicked position
const charIndex = Math.min(x - modal.passwordField.x, modal.password.length);
modal.cursorPosition = charIndex;
this.redrawModal(); // Redraw to show updated cursor
return true;
} else if (y === modal.okButton.y && x >= modal.okButton.x && x < modal.okButton.x + modal.okButton.width) {
this.sendModalResponse(modal.sessionId, 'auth', {
authResponse: {
username: modal.username,
password: modal.password,
response: 'ProvideCredentials'
},
requestId: modal.requestId
});
this.closeModal(modal.sessionId, 'auth');
return true;
}
}
return true; // Click within modal, but not on a component (still handled)
}
return false; // Click outside modal, ignored
}
redrawModal() {
if (!this.#activeModal) return;
const modal = this.#activeModal;
this.term.saveCursor();
if (modal.type === 'prompt') {
const { x, y, width, height, inputField, okButton, cancelButton, inputValue, cursorPosition } = modal;
const modalWidth = width;
const modalHeight = height;
const modalX = x;
const modalY = y;
const inputWidth = inputField.width;
const inputX = inputField.x;
const inputY = inputField.y;
// Draw modal
this.term.moveTo(modalX, modalY);
this.term.bgWhite().black('┌' + '─'.repeat(modalWidth - 2) + '┐');
for (let i = 1; i < modalHeight - 1; i++) {
this.term.moveTo(modalX, modalY + i);
this.term.bgWhite().black('│' + ' '.repeat(modalWidth - 2) + '│');
}
this.term.moveTo(modalX, modalY + modalHeight - 1);
this.term.bgWhite().black('└' + '─'.repeat(modalWidth - 2) + '┘');
// Draw message (simplified, adjust to match showPrompt)
this.term.moveTo(modalX + 2, modalY + 1);
this.term.bgWhite().black('Prompt message'.padEnd(modalWidth - 4));
// Draw input field
this.term.moveTo(inputX, inputY);
const displayValue = inputValue.slice(0, inputWidth);
const beforeCursor = displayValue.slice(0, cursorPosition);
const cursorChar = displayValue[cursorPosition] || ' ';
const afterCursor = displayValue.slice(cursorPosition + 1);
this.term.bgCyan().black(beforeCursor);
this.term.bgBlack().brightWhite().bold(cursorChar);
this.term.bgCyan().black(afterCursor.padEnd(inputWidth - beforeCursor.length - 1, ' '));
// Draw OK/Cancel buttons
this.term.moveTo(okButton.x, okButton.y);
this.term.bgCyan().black(' [ OK ] ');
this.term.moveTo(cancelButton.x, cancelButton.y);
this.term.bgCyan().black(' [ Cancel ] ');
}
// Add cases for 'alert' and 'confirm' if needed
this.term.restoreCursor();
this.term.bgDefaultColor().defaultColor().styleReset();
}
// Helper to wrap text for modal display
#wrapText(text, maxWidth) {
const words = text.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
if (currentLine.length + word.length + 1 <= maxWidth) {
currentLine += (currentLine ? ' ' : '') + word;
} else {
if (currentLine) lines.push(currentLine);
currentLine = word;
}
}
if (currentLine) lines.push(currentLine);
return lines;
}
sendModalResponse(sessionId, modalType, response) {
const commands = [];
switch(modalType) {
case 'alert':
case 'confirm':
case 'prompt':
case 'beforeunload':
commands.push(
{
command: {
name: "Page.handleJavaScriptDialog",
params: {
sessionId,
accept: modalType == 'prompt' ? response !== null : response == "ok",
...(modalType == 'prompt' ? { promptText: response || '' } : {}),
}
}
},
{
command: {
isZombieLordCommand: true,
name: "Connection.closeModal",
params: {
modalType,
sessionId
}
}
}
);
break;
case 'auth': {
const {requestId, authResponse} = response;
commands.push(
{
command: {
name: "Fetch.continueWithAuth",
params: {
requestId,
authChallengeResponse: authResponse
}
}
},
{
command: {
isZombieLordCommand: true,
name: "Connection.closeModal",
params: {
modalType: 'auth',
sessionId
}
}
}
);
}; break;
}
this.emit('tell-browserbox', commands);
}
getSameNameRadios(radioName, backendNodeId) {
return Array.from(this.inputFields.entries()).filter(
([id, state]) => state.type === 'radio' && state.name === radioName && id !== backendNodeId
);
}
redrawRadioGroup(name, backendNodeId) {
const sameNameRadios = this.getSameNameRadios(name)
Promise.all(sameNameRadios.map(([id,state]) => {
state.checked = id == backendNodeId;
return state.onChange(state.checked);
})).then(() => sameNameRadios.forEach(([,state]) => this.drawRadio(state)));
}
redrawFocusedInput() {
if (!this.focusManager.getFocusedElement()?.startsWith('input:')) return;
const backendNodeId = this.focusManager.getFocusedElement().split(':')[1];
const inputState = this.inputFields.get(backendNodeId);
if (!inputState || !inputState.focused) return;
debugLog(`redrawFocusedInput: backendNodeId=${backendNodeId}, type=${inputState.type}, checked=${inputState.checked}, value=${inputState.value}, width=${inputState.width}`);
if (inputState.type === 'radio') {
const radioName = inputState.name;
// Get all radio inputs with the same name, excluding current
const sameNameRadios = this.getSameNameRadios(radioName, backendNodeId);
// Redraw all other radios first (unfocused)
for (const [id, state] of sameNameRadios) {
this.drawRadio({
x: state.x,
y: state.y,
width: state.width,
key: id,
name: state.name,
value: state.value,
checked: state.checked,
onChange: state.onChange,
});
}
// Redraw the focused radio last
this.drawRadio({
x: inputState.x,
y: inputState.y,
width: inputState.width,
key: backendNodeId,
name: inputState.name,
value: inputState.value,
checked: inputState.checked,
onChange: inputState.onChange,
});
} else if (inputState.type === 'checkbox') {
const checkboxWidth = 3; // Fixed width for [ ]
const totalWidth = checkboxWidth;
const displayWidth = Math.min(totalWidth, this.term.width - inputState.x + 1);
this.term.moveTo(inputState.x, inputState.y);
this.term.bgCyan().white(`${inputState.checked ? '[x]' : '[ ]'}`.slice(0, displayWidth).padEnd(displayWidth, ' '));
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
} else if (inputState.type === 'select') {
const { x, y, width, selectedIndex, options } = inputState;
const displayWidth = Math.min(width, this.term.width - x + 1);
const selectedOption = options[selectedIndex] || { label: '' };
this.term.moveTo(x, y);
this.term.bgCyan().white(`[${selectedOption.label}]`.slice(0, displayWidth).padEnd(displayWidth, ' '));
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
} else {
const { x, y, width, value, cursorPosition } = inputState;
const displayWidth = Math.min(width, this.term.width - x + 1);
this.term.moveTo(x, y);
const beforeCursor = value.slice(0, cursorPosition);
const cursorChar = value[cursorPosition] || ' ';
const afterCursor = value.slice(cursorPosition + 1);
this.term.bgCyan().black(beforeCursor);
this.term.bgBlack().brightWhite().bold(cursorChar);
this.term.bgCyan().black(afterCursor.padEnd(displayWidth - beforeCursor.length - 1, ' '));
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
}
}
redrawUnfocusedInput(backendNodeId) {
const inputState = this.inputFields.get('' + backendNodeId);
if (!inputState) return;
debugLog(`redrawUnfocusedInput: backendNodeId=${backendNodeId}, type=${inputState.type}, checked=${inputState.checked}, value=${inputState.value}, width=${inputState.width}`);
if (inputState.type === 'radio') {
const radioName = inputState.name;
// Get all radio inputs with the same name, excluding current
const sameNameRadios = this.getSameNameRadios(radioName, backendNodeId);
// Redraw all other radios first
for (const [id, state] of sameNameRadios) {
this.drawRadio({
x: state.x,
y: state.y,
width: state.width,
key: id,
name: state.name,
value: state.value,
checked: state.checked,
onChange: state.onChange,
});
}
// Redraw the current radio
this.drawRadio({
x: inputState.x,
y: inputState.y,
width: inputState.width,
key: backendNodeId,
name: inputState.name,
value: inputState.value,
checked: inputState.checked,
onChange: inputState.onChange,
});
} else if (inputState.type === 'checkbox') {
const checkboxWidth = 3; // Fixed width for [ ]
const totalWidth = checkboxWidth;
const displayWidth = Math.min(totalWidth, this.term.width - inputState.x + 1);
this.term.moveTo(inputState.x, inputState.y);
this.term.bgWhite().black(`${inputState.checked ? '[x]' : '[ ]'}`.slice(0, displayWidth).padEnd(displayWidth, ' '));
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
} else if (inputState.type === 'select') {
const { x, y, width, selectedIndex, options } = inputState;
const displayWidth = Math.min(width, this.term.width - x + 1);
const selectedOption = options[selectedIndex] || { label: '' };
this.term.moveTo(x, y);
this.term.bgWhite().black(`[${selectedOption.label}]`.slice(0, displayWidth).padEnd(displayWidth, ' '));
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
} else {
const { x, y, width, value } = inputState;
const displayWidth = Math.min(width, this.term.width - x + 1);
this.term.moveTo(x, y);
this.term.bgWhite().black(value.slice(0, displayWidth).padEnd(displayWidth, ' '));
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
}
}
focusInput(backendNodeId) {
const backendNodeIdStr = '' + backendNodeId;
if (this.inputFields.has(backendNodeIdStr)) {
if (this.focusManager.getPreviousFocusedElement()?.startsWith('input:')) {
const prevBackendNodeId = this.focusManager.getPreviousFocusedElement().split(':')[1];
if (prevBackendNodeId !== backendNodeIdStr) {
this.redrawUnfocusedInput(prevBackendNodeId);
}
}
this.focusManager.setFocusedElement(`input:${backendNodeIdStr}`);
this.focusManager.setPreviousFocusedElement(this.focusManager.getFocusedElement());
const inputState = this.inputFields.get(backendNodeIdStr);
inputState.focused = true;
this.redrawFocusedInput();
}
}
render() {
this.term.moveTo(1, 6);
this.term.eraseDisplayAbove();
this.drawTabs();
this.drawOmnibox();
this.term.bgDefaultColor();
this.term.defaultColor();
this.term.styleReset();
this.term.moveTo(1, this.term.height);
}
// Update selectNextTab and selectPreviousTab to use new FocusManager methods
selectNextTab() {
const currentTabIndex = this.targets.findIndex(t => t.targetId === this.selectedTabId);
if (currentTabIndex === -1) return;
this.focusManager.saveFocusState();
const nextTabIndex = (currentTabIndex + 1) % this.targets.length;
this.selectedTabId = this.targets[nextTabIndex].targetId;
const selectedTab = this.targets[nextTabIndex];
this.emit('tabSelected', selectedTab);
this.focusManager.currentFocusIndex = 0;
this.focusManager.tabbableCached = false;
const focusRestored = this.focusManager.restoreFocusState(
element => this.setFocus(element)
);
if (!focusRestored) {
this.focusManager.setFocusedElement(`tabs:${this.selectedTabId}`);
}
this.render();
}
selectPreviousTab() {
const currentTabIndex = this.targets.findIndex(t => t.targetId === this.selectedTabId);
if (currentTabIndex === -1) return;
this.focusManager.saveFocusState();
const previousTabIndex = (currentTabIndex - 1 + this.targets.length) % this.targets.length;
this.selectedTabId = this.targets[previousTabIndex].targetId;
const selectedTab = this.targets[previousTabIndex];
this.emit('tabSelected', selectedTab);
this.focusManager.currentFocusIndex = 0;
this.focusManager.tabbableCached = false;
const focusRestored = this.focusManager.restoreFocusState(
element => this.setFocus(element)
);
if (!focusRestored) {
this.focusManager.setFocusedElement(`tabs:${this.selectedTabId}`);
}
this.render();
}
// Update focusNextTab and focusPreviousTab to use new FocusManager methods
focusNextTab() {
const currentTabIndex = this.targets.findIndex(t => t.targetId === this.selectedTabId);
if (currentTabIndex === -1) return;
const nextTabIndex = (currentTabIndex + 1) % this.targets.length;
const nextTabId = this.targets[nextTabIndex].targetId;
this.focusManager.setFocusedElement(`tabs:${nextTabId}`);
this.render();
}
focusPreviousTab() {
const currentTabIndex = this.targets.findIndex(t => t.targetId === this.selectedTabId);
if (currentTabIndex === -1) return;
const previousTabIndex = (currentTabIndex - 1 + this.targets.length) % this.targets.length;
const previousTabId = this.targets[previousTabIndex].targetId;
this.focusManager.setFocusedElement(`tabs:${previousTabId}`);
this.render();
}
setFocus(element) {
debugLog(`Setting focus to ${element.type}:${element.backendNodeId || element.index || element.type}`);
debugLog('set_focus_terminal', null, { element: `${element.type}:${element.backendNodeId || element.index || element.type}` }, (new Error).stack);
this.focusManager.setPreviousFocusedElement(th