/**
* Web3os Terminal
*
* Unless you have a reason to directly create a new Web3osTerminal(),
* you should probably use the static {@link Web3osTerminal.create} method
*
* Prompt Format
*
* When setting #promptFormat, you may include these substitutions:
*
* {cwd} - When the prompt is compiled, this will be replaced with the current working directory
*
* @class Web3osTerminal
* @extends Terminal
*
* @param {Object=} options - Options for the new terminal
* @param {Web3osKernel=} [options.kernel=globalThis.Kernel] - The kernel for the terminal to attach to
* @param {boolean=} [options.debug=false] - Enable verbose logging
* @param {Array.<CustomCommand>} options.customCommands - An array of custom commands only for this terminal
*
* @property {string} cmd - The current user input
* @property {string} cwd - The current working directory
* @property {Object} env - The terminal's environment variables
* @property {boolean} debug - Enable verbose output
* @property {Function} execute - Override command execution
* @property {Object} aliases - Map of command aliases
* @property {Array.<string>} history - The history of commands entered
* @property {Array.<string>} binSearchPath - An array of package scopes to search (in order) for executables
* @property {Array.<CustomCommand>} customCommands - An array of custom commands only for this terminal
* @property {number} cursorPosition - The current cursor position of the input string
* @property {number} historyPosition - The current position in the history array
* @property {string} promptFormat - The prompt format containing substitutions
* @property {boolean} tabSelectMode - Whether the prompt is cycling tab choices
* @property {Array.<string>} tabSelectChoices - The array of choices that match the user's input
* @property {number} tabSelectCurrentChoice - The current index of tabSelectChoices
* @property {Object} escapes - ANSI escapes via ansi-escape-sequences
*/
/**
* @typedef CustomCommand
* @memberof Web3osTerminal
* @property {string} name - The name to enter into the terminal to invoke this command
* @property {Function} run - The function to execute when this command is invoked
* @example
* {
* name: 'smile',
* run: (term, context) => console.log('π', { term, context })
* }
*/
import path from 'path'
import colors from 'ansi-colors'
import escapes from 'ansi-escape-sequences'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { WebglAddon } from 'xterm-addon-webgl'
import { AttachAddon } from 'xterm-addon-attach'
import { WebLinksAddon } from 'xterm-addon-web-links'
import 'xterm/css/xterm.css'
import { term } from '../kernel'
let defaults = {
fontSize: 16,
smoothScrollDuration: 100,
convertEol: true,
cursorBlink: true,
macOptionIsMeta: true,
allowProposedApi: true
}
export default class Web3osTerminal extends Terminal {
cmd = ''
cwd = '/'
env = {}
debug = false
execute = false
aliases = {}
history = []
binSearchPath = []
customCommands = []
cursorPosition = 0
historyPosition = 0
tabSelectMode = false
tabSelectChoices = []
tabSelectCurrentChoice = -1
promptFormat = ''
escapes = escapes
constructor (options = {}) {
super(options) // π¦ΈββοΈβ
const self = this
this.kernel = options.kernel || globalThis.Kernel
this.customCommands = options.customCommands || []
this.binSearchPath = options.binSearchPath || ['@web3os-core', '@web3os-fs', '@web3os-apps', '@web3os-utils']
this.debug = options.debug || false
this.execute = options.execute || false
this.promptFormat = options.promptFormat || `<${colors.cyan('3os')}>${colors.blue('{cwd}')}${colors.green('#')} `
this.log = this.log.bind(this)
if (this.debug) console.log('New Terminal Created:', this, { options, kernel: this.kernel })
this.options.linkHandler = this.options.linkHandler || {
activate (event, text, range) {
self.specialLinkHandler(event, text, range, self)
}
}
this.customCommands.push({
name: '$custom',
run: (term, context) => {
const customs = this.customCommands.map(command => ({ name: command.name, description: command.description }))
term.log(customs)
term.prompt()
return customs
}
})
this.customCommands.push({
name: '$env',
run: (term, context) => {
let result
const [key, value] = context.split(' ')
if (this.debug) console.log('ENV:', { key, value })
if (value) this.env[key] = isNaN(value) ? value : parseFloat(value)
if (key) result = this.env[key]
else result = this.env
term.log(result)
term.prompt()
return result
}
})
// Wait for textarea and apply fixes for mobile
if (this.kernel.isMobile) {
const waitInterval = setInterval(() => {
if (this.textarea) {
clearInterval(waitInterval)
this.textarea.setAttribute('enterkeyhint', 'send')
const pollInterval = setInterval(() => {
const input = this.textarea.value
if (input.trim().length > 0 && this.cmd.trim().length < input.trim().length) {
this.cmd = input
this.cursorPosition = this.cmd.length
}
}, 100)
}
}, 100)
}
}
/**
* Create a new Web3os Terminal instance with addons:
*
* xterm-addon-fit, xterm-addon-web-links, xterm-addon-attach
*
* @memberof Web3osTerminal
* @param {Object} options - The options for the new terminal
* @returns {Web3osTerminal}
*/
static create (options = {}) {
const term = new Web3osTerminal({ ...defaults, ...options })
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.loadAddon(new WebLinksAddon())
if (options.socket) {
const attachAddon = new AttachAddon(options.socket)
term.loadAddon(attachAddon)
}
term.fit = fitAddon.fit.bind(fitAddon)
return term
}
/**
* Create a special hyperlink introduced in xterm.js v5
*
* @param {String} uri
* @param {String} text
*/
createSpecialLink (uri, text) {
return `\x1b]8;;${uri}\x1b\\${text}\x1b]8;;\x1b\\`
}
/**
* Handle special hyperlinks introduced in xterm.js v5
*/
specialLinkHandler (event, text, range, term) {
const parts = text.split(':')
if (parts[0] !== 'web3os') return window.open(text)
const cmd = parts[1]
const args = parts[2]
switch (cmd) {
case 'execute':
return this.kernel.execute(args)
}
}
loadWebglAddon () {
const addon = new WebglAddon()
addon.onContextLoss(() => addon.dispose())
this.loadAddon(addon)
}
/**
* Log a message to the terminal
* @memberof Web3osTerminal
* @instance
* @param {...any} args - Strings or stringifiable object to log
*/
log (...args) {
for (let arg of args) {
arg = typeof arg === 'string' ? arg : JSON.stringify(arg, null, 2)
if (arg) this.writeln(arg)
}
return args
}
/**
* Compile the prompt string (symbol replacement)
* @memberof Web3osTerminal
* @instance
* @returns {string} prompt - The compiled prompt string
*/
promptCompile () {
return this.promptFormat.replace(/\{cwd\}/g, colors.muted(this.cwd))
}
/**
* Write the prompt to the terminal and listen for input
* @memberof Web3osTerminal
* @instance
* @param {string=} value - If passed, will update this terminal's promptFormat
*/
prompt (value) {
if (value) this.promptFormat = value
this.write(this.promptCompile())
this.listen()
}
/**
* Handle pasting content from the clipboard
* @memberof Web3osTerminal
* @instance
* @param {string=} [data=navigator.clipboard.readText()] - Data to paste into the terminal
*/
async paste (data) {
const clip = data ? data : await navigator.clipboard.readText()
this.write(clip)
this.cmd += clip
this.cursorPosition += clip.length
}
/**
* Check if the domEvent represents a printable character
* @memberof Web3osTerminal
* @instance
* @param {KeyboardEvent} event - The keyboard event
* @returns {boolean} true if the event represents a printable character
*/
isPrintable (event) {
const code = event.which
if (!code) return false
return !event.altKey && !event.ctrlKey && !event.metaKey &&
(
code >= 32 && code <= 126 ||
code >= 186 && code <= 192 ||
code >= 219 && code <= 222 ||
[173].includes(code)
)
}
/**
* Cancel tab completion
* @memberof Web3osTerminal
* @instance
*/
cancelTabSelection () {
if (!this.tabSelectMode) return
const currentChoice = this.tabSelectChoices[this.tabSelectCurrentChoice]
const goBack = currentChoice.slice(this.cmd.length).length
this.tabSelectMode = false
this.tabSelectChoices = []
this.tabSelectCurrentChoice = 0
if (goBack > 0) this.write(escapes.cursor.back(goBack))
this.write(escapes.erase.inLine())
}
/**
* Accept the current tab completion option
* @memberof Web3osTerminal
* @instance
*/
acceptTabSelection () {
this.cmd = this.tabSelectChoices[this.tabSelectCurrentChoice]
this.cursorPosition = this.cmd.length
this.tabSelectMode = false
this.write(escapes.cursor.back(this.cmd.length))
this.write(escapes.erase.inLine())
this.write(this.cmd)
}
/**
* Run the command
* @memberof Web3osTerminal
* @instance
*/
run (cmd) {
let exec = this.aliases[cmd] ? this.aliases[cmd] : cmd
const options = { terminal: this, doPrompt: true }
const customCommand = this.customCommands?.find(c => c.name === cmd.split(' ')[0])
if (customCommand) customCommand.run(this, cmd.split(' ').slice(1).join(' '))
else {
if (/^\/bin\/.+/.test(this.cwd)) {
const scopedBin = path.join(this.cwd, cmd)
if (this.kernel.fs.existsSync(scopedBin)) {
this.kernel.execute(scopedBin.replace('/bin/', ''), options)
} else {
this.kernel.execute(exec, options)
}
} else {
const searchPaths = [...this.cwd, ...this.binSearchPath.map(p => `/bin/${p}`)]
const match = searchPaths.find(p => p !== '/' && this.kernel.fs.existsSync(`${p}/${exec.split(' ')[0]}`))
if (match) exec = path.join(`${match}/${exec.split(' ')[0]}`) + ' ' + exec.split(' ').slice(1).join(' ')
this.execute ? this.execute(exec, options) : this.kernel.execute(exec, options)
}
}
}
/**
* Handle the keypress event
* @memberof Web3osTerminal
* @instance
* @param {Object} data - The data for the key handler
* @param {string} data.key - The ASCII representation of the key
* @param {KeyboardEvent} data.domEvent - The KeyboardEvent from the DOM listener
*/
async keyHandler ({ key, domEvent }) {
if (!key) return
const keyName = domEvent.key
const printable = this.isPrintable(domEvent)
const cursorPosition = this.cursorPosition
const cmd = this.cmd
if (this.debug) console.debug({ cursorPosition, cmd, keyName, domEvent, key, printable })
if (domEvent.ctrlKey) {
switch (keyName.toLowerCase()) {
case 'v':
return await this.paste()
case 'c':
if (this.getSelection() === '') {
this.cmd = ''
this.write('^C\n')
this.cursorPosition = 0
this.historyPosition = 0
return this.prompt()
}
return await navigator.clipboard.writeText(this.getSelection())
default:
return
}
}
switch (keyName) {
case 'Enter':
if (this.tabSelectMode) {
this.acceptTabSelection()
break
}
if (this.debug) console.debug('Enter:', this.cmd, this.textarea)
this.write('\n')
this.unlisten()
// Mobileish keyboard
if (this.cmd.trim().length === 0 && this.textarea.value.trim().length > 0) this.cmd = this.textarea.value.trim()
if (this.cmd.trim().length === 0) return this.prompt()
this.interruptListener = this.onKey(this.interruptHandler.bind(this))
const cmds = this.cmd.split(';').map(cmd => cmd.trim())
for (const cmd of cmds) this.run(cmd)
this.history.push(this.cmd)
this.cmd = ''
this.cursorPosition = 0
this.historyPosition = 0
break
case 'Delete':
if (this.tabSelectMode || this.cursorPosition === this.cmd.length) break
this.cmd = `${this.cmd.slice(0, this.cursorPosition)}${this.cmd.slice(this.cursorPosition + 1)}`
this.write(escapes.erase.inLine())
this.write(this.cmd.slice(this.cursorPosition))
if (this.cmd.length > this.cursorPosition) this.write(escapes.cursor.back(this.cmd.length - this.cursorPosition))
break
case 'Backspace':
if (this.tabSelectMode) {
this.cancelTabSelection()
break
}
if (this.cursorPosition === 0) break
this.cursorPosition--
this.write(escapes.cursor.back())
this.cmd = `${this.cmd.slice(0, this.cursorPosition)}${this.cmd.slice(this.cursorPosition + 1)}`
this.write(escapes.erase.inLine())
this.write(this.cmd.slice(this.cursorPosition))
if (this.cmd.length > this.cursorPosition) this.write(escapes.cursor.back(this.cmd.length - this.cursorPosition))
break
case 'ArrowLeft':
if (this.tabSelectMode) {
this.cancelTabSelection()
break
}
if (this.cursorPosition === 0) break
this.cursorPosition--
this.write(key)
break
case 'ArrowRight':
if (this.tabSelectMode) {
this.acceptTabSelection()
break
}
if (this.cursorPosition >= this.cmd.length) break
this.cursorPosition++
this.write(key)
break
case 'ArrowDown':
if (this.tabSelectMode) {
this.acceptTabSelection()
break
}
if (this.history.length > 0) this.historyPosition += 1
if (this.historyPosition > this.history.length) this.historyPosition = 0
if (this.historyPosition === 0 && this.cmd.length > 0) {
this.cmd = ''
this.write(escapes.cursor.back(this.cursorPosition))
this.write(escapes.erase.inLine())
this.cursorPosition = 0
break
}
const previousCommand = this.history[this.historyPosition - 1]
if (previousCommand) {
if (this.cursorPosition > 0) this.write(escapes.cursor.back(this.cmd.length))
this.write(escapes.erase.inLine())
this.write(previousCommand)
this.cmd = previousCommand
this.cursorPosition = this.cmd.length
} else {
this.historyPosition = 0
}
break
case 'ArrowUp':
if (this.tabSelectMode) {
this.acceptTabSelection()
break
}
if (this.history.length > 0) this.historyPosition -= 1
if (this.historyPosition < 0) this.historyPosition = this.history.length
if (this.historyPosition === 0 && this.cmd.length > 0) {
this.cmd = ''
this.write(escapes.cursor.back(this.cursorPosition))
this.write(escapes.erase.inLine())
this.cursorPosition = 0
break
}
const nextCommand = this.history[this.historyPosition - 1]
if (nextCommand) {
if (this.cursorPosition > 0) this.write(escapes.cursor.back(this.cmd.length))
this.write(escapes.erase.inLine())
this.write(nextCommand)
this.cmd = nextCommand
this.cursorPosition = this.cmd.length
} else {
this.historyPosition = 0
}
break
case 'Tab':
break // disable until bugs are worked out
if (this.cmd.trim().length === 0) break
this.tabCompletion(this.cmd)
break
case 'Home':
if (this.tabSelectMode) {
this.cancelTabSelection()
break
}
if (this.cursorPosition === 0) break
this.write(escapes.cursor.back(this.cursorPosition))
this.cursorPosition = 0
break
case 'End':
if (this.tabSelectMode) {
this.acceptTabSelection()
break
}
if (this.cursorPosition === this.cmd.length) break
if (this.cursorPosition > 0) this.write(escapes.cursor.back(this.cursorPosition))
this.write(escapes.cursor.forward(this.cmd.length))
this.cursorPosition = this.cmd.length
break
case 'Escape':
if (this.tabSelectMode) {
this.cancelTabSelection()
break
}
this.cmd = ''
this.cursorPosition = 0
this.options.cursorStyle = 'block'
this.writeln('')
this.prompt()
break
case 'Insert':
this.options.cursorStyle = this.options.cursorStyle === 'block' ? 'underline' : 'block'
break
case 'PageUp':
this.scrollPages(-1)
break
case 'PageDown':
this.scrollPages(1)
break
default:
if (printable) {
if (this.tabSelectMode) {
if (this.debug) console.debug({ tabSelectChoices: this.tabSelectChoices })
this.cmd = this.tabSelectChoices[this.tabSelectCurrentChoice]
this.cursorPosition = this.cmd.length
this.tabSelectMode = false
this.write(escapes.cursor.back(this.cmd.length))
this.write(escapes.erase.inLine())
this.write(this.cmd)
}
const replaceMode = this.options.cursorStyle === 'underline'
const remainderOffset = replaceMode && this.cursorPosition < this.cmd.length ? this.cursorPosition + 1: this.cursorPosition
this.cmd = `${this.cmd.slice(0, this.cursorPosition)}${key}${this.cmd.slice(remainderOffset)}`
const remainder = this.cmd.slice(remainderOffset)
this.cursorPosition++
this.write(escapes.erase.inLine())
this.write(replaceMode && (this.cmd.length > this.cursorPosition || remainder === '') ? key + remainder : remainder)
if (this.cmd.length > this.cursorPosition) this.write(escapes.cursor.back(this.cmd.length - this.cursorPosition))
}
}
}
/**
* Handle ESC and CTRL-C to return control of the terminal to the user.
* This doesn't actually kill anything, but it will recover terminal input.
*
* @memberof Web3osTerminal
* @instance
* @param {Object} data - The data for the interrupt handler
* @param {string} data.key - The ASCII representation of the key
* @param {KeyboardEvent} data.domEvent - The KeyboardEvent from the DOM listener
*/
interruptHandler ({ key, domEvent }) {
if (!key) return
const keyName = domEvent.key
if (
keyName === 'Escape'
|| domEvent.ctrlKey && keyName.toLowerCase() === 'c'
) {
this.interruptListener.dispose()
this.historyPosition = 0
this.cursorPosition = 0
this.cmd = ''
this.write('^C\n')
return this.prompt()
}
}
/**
* Start listening for user input on the terminal
*
* @memberof Web3osTerminal
* @instance
* @todo fix mobile input
*/
listen () {
this.unlisten()
this.keyListener = this.onKey(this.keyHandler.bind(this))
// A little workaround: optimistically assume any data over one character is a paste
// TODO: Also catch other paste events
this.pasteListener = this.onData(data => {
const containsUnprintable = data.split('').some(char => !this.isPrintable(data))
data.length > 1 && !containsUnprintable && this.paste()
})
}
/**
* Stop listening for user input
* @memberof Web3osTerminal
* @instance
*/
unlisten () {
try {
this.keyListener.dispose()
this.pasteListener.dispose()
this.interruptListener.dispose()
} catch {}
}
/**
* Handle tab completion
* @memberof Web3osTerminal
* @instance
* @async
*/
async tabCompletion (input) {
if (!this.tabSelectMode) {
const inputParts = input.split(' ')
const binPaths = this.binSearchPath.map(searchPath => `/bin/${searchPath}`)
const inputIsBin = binPaths.some(binPath => {
return this.kernel.fs.existsSync(`${binPath}/${inputParts[0]}`)
})
let choices =
(
inputIsBin ?
this.kernel.modules[inputParts[0]]?.autocomplete?.(input)
: []
) || []
if (inputIsBin && choices.length === 0) choices = this.kernel.fs.readdirSync(this.cwd).sort()
if (choices.length === 0) {
const entries = binPaths
.filter(exe => this.kernel.fs.existsSync(exe))
.map(exe => ({ path: exe, files: this.kernel.fs.readdirSync(exe) }))
for (const entry of entries) {
const reg = new RegExp(`^${input}`)
const file = entry.files.find(file => reg.test(file))
if (!file) continue
choices.push(file)
}
}
console.log({ choices })
if (choices.length > 0) {
this.tabSelectMode = true
this.tabSelectChoices = choices
this.tabSelectCurrentChoice = 0
this.write(colors.muted(choices[0].slice(input.length)))
}
} else {
const currentChoice = this.tabSelectChoices[this.tabSelectCurrentChoice]
const goBack = currentChoice.slice(input.length + currentChoice.length).length
if (goBack > 0) this.write(escapes.cursor.back(goBack))
this.write(escapes.erase.inLine())
this.tabSelectCurrentChoice++
if (this.tabSelectCurrentChoice >= this.tabSelectChoices.length) {
this.tabSelectMode = false
this.tabSelectChoices = []
this.tabSelectCurrentChoice = 0
} else {
const newChoice = this.tabSelectChoices[this.tabSelectCurrentChoice]
this.write(colors.muted(newChoice.slice(input.length)))
}
}
}
}