diff --git a/.github/workflows/deploy_to_azure.yml b/.github/workflows/deploy_to_azure.yml index 3196e79..eb6888c 100644 --- a/.github/workflows/deploy_to_azure.yml +++ b/.github/workflows/deploy_to_azure.yml @@ -31,6 +31,12 @@ jobs: npm ci npm run build:prod --if-present + - name: Copy built shared package to backend node_modules + run: | + mkdir -p ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}/node_modules/@webmud3 + cp -r shared/dist ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}/node_modules/@webmud3/shared + cp shared/package.json ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}/node_modules/@webmud3/shared/ + - name: Install raw dependencies for backend run: | cd ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} diff --git a/frontend/setup-jest.ts b/frontend/setup-jest.ts index 5553a4a..22d2d05 100644 --- a/frontend/setup-jest.ts +++ b/frontend/setup-jest.ts @@ -1 +1 @@ -import 'jest-preset-angular/setup-jest'; \ No newline at end of file +// import 'jest-preset-angular/setup-jest'; diff --git a/frontend/src/app/core/mud/components/mud-client/mud-client.component.html b/frontend/src/app/core/mud/components/mud-client/mud-client.component.html index e20dfa4..a35d0fa 100644 --- a/frontend/src/app/core/mud/components/mud-client/mud-client.component.html +++ b/frontend/src/app/core/mud/components/mud-client/mud-client.component.html @@ -1,3 +1,16 @@ +
+ +
+ +
+ +
+
@if (!(isConnected$ | async)) { diff --git a/frontend/src/app/core/mud/components/mud-client/mud-client.component.scss b/frontend/src/app/core/mud/components/mud-client/mud-client.component.scss index 18df375..1f42a1b 100644 --- a/frontend/src/app/core/mud/components/mud-client/mud-client.component.scss +++ b/frontend/src/app/core/mud/components/mud-client/mud-client.component.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: column; min-height: 0; + position: relative; .mud-output { flex: 1 1 0; @@ -10,6 +11,46 @@ overflow: hidden; } + .sr-announcer { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: pre-wrap; + border: 0; + } + + .sr-history { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 40vh; + opacity: 0.001; /* quasi unsichtbar */ + pointer-events: none; /* verhindert Maus-Klicks */ + overflow: auto; + z-index: 0; + } + + // .sr-input-committed { + // position: absolute; + // left: 0; + // top: 0; + // width: 100%; + // height: 40vh; + // opacity: 0.001; /* quasi unsichtbar */ + // pointer-events: none; /* verhindert Maus-Klicks */ + // overflow: auto; + // z-index: 0; + // } + + .sr-log-item { + white-space: pre-wrap; + } + /* Optionales Styling */ .disconnected-panel { flex: 0 0 auto; diff --git a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts index 5008698..6e2c0ad 100644 --- a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts +++ b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts @@ -15,86 +15,31 @@ import { Subscription } from 'rxjs'; import { MudService } from '../../services/mud.service'; import { SecureString } from '@webmud3/frontend/shared/types/secure-string'; import type { LinemodeState } from '@webmud3/shared'; - -type SocketListener = EventListener; -type MudSocketAdapterHooks = { - transformMessage?: (data: string) => string; - beforeMessage?: (data: string) => void; - afterMessage?: (data: string) => void; +import { + MudInputController, + MudPromptManager, + MudScreenReaderAnnouncer, + MudSocketAdapter, + MudPromptContext, + CTRL, +} from '../../../../features/terminal'; + +/** + * Component-internal shape that bundles the mutable Mud client flags. + */ +type MudClientState = { + isEditMode: boolean; + showEcho: boolean; + localEchoEnabled: boolean; + terminalReady: boolean; }; -class MudSocketAdapter { - public binaryType: BinaryType = 'arraybuffer'; - public readyState = WebSocket.OPEN; - - private readonly listeners = new Map>(); - private readonly subscription: Subscription; - - constructor( - private readonly mudService: MudService, - private readonly hooks?: MudSocketAdapterHooks, - ) { - this.subscription = this.mudService.mudOutput$.subscribe(({ data }) => { - this.hooks?.beforeMessage?.(data); - - const transformed = this.hooks?.transformMessage?.(data) ?? data; - - if (transformed.length > 0) { - this.dispatch( - 'message', - new MessageEvent('message', { data: transformed }), - ); - } - - this.hooks?.afterMessage?.(transformed); - }); - } - - public addEventListener(type: string, listener: SocketListener) { - if (!this.listeners.has(type)) { - this.listeners.set(type, new Set()); - } - - this.listeners.get(type)!.add(listener); - } - - public removeEventListener(type: string, listener: SocketListener) { - const listeners = this.listeners.get(type); - if (!listeners) { - return; - } - - listeners.delete(listener); - - if (listeners.size === 0) { - this.listeners.delete(type); - } - } - - public send(): void { - // Input handling is managed separately via terminal.onData - } - - public close() { - this.dispose(); - } - - public dispose() { - this.subscription.unsubscribe(); - this.listeners.clear(); - } - - private dispatch(type: string, event: Event) { - const listeners = this.listeners.get(type); - - if (!listeners) { - return; - } - - listeners.forEach((listener) => listener.call(this, event)); - } -} - +/** + * Angular wrapper around the xterm-based MUD client. The component hosts the terminal, + * wires the input/prompt helpers together and mirrors socket events to the view. A + * custom screenreader announcer replaces xterm's built-in screenReaderMode to avoid + * duplicated output and replaying history after reconnects. + */ @Component({ selector: 'app-mud-client', standalone: true, @@ -106,16 +51,12 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly mudService = inject(MudService); private readonly terminal: Terminal; + private readonly inputController: MudInputController; + private readonly promptManager: MudPromptManager; + private screenReader?: MudScreenReaderAnnouncer; private readonly terminalFitAddon = new FitAddon(); - private readonly socketAdapter = new MudSocketAdapter(this.mudService, { - transformMessage: (data) => this.transformMudOutput(data), - beforeMessage: (data) => this.beforeMudOutput(data), - afterMessage: (data) => this.afterMudOutput(data), - }); - private readonly terminalAttachAddon = new AttachAddon( - this.socketAdapter as unknown as WebSocket, - { bidirectional: false }, - ); + private socketAdapter?: MudSocketAdapter; + private terminalAttachAddon?: AttachAddon; private readonly terminalDisposables: IDisposable[] = []; private readonly resizeObs = new ResizeObserver(() => { @@ -124,34 +65,86 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private showEchoSubscription?: Subscription; private linemodeSubscription?: Subscription; - private inputBuffer = ''; - private lastInputWasCarriageReturn = false; - private localEchoEnabled = true; - private currentShowEcho = true; - private isEditMode = true; + private state: MudClientState = { + isEditMode: true, + showEcho: true, + localEchoEnabled: true, + terminalReady: false, + }; private lastViewportSize?: { columns: number; rows: number }; - private terminalReady = false; - private editLineHidden = false; - private serverLineBuffer = ''; - private hiddenPrompt = ''; - private leadingLineBreaksToStrip = 0; @ViewChild('hostRef', { static: true }) private readonly terminalRef!: ElementRef; + @ViewChild('liveRegionRef', { static: true }) + private readonly liveRegionRef!: ElementRef; + + @ViewChild('inputRegionRef', { static: true }) + private readonly inputRegionRef!: ElementRef; + + @ViewChild('inputCommittedRegionRef', { static: true }) + private readonly inputCommittedRegionRef!: ElementRef; + + @ViewChild('historyRegionRef', { static: true }) + private readonly historyRegionRef!: ElementRef; + protected readonly isConnected$ = this.mudService.connectedToMud$; protected readonly showEcho$ = this.mudService.showEcho$; + /** + * Instantiates the terminal plus helper controllers. All services (input/prompt) + * share the same terminal instance. + */ constructor() { this.terminal = new Terminal({ fontFamily: 'JetBrainsMono, monospace', theme: { background: '#000', foreground: '#ccc' }, disableStdin: false, - screenReaderMode: true, + screenReaderMode: false, }); + + this.inputController = new MudInputController( + this.terminal, + ({ message, echoed }) => this.handleCommittedInput(message, echoed), + ({ buffer }) => this.announceInputToScreenReader(buffer), + ); + this.inputController.setLocalEcho(this.state.localEchoEnabled); + + this.promptManager = new MudPromptManager( + this.terminal, + this.inputController, + ); } + /** + * Bootstraps the terminal after the view is ready: attaches addons, subscribes + * to socket events and reports the initial viewport dimensions to the server. + */ ngAfterViewInit() { + // Initialize screenreader announcer before terminal/socket setup + // to ensure we capture the session start BEFORE any output arrives + this.screenReader = new MudScreenReaderAnnouncer( + this.liveRegionRef.nativeElement, + this.historyRegionRef.nativeElement, + this.inputRegionRef.nativeElement, + this.inputCommittedRegionRef.nativeElement, + ); + console.debug( + '[MudClient] Screenreader announcer initialized, live region:', + this.liveRegionRef.nativeElement, + ); + + // Now initialize socket adapter AFTER screenreader is ready + this.socketAdapter = new MudSocketAdapter(this.mudService.mudOutput$, { + transformMessage: (data) => this.transformMudOutput(data), + beforeMessage: (data) => this.beforeMudOutput(data), + afterMessage: (data) => this.afterMudOutput(data), + }); + this.terminalAttachAddon = new AttachAddon( + this.socketAdapter as unknown as WebSocket, + { bidirectional: false }, + ); + this.terminal.open(this.terminalRef.nativeElement); this.terminal.loadAddon(this.terminalFitAddon); this.terminal.loadAddon(this.terminalAttachAddon); @@ -162,7 +155,6 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { ); this.showEchoSubscription = this.showEcho$.subscribe((showEcho) => { - this.currentShowEcho = showEcho; this.updateLocalEcho(showEcho); }); @@ -171,7 +163,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { ); this.resizeObs.observe(this.terminalRef.nativeElement); - this.terminalReady = true; + this.setState({ terminalReady: true }); const columns = this.terminal.cols; const rows = this.terminal.rows + 1; @@ -179,6 +171,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.mudService.connect({ columns, rows }); } + /** + * Cleans up subscriptions and disposes terminal resources. + */ ngOnDestroy() { this.resizeObs.disconnect(); @@ -186,18 +181,24 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.showEchoSubscription?.unsubscribe(); this.linemodeSubscription?.unsubscribe(); - this.terminalAttachAddon.dispose(); - this.socketAdapter.dispose(); + this.terminalAttachAddon?.dispose(); + this.socketAdapter?.dispose(); this.terminal.dispose(); + this.screenReader?.dispose(); } protected connect() { const columns = this.terminal.cols; const rows = this.terminal.rows; + this.screenReader?.markSessionStart(); this.mudService.connect({ columns, rows }); } + /** + * Handles DOM resize events, updating xterm and notifying the backend whenever + * the viewport size actually changes. + */ private handleTerminalResize() { this.terminalFitAddon.fit(); @@ -225,267 +226,155 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.mudService.updateViewportSize(columns, rows); } - private handleInput(data: string) { - if (!this.isEditMode) { - if (data.length > 0) { - this.mudService.sendMessage(data); - } + /** + * Sends a committed line (or secure string) to the server. + */ + private handleCommittedInput(message: string, echoed: boolean) { + const payload: string | SecureString = echoed + ? message + : { value: message }; - return; + if (typeof payload === 'string') { + this.screenReader?.appendToHistory(payload); + // Announce the complete input so user can verify what they typed + this.screenReader?.announceInputCommitted(payload); } - for (let index = 0; index < data.length; index += 1) { - const char = data[index]; - - switch (char) { - case '\r': - this.commitBuffer(); - this.lastInputWasCarriageReturn = true; - break; - case '\n': - if (!this.lastInputWasCarriageReturn) { - this.commitBuffer(); - } - - this.lastInputWasCarriageReturn = false; - break; - case '\b': - case '\u007f': - this.applyBackspace(); - this.lastInputWasCarriageReturn = false; - break; - case '\u001b': { - const consumed = this.skipEscapeSequence(data.slice(index)); - index += consumed - 1; - this.lastInputWasCarriageReturn = false; - break; - } - default: { - const charCode = char.charCodeAt(0); - - if (charCode < 32 && char !== '\t') { - // Ignore unsupported control characters (e.g. CTRL+C) - break; - } - - this.inputBuffer += char; + this.mudService.sendMessage(payload); + } - if (this.localEchoEnabled) { - this.terminal.write(char); - } + /** + * Announces input buffer changes to the screen reader announcer. + * Called whenever the user types, deletes, etc. (but not for cursor-only moves). + * This ensures screen reader users can hear their input in real-time. + */ + private announceInputToScreenReader(buffer: string): void { + this.screenReader?.announceInput(buffer); + } - this.lastInputWasCarriageReturn = false; - break; - } + /** + * Routes terminal keystrokes either directly to the socket (when not in edit mode) + * or through the {@link MudInputController}. + */ + private handleInput(data: string) { + if (!this.state.isEditMode) { + if (data.length > 0) { + const rewritten = this.rewriteBackspaceToDelete(data); + this.mudService.sendMessage(rewritten); } + + return; } + + this.inputController.handleData(data); } + /** + * Applies the negotiated LINEMODE. Pending local input is flushed before + * leaving edit mode; both prompt and controller state are reset afterwards. + */ private setLinemode(state: LinemodeState) { - const wasEditMode = this.isEditMode; + const wasEditMode = this.state.isEditMode; - this.isEditMode = state.edit; + if (!state.edit) { + if (wasEditMode) { + const pending = this.inputController.flush(); - if (!this.isEditMode) { - if (wasEditMode && this.inputBuffer.length > 0) { - this.mudService.sendMessage(this.inputBuffer); + if (pending) { + this.handleCommittedInput(pending.message, pending.echoed); + } } - this.inputBuffer = ''; - this.lastInputWasCarriageReturn = false; + this.inputController.reset(); } else if (!wasEditMode) { - this.inputBuffer = ''; - this.lastInputWasCarriageReturn = false; + this.inputController.reset(); } - this.editLineHidden = false; - this.serverLineBuffer = ''; - this.hiddenPrompt = ''; - this.leadingLineBreaksToStrip = 0; - this.updateLocalEcho(this.currentShowEcho); + this.setState({ isEditMode: state.edit }); + this.promptManager.reset(); + this.updateLocalEcho(this.state.showEcho); } + /** + * Enables/disables local echo and informs the input controller. The effective + * value depends on both LINEMODE and the server-provided flag. + */ private updateLocalEcho(showEcho: boolean) { - this.localEchoEnabled = this.isEditMode && showEcho; - } - - private applyBackspace() { - if (this.inputBuffer.length === 0) { - return; - } - - this.inputBuffer = this.inputBuffer.slice(0, -1); - - if (this.localEchoEnabled) { - this.terminal.write('\b \b'); - } - } - - private commitBuffer() { - const message = this.inputBuffer; - - this.inputBuffer = ''; - this.lastInputWasCarriageReturn = false; - - if (this.localEchoEnabled) { - this.terminal.write('\r\n'); - } - - const securedString: string | SecureString = this.localEchoEnabled - ? message - : { value: message }; - - this.mudService.sendMessage(securedString); - } - - private skipEscapeSequence(sequence: string): number { - const match = sequence.match(/^\u001b\[[0-9;]*[A-Za-z~]/); + const localEchoEnabled = this.state.isEditMode && showEcho; - if (match) { - return match[0].length; - } - - // Default to consuming only the ESC character - return 1; + this.setState({ showEcho, localEchoEnabled }); + this.inputController.setLocalEcho(localEchoEnabled); } + /** + * Delegates to the prompt manager so it can temporarily hide the local prompt. + */ private beforeMudOutput(_data: string) { - if ( - !this.isEditMode || - !this.terminalReady || - !this.localEchoEnabled || - this.inputBuffer.length === 0 || - this.editLineHidden - ) { - return; - } - - this.hiddenPrompt = this.serverLineBuffer; - this.serverLineBuffer = ''; - this.leadingLineBreaksToStrip = 1; - this.terminal.write('\r\u001b[2K'); - this.editLineHidden = true; + this.promptManager.beforeServerOutput(this.getPromptContext()); } + /** + * Restores prompt and user input after the server chunk has been rendered. + */ private afterMudOutput(data: string) { - this.trackServerLine(data); - - if ( - !this.editLineHidden || - !this.isEditMode || - !this.terminalReady || - !this.localEchoEnabled || - this.inputBuffer.length === 0 - ) { - return; - } + this.promptManager.afterServerOutput(data, this.getPromptContext()); + this.announceToScreenReader(data); + this.screenReader?.appendToHistory(data); + } - queueMicrotask(() => this.restoreEditInput()); + /** + * Lets the prompt manager strip redundant CR/LF characters. + */ + private transformMudOutput(data: string): string { + return this.promptManager.transformOutput(data); } - private restoreEditInput() { - if (!this.editLineHidden) { - return; - } + /** + * Builds the prompt context consumed by the prompt manager. + */ + private getPromptContext(): MudPromptContext { + return { + isEditMode: this.state.isEditMode, + terminalReady: this.state.terminalReady, + localEchoEnabled: this.state.localEchoEnabled, + }; + } - if ( - !this.isEditMode || - !this.terminalReady || - !this.localEchoEnabled || - this.inputBuffer.length === 0 - ) { - this.editLineHidden = false; + /** + * Announces new server output via the custom screenreader announcer. + * Called AFTER prompt restoration so we announce the final visible text. + */ + private announceToScreenReader(data: string): void { + if (!this.screenReader) { return; } - this.terminal.write('\r\u001b[2K'); - - const prefix = - this.serverLineBuffer.length > 0 - ? this.serverLineBuffer - : this.hiddenPrompt; - - if (prefix.length > 0) { - this.terminal.write(prefix); - } + console.debug('[MudClient] Announcing to screenreader:', { + rawLength: data.length, + raw: data, + }); - this.terminal.write(this.inputBuffer); - this.editLineHidden = false; - this.hiddenPrompt = ''; - this.serverLineBuffer = prefix; - this.leadingLineBreaksToStrip = 0; + this.screenReader.announce(data); } - private transformMudOutput(data: string): string { - if (this.leadingLineBreaksToStrip === 0 || data.length === 0) { - return data; - } - - let startIndex = 0; - let remainingBreaks = this.leadingLineBreaksToStrip; - - while (startIndex < data.length && remainingBreaks > 0) { - const char = data[startIndex]; - - if (char === '\n') { - remainingBreaks -= 1; - startIndex += 1; - continue; - } - - if (char === '\r') { - startIndex += 1; - continue; - } - - break; - } - - this.leadingLineBreaksToStrip = remainingBreaks; - - if (startIndex === 0) { - this.leadingLineBreaksToStrip = 0; - return data; - } - - if (startIndex >= data.length) { - return ''; - } - - this.leadingLineBreaksToStrip = 0; - return data.slice(startIndex); + /** + * Convenience helper for patching the local state object. + */ + private setState(patch: Partial): void { + this.state = { ...this.state, ...patch }; } - private trackServerLine(data: string) { - let index = 0; - - while (index < data.length) { - const char = data[index]; + /** + * Maps DEL to BACKSPACE for non-edit mode + */ + private rewriteBackspaceToDelete(data: string): string { + const containsDelete = data.includes(CTRL.DEL); - if (char === '\r' || char === '\n') { - this.serverLineBuffer = ''; - index += 1; - continue; - } - - if (char === '\b' || char === '\u007f') { - this.serverLineBuffer = this.serverLineBuffer.slice(0, -1); - index += 1; - continue; - } - - if (char === '\u001b') { - const consumed = this.skipEscapeSequence(data.slice(index)); - const sequence = - consumed > 0 ? data.slice(index, index + consumed) : char; - - this.serverLineBuffer += sequence; - index += Math.max(consumed, 1); - continue; - } - - this.serverLineBuffer += char; - index += 1; + if (containsDelete) { + // Many terminals internally map Backspace to Delete; mirror that when bypassing edit mode. + return CTRL.BS; } + + return data; } } diff --git a/frontend/src/app/features/serverconfig/server-config.service.ts b/frontend/src/app/features/serverconfig/server-config.service.ts index bddb3d2..f90859c 100644 --- a/frontend/src/app/features/serverconfig/server-config.service.ts +++ b/frontend/src/app/features/serverconfig/server-config.service.ts @@ -20,7 +20,7 @@ export class ServerConfigService { * @returns {Promise} resolves once the configuration has been loaded (or a fallback was used). * @memberof ServerConfigService */ - async load(): Promise { + public async load(): Promise { const configuration = await firstValueFrom( this.httpClient.get(this.configUrl), ); diff --git a/frontend/src/app/features/terminal/index.ts b/frontend/src/app/features/terminal/index.ts new file mode 100644 index 0000000..adc85d2 --- /dev/null +++ b/frontend/src/app/features/terminal/index.ts @@ -0,0 +1,5 @@ +export * from './models/escapes'; +export * from './mud-input.controller'; +export * from './mud-prompt.manager'; +export * from './mud-socket.adapter'; +export * from './mud-screenreader'; diff --git a/frontend/src/app/features/terminal/models/escapes.ts b/frontend/src/app/features/terminal/models/escapes.ts new file mode 100644 index 0000000..575741c --- /dev/null +++ b/frontend/src/app/features/terminal/models/escapes.ts @@ -0,0 +1,57 @@ +/** + * Centralized ANSI/terminal control sequences & helpers. + */ + +/** ASCII / terminal control characters. */ +export const CTRL = { + ESC: '\u001b', // Escape (0x1B) + BS: '\b', // Backspace (0x08) + DEL: '\u007f', // Delete (0x7F) – some terminals emit DEL instead of BS + CR: '\r', // Carriage Return – move cursor to column 0 + LF: '\n', + TAB: '\t', +} as const; + +/** CSI (Control Sequence Introducer) — ESC followed by '['. */ +export const CSI = `${CTRL.ESC}[`; + +/** SS3 (Single Shift 3) — ESC + 'O'; used for arrow keys in some modes. */ +export const SS3 = `${CTRL.ESC}O`; + +/** Common CSI helpers. */ +export const CSI_CMD = { + cursorLeft: (columns = 1) => `${CSI}${Math.max(columns, 1)}D`, + cursorRight: (columns = 1) => `${CSI}${Math.max(columns, 1)}C`, + eraseLineAll: () => `${CSI}2K`, +} as const; + +/** Regex that captures generic CSI sequences (ESC [ parameters final). */ +export const CSI_REGEX = /^\u001b\[[0-9;]*[A-Za-z~]/; + +/** SS3 sequences are always ESC + 'O' + one final char. */ +export const SS3_LEN = 3; + +/** Public aliases kept for existing callers. */ +export const carriageReturn = CTRL.CR; +export const backspace = CTRL.BS; +export const eraseLine = CSI_CMD.eraseLineAll(); + +/** CR followed by CSI 2K — clear the active line and move to column 0. */ +export const resetLine = `${carriageReturn}${eraseLine}`; + +/** Simulate the backspace effect (move left, overwrite, move left again). */ +export const backspaceErase = `${backspace} ${backspace}`; + +/** Cursor helpers for callers that expect standalone functions. */ +export function cursorLeft(columns = 1): string { + return CSI_CMD.cursorLeft(columns); +} + +export function cursorRight(columns = 1): string { + return CSI_CMD.cursorRight(columns); +} + +/** Compose multiple fragments without manual string concatenation. */ +export function sequence(...segments: string[]): string { + return segments.join(''); +} diff --git a/frontend/src/app/features/terminal/mud-input.controller.spec.ts b/frontend/src/app/features/terminal/mud-input.controller.spec.ts new file mode 100644 index 0000000..424ebe0 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-input.controller.spec.ts @@ -0,0 +1,103 @@ +import { MudInputController } from './mud-input.controller'; +import { CTRL } from './models/escapes'; + +describe('MudInputController', () => { + const makeController = () => { + const terminal = { write: jest.fn() } as { write: jest.Mock }; + const onCommit = jest.fn(); + const controller = new MudInputController(terminal as any, onCommit); + return { controller, terminal, onCommit }; + }; + + it('commits exactly once on CRLF', () => { + const { controller, onCommit, terminal } = makeController(); + + controller.handleData(CTRL.CR + CTRL.LF); + + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith({ message: '', echoed: true }); + // Local echo should emit CRLF once + expect(terminal.write).toHaveBeenCalledTimes(1); + expect(terminal.write).toHaveBeenCalledWith(CTRL.CR + CTRL.LF); + }); + + it('commits empty buffer on CR (allowed)', () => { + const { controller, onCommit } = makeController(); + + controller.handleData(CTRL.CR); + + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith({ message: '', echoed: true }); + }); + + it('ignores backspace at buffer start', () => { + const { controller, onCommit, terminal } = makeController(); + + controller.handleData(CTRL.BS); + + expect(onCommit).not.toHaveBeenCalled(); + expect(terminal.write).not.toHaveBeenCalled(); + expect(controller.getSnapshot()).toEqual({ buffer: '', cursor: 0 }); + }); + + it('inserts mid-line after moving cursor left', () => { + const { controller } = makeController(); + + controller.handleData('ab'); + controller.handleData('\u001b[D'); // Arrow left + controller.handleData('X'); + + expect(controller.getSnapshot()).toEqual({ buffer: 'aXb', cursor: 2 }); + }); + + it('applies delete (CSI 3~) at cursor position', () => { + const { controller } = makeController(); + + controller.handleData('abc'); + controller.handleData('\u001b[D'); // move to between b|c + controller.handleData('\u001b[3~'); // delete + + expect(controller.getSnapshot()).toEqual({ buffer: 'ab', cursor: 2 }); + }); + + it('suppresses terminal writes when echo is disabled, but still buffers', () => { + const { controller, terminal } = makeController(); + + controller.setLocalEcho(false); + controller.handleData('abc'); + + expect(controller.getSnapshot()).toEqual({ buffer: 'abc', cursor: 3 }); + expect(terminal.write).not.toHaveBeenCalled(); + }); + + it('commit reports echoed=false when echo is disabled', () => { + const { controller, onCommit } = makeController(); + + controller.setLocalEcho(false); + controller.handleData('hi' + CTRL.CR); + + expect(onCommit).toHaveBeenCalledWith({ message: 'hi', echoed: false }); + }); + + it('accepts TAB but ignores other control chars', () => { + const { controller } = makeController(); + + controller.handleData(CTRL.TAB); + controller.handleData('\u0001'); // SOH control char ignored + + expect(controller.getSnapshot()).toEqual({ buffer: CTRL.TAB, cursor: 1 }); + }); + + it('buffers incomplete escape and resumes on next chunk', () => { + const { controller } = makeController(); + + controller.handleData('ab'); + controller.handleData('\u001b['); // incomplete CSI + // No movement yet + expect(controller.getSnapshot()).toEqual({ buffer: 'ab', cursor: 2 }); + + controller.handleData('D'); // completes ESC[D (cursor left) + + expect(controller.getSnapshot()).toEqual({ buffer: 'ab', cursor: 1 }); + }); +}); diff --git a/frontend/src/app/features/terminal/mud-input.controller.ts b/frontend/src/app/features/terminal/mud-input.controller.ts new file mode 100644 index 0000000..8148b17 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-input.controller.ts @@ -0,0 +1,405 @@ +import type { Terminal } from '@xterm/xterm'; + +import { + CTRL, + CSI_REGEX, + SS3, + SS3_LEN, + backspaceErase, + cursorLeft, + cursorRight, + sequence, +} from './models/escapes'; + +/** + * Callback signature used whenever a buffered line is ready to be sent to the server. + */ +export type MudInputCommitHandler = (payload: { + message: string; + echoed: boolean; +}) => void; + +/** + * Callback signature for notifying about input buffer changes. + * Called whenever the buffer is modified (character inserted, deleted, etc.) + * but NOT for cursor-only movements. + */ +export type MudInputChangeHandler = (payload: { buffer: string }) => void; + +/** + * Encapsulates client-side editing state for LINEMODE input. The controller keeps + * track of the text buffer and cursor position, applies terminal side-effects + * when local echo is enabled, and turns user keystrokes into commit events. + */ +export class MudInputController { + private buffer = ''; + private cursor = 0; + private lastWasCarriageReturn = false; + private localEchoEnabled = true; + // Holds a partially received escape sequence to be completed by the next chunk. + private pendingEscape = ''; + + /** + * @param terminal Reference to the xterm instance we mirror the editing state to. + * @param onCommit Callback that receives a flushed line (with echo information). + * @param onInputChange Optional callback for input buffer changes (screen reader announcements). + */ + constructor( + private readonly terminal: Terminal, + private readonly onCommit: MudInputCommitHandler, + private readonly onInputChange?: MudInputChangeHandler, + ) {} + + /** + * Processes raw terminal data. Each character (or escape sequence) updates the + * internal buffer/cursor state and performs the corresponding terminal writes. + */ + public handleData(data: string): void { + const stream = this.pendingEscape + data; + this.pendingEscape = ''; + + for (let index = 0; index < stream.length; index += 1) { + const char = stream[index]; + + switch (char) { + case CTRL.CR: + this.commitBuffer(); + this.lastWasCarriageReturn = true; + break; + case CTRL.LF: + if (!this.lastWasCarriageReturn) { + this.commitBuffer(); + } + + this.lastWasCarriageReturn = false; + break; + case CTRL.BS: + case CTRL.DEL: + this.applyBackspace(); + this.lastWasCarriageReturn = false; + break; + case CTRL.ESC: { + const consumed = this.handleEscapeSequence(stream.slice(index)); + + // Incomplete escape sequence: buffer it and stop processing + if (consumed === 0) { + this.pendingEscape = stream.slice(index); + index = stream.length; // break loop + break; + } + + index += consumed - 1; + this.lastWasCarriageReturn = false; + break; + } + default: + this.insertCharacter(char); + this.lastWasCarriageReturn = false; + break; + } + } + } + + /** + * Enables or disables local echo. When disabled we still update the buffer, + * but no characters are written back to the terminal. + */ + public setLocalEcho(enabled: boolean): void { + this.localEchoEnabled = enabled; + } + + /** + * Clears all editing state (buffer, cursor, carriage-return tracker). + */ + public reset(): void { + this.buffer = ''; + this.cursor = 0; + this.lastWasCarriageReturn = false; + this.pendingEscape = ''; + } + + /** + * @returns `true` when the buffer currently contains user input. + */ + public hasContent(): boolean { + return this.buffer.length > 0; + } + + /** + * @returns immutable snapshot of buffer + cursor position used for redraws. + */ + public getSnapshot(): { buffer: string; cursor: number } { + return { buffer: this.buffer, cursor: this.cursor }; + } + + /** + * Flushes the buffer and resets the controller. When nothing has been typed + * the call is a no-op and `null` is returned. + */ + public flush(): { message: string; echoed: boolean } | null { + if (!this.hasContent()) { + this.lastWasCarriageReturn = false; + return null; + } + + const payload = { + message: this.buffer, + echoed: this.localEchoEnabled, + }; + + this.reset(); + + return payload; + } + + /** + * Commits the current buffer to the consumer and resets editing state. Local + * echo is honoured by writing CRLF before the callback is fired. + */ + private commitBuffer(): void { + const message = this.buffer; + + this.reset(); + + if (this.localEchoEnabled) { + this.terminal.write(sequence(CTRL.CR, CTRL.LF)); + } + + this.onCommit({ message, echoed: this.localEchoEnabled }); + } + + /** + * Inserts a printable character at the current cursor position and, when echo + * is enabled, rewrites the tail of the line and moves the cursor back. + */ + private insertCharacter(char: string): void { + const charCode = char.charCodeAt(0); + + if (charCode < 32 && char !== CTRL.TAB) { + return; + } + + const before = this.buffer.slice(0, this.cursor); + const after = this.buffer.slice(this.cursor); + + this.buffer = before + char + after; + this.cursor += 1; + + this.onInputChange?.({ + buffer: this.buffer, + }); + + if (!this.localEchoEnabled) { + return; + } + + this.terminal.write(sequence(char, after)); + + if (after.length > 0) { + this.terminal.write(cursorLeft(after.length)); + } + } + + /** + * Removes a character left of the cursor and reflows the remaining suffix so + * that the terminal visually matches the updated buffer. + */ + private applyBackspace(): void { + if (this.cursor === 0) { + return; + } + + const before = this.buffer.slice(0, this.cursor - 1); + const after = this.buffer.slice(this.cursor); + + this.buffer = before + after; + this.cursor -= 1; + + this.onInputChange?.({ + buffer: this.buffer, + }); + + if (!this.localEchoEnabled) { + return; + } + + if (after.length > 0) { + this.terminal.write(sequence(CTRL.BS, after, ' ')); + this.terminal.write(cursorLeft(after.length + 1)); + } else { + this.terminal.write(backspaceErase); + } + } + + /** + * Moves the logical cursor to the left and emits the matching terminal escape. + */ + private moveCursorLeft(amount: number): void { + if (amount <= 0) { + return; + } + + const target = Math.max(0, this.cursor - amount); + const delta = this.cursor - target; + + if (delta === 0) { + return; + } + + this.cursor = target; + + if (this.localEchoEnabled) { + this.terminal.write(cursorLeft(delta)); + } + } + + /** + * Moves the logical cursor to the right and emits the matching terminal escape. + */ + private moveCursorRight(amount: number): void { + if (amount <= 0) { + return; + } + + const target = Math.min(this.buffer.length, this.cursor + amount); + const delta = target - this.cursor; + + if (delta === 0) { + return; + } + + this.cursor = target; + + if (this.localEchoEnabled) { + this.terminal.write(cursorRight(delta)); + } + } + + /** + * Parses an escape sequence (CSI or SS3) emitted by the terminal for arrow keys. + * Cursor keys are translated into logical cursor movements. + * + * @returns number of characters consumed from the segment. + */ + private handleEscapeSequence(segment: string): number { + if (segment.startsWith(SS3)) { + if (segment.length < SS3_LEN) { + return 0; // incomplete SS3 + } + + const control = segment[2]; + + switch (control) { + case 'C': + this.moveCursorRight(1); + break; + case 'D': + this.moveCursorLeft(1); + break; + case 'H': + this.moveCursorToStart(); + break; + case 'F': + this.moveCursorToEnd(); + break; + default: + break; + } + + return SS3_LEN; + } + + const match = segment.match(CSI_REGEX); + + if (!match) { + // Incomplete CSI (ESC [ ... without terminator) + if (segment.startsWith(CTRL.ESC + '[')) { + return 0; + } + + // Unknown sequence: consume ESC to avoid locking up + return CTRL.ESC.length; + } + + const token = match[0]; + const finalChar = token[token.length - 1]; + const params = token.slice(2, -1); + const amount = + params.length === 0 ? 1 : Number.parseInt(params.split(';')[0], 10) || 1; + + switch (finalChar) { + case 'C': + this.moveCursorRight(amount); + break; + case 'D': + this.moveCursorLeft(amount); + break; + case 'H': + this.moveCursorToStart(); + break; + case 'F': + this.moveCursorToEnd(); + break; + case '~': + switch (amount) { + case 1: + case 7: + this.moveCursorToStart(); + break; + case 4: + case 8: + this.moveCursorToEnd(); + break; + case 3: + this.applyDelete(); + break; + default: + break; + } + break; + default: + break; + } + + return token.length; + } + + /** + * Removes the character at the cursor position without moving the cursor. + * The suffix is reflowed to keep the terminal in sync with the buffer. + */ + private applyDelete(): void { + if (this.cursor >= this.buffer.length) { + return; + } + + const before = this.buffer.slice(0, this.cursor); + const after = this.buffer.slice(this.cursor + 1); + + this.buffer = before + after; + + this.onInputChange?.({ + buffer: this.buffer, + }); + + if (!this.localEchoEnabled) { + return; + } + + if (after.length > 0) { + this.terminal.write(sequence(after, ' ')); + this.terminal.write(cursorLeft(after.length + 1)); + } else { + this.terminal.write(' '); + this.terminal.write(cursorLeft(1)); + } + } + + private moveCursorToStart(): void { + this.moveCursorLeft(this.cursor); + } + + private moveCursorToEnd(): void { + this.moveCursorRight(this.buffer.length - this.cursor); + } +} diff --git a/frontend/src/app/features/terminal/mud-prompt.manager.spec.ts b/frontend/src/app/features/terminal/mud-prompt.manager.spec.ts new file mode 100644 index 0000000..c5fa6e5 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-prompt.manager.spec.ts @@ -0,0 +1,714 @@ +import { Terminal } from '@xterm/xterm'; + +import { CTRL } from './models/escapes'; +import type { MudInputController } from './mud-input.controller'; +import { MudPromptManager, type MudPromptContext } from './mud-prompt.manager'; + +describe('MudPromptManager', () => { + let terminal: Terminal; + let inputController: jest.Mocked; + let manager: MudPromptManager; + let terminalWriteSpy: jest.SpyInstance; + + const createContext = ( + overrides: Partial = {}, + ): MudPromptContext => ({ + isEditMode: true, + terminalReady: true, + localEchoEnabled: true, + ...overrides, + }); + + beforeEach(() => { + terminal = new Terminal(); + terminalWriteSpy = jest.spyOn(terminal, 'write'); + + inputController = { + hasContent: jest.fn(), + getSnapshot: jest.fn(), + } as unknown as jest.Mocked; + + manager = new MudPromptManager(terminal, inputController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('reset()', () => { + it('should clear all state variables', () => { + // Arrange: Set some state + manager.beforeServerOutput(createContext()); + manager['currentPrompt'] = 'test> '; + manager['stripNextLineBreak'] = true; + manager['incompleteEscape'] = '\x1b['; + + // Act + manager.reset(); + + // Assert + expect(manager['currentPrompt']).toBe(''); + expect(manager['stripNextLineBreak']).toBe(false); + expect(manager['incompleteEscape']).toBe(''); + expect(manager['lineHidden']).toBe(false); + }); + }); + + describe('transformOutput()', () => { + it('should strip leading CRLF when flag is set', () => { + // Arrange + manager['stripNextLineBreak'] = true; + + // Act + const result = manager.transformOutput('\r\nHello World'); + + // Assert + expect(result).toBe('Hello World'); + expect(manager['stripNextLineBreak']).toBe(false); + }); + + it('should strip leading LF only (Unix style)', () => { + // Arrange + manager['stripNextLineBreak'] = true; + + // Act + const result = manager.transformOutput('\nHello World'); + + // Assert + expect(result).toBe('Hello World'); + }); + + it('should strip leading CR only (old Mac style)', () => { + // Arrange + manager['stripNextLineBreak'] = true; + + // Act + const result = manager.transformOutput('\rHello World'); + + // Assert + expect(result).toBe('Hello World'); + }); + + it('should return data unchanged when flag is false', () => { + // Arrange + manager['stripNextLineBreak'] = false; + + // Act + const result = manager.transformOutput('\r\nHello World'); + + // Assert + expect(result).toBe('\r\nHello World'); + }); + + it('should reset flag even if no line break found', () => { + // Arrange + manager['stripNextLineBreak'] = true; + + // Act + const result = manager.transformOutput('Hello World'); + + // Assert + expect(result).toBe('Hello World'); + expect(manager['stripNextLineBreak']).toBe(false); + }); + + it('should handle empty string', () => { + // Arrange + manager['stripNextLineBreak'] = true; + + // Act + const result = manager.transformOutput(''); + + // Assert + expect(result).toBe(''); + expect(manager['stripNextLineBreak']).toBe(true); // Not consumed + }); + + it('should not strip multiple line breaks', () => { + // Arrange + manager['stripNextLineBreak'] = true; + + // Act + const result = manager.transformOutput('\r\n\r\nDouble break'); + + // Assert + expect(result).toBe('\r\nDouble break'); // Only first CRLF stripped + }); + }); + + describe('beforeServerOutput()', () => { + it('should hide line when all conditions are met', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + const context = createContext(); + + // Act + manager.beforeServerOutput(context); + + // Assert + expect(terminalWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('\r'), + ); + expect(manager['lineHidden']).toBe(true); + expect(manager['stripNextLineBreak']).toBe(true); + }); + + it('should not hide line when not in edit mode', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + const context = createContext({ isEditMode: false }); + + // Act + manager.beforeServerOutput(context); + + // Assert + expect(terminalWriteSpy).not.toHaveBeenCalled(); + expect(manager['lineHidden']).toBe(false); + }); + + it('should not hide line when terminal not ready', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + const context = createContext({ terminalReady: false }); + + // Act + manager.beforeServerOutput(context); + + // Assert + expect(terminalWriteSpy).not.toHaveBeenCalled(); + expect(manager['lineHidden']).toBe(false); + }); + + it('should not hide line when local echo disabled', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + const context = createContext({ localEchoEnabled: false }); + + // Act + manager.beforeServerOutput(context); + + // Assert + expect(terminalWriteSpy).not.toHaveBeenCalled(); + expect(manager['lineHidden']).toBe(false); + }); + + it('should not hide line when already hidden', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + manager['lineHidden'] = true; + const context = createContext(); + + // Act + manager.beforeServerOutput(context); + + // Assert + expect(terminalWriteSpy).not.toHaveBeenCalled(); + }); + + it('should not hide line when no content', () => { + // Arrange + inputController.hasContent.mockReturnValue(false); + manager['currentPrompt'] = ''; + const context = createContext(); + + // Act + manager.beforeServerOutput(context); + + // Assert + expect(terminalWriteSpy).not.toHaveBeenCalled(); + expect(manager['lineHidden']).toBe(false); + }); + + it('should hide line when prompt exists even without user input', () => { + // Arrange + inputController.hasContent.mockReturnValue(false); + manager['currentPrompt'] = '> '; + const context = createContext(); + + // Act + manager.beforeServerOutput(context); + + // Assert + expect(terminalWriteSpy).toHaveBeenCalled(); + expect(manager['lineHidden']).toBe(true); + }); + + it('should preserve currentPrompt when hiding', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + manager['currentPrompt'] = 'HP:100> '; + const context = createContext(); + + // Act + manager.beforeServerOutput(context); + + // Assert + expect(manager['currentPrompt']).toBe('HP:100> '); // Not cleared + }); + }); + + describe('afterServerOutput() and restoreLine()', () => { + beforeEach(() => { + // Mock queueMicrotask to execute synchronously + global.queueMicrotask = jest.fn((callback) => callback()) as any; + }); + + it('should restore line after server output', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + inputController.getSnapshot.mockReturnValue({ + buffer: 'say hello', + cursor: 9, + }); + manager['lineHidden'] = true; + manager['currentPrompt'] = '> '; + const context = createContext(); + + // Act + manager.afterServerOutput('test\r\n> ', context); + + // Assert: Line should be restored + expect(terminalWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('> '), + ); + expect(terminalWriteSpy).toHaveBeenCalledWith('say hello'); + expect(manager['lineHidden']).toBe(false); + }); + + it('should not restore when line is not hidden', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + manager['lineHidden'] = false; + const context = createContext(); + terminalWriteSpy.mockClear(); + + // Act + manager.afterServerOutput('test', context); + + // Assert + expect(terminalWriteSpy).not.toHaveBeenCalled(); + }); + + it('should not restore when no content', () => { + // Arrange + inputController.hasContent.mockReturnValue(false); + manager['lineHidden'] = true; + manager['currentPrompt'] = ''; + const context = createContext(); + terminalWriteSpy.mockClear(); + + // Act: afterServerOutput should NOT call restoreLine when no content + manager.afterServerOutput('test', context); + + // Assert: queueMicrotask should NOT have been called + expect(terminalWriteSpy).not.toHaveBeenCalled(); + }); + + it('should reposition cursor when not at end', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + inputController.getSnapshot.mockReturnValue({ + buffer: 'say hello', + cursor: 4, // After "say " + }); + manager['lineHidden'] = true; + manager['currentPrompt'] = '> '; + const context = createContext(); + + // Act + manager.afterServerOutput('test\r\n> ', context); + + // Assert: Cursor should move left by (9 - 4) = 5 + expect(terminalWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('\x1b[5D'), + ); + }); + + it('should handle context changes during async restore (race condition)', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + inputController.getSnapshot.mockReturnValue({ + buffer: 'test', + cursor: 4, + }); + manager['lineHidden'] = true; + const context = createContext(); + + // Mock queueMicrotask to modify context before executing + global.queueMicrotask = jest.fn((callback) => { + // Context changes before callback executes + return callback(); + }) as any; + + // Change context after afterServerOutput but before restore + const changedContext = createContext({ isEditMode: false }); + + // Act: Pass original context, but it should be snapshotted + manager.afterServerOutput('test', context); + + // Manually call restoreLine with changed context to simulate race + terminalWriteSpy.mockClear(); + manager['restoreLine'](changedContext); + + // Assert: Should abort restore due to context change + expect(terminalWriteSpy).not.toHaveBeenCalled(); + expect(manager['lineHidden']).toBe(false); // Flag cleared + }); + + it('should validate snapshot integrity', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + inputController.getSnapshot.mockReturnValue({ + buffer: 'test', + cursor: 10, // Invalid: cursor beyond buffer length + }); + manager['lineHidden'] = true; + const context = createContext(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Act + manager.afterServerOutput('test', context); + + // Assert + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid snapshot'), + expect.any(Object), + expect.any(String), + ); + expect(manager['lineHidden']).toBe(false); + consoleErrorSpy.mockRestore(); + }); + + it('should restore only prompt when no user input', () => { + // Arrange + inputController.hasContent.mockReturnValue(false); + inputController.getSnapshot.mockReturnValue({ + buffer: '', + cursor: 0, + }); + manager['lineHidden'] = true; + manager['currentPrompt'] = 'HP:50> '; + const context = createContext(); + + // Act + manager.afterServerOutput('test\r\nHP:50> ', context); + + // Assert + expect(terminalWriteSpy).toHaveBeenCalledWith('HP:50> '); + expect(manager['lineHidden']).toBe(false); + }); + }); + + describe('trackServerLine() - ANSI handling', () => { + it('should accumulate prompt characters', () => { + // Act + manager['trackServerLine']('> '); + + // Assert + expect(manager['currentPrompt']).toBe('> '); + }); + + it('should reset prompt on CR', () => { + // Arrange + manager['currentPrompt'] = 'old prompt'; + + // Act + manager['trackServerLine']('new\rprompt'); + + // Assert + expect(manager['currentPrompt']).toBe('prompt'); + }); + + it('should reset prompt on LF', () => { + // Arrange + manager['currentPrompt'] = 'old prompt'; + + // Act + manager['trackServerLine']('new\nprompt'); + + // Assert + expect(manager['currentPrompt']).toBe('prompt'); + }); + + it('should preserve ANSI color codes', () => { + // Act + manager['trackServerLine']('\x1b[31mRed> \x1b[0m'); + + // Assert + expect(manager['currentPrompt']).toBe('\x1b[31mRed> \x1b[0m'); + }); + + it('should handle incomplete escape at chunk boundary', () => { + // Act: First chunk ends with incomplete CSI (\x1b[ has no terminator) + manager['trackServerLine']('Test\x1b['); + + // Assert: Incomplete escape buffered (but 'Test' was added to prompt first) + expect(manager['incompleteEscape']).toBe('\x1b['); + expect(manager['currentPrompt']).toBe('Test'); + + // Act: Second chunk completes the escape + manager['trackServerLine']('31mRed'); + + // Assert: Complete escape preserved + expect(manager['incompleteEscape']).toBe(''); + expect(manager['currentPrompt']).toBe('Test\x1b[31mRed'); + }); + + it('should handle backspace with ANSI-aware removal', () => { + // Arrange + manager['currentPrompt'] = 'Test\x1b[31mX\x1b[0m'; + + // Act: Server sends backspace + manager['trackServerLine'](CTRL.BS); + + // Assert: Last visible char 'X' removed, escapes preserved + expect(manager['currentPrompt']).toBe('Test\x1b[31m\x1b[0m'); + }); + + it('should handle backspace removing regular character', () => { + // Arrange + manager['currentPrompt'] = 'Hello'; + + // Act + manager['trackServerLine'](CTRL.BS); + + // Assert + expect(manager['currentPrompt']).toBe('Hell'); + }); + + it('should handle multiple backspaces', () => { + // Arrange + manager['currentPrompt'] = 'Test'; + + // Act + manager['trackServerLine'](CTRL.BS + CTRL.BS); + + // Assert + expect(manager['currentPrompt']).toBe('Te'); + }); + + it('should handle DELETE character same as backspace', () => { + // Arrange + manager['currentPrompt'] = 'Test'; + + // Act + manager['trackServerLine'](CTRL.DEL); + + // Assert + expect(manager['currentPrompt']).toBe('Tes'); + }); + + it('should handle complex prompt with multiple ANSI codes', () => { + // Act + manager['trackServerLine']('\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m> '); + + // Assert + expect(manager['currentPrompt']).toBe( + '\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m> ', + ); + }); + + it('should handle SS3 sequences (arrow keys)', () => { + // Act + manager['trackServerLine']('Prompt> \x1bOH'); // Home key + + // Assert + expect(manager['currentPrompt']).toBe('Prompt> \x1bOH'); + }); + }); + + describe('removeLastVisibleChar() - ANSI-aware backspace', () => { + it('should remove last regular character', () => { + // Act + const result = manager['removeLastVisibleChar']('Hello'); + + // Assert + expect(result).toBe('Hell'); + }); + + it('should skip over trailing escape sequence', () => { + // Arrange: String ends with ANSI reset code + const input = 'Test\x1b[0m'; + + // Act + const result = manager['removeLastVisibleChar'](input); + + // Assert: 't' removed, escape preserved + expect(result).toBe('Tes\x1b[0m'); + }); + + it('should remove character before escape sequence', () => { + // Arrange + const input = 'A\x1b[31mB'; + + // Act + const result = manager['removeLastVisibleChar'](input); + + // Assert: 'B' removed + expect(result).toBe('A\x1b[31m'); + }); + + it('should handle multiple escape sequences', () => { + // Arrange: "X" then red code then reset code + const input = 'X\x1b[31m\x1b[0m'; + + // Act + const result = manager['removeLastVisibleChar'](input); + + // Assert: 'X' removed, both escapes preserved + expect(result).toBe('\x1b[31m\x1b[0m'); + }); + + it('should handle empty string', () => { + // Act + const result = manager['removeLastVisibleChar'](''); + + // Assert + expect(result).toBe(''); + }); + + it('should handle string with only escape sequences', () => { + // Arrange + const input = '\x1b[31m\x1b[0m'; + + // Act + const result = manager['removeLastVisibleChar'](input); + + // Assert: No visible chars, return unchanged + expect(result).toBe('\x1b[31m\x1b[0m'); + }); + + it('should handle complex real-world prompt', () => { + // Arrange: HP bar with color codes + const input = '\x1b[32mHP:\x1b[0m100\x1b[32m>\x1b[0m '; + + // Act: Remove the trailing space + const result = manager['removeLastVisibleChar'](input); + + // Assert + expect(result).toBe('\x1b[32mHP:\x1b[0m100\x1b[32m>\x1b[0m'); + }); + }); + + describe('skipEscapeSequence()', () => { + it('should detect CSI sequence', () => { + // Act + const length = manager['skipEscapeSequence']('\x1b[31mRest'); + + // Assert + expect(length).toBe(5); // ESC [ 3 1 m + }); + + it('should detect SS3 sequence', () => { + // Act + const length = manager['skipEscapeSequence']('\x1bOHRest'); + + // Assert + expect(length).toBe(3); // ESC O H + }); + + it('should return 0 for incomplete CSI', () => { + // Act: ESC[ without terminator is incomplete + const length = manager['skipEscapeSequence']('\x1b['); + + // Assert: Should return 0 (incomplete) + expect(length).toBe(0); + }); + + it('should return 0 for incomplete SS3', () => { + // Act + const length = manager['skipEscapeSequence']('\x1bO'); + + // Assert + expect(length).toBe(0); // Incomplete + }); + + it('should return 0 for lone ESC', () => { + // Act + const length = manager['skipEscapeSequence']('\x1b'); + + // Assert + expect(length).toBe(0); // Incomplete, might be start of sequence + }); + + it('should handle ESC followed by unexpected character', () => { + // Act + const length = manager['skipEscapeSequence']('\x1bX'); + + // Assert + expect(length).toBe(1); // Just ESC, not a known sequence + }); + + it('should detect complex CSI with parameters', () => { + // Act + const length = manager['skipEscapeSequence']('\x1b[1;32mRest'); + + // Assert + expect(length).toBe(7); // ESC [ 1 ; 3 2 m + }); + }); + + describe('Integration: Full hide/restore cycle', () => { + beforeEach(() => { + global.queueMicrotask = jest.fn((callback) => callback()) as any; + }); + + it('should complete full cycle with ANSI prompt', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + inputController.getSnapshot.mockReturnValue({ + buffer: 'look', + cursor: 4, + }); + + // Initial prompt from server + manager['trackServerLine']('\x1b[32mHP:100\x1b[0m> '); + expect(manager['currentPrompt']).toBe('\x1b[32mHP:100\x1b[0m> '); + + const context = createContext(); + + // Act: Hide before server output + manager.beforeServerOutput(context); + expect(manager['lineHidden']).toBe(true); + + // Server sends output with new prompt + const transformed = manager.transformOutput( + '\r\nYou see nothing.\r\n\x1b[32mHP:95\x1b[0m> ', + ); + expect(transformed).toBe('You see nothing.\r\n\x1b[32mHP:95\x1b[0m> '); + + // Restore after server output + terminalWriteSpy.mockClear(); + manager.afterServerOutput(transformed, context); + + // Assert: Prompt updated and line restored + expect(manager['currentPrompt']).toBe('\x1b[32mHP:95\x1b[0m> '); + expect(manager['lineHidden']).toBe(false); + expect(terminalWriteSpy).toHaveBeenCalledWith('\x1b[32mHP:95\x1b[0m> '); + expect(terminalWriteSpy).toHaveBeenCalledWith('look'); + }); + + it('should handle rapid server output bursts', () => { + // Arrange + inputController.hasContent.mockReturnValue(true); + inputController.getSnapshot.mockReturnValue({ + buffer: 'test', + cursor: 4, + }); + const context = createContext(); + + // Act: First output + manager.beforeServerOutput(context); + manager.afterServerOutput('Message 1\r\n> ', context); + expect(manager['lineHidden']).toBe(false); + + // Second output arrives immediately + manager.beforeServerOutput(context); + manager.afterServerOutput('Message 2\r\n> ', context); + + // Assert: Should complete successfully both times + expect(manager['lineHidden']).toBe(false); + expect(manager['currentPrompt']).toBe('> '); + }); + }); +}); diff --git a/frontend/src/app/features/terminal/mud-prompt.manager.ts b/frontend/src/app/features/terminal/mud-prompt.manager.ts new file mode 100644 index 0000000..bafe5d8 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-prompt.manager.ts @@ -0,0 +1,580 @@ +import type { Terminal } from '@xterm/xterm'; + +import { + CTRL, + CSI_REGEX, + SS3, + SS3_LEN, + cursorLeft, + resetLine, +} from './models/escapes'; +import type { MudInputController } from './mud-input.controller'; + +/** + * Minimal context required to decide whether the prompt may be hidden/restored. + */ +export type MudPromptContext = { + isEditMode: boolean; + terminalReady: boolean; + localEchoEnabled: boolean; +}; + +/** + * Manages the visibility cycle of the user's input line during server output. + * + * ## Problem + * When the server sends output while the user is typing, we must prevent the + * server text from interleaving with the local input buffer. This manager + * temporarily hides the current line, lets the server output render, then + * restores the prompt and user input. + * + * ## State Machine + * ``` + * ┌─────────┐ beforeServerOutput() ┌────────┐ + * │ VISIBLE │ ────────────────────> │ HIDDEN │ + * │ │ │ │ + * │ User is │ │ Server │ + * │ typing │ │ writes │ + * │ │ <───────────────────- │ │ + * └─────────┘ restoreLine() └────────┘ + * (async via queueMicrotask) + * ``` + * + * ## State Variables + * - **currentPrompt**: The prompt characters accumulated from server output + * (e.g., "> " or "HP:100> "). Reset on CR/LF. Preserved across hide/restore. + * Example progression: "" → ">" → "> " (as server sends chars) + * + * - **stripNextLineBreak**: Boolean flag indicating we should remove leading + * CRLF from the next server chunk to prevent blank lines after restore. + * Set to true in beforeServerOutput(), consumed in transformOutput(). + * + * - **incompleteEscape**: Buffer holding partial ANSI escape sequence from + * previous chunk (e.g., if chunk ends with "\x1b["). Combined with next + * chunk to parse complete sequence. + * + * - **lineHidden**: Boolean guard preventing double hide/restore operations. + * Set to true in beforeServerOutput(), false in restoreLine(). + * + * @example + * // Scenario: User types "say hello" while server sends combat message + * // 1. User buffer: "say hello", cursor at position 9 + * // 2. Server about to send: "\r\nGoblin attacks!\r\n> " + * // 3. beforeServerOutput(): Hide line, set stripNextLineBreak=true + * // 4. transformOutput(): Strip leading "\r\n", return "Goblin attacks!\r\n> " + * // 5. trackServerLine(): Accumulate "> " into currentPrompt + * // 6. afterServerOutput(): Schedule restore + * // 7. restoreLine(): Write "> say hello", cursor at 9 + */ +export class MudPromptManager { + /** Current prompt accumulated from server output (e.g., "> " or "HP:100> ") */ + private currentPrompt = ''; + + /** Flag to strip next CRLF sequence to prevent blank line after restore */ + private stripNextLineBreak = false; + + /** Buffer for incomplete escape sequence at chunk boundary */ + private incompleteEscape = ''; + + /** Guard flag: true when line is hidden, false when visible */ + private lineHidden = false; + + /** + * @param terminal xterm instance that receives redraw commands. + * @param inputController input controller used to fetch the editable buffer. + */ + constructor( + private readonly terminal: Terminal, + private readonly inputController: MudInputController, + ) {} + + /** + * Clears all tracked prompt state. + * + * **When to call:** + * - LINEMODE changes (edit ↔ character mode) + * - Terminal is reinitialized + * - Connection is reset + * + * **Postcondition:** All state variables are reset to initial values. + */ + public reset(): void { + this.currentPrompt = ''; + this.stripNextLineBreak = false; + this.incompleteEscape = ''; + this.lineHidden = false; + } + + /** + * Strips leading CRLF sequence from server output after a line was hidden. + * + * **Purpose:** When we hide the user's line, the terminal cursor is at column 0. + * The next server output often starts with "\r\n" to move to a new line, but + * since we already cleared the line, this would create a blank row. We strip + * exactly one CRLF sequence to prevent this. + * + * **Precondition:** stripNextLineBreak was set to true in beforeServerOutput() + * **Postcondition:** stripNextLineBreak is false, leading CRLF (if present) removed + * + * @param data Raw server output chunk + * @returns Transformed data with leading CRLF stripped (if flag was set) + * + * @example + * // stripNextLineBreak = true + * transformOutput("\r\nYou see a goblin.\r\n> ") + * // returns: "You see a goblin.\r\n> " + * // stripNextLineBreak = false + */ + public transformOutput(data: string): string { + if (!this.stripNextLineBreak || data.length === 0) { + return data; + } + + let startIndex = 0; + + // Handle CRLF as atomic unit: \r\n (Windows style) + if (data.startsWith(CTRL.CR + CTRL.LF)) { + startIndex = 2; + } + // Handle LF only (Unix style) + else if (data.startsWith(CTRL.LF)) { + startIndex = 1; + } + // Handle CR only (old Mac style) + else if (data.startsWith(CTRL.CR)) { + startIndex = 1; + } + + // Always reset flag after first call, even if no line break found + this.stripNextLineBreak = false; + + return startIndex > 0 ? data.slice(startIndex) : data; + } + + /** + * Hides the current line before server output is rendered. + * + * **Preconditions:** + * - Must be in edit mode (LINEMODE.edit = true) + * - Terminal must be ready (after ngAfterViewInit) + * - Local echo must be enabled (edit mode AND server allows echo) + * - Line must not already be hidden (prevents double-hide) + * - Must have content to hide (prompt or user input) + * + * **Operation:** + * 1. Clear terminal line (cursor moves to column 0) + * 2. Set stripNextLineBreak flag (for transformOutput) + * 3. Mark line as hidden + * + * **Postcondition:** + * - Terminal line is cleared + * - lineHidden = true + * - stripNextLineBreak = true + * - currentPrompt preserved (not cleared) + * + * @param context Current terminal/mode state + */ + public beforeServerOutput(context: MudPromptContext): void { + // Guard: Check all preconditions + if ( + !context.isEditMode || + !context.terminalReady || + !context.localEchoEnabled || + this.lineHidden + ) { + return; + } + + // Check if there's anything to hide (prompt or user input) + const hasLineContent = + this.inputController.hasContent() || this.currentPrompt.length > 0; + + if (!hasLineContent) { + return; + } + + // Clear the terminal line and prepare for restoration + this.terminal.write(resetLine); + this.stripNextLineBreak = true; + this.lineHidden = true; + // Note: currentPrompt is NOT cleared - we need it for restore + } + + /** + * Schedules prompt restoration after server output has been rendered. + * + * **Purpose:** After the server writes its output, we need to restore the + * user's input line. This happens asynchronously (next microtask) to ensure + * the terminal has finished rendering the server chunk first. + * + * **Preconditions:** + * - Line must be hidden (lineHidden = true) + * - Must be in edit mode with local echo + * - Must have content to restore (prompt or user input) + * + * **Operation:** + * 1. Parse server output to update currentPrompt + * 2. Create context snapshot (fixes race condition) + * 3. Schedule async restore via queueMicrotask + * + * **Race Condition Fix:** We snapshot the context now rather than passing + * the reference, because by the time restoreLine() executes, the actual + * component state may have changed (e.g., mode switch, echo toggle). + * + * @param data Server output chunk (after transformOutput) + * @param context Current terminal/mode state (will be snapshotted) + */ + public afterServerOutput(data: string, context: MudPromptContext): void { + // Always track server output to update currentPrompt + this.trackServerLine(data); + + // Guard: Check if restoration is needed + if ( + !this.lineHidden || + !context.isEditMode || + !context.terminalReady || + !context.localEchoEnabled + ) { + return; + } + + // Check if there's anything to restore + if (!this.inputController.hasContent() && this.currentPrompt.length === 0) { + return; + } + + // Create context snapshot to avoid race condition + const contextSnapshot: MudPromptContext = { + isEditMode: context.isEditMode, + terminalReady: context.terminalReady, + localEchoEnabled: context.localEchoEnabled, + }; + + // Schedule async restore (terminal needs to finish rendering first) + queueMicrotask(() => this.restoreLine(contextSnapshot)); + } + + /** + * Restores the hidden line to the terminal (async, called via queueMicrotask). + * + * **Preconditions:** + * - lineHidden must be true + * - Context must still be valid (edit mode, terminal ready, echo enabled) + * - Terminal has finished rendering server output + * + * **Operation:** + * 1. Validate state (if invalid, clear lineHidden flag and abort) + * 2. Get input snapshot from controller + * 3. Validate snapshot integrity (cursor within buffer bounds) + * 4. Clear terminal line + * 5. Write currentPrompt (if any) + * 6. Write user's input buffer + * 7. Reposition cursor to match snapshot + * 8. Update state flags + * + * **Postcondition:** + * - Terminal displays: currentPrompt + user buffer + * - Cursor is at correct position + * - lineHidden = false + * + * @param context Snapshotted context from afterServerOutput (immutable) + */ + private restoreLine(context: MudPromptContext): void { + // Guard: Line must be hidden + if (!this.lineHidden) { + return; + } + + // Validate context is still appropriate for restoration + if ( + !context.isEditMode || + !context.terminalReady || + !context.localEchoEnabled + ) { + // Context changed - clear flag but preserve state for next time + this.lineHidden = false; + return; + } + + // Get current input state + const snapshot = this.inputController.getSnapshot(); + + // Validate snapshot exists + if (!snapshot) { + console.error( + '[MudPromptManager] No snapshot available - aborting restore', + ); + this.lineHidden = false; + return; + } + + // Validate snapshot integrity + if (snapshot.cursor < 0 || snapshot.cursor > snapshot.buffer.length) { + console.error( + '[MudPromptManager] Invalid snapshot:', + snapshot, + '- aborting restore', + ); + this.lineHidden = false; + return; + } + + // Clear line and rewrite everything + this.terminal.write(resetLine); + + // Write prompt if present + if (this.currentPrompt.length > 0) { + this.terminal.write(this.currentPrompt); + } + + // Write user's input buffer + if (snapshot.buffer.length > 0) { + this.terminal.write(snapshot.buffer); + } + + // Reposition cursor if not at end + const moveLeft = snapshot.buffer.length - snapshot.cursor; + if (moveLeft > 0) { + this.terminal.write(cursorLeft(moveLeft)); + } + + // Update state + this.lineHidden = false; + // Note: currentPrompt is NOT cleared - we need it for next cycle + } + + /** + * Parses server output to maintain currentPrompt state. + * + * **Purpose:** As the server sends characters, we track the current line to + * know what the prompt looks like. This is used when restoring the line. + * + * **Behavior:** + * - CR/LF: Reset currentPrompt (new line started) + * - BS/DEL: Remove last *visible* character (ANSI-aware) + * - ESC sequences: Preserve entire sequence in prompt + * - Regular chars: Append to currentPrompt + * - Incomplete escapes: Buffer in incompleteEscape for next chunk + * + * **ANSI-Aware Backspace:** When server sends backspace, we don't blindly + * remove the last character. Instead, we skip backwards over ANSI escape + * sequences to remove the last *visible* character. + * + * @param chunk Server output chunk (after transformOutput) + * + * @example + * // Input: "HP:\x1b[31m100\x1b[0m> " + * // After CR: currentPrompt = "" + * // After 'H': currentPrompt = "H" + * // After 'P': currentPrompt = "HP" + * // After ':\x1b[31m': currentPrompt = "HP:\x1b[31m" + * // After '1': currentPrompt = "HP:\x1b[31m1" + * // etc. + */ + private trackServerLine(chunk: string): void { + // Prepend any incomplete escape from previous chunk + const data = this.incompleteEscape + chunk; + this.incompleteEscape = ''; + + let index = 0; + + while (index < data.length) { + const char = data[index]; + + // Line breaks reset the prompt + if (char === CTRL.CR || char === CTRL.LF) { + this.currentPrompt = ''; + index += 1; + continue; + } + + // Backspace/Delete: Remove last visible character (ANSI-aware) + if (char === CTRL.BS || char === CTRL.DEL) { + this.currentPrompt = this.removeLastVisibleChar(this.currentPrompt); + index += 1; + continue; + } + + // Escape sequence: Parse and preserve in prompt + if (char === CTRL.ESC) { + const remaining = data.slice(index); + const consumed = this.skipEscapeSequence(remaining); + + // Check if escape sequence is incomplete (at chunk boundary) + if (consumed === 0) { + // Incomplete sequence - buffer it for next chunk + this.incompleteEscape = remaining; + break; // Stop processing this chunk + } + + const parsedSequence = data.slice(index, index + consumed); + this.currentPrompt += parsedSequence; + index += consumed; + continue; + } + + // Regular character: Append to prompt + this.currentPrompt += char; + index += 1; + } + } + + /** + * Detects and measures ANSI escape sequences. + * + * **Purpose:** When we encounter ESC in the stream, we need to know how many + * characters belong to the complete escape sequence so we can preserve it + * as a unit in the prompt. + * + * **Supported Sequences:** + * - CSI: ESC [ ... [A-Za-z~] (e.g., ESC[31m for red color) + * - SS3: ESC O X (e.g., ESC O H for Home key) + * + * **Incomplete Detection:** If the segment starts with ESC but doesn't + * contain a complete sequence, returns 0 to signal "buffer this for next chunk". + * + * @param segment String starting with ESC character + * @returns Number of characters in the complete escape sequence, or 0 if incomplete + * + * @example + * skipEscapeSequence("\x1b[31mHello") // returns 5 (ESC[31m) + * skipEscapeSequence("\x1bOH") // returns 3 (ESCOH) + * skipEscapeSequence("\x1b[") // returns 0 (incomplete CSI) + * skipEscapeSequence("\x1b") // returns 0 (incomplete, might be CSI or SS3) + */ + private skipEscapeSequence(segment: string): number { + // Must start with ESC + if (!segment.startsWith(CTRL.ESC)) { + return 0; + } + + // Check for SS3 (3 characters: ESC O X) + if (segment.startsWith(SS3)) { + if (segment.length >= SS3_LEN) { + return SS3_LEN; + } + // Incomplete SS3 (need 1 more character) + return 0; + } + + // Check for CSI (ESC [ ...) + const match = segment.match(CSI_REGEX); + if (match) { + return match[0].length; + } + + // Check if it looks like start of CSI but incomplete (ESC [ without terminator) + if (segment.length >= 2 && segment[1] === '[') { + // Incomplete CSI sequence + return 0; + } + + // ESC not followed by [ or O - might be incomplete + if (segment.length === 1) { + return 0; // Just ESC, need more data + } + + // ESC followed by something else - treat as single ESC + return CTRL.ESC.length; + } + + /** + * Removes the last visible character from a string, skipping ANSI sequences. + * + * **Purpose:** When the server sends a backspace, we want to remove the last + * *visible* character, not just the last byte. If the last character is part + * of an ANSI escape sequence, we need to skip backwards over the entire + * sequence to find the actual visible character to remove. + * + * **Algorithm:** + * 1. Scan backwards from end + * 2. If we find a regular character, remove it and return + * 3. If we find an escape sequence, skip over it entirely + * 4. Repeat until we find a visible character or reach start + * + * @param str String potentially containing ANSI escape sequences + * @returns String with last visible character removed + * + * @example + * removeLastVisibleChar("Hello") // "Hell" + * removeLastVisibleChar("Test\x1b[31m") // "Test\x1b[31m" (no visible char after escape) + * removeLastVisibleChar("A\x1b[31mB") // "A\x1b[31m" (removes 'B') + * removeLastVisibleChar("X\x1b[31mY\x1b[0m") // "X\x1b[31m\x1b[0m" (removes 'Y', keeps both escapes) + */ + private removeLastVisibleChar(str: string): string { + if (str.length === 0) { + return str; + } + + let pos = str.length - 1; + + // Scan backwards to find last visible character + while (pos >= 0) { + const char = str[pos]; + + // Found a regular visible character - remove it + if (char !== CTRL.ESC && !this.isPartOfEscapeSequence(str, pos)) { + return str.slice(0, pos) + str.slice(pos + 1); + } + + // If this is part of an escape sequence, skip backwards over it + if (char === CTRL.ESC || this.isPartOfEscapeSequence(str, pos)) { + pos = this.findEscapeStart(str, pos); + pos -= 1; // Move before the escape sequence + continue; + } + + pos -= 1; + } + + // No visible characters found - return as-is + return str; + } + + /** + * Checks if the character at `pos` is part of an escape sequence. + * + * @param str String to check + * @param pos Position to check + * @returns True if the character at pos is inside an escape sequence + */ + private isPartOfEscapeSequence(str: string, pos: number): boolean { + if (pos === 0) { + return false; + } + + // Scan backwards to find a potential ESC start + for (let i = pos; i >= Math.max(0, pos - 20); i--) { + // Look up to 20 chars back (reasonable escape sequence limit) + if (str[i] === CTRL.ESC) { + const segment = str.slice(i); + const length = this.skipEscapeSequence(segment); + // Check if pos falls within this escape sequence + if (length > 0 && i + length > pos) { + return true; + } + break; // Found ESC but pos is not in its range + } + } + + return false; + } + + /** + * Finds the start position of the escape sequence that includes `pos`. + * + * @param str String to search + * @param pos Position within or at start of escape sequence + * @returns Index of ESC character starting the sequence + */ + private findEscapeStart(str: string, pos: number): number { + // Scan backwards to find ESC + for (let i = pos; i >= Math.max(0, pos - 20); i--) { + if (str[i] === CTRL.ESC) { + return i; + } + } + // Shouldn't reach here if called correctly + return pos; + } +} diff --git a/frontend/src/app/features/terminal/mud-screenreader.spec.ts b/frontend/src/app/features/terminal/mud-screenreader.spec.ts new file mode 100644 index 0000000..3d8d923 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-screenreader.spec.ts @@ -0,0 +1,67 @@ +import { MudScreenReaderAnnouncer } from './mud-screenreader'; + +describe('MudScreenReaderAnnouncer', () => { + let liveRegion: HTMLElement; + let announcer: MudScreenReaderAnnouncer; + + beforeEach(() => { + jest.useFakeTimers(); + liveRegion = document.createElement('div'); + // Explicitly skip history region while overriding clear delay for tests + announcer = new MudScreenReaderAnnouncer(liveRegion, undefined, 100); + }); + + afterEach(() => { + announcer.dispose(); + jest.useRealTimers(); + }); + + it('announces sanitized text and clears after delay', () => { + announcer.announce('Hello \x1b[31mWorld\x1b[0m\r\n'); + + expect(liveRegion.textContent).toBe('Hello World'); + + jest.advanceTimersByTime(99); + expect(liveRegion.textContent).toBe('Hello World'); + + jest.advanceTimersByTime(1); + expect(liveRegion.textContent).toBe(''); + }); + + it('ignores announcements older than the current session', () => { + const now = Date.now(); + const earlier = now - 500; + + announcer.markSessionStart(now); + announcer.announce('Old content', earlier); + + expect(liveRegion.textContent).toBe(''); + }); + + it('resets the clear timer for rapid consecutive announcements', () => { + announcer.announce('First'); + jest.advanceTimersByTime(50); + + announcer.announce('Second'); + jest.advanceTimersByTime(99); + + expect(liveRegion.textContent).toBe('Second'); + + jest.advanceTimersByTime(1); + expect(liveRegion.textContent).toBe(''); + }); + + it('clear() empties the live region immediately', () => { + announcer.announce('Message'); + expect(liveRegion.textContent).toBe('Message'); + + announcer.clear(); + expect(liveRegion.textContent).toBe(''); + }); + + it('ignores empty output after normalization', () => { + announcer.announce('\x1b[31m\x1b[0m'); + + expect(liveRegion.textContent).toBe(''); + }); +}); diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts new file mode 100644 index 0000000..192e272 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -0,0 +1,247 @@ +const DEFAULT_CLEAR_DELAY_MS = 300; +const INPUT_CLEAR_DELAY_MS = 700; +const ANSI_ESCAPE_PATTERN = /\x1B\[[0-9;?]*[ -\/]*[@-~]/g; +const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g; + +/** + * Minimal screenreader announcer tailored for xterm output. + * + * Responsibilities: + * - Announce only new chunks (based on session start timestamp) + * - Normalize data by stripping control / ANSI sequences + * - Clear the live region shortly after announcing to avoid re-reading history + */ +export class MudScreenReaderAnnouncer { + private clearTimer: number | undefined; + private inputClearTimer: number | undefined; + private sessionStartedAt: number; + private lastAnnouncedBuffer = ''; + + constructor( + private readonly liveRegion: HTMLElement, + private readonly historyRegion?: HTMLElement, + private readonly inputRegion?: HTMLElement, + private readonly inputCommittedRegion?: HTMLElement, + private readonly clearDelayMs: number = DEFAULT_CLEAR_DELAY_MS, + ) { + this.sessionStartedAt = Date.now(); + } + + /** + * Marks the current connection session start and clears any pending output. + */ + public markSessionStart(timestamp: number = Date.now()): void { + this.sessionStartedAt = timestamp; + this.clear(); + this.clearHistory(); + } + + /** + * Announces sanitized output to the aria-live region when it is newer than the current session. + */ + public announce(raw: string, receivedAt: number = Date.now()): void { + if (receivedAt < this.sessionStartedAt) { + console.debug( + '[ScreenReader] Ignoring old output (before session start):', + { + receivedAt, + sessionStartedAt: this.sessionStartedAt, + diff: receivedAt - this.sessionStartedAt, + }, + ); + return; + } + + const normalized = this.normalize(raw); + + console.debug('[ScreenReader] Announcing:', { + raw: raw.substring(0, 100), + normalized: normalized.substring(0, 100), + }); + + if (!normalized) { + console.debug('[ScreenReader] Skipped empty normalized output'); + return; + } + + this.liveRegion.textContent = normalized; + console.debug( + '[ScreenReader] Live region updated:', + this.liveRegion.textContent, + ); + this.scheduleClear(); + } + + /** + * Clears the live region and any pending timers. + */ + public clear(): void { + this.liveRegion.textContent = ''; + this.cancelClearTimer(); + } + + /** + * Disposes internal timers. + */ + public dispose(): void { + this.clear(); + this.cancelInputClearTimer(); + } + + /** + * Appends sanitized text to the history region so users can navigate it later. + */ + public appendToHistory(raw: string): void { + if (!this.historyRegion) { + return; + } + + const normalized = this.normalize(raw); + if (!normalized) { + return; + } + + const doc = this.historyRegion.ownerDocument; + + const item = doc.createElement('div'); + item.className = 'sr-log-item'; + item.textContent = normalized; + + this.historyRegion.appendChild(item); + } + + /** + * Clears the history region entirely (e.g., on reconnect). + */ + public clearHistory(): void { + if (this.historyRegion) { + this.historyRegion.textContent = ''; + } + } + + /** + * Announces only the newest character (delta) to avoid re-reading the full buffer. + * Uses textContent (not appendChild) so VO/NVDA get a simple change event. + * No auto-clear to give VO time; if needed we can add a small debounce later. + */ + public announceInput(buffer: string): void { + if (!this.inputRegion) { + return; + } + + const lastLength = this.lastAnnouncedBuffer.length; + const currentLength = buffer.length; + + if (currentLength > lastLength) { + const newestChar = buffer[currentLength - 1]; + const normalized = this.normalizeInput(newestChar); + + console.debug('[ScreenReader] Announcing input char:', { + newestChar, + normalized, + lastLength, + currentLength, + }); + + if (normalized.length === 0) { + this.lastAnnouncedBuffer = buffer; + return; + } + + this.inputRegion.textContent = normalized; + } + + this.lastAnnouncedBuffer = buffer; + } + + /** + * Announces the complete, committed input after user presses Enter. + * This reads back the entire line so the user can verify what they typed. + */ + public announceInputCommitted(buffer: string): void { + if (!this.inputCommittedRegion) { + return; + } + + const normalized = this.normalize(buffer); + + console.debug('[ScreenReader] Announcing committed input:', { + raw: buffer.substring(0, 100), + normalized: normalized.substring(0, 100), + }); + + if (!normalized) { + return; + } + + this.inputCommittedRegion.textContent = `${normalized}`; + // Reset buffer tracker since we're starting fresh after commit + this.lastAnnouncedBuffer = ''; + } + + private scheduleClear(): void { + this.cancelClearTimer(); + + this.clearTimer = window.setTimeout(() => { + this.clear(); + }, this.clearDelayMs); + } + + private cancelClearTimer(): void { + if (this.clearTimer !== undefined) { + window.clearTimeout(this.clearTimer); + this.clearTimer = undefined; + } + } + + // Input clear helpers are retained for potential future use (currently unused) + private scheduleInputClear(): void { + this.cancelInputClearTimer(); + + this.inputClearTimer = window.setTimeout(() => { + this.clearInputRegion(); + }, INPUT_CLEAR_DELAY_MS); + } + + private cancelInputClearTimer(): void { + if (this.inputClearTimer !== undefined) { + window.clearTimeout(this.inputClearTimer); + this.inputClearTimer = undefined; + } + } + + private clearInputRegion(): void { + if (this.inputRegion) { + this.inputRegion.textContent = ''; + } + } + + private normalizeInput(raw: string): string { + if (raw === undefined || raw === null) { + return ''; + } + + // Do not trim for input to preserve spaces; still strip ANSI/control chars. + const unifiedNewlines = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const withoutAnsi = unifiedNewlines.replace(ANSI_ESCAPE_PATTERN, ''); + const withoutControl = withoutAnsi.replace(CONTROL_CHAR_PATTERN, ''); + return withoutControl; + } + private normalize(raw: string): string { + if (!raw) { + return ''; + } + + // Convert CRLF/CR to LF to keep announcements concise + const unifiedNewlines = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + // Strip ANSI escapes and non-printable control chars (except LF) + const withoutAnsi = unifiedNewlines.replace(ANSI_ESCAPE_PATTERN, ''); + const withoutControl = withoutAnsi.replace(CONTROL_CHAR_PATTERN, ''); + + // Collapse excessive blank lines and trim + const collapsed = withoutControl.replace(/\n{3,}/g, '\n\n'); + + return collapsed.trim(); + } +} diff --git a/frontend/src/app/features/terminal/mud-socket.adapter.ts b/frontend/src/app/features/terminal/mud-socket.adapter.ts new file mode 100644 index 0000000..9ad2a88 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-socket.adapter.ts @@ -0,0 +1,119 @@ +import { Observable, Subscription } from 'rxjs'; + +/** + * Optional hooks invoked while processing a chunk of MUD output. + */ +export type MudSocketAdapterHooks = { + transformMessage?: (data: string) => string; + beforeMessage?: (data: string) => void; + afterMessage?: (data: string) => void; +}; + +type SocketListener = EventListener; + +/** + * Minimal WebSocket-like adapter that feeds xterm's AttachAddon with the + * server output stream coming from the MudService. Each emission from the + * observable is converted into a `message` event, with optional transform + * hooks to intercept or mutate the payload. + */ +export class MudSocketAdapter { + public binaryType: BinaryType = 'arraybuffer'; + // Mimics WebSocket state; set to CLOSED when dispose/close is called. + public readyState: number = WebSocket.OPEN; + + private readonly listeners = new Map>(); + private readonly subscription: Subscription; + + /** + * @param output$ Observable delivering MUD output chunks. + * @param hooks Optional callbacks invoked before/after transforming emissions. + */ + constructor( + output$: Observable<{ data: string }>, + private readonly hooks?: MudSocketAdapterHooks, + ) { + this.subscription = output$.subscribe(({ data }) => { + this.hooks?.beforeMessage?.(data); + + const transformed = this.hooks?.transformMessage?.(data) ?? data; + + if (transformed.length > 0) { + this.dispatch( + 'message', + new MessageEvent('message', { data: transformed }), + ); + } + + this.hooks?.afterMessage?.(transformed); + }); + } + + /** + * Registers a listener for the given event type (only `message` is relevant). + */ + public addEventListener(type: string, listener: SocketListener) { + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + + this.listeners.get(type)!.add(listener); + } + + /** + * Removes a previously registered listener, cleaning up empty buckets. + */ + public removeEventListener(type: string, listener: SocketListener) { + const listeners = this.listeners.get(type); + if (!listeners) { + return; + } + + listeners.delete(listener); + + if (listeners.size === 0) { + this.listeners.delete(type); + } + } + + /** + * This is a no-op since input flows via terminal.onData. We need to implement this to satisfy + * the WebSocket interface, but since this adapter is output-only calls to send() are ignored. + */ + public send(): void { + if (this.readyState !== WebSocket.OPEN) { + console.warn( + 'MudSocketAdapter.send(): adapter is closed; input is output-only', + ); + } + } + + /** + * Closes the adapter by disposing the subscription (alias of {@link dispose}). + */ + public close() { + this.dispose(); + } + + /** + * Removes all listeners and unsubscribes from the output stream. + */ + public dispose() { + this.subscription.unsubscribe(); + this.listeners.clear(); + this.readyState = WebSocket.CLOSED; + } + + /** + * Dispatches a cloned event to all listeners of the given type. + */ + private dispatch(type: string, event: Event) { + const listeners = this.listeners.get(type); + + if (!listeners) { + return; + } + + listeners.forEach((listener) => listener.call(this, event)); + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html index 3b5b525..f454d96 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -1,5 +1,5 @@ - + WebMUD3 UNItopia diff --git a/shared/.eslintrc.js b/shared/.eslintrc.js new file mode 100644 index 0000000..00ea5e9 --- /dev/null +++ b/shared/.eslintrc.js @@ -0,0 +1,78 @@ +module.exports = { + ignorePatterns: ["projects/**/*"], + plugins: ["simple-import-sort", "import"], + overrides: [ + { + files: ["*.spec.ts"], + parserOptions: { + project: ["./tsconfig.spec.json"], + tsconfigRootDir: __dirname, + sourceType: "module", + ecmaVersion: "latest", + }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended-type-checked", + "plugin:prettier/recommended", + ], + rules: { + eqeqeq: "error", + "grouped-accessor-pairs": "warn", + "guard-for-in": "error", + "no-alert": "warn", + "no-delete-var": "error", + "no-duplicate-imports": "error", + "no-empty-function": "warn", + "no-implicit-coercion": "error", + "no-implicit-globals": "error", + "no-labels": "error", + "no-shadow": "error", + "no-unused-vars": "warn", + "no-use-before-define": "error", + "no-var": "error", + "prefer-const": "warn", + "simple-import-sort/imports": "warn", + "simple-import-sort/exports": "warn", + "import/first": "warn", + "import/newline-after-import": "warn", + "import/no-duplicates": "warn", + }, + }, + { + files: ["*.ts"], + parserOptions: { + project: ["./tsconfig.json"], + tsconfigRootDir: __dirname, + sourceType: "module", + ecmaVersion: "latest", + }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended-type-checked", + "plugin:prettier/recommended", + ], + rules: { + eqeqeq: "error", + "grouped-accessor-pairs": "warn", + "guard-for-in": "error", + "no-alert": "warn", + "no-delete-var": "error", + "no-duplicate-imports": "error", + "no-empty-function": "warn", + "no-implicit-coercion": "error", + "no-implicit-globals": "error", + "no-labels": "error", + "no-shadow": "error", + "no-unused-vars": "warn", + "no-use-before-define": "error", + "no-var": "error", + "prefer-const": "warn", + "simple-import-sort/imports": "warn", + "simple-import-sort/exports": "warn", + "import/first": "warn", + "import/newline-after-import": "warn", + "import/no-duplicates": "warn", + }, + }, + ], +}; diff --git a/shared/package.json b/shared/package.json index e0dcfbe..ee2fb0e 100644 --- a/shared/package.json +++ b/shared/package.json @@ -4,7 +4,10 @@ "version": "1.0.0-alpha", "description": "Shared types and utilities for webmud3", "type": "module", - "authors": ["Myonara", "Felag"], + "authors": [ + "Myonara", + "Felag" + ], "license": "MIT", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -27,9 +30,20 @@ "build": "tsc -p tsconfig.json", "clean": "npm run clean:dist && npm run clean:packages", "clean:dist": "rimraf dist", - "clean:packages": "npx rimraf node_modules" + "clean:packages": "npx rimraf node_modules", + "format": "prettier --write \"src/**/*.ts\"", + "lint": "eslint ./src --ext .ts", + "lint:fix": "eslint ./src --ext .ts --fix" }, "devDependencies": { + "@typescript-eslint/eslint-plugin": "~8.46.0", + "@typescript-eslint/parser": "~8.46.0", + "eslint": "~8.57.1", + "eslint-config-standard": "~17.1.0", + "eslint-plugin-import": "~2.32.0", + "eslint-plugin-node": "~11.1.0", + "eslint-plugin-simple-import-sort": "~12.1.1", + "prettier": "~3.6.2", "rimraf": "~6.0.1", "typescript": "~5.9.3" }