desktop: components/apps/terminal — basic terminal emulator with command history, help/clear/echo/date/uptime/whoami

This commit is contained in:
Butterfly Dev 2026-04-07 03:33:40 +00:00
parent 6b4c6419ce
commit 7496fbeced
3 changed files with 180 additions and 0 deletions

View File

@ -0,0 +1,19 @@
<div class="terminal" (click)="onClick()">
<div class="terminal-body" #terminalBody>
@for (line of lines(); track $index) {
<div class="terminal-line">{{ line }}</div>
}
<div class="terminal-input-line">
<span class="prompt">&gt;</span>
<input
#termInput
class="terminal-input"
type="text"
spellcheck="false"
autocomplete="off"
(keydown)="onKeyDown($event)"
(input)="onInput($event)"
/>
</div>
</div>
</div>

View File

@ -0,0 +1,51 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.terminal {
width: 100%;
height: 100%;
background: #0c0c0c;
color: #cccccc;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 14px;
line-height: 1.4;
display: flex;
flex-direction: column;
}
.terminal-body {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
white-space: pre-wrap;
word-break: break-all;
}
.terminal-line {
min-height: 1.4em;
}
.terminal-input-line {
display: flex;
align-items: center;
}
.prompt {
color: #6ec6ff;
margin-right: 8px;
user-select: none;
}
.terminal-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #cccccc;
font-family: inherit;
font-size: inherit;
caret-color: #ccc;
}

View File

@ -0,0 +1,110 @@
import { Component, ElementRef, ViewChild, AfterViewInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-terminal',
standalone: true,
imports: [CommonModule],
templateUrl: './terminal.component.html',
styleUrl: './terminal.component.scss',
})
export class TerminalComponent implements AfterViewInit {
@ViewChild('terminalBody') terminalBody!: ElementRef<HTMLDivElement>;
@ViewChild('termInput') termInput!: ElementRef<HTMLInputElement>;
/** Command history for up/down arrow navigation. */
private history: string[] = [];
private historyIndex = -1;
/** Lines of terminal output. */
readonly lines = signal<string[]>([
'Butterfly Terminal v0.1',
'Type "help" for available commands.',
'',
]);
ngAfterViewInit(): void {
this.termInput?.nativeElement.focus();
}
onInput(event: Event): void {
// Just keep focus.
}
onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
const input = this.termInput.nativeElement;
const cmd = input.value.trim();
input.value = '';
if (cmd) {
this.history.push(cmd);
this.historyIndex = this.history.length;
this.executeCommand(cmd);
}
this.lines.update((l) => [...l, `> ${cmd}`]);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (this.historyIndex > 0) {
this.historyIndex--;
this.termInput.nativeElement.value = this.history[this.historyIndex];
}
} else if (event.key === 'ArrowDown') {
event.preventDefault();
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.termInput.nativeElement.value = this.history[this.historyIndex];
} else {
this.historyIndex = this.history.length;
this.termInput.nativeElement.value = '';
}
}
}
private executeCommand(cmd: string): void {
const parts = cmd.split(/\s+/);
const command = parts[0].toLowerCase();
switch (command) {
case 'help':
this.addOutput('Available commands: help, clear, echo, date, uptime, whoami, about');
break;
case 'clear':
this.lines.set([]);
return;
case 'echo':
this.addOutput(parts.slice(1).join(' '));
break;
case 'date':
this.addOutput(new Date().toString());
break;
case 'uptime':
this.addOutput(`System uptime: ${Math.floor(performance.now() / 1000)}s`);
break;
case 'whoami':
this.addOutput('butterfly-user');
break;
case 'about':
this.addOutput('Butterfly Desktop Environment v0.1');
this.addOutput('A browser-based remote desktop environment.');
break;
default:
this.addOutput(`Unknown command: ${command}. Type "help" for available commands.`);
}
}
private addOutput(text: string): void {
this.lines.update((l) => [...l, text]);
}
/** Focus the input when the terminal is clicked. */
onClick(): void {
this.termInput?.nativeElement.focus();
}
/** Called after view updates to scroll to bottom. */
scrollToBottom(): void {
if (this.terminalBody) {
this.terminalBody.nativeElement.scrollTop = this.terminalBody.nativeElement.scrollHeight;
}
}
}