From db69a613a69a2da80869ea03ab04a61c69c64ef8 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 19 Oct 2025 01:48:35 +0200 Subject: [PATCH 01/77] refactor(frontend): extracted ANSI and terminal escape codes --- .../mud-client/mud-client.component.ts | 220 ++++++++++++++---- frontend/src/app/features/terminal/index.ts | 1 + .../app/features/terminal/models/escapes.ts | 57 +++++ frontend/tsconfig.json | 43 +--- 4 files changed, 240 insertions(+), 81 deletions(-) create mode 100644 frontend/src/app/features/terminal/index.ts create mode 100644 frontend/src/app/features/terminal/models/escapes.ts 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 8762e98..de74944 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 @@ -12,9 +12,20 @@ import { FitAddon } from '@xterm/addon-fit'; import { IDisposable, Terminal } from '@xterm/xterm'; import { Subscription } from 'rxjs'; -import { LinemodeState } from '@mudlet3/frontend/features/sockets'; import { MudService } from '../../services/mud.service'; import { SecureString } from '@mudlet3/frontend/shared'; +import { LinemodeState } from '@mudlet3/frontend/features/sockets'; +import { + CTRL, + CSI_REGEX, + SS3, + SS3_LEN, + backspaceErase, + cursorLeft, + cursorRight, + resetLine, + sequence, +} from '@mudlet3/frontend/features/terminal'; type SocketListener = EventListener; type MudSocketAdapterHooks = { @@ -107,14 +118,11 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly terminal: Terminal; 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 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 }, @@ -128,6 +136,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private showEchoSubscription?: Subscription; private linemodeSubscription?: Subscription; private inputBuffer = ''; + private inputCursor = 0; private lastInputWasCarriageReturn = false; private localEchoEnabled = true; private currentShowEcho = true; @@ -241,42 +250,30 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { const char = data[index]; switch (char) { - case '\r': + case CTRL.CR: this.commitBuffer(); this.lastInputWasCarriageReturn = true; break; - case '\n': + case CTRL.LF: if (!this.lastInputWasCarriageReturn) { this.commitBuffer(); } this.lastInputWasCarriageReturn = false; break; - case '\b': - case '\u007f': + case CTRL.BS: + case CTRL.DEL: this.applyBackspace(); this.lastInputWasCarriageReturn = false; break; - case '\u001b': { - const consumed = this.skipEscapeSequence(data.slice(index)); + case CTRL.ESC: { + const consumed = this.handleEscapeSequence(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; - - if (this.localEchoEnabled) { - this.terminal.write(char); - } - + this.insertCharacter(char); this.lastInputWasCarriageReturn = false; break; } @@ -295,9 +292,11 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { } this.inputBuffer = ''; + this.inputCursor = 0; this.lastInputWasCarriageReturn = false; } else if (!wasEditMode) { this.inputBuffer = ''; + this.inputCursor = 0; this.lastInputWasCarriageReturn = false; } @@ -313,14 +312,86 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { } private applyBackspace() { - if (this.inputBuffer.length === 0) { + if (this.inputCursor === 0) { return; } - this.inputBuffer = this.inputBuffer.slice(0, -1); + const before = this.inputBuffer.slice(0, this.inputCursor - 1); + const after = this.inputBuffer.slice(this.inputCursor); + + this.inputBuffer = before + after; + this.inputCursor -= 1; if (this.localEchoEnabled) { - this.terminal.write('\b \b'); + if (after.length > 0) { + this.terminal.write(sequence(CTRL.BS, after, ' ')); + this.terminal.write(cursorLeft(after.length + 1)); + } else { + this.terminal.write(backspaceErase); + } + } + } + + private insertCharacter(char: string) { + const charCode = char.charCodeAt(0); + + if (charCode < 32 && char !== CTRL.TAB) { + // Ignore unsupported control characters (e.g. CTRL+C) + return; + } + + const before = this.inputBuffer.slice(0, this.inputCursor); + const after = this.inputBuffer.slice(this.inputCursor); + + this.inputBuffer = before + char + after; + this.inputCursor += 1; + + if (!this.localEchoEnabled) { + return; + } + + this.terminal.write(sequence(char, after)); + + if (after.length > 0) { + this.terminal.write(cursorLeft(after.length)); + } + } + + private moveCursorLeft(amount: number) { + if (amount <= 0) { + return; + } + + const target = Math.max(0, this.inputCursor - amount); + const delta = this.inputCursor - target; + + if (delta === 0) { + return; + } + + this.inputCursor = target; + + if (this.localEchoEnabled) { + this.terminal.write(cursorLeft(delta)); + } + } + + private moveCursorRight(amount: number) { + if (amount <= 0) { + return; + } + + const target = Math.min(this.inputBuffer.length, this.inputCursor + amount); + const delta = target - this.inputCursor; + + if (delta === 0) { + return; + } + + this.inputCursor = target; + + if (this.localEchoEnabled) { + this.terminal.write(cursorRight(delta)); } } @@ -328,10 +399,11 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { const message = this.inputBuffer; this.inputBuffer = ''; + this.inputCursor = 0; this.lastInputWasCarriageReturn = false; if (this.localEchoEnabled) { - this.terminal.write('\r\n'); + this.terminal.write(sequence(CTRL.CR, CTRL.LF)); } const securedString: string | SecureString = this.localEchoEnabled @@ -341,15 +413,63 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.mudService.sendMessage(securedString); } - private skipEscapeSequence(sequence: string): number { - const match = sequence.match(/^\u001b\[[0-9;]*[A-Za-z~]/); + private handleEscapeSequence(segment: string): number { + if (segment.startsWith(SS3) && segment.length >= SS3_LEN) { + const control = segment[2]; + + switch (control) { + case 'C': + this.moveCursorRight(1); + break; + case 'D': + this.moveCursorLeft(1); + break; + default: + break; + } + + return SS3_LEN; + } + + const match = segment.match(CSI_REGEX); + + if (!match) { + 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; + default: + break; + } + + return token.length; + } + + private skipEscapeSequence(segment: string): number { + if (segment.startsWith(SS3) && segment.length >= SS3_LEN) { + return SS3_LEN; + } + + const match = segment.match(CSI_REGEX); if (match) { return match[0].length; } // Default to consuming only the ESC character - return 1; + return CTRL.ESC.length; } private beforeMudOutput(_data: string) { @@ -366,7 +486,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.hiddenPrompt = this.serverLineBuffer; this.serverLineBuffer = ''; this.leadingLineBreaksToStrip = 1; - this.terminal.write('\r\u001b[2K'); + this.terminal.write(resetLine); this.editLineHidden = true; } @@ -401,16 +521,26 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { return; } - this.terminal.write('\r\u001b[2K'); + this.terminal.write(resetLine); const prefix = - this.serverLineBuffer.length > 0 ? this.serverLineBuffer : this.hiddenPrompt; + this.serverLineBuffer.length > 0 + ? this.serverLineBuffer + : this.hiddenPrompt; if (prefix.length > 0) { this.terminal.write(prefix); } + this.inputCursor = Math.min(this.inputCursor, this.inputBuffer.length); this.terminal.write(this.inputBuffer); + + const moveLeft = this.inputBuffer.length - this.inputCursor; + + if (moveLeft > 0) { + this.terminal.write(cursorLeft(moveLeft)); + } + this.editLineHidden = false; this.hiddenPrompt = ''; this.serverLineBuffer = prefix; @@ -428,13 +558,13 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { while (startIndex < data.length && remainingBreaks > 0) { const char = data[startIndex]; - if (char === '\n') { + if (char === CTRL.LF) { remainingBreaks -= 1; startIndex += 1; continue; } - if (char === '\r') { + if (char === CTRL.CR) { startIndex += 1; continue; } @@ -463,24 +593,24 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { while (index < data.length) { const char = data[index]; - if (char === '\r' || char === '\n') { + if (char === CTRL.CR || char === CTRL.LF) { this.serverLineBuffer = ''; index += 1; continue; } - if (char === '\b' || char === '\u007f') { + if (char === CTRL.BS || char === CTRL.DEL) { this.serverLineBuffer = this.serverLineBuffer.slice(0, -1); index += 1; continue; } - if (char === '\u001b') { + if (char === CTRL.ESC) { const consumed = this.skipEscapeSequence(data.slice(index)); - const sequence = + const parsedSequence = consumed > 0 ? data.slice(index, index + consumed) : char; - this.serverLineBuffer += sequence; + this.serverLineBuffer += parsedSequence; index += Math.max(consumed, 1); continue; } diff --git a/frontend/src/app/features/terminal/index.ts b/frontend/src/app/features/terminal/index.ts new file mode 100644 index 0000000..58e57cc --- /dev/null +++ b/frontend/src/app/features/terminal/index.ts @@ -0,0 +1 @@ +export * from './models/escapes'; 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..5b8382e --- /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/tsconfig.json b/frontend/tsconfig.json index 2fedf48..a1140bc 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -12,47 +12,18 @@ "importHelpers": true, "target": "ES2022", "module": "es2020", - "lib": [ - "es2018", - "dom" - ], + "lib": ["es2018", "dom"], "useDefineForClassFields": false, "strict": true, "resolveJsonModule": true, "paths": { - "@mudlet3/frontend/core": [ - "src/app/core" + "@mudlet3/frontend/core": ["src/app/core"], + "@mudlet3/frontend/shared": ["src/app/shared"], + "@mudlet3/frontend/features/sockets": ["src/app/features/sockets"], + "@mudlet3/frontend/features/serverconfig": [ + "src/app/features/serverconfig" ], - "@mudlet3/frontend/shared": [ - "src/app/shared" - ], - "@mudlet3/frontend/features/ansi": [ - "src/app/features/ansi" - ], - "@mudlet3/frontend/features/config": [ - "src/app/features/config" - ], - "@mudlet3/frontend/features/files": [ - "src/app/features/files" - ], - "@mudlet3/frontend/features/gmcp": [ - "src/app/features/gmcp" - ], - "@mudlet3/frontend/features/modeless": [ - "src/app/features/modeless" - ], - "@mudlet3/frontend/features/mudconfig": [ - "src/app/features/mudconfig" - ], - "@mudlet3/frontend/features/settings": [ - "src/app/features/settings" - ], - "@mudlet3/frontend/features/sockets": [ - "src/app/features/sockets" - ], - "@mudlet3/frontend/features/widgets": [ - "src/app/features/widgets" - ] + "@mudlet3/frontend/features/terminal": ["src/app/features/terminal"] } }, "angularCompilerOptions": { From 62a24f1c06c2f4e31d9faf44654d506a41b07b67 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 19 Oct 2025 02:07:05 +0200 Subject: [PATCH 02/77] refactor(frontend): mud input controller for input handling --- .../mud-client/mud-client.component.ts | 239 +++-------------- frontend/src/app/features/terminal/index.ts | 1 + .../features/terminal/mud-input.controller.ts | 243 ++++++++++++++++++ 3 files changed, 279 insertions(+), 204 deletions(-) create mode 100644 frontend/src/app/features/terminal/mud-input.controller.ts 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 de74944..b15399e 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 @@ -20,12 +20,10 @@ import { CSI_REGEX, SS3, SS3_LEN, - backspaceErase, cursorLeft, - cursorRight, resetLine, - sequence, } from '@mudlet3/frontend/features/terminal'; +import { MudInputController } from '@mudlet3/frontend/features/terminal'; type SocketListener = EventListener; type MudSocketAdapterHooks = { @@ -117,6 +115,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly mudService = inject(MudService); private readonly terminal: Terminal; + private readonly inputController: MudInputController; private readonly terminalFitAddon = new FitAddon(); private readonly socketAdapter = new MudSocketAdapter(this.mudService, { transformMessage: (data) => this.transformMudOutput(data), @@ -135,9 +134,6 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private showEchoSubscription?: Subscription; private linemodeSubscription?: Subscription; - private inputBuffer = ''; - private inputCursor = 0; - private lastInputWasCarriageReturn = false; private localEchoEnabled = true; private currentShowEcho = true; private isEditMode = true; @@ -161,6 +157,11 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { disableStdin: false, screenReaderMode: true, }); + + this.inputController = new MudInputController(this.terminal, ({ message, echoed }) => + this.handleCommittedInput(message, echoed), + ); + this.inputController.setLocalEcho(this.localEchoEnabled); } ngAfterViewInit() { @@ -237,6 +238,12 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.mudService.updateViewportSize(columns, rows); } + private handleCommittedInput(message: string, echoed: boolean) { + const payload: string | SecureString = echoed ? message : { value: message }; + + this.mudService.sendMessage(payload); + } + private handleInput(data: string) { if (!this.isEditMode) { if (data.length > 0) { @@ -246,39 +253,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { return; } - for (let index = 0; index < data.length; index += 1) { - const char = data[index]; - - switch (char) { - case CTRL.CR: - this.commitBuffer(); - this.lastInputWasCarriageReturn = true; - break; - case CTRL.LF: - if (!this.lastInputWasCarriageReturn) { - this.commitBuffer(); - } - - this.lastInputWasCarriageReturn = false; - break; - case CTRL.BS: - case CTRL.DEL: - this.applyBackspace(); - this.lastInputWasCarriageReturn = false; - break; - case CTRL.ESC: { - const consumed = this.handleEscapeSequence(data.slice(index)); - index += consumed - 1; - this.lastInputWasCarriageReturn = false; - break; - } - default: { - this.insertCharacter(char); - this.lastInputWasCarriageReturn = false; - break; - } - } - } + this.inputController.handleData(data); } private setLinemode(state: LinemodeState) { @@ -287,17 +262,17 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.isEditMode = state.edit; if (!this.isEditMode) { - if (wasEditMode && this.inputBuffer.length > 0) { - this.mudService.sendMessage(this.inputBuffer); + if (wasEditMode) { + const pending = this.inputController.flush(); + + if (pending) { + this.handleCommittedInput(pending.message, pending.echoed); + } } - this.inputBuffer = ''; - this.inputCursor = 0; - this.lastInputWasCarriageReturn = false; + this.inputController.reset(); } else if (!wasEditMode) { - this.inputBuffer = ''; - this.inputCursor = 0; - this.lastInputWasCarriageReturn = false; + this.inputController.reset(); } this.editLineHidden = false; @@ -309,152 +284,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private updateLocalEcho(showEcho: boolean) { this.localEchoEnabled = this.isEditMode && showEcho; - } - - private applyBackspace() { - if (this.inputCursor === 0) { - return; - } - - const before = this.inputBuffer.slice(0, this.inputCursor - 1); - const after = this.inputBuffer.slice(this.inputCursor); - - this.inputBuffer = before + after; - this.inputCursor -= 1; - - if (this.localEchoEnabled) { - if (after.length > 0) { - this.terminal.write(sequence(CTRL.BS, after, ' ')); - this.terminal.write(cursorLeft(after.length + 1)); - } else { - this.terminal.write(backspaceErase); - } - } - } - - private insertCharacter(char: string) { - const charCode = char.charCodeAt(0); - - if (charCode < 32 && char !== CTRL.TAB) { - // Ignore unsupported control characters (e.g. CTRL+C) - return; - } - - const before = this.inputBuffer.slice(0, this.inputCursor); - const after = this.inputBuffer.slice(this.inputCursor); - - this.inputBuffer = before + char + after; - this.inputCursor += 1; - - if (!this.localEchoEnabled) { - return; - } - - this.terminal.write(sequence(char, after)); - - if (after.length > 0) { - this.terminal.write(cursorLeft(after.length)); - } - } - - private moveCursorLeft(amount: number) { - if (amount <= 0) { - return; - } - - const target = Math.max(0, this.inputCursor - amount); - const delta = this.inputCursor - target; - - if (delta === 0) { - return; - } - - this.inputCursor = target; - - if (this.localEchoEnabled) { - this.terminal.write(cursorLeft(delta)); - } - } - - private moveCursorRight(amount: number) { - if (amount <= 0) { - return; - } - - const target = Math.min(this.inputBuffer.length, this.inputCursor + amount); - const delta = target - this.inputCursor; - - if (delta === 0) { - return; - } - - this.inputCursor = target; - - if (this.localEchoEnabled) { - this.terminal.write(cursorRight(delta)); - } - } - - private commitBuffer() { - const message = this.inputBuffer; - - this.inputBuffer = ''; - this.inputCursor = 0; - this.lastInputWasCarriageReturn = false; - - if (this.localEchoEnabled) { - this.terminal.write(sequence(CTRL.CR, CTRL.LF)); - } - - const securedString: string | SecureString = this.localEchoEnabled - ? message - : { value: message }; - - this.mudService.sendMessage(securedString); - } - - private handleEscapeSequence(segment: string): number { - if (segment.startsWith(SS3) && segment.length >= SS3_LEN) { - const control = segment[2]; - - switch (control) { - case 'C': - this.moveCursorRight(1); - break; - case 'D': - this.moveCursorLeft(1); - break; - default: - break; - } - - return SS3_LEN; - } - - const match = segment.match(CSI_REGEX); - - if (!match) { - 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; - default: - break; - } - - return token.length; + this.inputController.setLocalEcho(this.localEchoEnabled); } private skipEscapeSequence(segment: string): number { @@ -477,7 +307,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { !this.isEditMode || !this.terminalReady || !this.localEchoEnabled || - this.inputBuffer.length === 0 || + !this.inputController.hasContent() || this.editLineHidden ) { return; @@ -498,7 +328,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { !this.isEditMode || !this.terminalReady || !this.localEchoEnabled || - this.inputBuffer.length === 0 + !this.inputController.hasContent() ) { return; } @@ -511,12 +341,14 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { return; } - if ( - !this.isEditMode || - !this.terminalReady || - !this.localEchoEnabled || - this.inputBuffer.length === 0 - ) { + if (!this.isEditMode || !this.terminalReady || !this.localEchoEnabled) { + this.editLineHidden = false; + return; + } + + const snapshot = this.inputController.getSnapshot(); + + if (snapshot.buffer.length === 0) { this.editLineHidden = false; return; } @@ -532,10 +364,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.terminal.write(prefix); } - this.inputCursor = Math.min(this.inputCursor, this.inputBuffer.length); - this.terminal.write(this.inputBuffer); + this.terminal.write(snapshot.buffer); - const moveLeft = this.inputBuffer.length - this.inputCursor; + const moveLeft = snapshot.buffer.length - snapshot.cursor; if (moveLeft > 0) { this.terminal.write(cursorLeft(moveLeft)); diff --git a/frontend/src/app/features/terminal/index.ts b/frontend/src/app/features/terminal/index.ts index 58e57cc..ff37772 100644 --- a/frontend/src/app/features/terminal/index.ts +++ b/frontend/src/app/features/terminal/index.ts @@ -1 +1,2 @@ export * from './models/escapes'; +export * from './mud-input.controller'; 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..d1cb676 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-input.controller.ts @@ -0,0 +1,243 @@ +import type { Terminal } from '@xterm/xterm'; + +import { + CTRL, + CSI_REGEX, + SS3, + SS3_LEN, + backspaceErase, + cursorLeft, + cursorRight, + sequence, +} from './models/escapes'; + +export type MudInputCommitHandler = (payload: { + message: string; + echoed: boolean; +}) => void; + +/** + * Encapsulates client-side editing state for LINEMODE input. + * Keeps track of the text buffer, cursor position and terminal echo updates. + */ +export class MudInputController { + private buffer = ''; + private cursor = 0; + private lastWasCarriageReturn = false; + private localEchoEnabled = true; + + constructor( + private readonly terminal: Terminal, + private readonly onCommit: MudInputCommitHandler, + ) {} + + public handleData(data: string): void { + for (let index = 0; index < data.length; index += 1) { + const char = data[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(data.slice(index)); + index += consumed - 1; + this.lastWasCarriageReturn = false; + break; + } + default: + this.insertCharacter(char); + this.lastWasCarriageReturn = false; + break; + } + } + } + + public setLocalEcho(enabled: boolean): void { + this.localEchoEnabled = enabled; + } + + public reset(): void { + this.buffer = ''; + this.cursor = 0; + this.lastWasCarriageReturn = false; + } + + public hasContent(): boolean { + return this.buffer.length > 0; + } + + public getSnapshot(): { buffer: string; cursor: number } { + return { buffer: this.buffer, cursor: this.cursor }; + } + + 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; + } + + 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 }); + } + + 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; + + if (!this.localEchoEnabled) { + return; + } + + this.terminal.write(sequence(char, after)); + + if (after.length > 0) { + this.terminal.write(cursorLeft(after.length)); + } + } + + 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; + + 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); + } + } + + 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)); + } + } + + 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)); + } + } + + private handleEscapeSequence(segment: string): number { + if (segment.startsWith(SS3) && segment.length >= SS3_LEN) { + const control = segment[2]; + + switch (control) { + case 'C': + this.moveCursorRight(1); + break; + case 'D': + this.moveCursorLeft(1); + break; + default: + break; + } + + return SS3_LEN; + } + + const match = segment.match(CSI_REGEX); + + if (!match) { + 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; + default: + break; + } + + return token.length; + } +} From 30209fb73ca3018c22b69ff385dfcbd2fc8188af Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 19 Oct 2025 02:07:57 +0200 Subject: [PATCH 03/77] refactor(frontend): implement MudPromptManager for managing prompt state and server output --- .../mud-client/mud-client.component.ts | 180 ++------------- frontend/src/app/features/terminal/index.ts | 1 + .../features/terminal/mud-prompt.manager.ts | 208 ++++++++++++++++++ 3 files changed, 227 insertions(+), 162 deletions(-) create mode 100644 frontend/src/app/features/terminal/mud-prompt.manager.ts 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 b15399e..456531f 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 @@ -16,14 +16,10 @@ import { MudService } from '../../services/mud.service'; import { SecureString } from '@mudlet3/frontend/shared'; import { LinemodeState } from '@mudlet3/frontend/features/sockets'; import { - CTRL, - CSI_REGEX, - SS3, - SS3_LEN, - cursorLeft, - resetLine, + MudInputController, + MudPromptManager, + MudPromptContext, } from '@mudlet3/frontend/features/terminal'; -import { MudInputController } from '@mudlet3/frontend/features/terminal'; type SocketListener = EventListener; type MudSocketAdapterHooks = { @@ -116,6 +112,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly terminal: Terminal; private readonly inputController: MudInputController; + private readonly promptManager: MudPromptManager; private readonly terminalFitAddon = new FitAddon(); private readonly socketAdapter = new MudSocketAdapter(this.mudService, { transformMessage: (data) => this.transformMudOutput(data), @@ -139,10 +136,6 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private isEditMode = true; 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; @@ -162,6 +155,8 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.handleCommittedInput(message, echoed), ); this.inputController.setLocalEcho(this.localEchoEnabled); + + this.promptManager = new MudPromptManager(this.terminal, this.inputController); } ngAfterViewInit() { @@ -275,10 +270,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.inputController.reset(); } - this.editLineHidden = false; - this.serverLineBuffer = ''; - this.hiddenPrompt = ''; - this.leadingLineBreaksToStrip = 0; + this.promptManager.reset(); this.updateLocalEcho(this.currentShowEcho); } @@ -287,167 +279,31 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.inputController.setLocalEcho(this.localEchoEnabled); } - private skipEscapeSequence(segment: string): number { - if (segment.startsWith(SS3) && segment.length >= SS3_LEN) { - return SS3_LEN; - } - - const match = segment.match(CSI_REGEX); - - if (match) { - return match[0].length; - } - // Default to consuming only the ESC character - return CTRL.ESC.length; - } private beforeMudOutput(_data: string) { - if ( - !this.isEditMode || - !this.terminalReady || - !this.localEchoEnabled || - !this.inputController.hasContent() || - this.editLineHidden - ) { - return; - } - - this.hiddenPrompt = this.serverLineBuffer; - this.serverLineBuffer = ''; - this.leadingLineBreaksToStrip = 1; - this.terminal.write(resetLine); - this.editLineHidden = true; + this.promptManager.beforeServerOutput(this.getPromptContext()); } private afterMudOutput(data: string) { - this.trackServerLine(data); - - if ( - !this.editLineHidden || - !this.isEditMode || - !this.terminalReady || - !this.localEchoEnabled || - !this.inputController.hasContent() - ) { - return; - } - - queueMicrotask(() => this.restoreEditInput()); - } - - private restoreEditInput() { - if (!this.editLineHidden) { - return; - } - - if (!this.isEditMode || !this.terminalReady || !this.localEchoEnabled) { - this.editLineHidden = false; - return; - } - - const snapshot = this.inputController.getSnapshot(); - - if (snapshot.buffer.length === 0) { - this.editLineHidden = false; - return; - } - - this.terminal.write(resetLine); - - const prefix = - this.serverLineBuffer.length > 0 - ? this.serverLineBuffer - : this.hiddenPrompt; - - if (prefix.length > 0) { - this.terminal.write(prefix); - } - - this.terminal.write(snapshot.buffer); - - const moveLeft = snapshot.buffer.length - snapshot.cursor; - - if (moveLeft > 0) { - this.terminal.write(cursorLeft(moveLeft)); - } - - this.editLineHidden = false; - this.hiddenPrompt = ''; - this.serverLineBuffer = prefix; - this.leadingLineBreaksToStrip = 0; + this.promptManager.afterServerOutput(data, this.getPromptContext()); } 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 === CTRL.LF) { - remainingBreaks -= 1; - startIndex += 1; - continue; - } - - if (char === CTRL.CR) { - startIndex += 1; - continue; - } - - break; - } - - this.leadingLineBreaksToStrip = remainingBreaks; - - if (startIndex === 0) { - this.leadingLineBreaksToStrip = 0; - return data; - } - - if (startIndex >= data.length) { - return ''; - } + return this.promptManager.transformOutput(data); + } - this.leadingLineBreaksToStrip = 0; - return data.slice(startIndex); + private getPromptContext(): MudPromptContext { + return { + isEditMode: this.isEditMode, + terminalReady: this.terminalReady, + localEchoEnabled: this.localEchoEnabled, + }; } - private trackServerLine(data: string) { - let index = 0; - while (index < data.length) { - const char = data[index]; - if (char === CTRL.CR || char === CTRL.LF) { - this.serverLineBuffer = ''; - index += 1; - continue; - } - if (char === CTRL.BS || char === CTRL.DEL) { - this.serverLineBuffer = this.serverLineBuffer.slice(0, -1); - index += 1; - continue; - } - - if (char === CTRL.ESC) { - const consumed = this.skipEscapeSequence(data.slice(index)); - const parsedSequence = - consumed > 0 ? data.slice(index, index + consumed) : char; +} - this.serverLineBuffer += parsedSequence; - index += Math.max(consumed, 1); - continue; - } - this.serverLineBuffer += char; - index += 1; - } - } -} diff --git a/frontend/src/app/features/terminal/index.ts b/frontend/src/app/features/terminal/index.ts index ff37772..d77c135 100644 --- a/frontend/src/app/features/terminal/index.ts +++ b/frontend/src/app/features/terminal/index.ts @@ -1,2 +1,3 @@ export * from './models/escapes'; export * from './mud-input.controller'; +export * from './mud-prompt.manager'; 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..cb448f8 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-prompt.manager.ts @@ -0,0 +1,208 @@ +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'; + +export type MudPromptContext = { + isEditMode: boolean; + terminalReady: boolean; + localEchoEnabled: boolean; +}; + +/** + * Keeps track of prompt / current line state so that we can temporarily hide + * the local edit buffer while server output is rendered and then restore it. + */ +export class MudPromptManager { + private serverLineBuffer = ''; + private hiddenPrompt = ''; + private leadingLineBreaksToStrip = 0; + private lineHidden = false; + + constructor( + private readonly terminal: Terminal, + private readonly inputController: MudInputController, + ) {} + + public reset(): void { + this.serverLineBuffer = ''; + this.hiddenPrompt = ''; + this.leadingLineBreaksToStrip = 0; + this.lineHidden = false; + } + + public transformOutput(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 === CTRL.LF) { + remainingBreaks -= 1; + startIndex += 1; + continue; + } + + if (char === CTRL.CR) { + startIndex += 1; + continue; + } + + break; + } + + this.leadingLineBreaksToStrip = remainingBreaks; + + if (startIndex === 0) { + this.leadingLineBreaksToStrip = 0; + return data; + } + + if (startIndex >= data.length) { + this.leadingLineBreaksToStrip = 0; + return ''; + } + + this.leadingLineBreaksToStrip = 0; + return data.slice(startIndex); + } + + public beforeServerOutput(context: MudPromptContext): void { + if ( + !context.isEditMode || + !context.terminalReady || + !context.localEchoEnabled || + !this.inputController.hasContent() || + this.lineHidden + ) { + return; + } + + this.hiddenPrompt = this.serverLineBuffer; + this.serverLineBuffer = ''; + this.leadingLineBreaksToStrip = 1; + this.terminal.write(resetLine); + this.lineHidden = true; + } + + public afterServerOutput(data: string, context: MudPromptContext): void { + this.trackServerLine(data); + + if ( + !this.lineHidden || + !context.isEditMode || + !context.terminalReady || + !context.localEchoEnabled || + !this.inputController.hasContent() + ) { + return; + } + + queueMicrotask(() => this.restoreLine(context)); + } + + private restoreLine(context: MudPromptContext): void { + if (!this.lineHidden) { + return; + } + + if ( + !context.isEditMode || + !context.terminalReady || + !context.localEchoEnabled + ) { + this.lineHidden = false; + return; + } + + const snapshot = this.inputController.getSnapshot(); + + if (snapshot.buffer.length === 0) { + this.lineHidden = false; + return; + } + + this.terminal.write(resetLine); + + const prefix = + this.serverLineBuffer.length > 0 + ? this.serverLineBuffer + : this.hiddenPrompt; + + if (prefix.length > 0) { + this.terminal.write(prefix); + } + + this.terminal.write(snapshot.buffer); + + const moveLeft = snapshot.buffer.length - snapshot.cursor; + + if (moveLeft > 0) { + this.terminal.write(cursorLeft(moveLeft)); + } + + this.lineHidden = false; + this.hiddenPrompt = ''; + this.serverLineBuffer = prefix; + this.leadingLineBreaksToStrip = 0; + } + + private trackServerLine(chunk: string): void { + let index = 0; + + while (index < chunk.length) { + const char = chunk[index]; + + if (char === CTRL.CR || char === CTRL.LF) { + this.serverLineBuffer = ''; + index += 1; + continue; + } + + if (char === CTRL.BS || char === CTRL.DEL) { + this.serverLineBuffer = this.serverLineBuffer.slice(0, -1); + index += 1; + continue; + } + + if (char === CTRL.ESC) { + const consumed = this.skipEscapeSequence(chunk.slice(index)); + const parsedSequence = + consumed > 0 ? chunk.slice(index, index + consumed) : char; + + this.serverLineBuffer += parsedSequence; + index += Math.max(consumed, 1); + continue; + } + + this.serverLineBuffer += char; + index += 1; + } + } + + private skipEscapeSequence(segment: string): number { + if (segment.startsWith(SS3) && segment.length >= SS3_LEN) { + return SS3_LEN; + } + + const match = segment.match(CSI_REGEX); + + if (match) { + return match[0].length; + } + + return CTRL.ESC.length; + } +} From adc1923840db8c2732becb0e082e61296469f738 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 19 Oct 2025 02:14:50 +0200 Subject: [PATCH 04/77] refactor(frontend): integrate MudSocketAdapter for enhanced WebSocket handling and message transformation --- .../mud-client/mud-client.component.ts | 89 +------------------ frontend/src/app/features/terminal/index.ts | 1 + .../features/terminal/mud-socket.adapter.ts | 86 ++++++++++++++++++ 3 files changed, 91 insertions(+), 85 deletions(-) create mode 100644 frontend/src/app/features/terminal/mud-socket.adapter.ts 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 456531f..226f277 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,90 +15,7 @@ import { Subscription } from 'rxjs'; import { MudService } from '../../services/mud.service'; import { SecureString } from '@mudlet3/frontend/shared'; import { LinemodeState } from '@mudlet3/frontend/features/sockets'; -import { - MudInputController, - MudPromptManager, - MudPromptContext, -} from '@mudlet3/frontend/features/terminal'; - -type SocketListener = EventListener; -type MudSocketAdapterHooks = { - transformMessage?: (data: string) => string; - beforeMessage?: (data: string) => void; - afterMessage?: (data: string) => void; -}; - -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)); - } -} +import { MudInputController, MudPromptContext, MudPromptManager, MudSocketAdapter } from '@mudlet3/frontend/features/terminal'; @Component({ selector: 'app-mud-client', @@ -114,7 +31,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly inputController: MudInputController; private readonly promptManager: MudPromptManager; private readonly terminalFitAddon = new FitAddon(); - private readonly socketAdapter = new MudSocketAdapter(this.mudService, { + private readonly socketAdapter = new MudSocketAdapter(this.mudService.mudOutput$, { transformMessage: (data) => this.transformMudOutput(data), beforeMessage: (data) => this.beforeMudOutput(data), afterMessage: (data) => this.afterMudOutput(data), @@ -307,3 +224,5 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { } + + diff --git a/frontend/src/app/features/terminal/index.ts b/frontend/src/app/features/terminal/index.ts index d77c135..30d50e2 100644 --- a/frontend/src/app/features/terminal/index.ts +++ b/frontend/src/app/features/terminal/index.ts @@ -1,3 +1,4 @@ export * from './models/escapes'; export * from './mud-input.controller'; export * from './mud-prompt.manager'; +export * from './mud-socket.adapter'; 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..760d001 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-socket.adapter.ts @@ -0,0 +1,86 @@ +import { Observable, Subscription } from 'rxjs'; + +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. It translates output$ + * emissions into `message` events for the addon. + */ +export class MudSocketAdapter { + public binaryType: BinaryType = 'arraybuffer'; + public readyState = WebSocket.OPEN; + + private readonly listeners = new Map>(); + private readonly subscription: Subscription; + + 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); + }); + } + + 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)); + } +} From 88faff07d092bb8c346f9de5273a4d086697e236 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 19 Oct 2025 02:47:56 +0200 Subject: [PATCH 05/77] refactor(frontend): move includes prompt in linemode --- .../mud-client/mud-client.component.ts | 54 ++++++++++--------- .../features/terminal/mud-prompt.manager.ts | 26 ++++++--- 2 files changed, 48 insertions(+), 32 deletions(-) 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 226f277..b9dd746 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 @@ -17,6 +17,13 @@ import { SecureString } from '@mudlet3/frontend/shared'; import { LinemodeState } from '@mudlet3/frontend/features/sockets'; import { MudInputController, MudPromptContext, MudPromptManager, MudSocketAdapter } from '@mudlet3/frontend/features/terminal'; +type MudClientState = { + isEditMode: boolean; + showEcho: boolean; + localEchoEnabled: boolean; + terminalReady: boolean; +}; + @Component({ selector: 'app-mud-client', standalone: true, @@ -48,11 +55,13 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private showEchoSubscription?: Subscription; private linemodeSubscription?: Subscription; - 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; @ViewChild('hostRef', { static: true }) private readonly terminalRef!: ElementRef; @@ -71,7 +80,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.inputController = new MudInputController(this.terminal, ({ message, echoed }) => this.handleCommittedInput(message, echoed), ); - this.inputController.setLocalEcho(this.localEchoEnabled); + this.inputController.setLocalEcho(this.state.localEchoEnabled); this.promptManager = new MudPromptManager(this.terminal, this.inputController); } @@ -87,7 +96,6 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { ); this.showEchoSubscription = this.showEcho$.subscribe((showEcho) => { - this.currentShowEcho = showEcho; this.updateLocalEcho(showEcho); }); @@ -96,7 +104,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; @@ -157,7 +165,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { } private handleInput(data: string) { - if (!this.isEditMode) { + if (!this.state.isEditMode) { if (data.length > 0) { this.mudService.sendMessage(data); } @@ -169,11 +177,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { } private setLinemode(state: LinemodeState) { - const wasEditMode = this.isEditMode; - - this.isEditMode = state.edit; + const wasEditMode = this.state.isEditMode; - if (!this.isEditMode) { + if (!state.edit) { if (wasEditMode) { const pending = this.inputController.flush(); @@ -187,16 +193,17 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.inputController.reset(); } + this.setState({ isEditMode: state.edit }); this.promptManager.reset(); - this.updateLocalEcho(this.currentShowEcho); + this.updateLocalEcho(this.state.showEcho); } private updateLocalEcho(showEcho: boolean) { - this.localEchoEnabled = this.isEditMode && showEcho; - this.inputController.setLocalEcho(this.localEchoEnabled); - } - + const localEchoEnabled = this.state.isEditMode && showEcho; + this.setState({ showEcho, localEchoEnabled }); + this.inputController.setLocalEcho(localEchoEnabled); + } private beforeMudOutput(_data: string) { this.promptManager.beforeServerOutput(this.getPromptContext()); @@ -212,17 +219,16 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private getPromptContext(): MudPromptContext { return { - isEditMode: this.isEditMode, - terminalReady: this.terminalReady, - localEchoEnabled: this.localEchoEnabled, + isEditMode: this.state.isEditMode, + terminalReady: this.state.terminalReady, + localEchoEnabled: this.state.localEchoEnabled, }; } - - + private setState(patch: Partial): void { + this.state = { ...this.state, ...patch }; + } } - - diff --git a/frontend/src/app/features/terminal/mud-prompt.manager.ts b/frontend/src/app/features/terminal/mud-prompt.manager.ts index cb448f8..ee4ee45 100644 --- a/frontend/src/app/features/terminal/mud-prompt.manager.ts +++ b/frontend/src/app/features/terminal/mud-prompt.manager.ts @@ -84,12 +84,20 @@ export class MudPromptManager { !context.isEditMode || !context.terminalReady || !context.localEchoEnabled || - !this.inputController.hasContent() || this.lineHidden ) { return; } + const hasLineContent = + this.inputController.hasContent() || + this.serverLineBuffer.length > 0 || + this.hiddenPrompt.length > 0; + + if (!hasLineContent) { + return; + } + this.hiddenPrompt = this.serverLineBuffer; this.serverLineBuffer = ''; this.leadingLineBreaksToStrip = 1; @@ -104,8 +112,15 @@ export class MudPromptManager { !this.lineHidden || !context.isEditMode || !context.terminalReady || - !context.localEchoEnabled || - !this.inputController.hasContent() + !context.localEchoEnabled + ) { + return; + } + + if ( + !this.inputController.hasContent() && + this.hiddenPrompt.length === 0 && + this.serverLineBuffer.length === 0 ) { return; } @@ -129,11 +144,6 @@ export class MudPromptManager { const snapshot = this.inputController.getSnapshot(); - if (snapshot.buffer.length === 0) { - this.lineHidden = false; - return; - } - this.terminal.write(resetLine); const prefix = From 26ac3dfea36546f2758e32b743592c81f7f518e7 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 19 Oct 2025 04:03:27 +0200 Subject: [PATCH 06/77] refactor(frontend): enhance documentation with detailed comments across MudClient, MudInputController, MudPromptManager, and MudSocketAdapter --- .../mud-client/mud-client.component.ts | 52 +++++++++++++++++ .../features/terminal/mud-input.controller.ts | 57 ++++++++++++++++++- .../features/terminal/mud-prompt.manager.ts | 39 ++++++++++++- .../features/terminal/mud-socket.adapter.ts | 27 ++++++++- 4 files changed, 170 insertions(+), 5 deletions(-) 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 b9dd746..f4ab29d 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 @@ -17,6 +17,9 @@ import { SecureString } from '@mudlet3/frontend/shared'; import { LinemodeState } from '@mudlet3/frontend/features/sockets'; import { MudInputController, MudPromptContext, MudPromptManager, MudSocketAdapter } from '@mudlet3/frontend/features/terminal'; +/** + * Component-internal shape that bundles the mutable Mud client flags. + */ type MudClientState = { isEditMode: boolean; showEcho: boolean; @@ -24,6 +27,10 @@ type MudClientState = { terminalReady: boolean; }; +/** + * 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. + */ @Component({ selector: 'app-mud-client', standalone: true, @@ -69,6 +76,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { 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', @@ -85,6 +96,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { 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() { this.terminal.open(this.terminalRef.nativeElement); this.terminal.loadAddon(this.terminalFitAddon); @@ -112,6 +127,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.mudService.connect({ columns, rows }); } + /** + * Cleans up subscriptions and disposes terminal resources. + */ ngOnDestroy() { this.resizeObs.disconnect(); @@ -131,6 +149,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { 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(); @@ -158,12 +180,19 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.mudService.updateViewportSize(columns, rows); } + /** + * Sends a committed line (or secure string) to the server. + */ private handleCommittedInput(message: string, echoed: boolean) { const payload: string | SecureString = echoed ? message : { value: message }; this.mudService.sendMessage(payload); } + /** + * 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) { @@ -176,6 +205,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { 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.state.isEditMode; @@ -198,6 +231,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { 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) { const localEchoEnabled = this.state.isEditMode && showEcho; @@ -205,18 +242,30 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.inputController.setLocalEcho(localEchoEnabled); } + /** + * Delegates to the prompt manager so it can temporarily hide the local prompt. + */ private beforeMudOutput(_data: string) { this.promptManager.beforeServerOutput(this.getPromptContext()); } + /** + * Restores prompt and user input after the server chunk has been rendered. + */ private afterMudOutput(data: string) { this.promptManager.afterServerOutput(data, this.getPromptContext()); } + /** + * Lets the prompt manager strip redundant CR/LF characters. + */ private transformMudOutput(data: string): string { return this.promptManager.transformOutput(data); } + /** + * Builds the prompt context consumed by the prompt manager. + */ private getPromptContext(): MudPromptContext { return { isEditMode: this.state.isEditMode, @@ -225,6 +274,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { }; } + /** + * Convenience helper for patching the local state object. + */ private setState(patch: Partial): void { this.state = { ...this.state, ...patch }; } diff --git a/frontend/src/app/features/terminal/mud-input.controller.ts b/frontend/src/app/features/terminal/mud-input.controller.ts index d1cb676..df2dc80 100644 --- a/frontend/src/app/features/terminal/mud-input.controller.ts +++ b/frontend/src/app/features/terminal/mud-input.controller.ts @@ -11,14 +11,18 @@ import { 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; /** - * Encapsulates client-side editing state for LINEMODE input. - * Keeps track of the text buffer, cursor position and terminal echo updates. + * 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 = ''; @@ -26,11 +30,19 @@ export class MudInputController { private lastWasCarriageReturn = false; private localEchoEnabled = true; + /** + * @param terminal Reference to the xterm instance we mirror the editing state to. + * @param onCommit Callback that receives a flushed line (with echo information). + */ constructor( private readonly terminal: Terminal, private readonly onCommit: MudInputCommitHandler, ) {} + /** + * 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 { for (let index = 0; index < data.length; index += 1) { const char = data[index]; @@ -66,24 +78,41 @@ export class MudInputController { } } + /** + * 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; } + /** + * @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; @@ -100,6 +129,10 @@ export class MudInputController { 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; @@ -112,6 +145,10 @@ export class MudInputController { 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); @@ -136,6 +173,10 @@ export class MudInputController { } } + /** + * 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; @@ -159,6 +200,9 @@ export class MudInputController { } } + /** + * Moves the logical cursor to the left and emits the matching terminal escape. + */ private moveCursorLeft(amount: number): void { if (amount <= 0) { return; @@ -178,6 +222,9 @@ export class MudInputController { } } + /** + * Moves the logical cursor to the right and emits the matching terminal escape. + */ private moveCursorRight(amount: number): void { if (amount <= 0) { return; @@ -197,6 +244,12 @@ export class MudInputController { } } + /** + * 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) && segment.length >= SS3_LEN) { const control = segment[2]; diff --git a/frontend/src/app/features/terminal/mud-prompt.manager.ts b/frontend/src/app/features/terminal/mud-prompt.manager.ts index ee4ee45..a0f50a1 100644 --- a/frontend/src/app/features/terminal/mud-prompt.manager.ts +++ b/frontend/src/app/features/terminal/mud-prompt.manager.ts @@ -10,6 +10,9 @@ import { } 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; @@ -19,6 +22,8 @@ export type MudPromptContext = { /** * Keeps track of prompt / current line state so that we can temporarily hide * the local edit buffer while server output is rendered and then restore it. + * The manager stores visual state (prompt characters already printed by the + * server) and collaborates with the {@link MudInputController} for user input. */ export class MudPromptManager { private serverLineBuffer = ''; @@ -26,11 +31,19 @@ export class MudPromptManager { private leadingLineBreaksToStrip = 0; 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. Typically invoked when the editing mode + * changes or the terminal is reinitialised. + */ public reset(): void { this.serverLineBuffer = ''; this.hiddenPrompt = ''; @@ -38,6 +51,10 @@ export class MudPromptManager { this.lineHidden = false; } + /** + * Strips leading CR/LF characters that belong to a previously hidden prompt so + * the restored line does not produce blank rows when the server pushes output. + */ public transformOutput(data: string): string { if (this.leadingLineBreaksToStrip === 0 || data.length === 0) { return data; @@ -79,6 +96,10 @@ export class MudPromptManager { return data.slice(startIndex); } + /** + * Records the current prompt/input line and clears it from the terminal so + * that incoming server output appears in the correct position. + */ public beforeServerOutput(context: MudPromptContext): void { if ( !context.isEditMode || @@ -105,6 +126,11 @@ export class MudPromptManager { this.lineHidden = true; } + /** + * Restores a hidden prompt after new server output has been flushed. The + * restoration happens asynchronously (next microtask) to ensure the terminal + * has finished rendering the server chunk first. + */ public afterServerOutput(data: string, context: MudPromptContext): void { this.trackServerLine(data); @@ -128,6 +154,10 @@ export class MudPromptManager { queueMicrotask(() => this.restoreLine(context)); } + /** + * Replays prompt and local input back to the terminal. Cursor positioning is + * recalculated from the last input snapshot to maintain the editing position. + */ private restoreLine(context: MudPromptContext): void { if (!this.lineHidden) { return; @@ -166,9 +196,13 @@ export class MudPromptManager { this.lineHidden = false; this.hiddenPrompt = ''; this.serverLineBuffer = prefix; - this.leadingLineBreaksToStrip = 0; + this.leadingLineBreaksToStrip = 0; } + /** + * Tracks server-provided characters for the current line so that we can + * rebuild the prompt later. Escape sequences are preserved as-is. + */ private trackServerLine(chunk: string): void { let index = 0; @@ -202,6 +236,9 @@ export class MudPromptManager { } } + /** + * @returns number of characters that belong to an escape sequence (CSI/SS3). + */ private skipEscapeSequence(segment: string): number { if (segment.startsWith(SS3) && segment.length >= SS3_LEN) { return SS3_LEN; diff --git a/frontend/src/app/features/terminal/mud-socket.adapter.ts b/frontend/src/app/features/terminal/mud-socket.adapter.ts index 760d001..b65a274 100644 --- a/frontend/src/app/features/terminal/mud-socket.adapter.ts +++ b/frontend/src/app/features/terminal/mud-socket.adapter.ts @@ -1,5 +1,8 @@ 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; @@ -10,8 +13,9 @@ type SocketListener = EventListener; /** * Minimal WebSocket-like adapter that feeds xterm's AttachAddon with the - * server output stream coming from the MudService. It translates output$ - * emissions into `message` events for the addon. + * 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'; @@ -20,6 +24,10 @@ export class MudSocketAdapter { 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, @@ -40,6 +48,9 @@ export class MudSocketAdapter { }); } + /** + * 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()); @@ -48,6 +59,9 @@ export class MudSocketAdapter { 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) { @@ -65,15 +79,24 @@ export class MudSocketAdapter { // Input handling is managed separately via terminal.onData } + /** + * 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(); } + /** + * Dispatches a cloned event to all listeners of the given type. + */ private dispatch(type: string, event: Event) { const listeners = this.listeners.get(type); From 21e93bbd1be59fc57c9845b2d44fe736081b52ba Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 19 Oct 2025 15:21:36 +0200 Subject: [PATCH 07/77] refactor(frontend): improve MudInputController with cursor navigation and delete functionality --- .../mud-client/mud-client.component.ts | 53 +++++++++++---- .../features/terminal/mud-input.controller.ts | 64 +++++++++++++++++++ 2 files changed, 105 insertions(+), 12 deletions(-) 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 f4ab29d..376cc6a 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,7 +15,13 @@ import { Subscription } from 'rxjs'; import { MudService } from '../../services/mud.service'; import { SecureString } from '@mudlet3/frontend/shared'; import { LinemodeState } from '@mudlet3/frontend/features/sockets'; -import { MudInputController, MudPromptContext, MudPromptManager, MudSocketAdapter } from '@mudlet3/frontend/features/terminal'; +import { + CTRL, + MudInputController, + MudPromptContext, + MudPromptManager, + MudSocketAdapter, +} from '@mudlet3/frontend/features/terminal'; /** * Component-internal shape that bundles the mutable Mud client flags. @@ -27,6 +33,8 @@ type MudClientState = { terminalReady: boolean; }; +const DELETE_SEQUENCE = `${CTRL.ESC}[3~`; + /** * 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. @@ -45,11 +53,14 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly inputController: MudInputController; private readonly promptManager: MudPromptManager; private readonly terminalFitAddon = new FitAddon(); - private readonly socketAdapter = new MudSocketAdapter(this.mudService.mudOutput$, { - transformMessage: (data) => this.transformMudOutput(data), - beforeMessage: (data) => this.beforeMudOutput(data), - afterMessage: (data) => this.afterMudOutput(data), - }); + private readonly socketAdapter = new MudSocketAdapter( + this.mudService.mudOutput$, + { + 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 }, @@ -88,12 +99,16 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { screenReaderMode: true, }); - this.inputController = new MudInputController(this.terminal, ({ message, echoed }) => - this.handleCommittedInput(message, echoed), + this.inputController = new MudInputController( + this.terminal, + ({ message, echoed }) => this.handleCommittedInput(message, echoed), ); this.inputController.setLocalEcho(this.state.localEchoEnabled); - this.promptManager = new MudPromptManager(this.terminal, this.inputController); + this.promptManager = new MudPromptManager( + this.terminal, + this.inputController, + ); } /** @@ -184,7 +199,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { * Sends a committed line (or secure string) to the server. */ private handleCommittedInput(message: string, echoed: boolean) { - const payload: string | SecureString = echoed ? message : { value: message }; + const payload: string | SecureString = echoed + ? message + : { value: message }; this.mudService.sendMessage(payload); } @@ -196,7 +213,8 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private handleInput(data: string) { if (!this.state.isEditMode) { if (data.length > 0) { - this.mudService.sendMessage(data); + const rewritten = this.rewriteBackspaceToDelete(data); + this.mudService.sendMessage(rewritten); } return; @@ -281,6 +299,17 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.state = { ...this.state, ...patch }; } -} + /** + * Maps DEL to BACKSPACE for non-edit mode + */ + private rewriteBackspaceToDelete(data: string): string { + const containsDelete = data.includes(CTRL.DEL); + 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/terminal/mud-input.controller.ts b/frontend/src/app/features/terminal/mud-input.controller.ts index df2dc80..ed70399 100644 --- a/frontend/src/app/features/terminal/mud-input.controller.ts +++ b/frontend/src/app/features/terminal/mud-input.controller.ts @@ -261,6 +261,12 @@ export class MudInputController { case 'D': this.moveCursorLeft(1); break; + case 'H': + this.moveCursorToStart(); + break; + case 'F': + this.moveCursorToEnd(); + break; default: break; } @@ -287,10 +293,68 @@ export class MudInputController { 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; + + 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); + } } From 41490a65760e1246f6f42731dd18378bddcaf245 Mon Sep 17 00:00:00 2001 From: myst Date: Thu, 22 Jan 2026 15:42:28 +0100 Subject: [PATCH 08/77] feat(frontend): hardened in-/output handling --- .../serverconfig/server-config.service.ts | 2 +- .../terminal/mud-input.controller.spec.ts | 103 +++ .../features/terminal/mud-input.controller.ts | 32 +- .../terminal/mud-prompt.manager.spec.ts | 714 ++++++++++++++++++ .../features/terminal/mud-prompt.manager.ts | 507 ++++++++++--- .../features/terminal/mud-socket.adapter.ts | 19 +- 6 files changed, 1279 insertions(+), 98 deletions(-) create mode 100644 frontend/src/app/features/terminal/mud-input.controller.spec.ts create mode 100644 frontend/src/app/features/terminal/mud-prompt.manager.spec.ts 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/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 index ed70399..7a12ffa 100644 --- a/frontend/src/app/features/terminal/mud-input.controller.ts +++ b/frontend/src/app/features/terminal/mud-input.controller.ts @@ -29,6 +29,8 @@ export class MudInputController { 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. @@ -44,8 +46,11 @@ export class MudInputController { * internal buffer/cursor state and performs the corresponding terminal writes. */ public handleData(data: string): void { - for (let index = 0; index < data.length; index += 1) { - const char = data[index]; + 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: @@ -65,7 +70,15 @@ export class MudInputController { this.lastWasCarriageReturn = false; break; case CTRL.ESC: { - const consumed = this.handleEscapeSequence(data.slice(index)); + 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; @@ -93,6 +106,7 @@ export class MudInputController { this.buffer = ''; this.cursor = 0; this.lastWasCarriageReturn = false; + this.pendingEscape = ''; } /** @@ -251,7 +265,11 @@ export class MudInputController { * @returns number of characters consumed from the segment. */ private handleEscapeSequence(segment: string): number { - if (segment.startsWith(SS3) && segment.length >= SS3_LEN) { + if (segment.startsWith(SS3)) { + if (segment.length < SS3_LEN) { + return 0; // incomplete SS3 + } + const control = segment[2]; switch (control) { @@ -277,6 +295,12 @@ export class MudInputController { 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; } 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 index a0f50a1..9a1883d 100644 --- a/frontend/src/app/features/terminal/mud-prompt.manager.ts +++ b/frontend/src/app/features/terminal/mud-prompt.manager.ts @@ -20,15 +20,63 @@ export type MudPromptContext = { }; /** - * Keeps track of prompt / current line state so that we can temporarily hide - * the local edit buffer while server output is rendered and then restore it. - * The manager stores visual state (prompt characters already printed by the - * server) and collaborates with the {@link MudInputController} for user input. + * 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 { - private serverLineBuffer = ''; - private hiddenPrompt = ''; - private leadingLineBreaksToStrip = 0; + /** 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; /** @@ -41,66 +89,93 @@ export class MudPromptManager { ) {} /** - * Clears all tracked prompt state. Typically invoked when the editing mode - * changes or the terminal is reinitialised. + * 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.serverLineBuffer = ''; - this.hiddenPrompt = ''; - this.leadingLineBreaksToStrip = 0; + this.currentPrompt = ''; + this.stripNextLineBreak = false; + this.incompleteEscape = ''; this.lineHidden = false; } /** - * Strips leading CR/LF characters that belong to a previously hidden prompt so - * the restored line does not produce blank rows when the server pushes output. + * 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.leadingLineBreaksToStrip === 0 || data.length === 0) { + if (!this.stripNextLineBreak || data.length === 0) { return data; } let startIndex = 0; - let remainingBreaks = this.leadingLineBreaksToStrip; - - while (startIndex < data.length && remainingBreaks > 0) { - const char = data[startIndex]; - - if (char === CTRL.LF) { - remainingBreaks -= 1; - startIndex += 1; - continue; - } - - if (char === CTRL.CR) { - startIndex += 1; - continue; - } - break; + // Handle CRLF as atomic unit: \r\n (Windows style) + if (data.startsWith(CTRL.CR + CTRL.LF)) { + startIndex = 2; } - - this.leadingLineBreaksToStrip = remainingBreaks; - - if (startIndex === 0) { - this.leadingLineBreaksToStrip = 0; - return data; + // Handle LF only (Unix style) + else if (data.startsWith(CTRL.LF)) { + startIndex = 1; } - - if (startIndex >= data.length) { - this.leadingLineBreaksToStrip = 0; - return ''; + // Handle CR only (old Mac style) + else if (data.startsWith(CTRL.CR)) { + startIndex = 1; } - this.leadingLineBreaksToStrip = 0; - return data.slice(startIndex); + // Always reset flag after first call, even if no line break found + this.stripNextLineBreak = false; + + return startIndex > 0 ? data.slice(startIndex) : data; } /** - * Records the current prompt/input line and clears it from the terminal so - * that incoming server output appears in the correct position. + * 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 || @@ -110,30 +185,50 @@ export class MudPromptManager { return; } + // Check if there's anything to hide (prompt or user input) const hasLineContent = - this.inputController.hasContent() || - this.serverLineBuffer.length > 0 || - this.hiddenPrompt.length > 0; + this.inputController.hasContent() || this.currentPrompt.length > 0; if (!hasLineContent) { return; } - this.hiddenPrompt = this.serverLineBuffer; - this.serverLineBuffer = ''; - this.leadingLineBreaksToStrip = 1; + // 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 } /** - * Restores a hidden prompt after new server output has been flushed. The - * restoration happens asynchronously (next microtask) to ensure the terminal - * has finished rendering the server chunk first. + * 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 || @@ -143,113 +238,343 @@ export class MudPromptManager { return; } - if ( - !this.inputController.hasContent() && - this.hiddenPrompt.length === 0 && - this.serverLineBuffer.length === 0 - ) { + // Check if there's anything to restore + if (!this.inputController.hasContent() && this.currentPrompt.length === 0) { return; } - queueMicrotask(() => this.restoreLine(context)); + // 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)); } /** - * Replays prompt and local input back to the terminal. Cursor positioning is - * recalculated from the last input snapshot to maintain the editing position. + * 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(); - this.terminal.write(resetLine); + // Validate snapshot exists + if (!snapshot) { + console.error( + '[MudPromptManager] No snapshot available - aborting restore', + ); + this.lineHidden = false; + return; + } - const prefix = - this.serverLineBuffer.length > 0 - ? this.serverLineBuffer - : this.hiddenPrompt; + // 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); - if (prefix.length > 0) { - this.terminal.write(prefix); + // Write prompt if present + if (this.currentPrompt.length > 0) { + this.terminal.write(this.currentPrompt); } - this.terminal.write(snapshot.buffer); + // 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; - this.hiddenPrompt = ''; - this.serverLineBuffer = prefix; - this.leadingLineBreaksToStrip = 0; + // Note: currentPrompt is NOT cleared - we need it for next cycle } /** - * Tracks server-provided characters for the current line so that we can - * rebuild the prompt later. Escape sequences are preserved as-is. + * 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 < chunk.length) { - const char = chunk[index]; + while (index < data.length) { + const char = data[index]; + // Line breaks reset the prompt if (char === CTRL.CR || char === CTRL.LF) { - this.serverLineBuffer = ''; + this.currentPrompt = ''; index += 1; continue; } + // Backspace/Delete: Remove last visible character (ANSI-aware) if (char === CTRL.BS || char === CTRL.DEL) { - this.serverLineBuffer = this.serverLineBuffer.slice(0, -1); + this.currentPrompt = this.removeLastVisibleChar(this.currentPrompt); index += 1; continue; } + // Escape sequence: Parse and preserve in prompt if (char === CTRL.ESC) { - const consumed = this.skipEscapeSequence(chunk.slice(index)); - const parsedSequence = - consumed > 0 ? chunk.slice(index, index + consumed) : char; - - this.serverLineBuffer += parsedSequence; - index += Math.max(consumed, 1); + 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; } - this.serverLineBuffer += char; + // Regular character: Append to prompt + this.currentPrompt += char; index += 1; } } /** - * @returns number of characters that belong to an escape sequence (CSI/SS3). + * 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 { - if (segment.startsWith(SS3) && segment.length >= SS3_LEN) { - return SS3_LEN; + // Must start with ESC + if (!segment.startsWith(CTRL.ESC)) { + return 0; } - const match = segment.match(CSI_REGEX); + // 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-socket.adapter.ts b/frontend/src/app/features/terminal/mud-socket.adapter.ts index b65a274..44a1f17 100644 --- a/frontend/src/app/features/terminal/mud-socket.adapter.ts +++ b/frontend/src/app/features/terminal/mud-socket.adapter.ts @@ -19,7 +19,8 @@ type SocketListener = EventListener; */ export class MudSocketAdapter { public binaryType: BinaryType = 'arraybuffer'; - public readyState = WebSocket.OPEN; + // 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; @@ -75,8 +76,21 @@ export class MudSocketAdapter { } } + /** + * 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 we just log a warning. + */ public send(): void { - // Input handling is managed separately via terminal.onData + if (this.readyState !== WebSocket.OPEN) { + console.warn( + 'MudSocketAdapter.send(): adapter is closed; input is output-only', + ); + return; + } + + console.warn( + 'MudSocketAdapter.send(): no-op (output-only adapter; input flows via terminal.onData)', + ); } /** @@ -92,6 +106,7 @@ export class MudSocketAdapter { public dispose() { this.subscription.unsubscribe(); this.listeners.clear(); + this.readyState = WebSocket.CLOSED; } /** From 1df7b8ab45ae98bcbdf07d2635bb65cbca435994 Mon Sep 17 00:00:00 2001 From: myst Date: Thu, 22 Jan 2026 19:44:51 +0100 Subject: [PATCH 09/77] feat(frontend): implement MudScreenReader for improved accessibility --- frontend/setup-jest.ts | 2 +- .../mud-client/mud-client.component.html | 10 ++ .../mud-client/mud-client.component.scss | 29 ++++ .../mud-client/mud-client.component.ts | 79 ++++++++-- frontend/src/app/features/terminal/index.ts | 1 + .../terminal/mud-screenreader.spec.ts | 67 ++++++++ .../app/features/terminal/mud-screenreader.ts | 148 ++++++++++++++++++ frontend/src/index.html | 2 +- shared/.eslintrc.js | 92 +++++++++++ shared/package.json | 10 +- 10 files changed, 420 insertions(+), 20 deletions(-) create mode 100644 frontend/src/app/features/terminal/mud-screenreader.spec.ts create mode 100644 frontend/src/app/features/terminal/mud-screenreader.ts create mode 100644 shared/.eslintrc.js 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..4714e00 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,13 @@ +
+ +
+
@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..9f0d3ae 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,34 @@ 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; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: pre-wrap; + border: 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 b2eb680..199299c 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 @@ -21,6 +21,7 @@ import { MudPromptManager, MudSocketAdapter, MudPromptContext, + MudScreenReaderAnnouncer, } from '../../../../features/terminal'; /** @@ -37,7 +38,9 @@ const DELETE_SEQUENCE = `${CTRL.ESC}[3~`; /** * 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. + * 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', @@ -52,19 +55,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { 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.mudOutput$, - { - 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(() => { @@ -84,6 +78,12 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { @ViewChild('hostRef', { static: true }) private readonly terminalRef!: ElementRef; + @ViewChild('liveRegionRef', { static: true }) + private readonly liveRegionRef!: ElementRef; + + @ViewChild('historyRegionRef', { static: true }) + private readonly historyRegionRef!: ElementRef; + protected readonly isConnected$ = this.mudService.connectedToMud$; protected readonly showEcho$ = this.mudService.showEcho$; @@ -96,7 +96,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { fontFamily: 'JetBrainsMono, monospace', theme: { background: '#000', foreground: '#ccc' }, disableStdin: false, - screenReaderMode: true, + screenReaderMode: false, }); this.inputController = new MudInputController( @@ -116,6 +116,28 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { * 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, + ); + 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); @@ -152,15 +174,17 @@ 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 }); } @@ -203,6 +227,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { ? message : { value: message }; + if (typeof payload === 'string') { + this.screenReader?.appendToHistory(payload); + } + this.mudService.sendMessage(payload); } @@ -272,6 +300,8 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { */ private afterMudOutput(data: string) { this.promptManager.afterServerOutput(data, this.getPromptContext()); + this.announceToScreenReader(data); + this.screenReader?.appendToHistory(data); } /** @@ -292,6 +322,23 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { }; } + /** + * 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; + } + + console.debug('[MudClient] Announcing to screenreader:', { + rawLength: data.length, + raw: data, + }); + + this.screenReader.announce(data); + } + /** * Convenience helper for patching the local state object. */ diff --git a/frontend/src/app/features/terminal/index.ts b/frontend/src/app/features/terminal/index.ts index 30d50e2..adc85d2 100644 --- a/frontend/src/app/features/terminal/index.ts +++ b/frontend/src/app/features/terminal/index.ts @@ -2,3 +2,4 @@ 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/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..7e35bf4 --- /dev/null +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -0,0 +1,148 @@ +const DEFAULT_CLEAR_DELAY_MS = 300; +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 sessionStartedAt: number; + + constructor( + private readonly liveRegion: HTMLElement, + private readonly historyRegion?: 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(); + } + + /** + * 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 = ''; + } + } + + 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; + } + } + + 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/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..138e6e1 --- /dev/null +++ b/shared/.eslintrc.js @@ -0,0 +1,92 @@ +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.app.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: ["*.html"], + extends: [ + "plugin:prettier/recommended", + ], + rules: { + "prettier/prettier": [ + "error", + { + parser: "angular", + }, + ], + }, + }, + ], +}; diff --git a/shared/package.json b/shared/package.json index e0dcfbe..310f28c 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,7 +30,10 @@ "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": { "rimraf": "~6.0.1", From dab908a86a49a31a4eb94c03bfb8bf02fb2cbd16 Mon Sep 17 00:00:00 2001 From: myst Date: Thu, 22 Jan 2026 20:06:45 +0100 Subject: [PATCH 10/77] chore: testing pipeline --- .github/workflows/deploy_to_azure.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/deploy_to_azure.yml b/.github/workflows/deploy_to_azure.yml index 3196e79..431533f 100644 --- a/.github/workflows/deploy_to_azure.yml +++ b/.github/workflows/deploy_to_azure.yml @@ -35,6 +35,12 @@ jobs: run: | cd ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} npm install --omit=dev + + - 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: "Deploy to Azure Web App" id: deploy-to-webapp From d1728c035e2fcc6790292f8c83ded7615cb86487 Mon Sep 17 00:00:00 2001 From: myst Date: Thu, 22 Jan 2026 20:13:48 +0100 Subject: [PATCH 11/77] chore: testing pipeline 2 --- .github/workflows/deploy_to_azure.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy_to_azure.yml b/.github/workflows/deploy_to_azure.yml index 431533f..eb6888c 100644 --- a/.github/workflows/deploy_to_azure.yml +++ b/.github/workflows/deploy_to_azure.yml @@ -31,17 +31,17 @@ jobs: npm ci npm run build:prod --if-present - - name: Install raw dependencies for backend - run: | - cd ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} - npm install --omit=dev - - 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 }} + npm install --omit=dev + - name: "Deploy to Azure Web App" id: deploy-to-webapp uses: azure/webapps-deploy@v3 From 7d794d0eb3cf411a3b3f69633c0788619f4f7f9b Mon Sep 17 00:00:00 2001 From: myst Date: Thu, 22 Jan 2026 20:55:48 +0100 Subject: [PATCH 12/77] testing: test other aria configs --- .../core/mud/components/mud-client/mud-client.component.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 4714e00..af601de 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,8 +1,7 @@
From aad60a4355e1872fef136aa05cda2b01ffc63fde Mon Sep 17 00:00:00 2001 From: myst Date: Thu, 22 Jan 2026 21:16:09 +0100 Subject: [PATCH 13/77] testing: test aria config without atomic --- .../mud/components/mud-client/mud-client.component.html | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 af601de..8e08966 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,9 +1,4 @@ -
+
From e228f2950867ff444ff67d2f7678d26ac106ead1 Mon Sep 17 00:00:00 2001 From: myst Date: Thu, 22 Jan 2026 22:50:21 +0100 Subject: [PATCH 14/77] chore: fixed smaller issues --- .../mud-client/mud-client.component.ts | 6 ++---- .../src/app/features/terminal/models/escapes.ts | 2 +- .../app/features/terminal/mud-socket.adapter.ts | 7 +------ shared/.eslintrc.js | 16 +--------------- shared/package.json | 8 ++++++++ 5 files changed, 13 insertions(+), 26 deletions(-) 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 199299c..6e74286 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 @@ -16,12 +16,12 @@ import { MudService } from '../../services/mud.service'; import { SecureString } from '@webmud3/frontend/shared/types/secure-string'; import type { LinemodeState } from '@webmud3/shared'; import { - CTRL, MudInputController, MudPromptManager, + MudScreenReaderAnnouncer, MudSocketAdapter, MudPromptContext, - MudScreenReaderAnnouncer, + CTRL, } from '../../../../features/terminal'; /** @@ -34,8 +34,6 @@ type MudClientState = { terminalReady: boolean; }; -const DELETE_SEQUENCE = `${CTRL.ESC}[3~`; - /** * 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 diff --git a/frontend/src/app/features/terminal/models/escapes.ts b/frontend/src/app/features/terminal/models/escapes.ts index 5b8382e..575741c 100644 --- a/frontend/src/app/features/terminal/models/escapes.ts +++ b/frontend/src/app/features/terminal/models/escapes.ts @@ -1,6 +1,6 @@ /** * Centralized ANSI/terminal control sequences & helpers. - * + */ /** ASCII / terminal control characters. */ export const CTRL = { diff --git a/frontend/src/app/features/terminal/mud-socket.adapter.ts b/frontend/src/app/features/terminal/mud-socket.adapter.ts index 44a1f17..9ad2a88 100644 --- a/frontend/src/app/features/terminal/mud-socket.adapter.ts +++ b/frontend/src/app/features/terminal/mud-socket.adapter.ts @@ -78,19 +78,14 @@ export class MudSocketAdapter { /** * 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 we just log a warning. + * 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', ); - return; } - - console.warn( - 'MudSocketAdapter.send(): no-op (output-only adapter; input flows via terminal.onData)', - ); } /** diff --git a/shared/.eslintrc.js b/shared/.eslintrc.js index 138e6e1..00ea5e9 100644 --- a/shared/.eslintrc.js +++ b/shared/.eslintrc.js @@ -41,7 +41,7 @@ module.exports = { { files: ["*.ts"], parserOptions: { - project: ["./tsconfig.app.json"], + project: ["./tsconfig.json"], tsconfigRootDir: __dirname, sourceType: "module", ecmaVersion: "latest", @@ -74,19 +74,5 @@ module.exports = { "import/no-duplicates": "warn", }, }, - { - files: ["*.html"], - extends: [ - "plugin:prettier/recommended", - ], - rules: { - "prettier/prettier": [ - "error", - { - parser: "angular", - }, - ], - }, - }, ], }; diff --git a/shared/package.json b/shared/package.json index 310f28c..ee2fb0e 100644 --- a/shared/package.json +++ b/shared/package.json @@ -36,6 +36,14 @@ "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" } From 37d708807e1a6f8b14280a11995530e458d66a66 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 01:00:47 +0100 Subject: [PATCH 15/77] feat(backend): add configurable log level to environment settings --- README.md | 1 + backend/src/core/environment/environment.ts | 7 ++++++- .../src/core/environment/types/environment-keys.ts | 3 ++- backend/src/shared/utils/logger.ts | 12 ++++++++++-- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7a22f67..b0696be 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ SOCKET_TIMEOUT=900000 # Optional | defaults to 900000 (15 SOCKET_ROOT=/socket.io # Required | URL for the socket connection. e.g. 'https://mud.example.com/socket.io' ENVIRONMENT='development' # Optional | accepts values 'development' or 'production' | defaults to 'production' | Enables Debug REST Endpoint /api/info and allows for permissive CORS if set to 'development' CORS_ALLOWED_ORIGINS='8.8.8.8,12.12.12.12' # Optional | comma separated list of origins that are allowed when ENVIRONMENT=production +LOG_LEVEL='debug' # Optional | accepts values 'error'|'warn'|'info'|'http'|'verbose'|'debug'|'silly' | defaults to 'debug' | sets the logging level ``` > [!TIP] diff --git a/backend/src/core/environment/environment.ts b/backend/src/core/environment/environment.ts index 6845f49..585c725 100644 --- a/backend/src/core/environment/environment.ts +++ b/backend/src/core/environment/environment.ts @@ -1,6 +1,6 @@ import { config as configureEnvironment } from 'dotenv'; -import { logger } from '../../shared/utils/logger.js'; +import { logger, setLogLevel } from '../../shared/utils/logger.js'; import { IEnvironment } from './types/environment.js'; import { getEnvironmentVariable } from './utils/get-environment-variable.js'; import { resolveModulePath } from './utils/resolve-modulepath.js'; @@ -23,6 +23,7 @@ export class Environment implements IEnvironment { public readonly environment: 'production' | 'development'; public readonly name: string; public readonly corsAllowList: string[]; + public readonly logLevel: string; /** * Private constructor to enforce singleton pattern. @@ -78,6 +79,10 @@ export class Environment implements IEnvironment { .map((origin) => origin.trim()) .filter((origin) => origin.length > 0); + this.logLevel = String(getEnvironmentVariable('LOG_LEVEL', false, 'debug')); + + setLogLevel(this.logLevel); + logger.info('[Environment] initialized', this); } diff --git a/backend/src/core/environment/types/environment-keys.ts b/backend/src/core/environment/types/environment-keys.ts index d92eaae..4c181d9 100644 --- a/backend/src/core/environment/types/environment-keys.ts +++ b/backend/src/core/environment/types/environment-keys.ts @@ -8,4 +8,5 @@ export type EnvironmentKeys = | 'SOCKET_TIMEOUT' // in milliseconds | default: 900000 (15 min) | determines how long messages are buffed for the disconnected frontend and when the telnet connection is closed | 'SOCKET_ROOT' // Required | URL for the socket connection. e.g. 'https://mud.example.com/socket.io' | 'ENVIRONMENT' // Optional | accepts values 'development' or 'production' | defaults to 'production' | Enables Debug REST Endpoint /api/info and allows for permissive CORS if set to 'development' - | 'CORS_ALLOWED_ORIGINS'; // Optional | comma separated list of origins that are allowed when ENVIRONMENT=production + | 'CORS_ALLOWED_ORIGINS' // Optional | comma separated list of origins that are allowed when ENVIRONMENT=production + | 'LOG_LEVEL'; // Optional | winston level: error|warn|info|http|verbose|debug|silly | defaults to 'debug' diff --git a/backend/src/shared/utils/logger.ts b/backend/src/shared/utils/logger.ts index 0f73c82..2493c70 100644 --- a/backend/src/shared/utils/logger.ts +++ b/backend/src/shared/utils/logger.ts @@ -24,7 +24,7 @@ const logMetadata = (enable: boolean) => * - silly **/ const logger = winston.createLogger({ - level: 'debug', + level: 'info', levels: winston.config.npm.levels, format: winston.format.combine( winston.format.timestamp({ @@ -53,4 +53,12 @@ const logger = winston.createLogger({ exitOnError: false, }); -export { logger }; +/** + * Sets the log level for the logger. + * @param level The log level to set (error|warn|info|http|verbose|debug|silly) + */ +function setLogLevel(level: string): void { + logger.level = level; +} + +export { logger, setLogLevel }; From ef263da4ce06a195cee875fcdbe19d26a0b6d373 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 16:27:19 +0100 Subject: [PATCH 16/77] feat(backend): small logging fixes --- backend/src/core/routes/routes.ts | 15 +++++++++++ .../telnet/utils/handle-naws-option.ts | 2 +- .../src/shared/utils/create-http-server.ts | 27 ++++++++++++++----- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/backend/src/core/routes/routes.ts b/backend/src/core/routes/routes.ts index b16ca0c..528bc53 100644 --- a/backend/src/core/routes/routes.ts +++ b/backend/src/core/routes/routes.ts @@ -16,6 +16,11 @@ export const useRoutes = (app: Express) => { path.join(__dirname, 'dist', 'manifest.webmanifest'), function (err, data) { if (err) { + logger.error('[Routes] Failed to read manifest.webmanifest', { + error: err.message, + path: path.join(__dirname, 'dist', 'manifest.webmanifest'), + }); + res.sendStatus(404); } else { res.send(data); @@ -48,6 +53,16 @@ export const useRoutes = (app: Express) => { res.sendFile( path.join(Environment.getInstance().projectRoot, 'wwwroot/index.html'), + (err) => { + if (err) { + logger.error('[Routes] Failed to send index.html', { + error: err.message, + path: req.path, + }); + + res.sendStatus(500); + } + }, ); }); }; diff --git a/backend/src/features/telnet/utils/handle-naws-option.ts b/backend/src/features/telnet/utils/handle-naws-option.ts index e7c28d0..11d3feb 100644 --- a/backend/src/features/telnet/utils/handle-naws-option.ts +++ b/backend/src/features/telnet/utils/handle-naws-option.ts @@ -150,7 +150,7 @@ class NawsNegotiator { this.socket.writeSub(TelnetOptions.TELOPT_NAWS, buffer); - logger.info('SENDING NAWS: ', { + logger.verbose('SENDING NAWS: ', { width: this.state.width, height: this.state.height, }); diff --git a/backend/src/shared/utils/create-http-server.ts b/backend/src/shared/utils/create-http-server.ts index d3017fb..7508bc5 100644 --- a/backend/src/shared/utils/create-http-server.ts +++ b/backend/src/shared/utils/create-http-server.ts @@ -11,15 +11,30 @@ export function createHttpServer( settings: { tls?: { cert: string; key: string } }, ): HttpServer | HttpsServer { if (settings.tls !== undefined) { - const options = { - key: fs.readFileSync(settings.tls.key), - cert: fs.readFileSync(settings.tls.cert), - }; + try { + const options = { + key: fs.readFileSync(settings.tls.key), + cert: fs.readFileSync(settings.tls.cert), + }; - logger.debug('SRV://5000 : INIT: https active'); + logger.debug('[HTTP-Server] HTTPS active', { + certPath: settings.tls.cert, + keyPath: settings.tls.key, + }); - return new HttpsServer(options, app); + return new HttpsServer(options, app); + } catch (error) { + logger.error('[HTTP-Server] Failed to read TLS certificate or key', { + error: error instanceof Error ? error.message : String(error), + certPath: settings.tls.cert, + keyPath: settings.tls.key, + }); + + throw error; + } } else { + logger.debug('[HTTP-Server] HTTP mode (no TLS)'); + return new HttpServer(app); } } From 3a5916484d80dcbeb30bdf952706d32b70a5c0d7 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 16:37:27 +0100 Subject: [PATCH 17/77] test(frontend): testing another aria feature --- .../mud/components/mud-client/mud-client.component.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 8e08966..f342a76 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,6 +1,12 @@
-
+
From 6e10e7c6c04c894cc02e91e9a6039cea29b13484 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 18:32:58 +0100 Subject: [PATCH 18/77] test(frontend): role=region test --- .../mud/components/mud-client/mud-client.component.html | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 f342a76..c8b71bd 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,12 +1,6 @@
-
+
From 07f1688ab950e964e02ed49bcfd302fee0a44da0 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 18:51:31 +0100 Subject: [PATCH 19/77] test(frontend): visible history log --- .../mud-client/mud-client.component.scss | 15 ++++++--------- .../app/features/terminal/mud-prompt.manager.ts | 4 ++-- 2 files changed, 8 insertions(+), 11 deletions(-) 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 9f0d3ae..4c04ba5 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 @@ -24,15 +24,12 @@ } .sr-history { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: pre-wrap; - border: 0; + position: relative; + width: auto; + height: 200px; + overflow: auto; + clip: auto; + margin: 0; } .sr-log-item { diff --git a/frontend/src/app/features/terminal/mud-prompt.manager.ts b/frontend/src/app/features/terminal/mud-prompt.manager.ts index 9a1883d..bafe5d8 100644 --- a/frontend/src/app/features/terminal/mud-prompt.manager.ts +++ b/frontend/src/app/features/terminal/mud-prompt.manager.ts @@ -31,11 +31,11 @@ export type MudPromptContext = { * ## State Machine * ``` * ┌─────────┐ beforeServerOutput() ┌────────┐ - * │ VISIBLE │ ────────────────────> │ HIDDEN │ + * │ VISIBLE │ ────────────────────> │ HIDDEN │ * │ │ │ │ * │ User is │ │ Server │ * │ typing │ │ writes │ - * │ │ <──────────────────── │ │ + * │ │ <───────────────────- │ │ * └─────────┘ restoreLine() └────────┘ * (async via queueMicrotask) * ``` From 9671dd6be6a89ee4338b042a99d49cdfd9847fe6 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 19:03:20 +0100 Subject: [PATCH 20/77] test(frontend): [was working] make stuff invisible test --- .../components/mud-client/mud-client.component.scss | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 4c04ba5..b252bee 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 @@ -24,12 +24,15 @@ } .sr-history { - position: relative; - width: auto; - height: 200px; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 40vh; + opacity: 0.001; /* quasi unsichtbar */ + pointer-events: none; /* verhindert Maus-Klicks */ overflow: auto; - clip: auto; - margin: 0; + z-index: 0; } .sr-log-item { From 5b26588b53d65e2c676fca8206623d8dc97cf3d4 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 21:20:37 +0100 Subject: [PATCH 21/77] test(frontend): live region for inputs --- .../mud-client/mud-client.component.html | 7 +++++ .../mud-client/mud-client.component.ts | 14 +++++++++ .../features/terminal/mud-input.controller.ts | 21 +++++++++++++ .../app/features/terminal/mud-screenreader.ts | 30 +++++++++++++++++++ 4 files changed, 72 insertions(+) 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 c8b71bd..b4f5f05 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,5 +1,12 @@
+
+
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 6e74286..fe08b65 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 @@ -79,6 +79,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { @ViewChild('liveRegionRef', { static: true }) private readonly liveRegionRef!: ElementRef; + @ViewChild('inputRegionRef', { static: true }) + private readonly inputRegionRef!: ElementRef; + @ViewChild('historyRegionRef', { static: true }) private readonly historyRegionRef!: ElementRef; @@ -100,6 +103,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.inputController = new MudInputController( this.terminal, ({ message, echoed }) => this.handleCommittedInput(message, echoed), + ({ buffer }) => this.announceInputToScreenReader(buffer), ); this.inputController.setLocalEcho(this.state.localEchoEnabled); @@ -119,6 +123,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.screenReader = new MudScreenReaderAnnouncer( this.liveRegionRef.nativeElement, this.historyRegionRef.nativeElement, + this.inputRegionRef.nativeElement, ); console.debug( '[MudClient] Screenreader announcer initialized, live region:', @@ -232,6 +237,15 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.mudService.sendMessage(payload); } + /** + * 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); + } + /** * Routes terminal keystrokes either directly to the socket (when not in edit mode) * or through the {@link MudInputController}. diff --git a/frontend/src/app/features/terminal/mud-input.controller.ts b/frontend/src/app/features/terminal/mud-input.controller.ts index 7a12ffa..8148b17 100644 --- a/frontend/src/app/features/terminal/mud-input.controller.ts +++ b/frontend/src/app/features/terminal/mud-input.controller.ts @@ -19,6 +19,13 @@ export type MudInputCommitHandler = (payload: { 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 @@ -35,10 +42,12 @@ export class MudInputController { /** * @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, ) {} /** @@ -176,6 +185,10 @@ export class MudInputController { this.buffer = before + char + after; this.cursor += 1; + this.onInputChange?.({ + buffer: this.buffer, + }); + if (!this.localEchoEnabled) { return; } @@ -202,6 +215,10 @@ export class MudInputController { this.buffer = before + after; this.cursor -= 1; + this.onInputChange?.({ + buffer: this.buffer, + }); + if (!this.localEchoEnabled) { return; } @@ -361,6 +378,10 @@ export class MudInputController { this.buffer = before + after; + this.onInputChange?.({ + buffer: this.buffer, + }); + if (!this.localEchoEnabled) { return; } diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 7e35bf4..41293b4 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -17,6 +17,7 @@ export class MudScreenReaderAnnouncer { constructor( private readonly liveRegion: HTMLElement, private readonly historyRegion?: HTMLElement, + private readonly inputRegion?: HTMLElement, private readonly clearDelayMs: number = DEFAULT_CLEAR_DELAY_MS, ) { this.sessionStartedAt = Date.now(); @@ -113,6 +114,35 @@ export class MudScreenReaderAnnouncer { } } + /** + * Announces the current input buffer to the input region. + * Used to inform screen reader users of their live typing. + * Unlike server output announcements, input is NOT auto-cleared + * to allow users to review what they typed. + */ + public announceInput(buffer: string): void { + if (!this.inputRegion) { + return; + } + + const normalized = this.normalize(buffer); + + console.debug('[ScreenReader] Announcing input:', { + raw: buffer.substring(0, 100), + normalized: normalized.substring(0, 100), + }); + + if (!normalized) { + this.inputRegion.textContent = ''; + return; + } + + // Show buffer with cursor indicator (helpful for users to know where they are) + // Format: "typed text (cursor at position X)" + const display = `${normalized}`; + this.inputRegion.textContent = display; + } + private scheduleClear(): void { this.cancelClearTimer(); From fdec0be97e3d97b5435b9a68ec0de60080226f18 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 21:42:28 +0100 Subject: [PATCH 22/77] test(frontend): .. with atomic and role --- .../core/mud/components/mud-client/mud-client.component.html | 3 +++ 1 file changed, 3 insertions(+) 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 b4f5f05..e66df0b 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 @@ -2,7 +2,10 @@
From 81cf85c7c3a60886814c8fc22d18f4f700048292 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 22:04:37 +0100 Subject: [PATCH 23/77] test(frontend): .. and no label --- .../app/core/mud/components/mud-client/mud-client.component.html | 1 - 1 file changed, 1 deletion(-) 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 e66df0b..e938b4f 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 @@ -6,7 +6,6 @@ aria-live="polite" aria-readonly="true" aria-atomic="true" - aria-label="Aktuelle Eingabe" #inputRegionRef > From 2369d2de721409eb98a9bad226510ab243136251 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 23:10:16 +0100 Subject: [PATCH 24/77] test(frontend): .. and custom announcement --- .../mud-client/mud-client.component.html | 7 +++ .../mud-client/mud-client.component.ts | 6 +++ .../app/features/terminal/mud-screenreader.ts | 54 +++++++++++++++---- 3 files changed, 57 insertions(+), 10 deletions(-) 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 e938b4f..a53b3f8 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 @@ -9,6 +9,13 @@ #inputRegionRef > +
+
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 fe08b65..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 @@ -82,6 +82,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { @ViewChild('inputRegionRef', { static: true }) private readonly inputRegionRef!: ElementRef; + @ViewChild('inputCommittedRegionRef', { static: true }) + private readonly inputCommittedRegionRef!: ElementRef; + @ViewChild('historyRegionRef', { static: true }) private readonly historyRegionRef!: ElementRef; @@ -124,6 +127,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.liveRegionRef.nativeElement, this.historyRegionRef.nativeElement, this.inputRegionRef.nativeElement, + this.inputCommittedRegionRef.nativeElement, ); console.debug( '[MudClient] Screenreader announcer initialized, live region:', @@ -232,6 +236,8 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { if (typeof payload === 'string') { this.screenReader?.appendToHistory(payload); + // Announce the complete input so user can verify what they typed + this.screenReader?.announceInputCommitted(payload); } this.mudService.sendMessage(payload); diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 41293b4..59e3484 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -13,11 +13,13 @@ const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g; export class MudScreenReaderAnnouncer { private clearTimer: 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(); @@ -115,32 +117,64 @@ export class MudScreenReaderAnnouncer { } /** - * Announces the current input buffer to the input region. - * Used to inform screen reader users of their live typing. - * Unlike server output announcements, input is NOT auto-cleared - * to allow users to review what they typed. + * Announces only the change in the input buffer (delta). + * Each character typed results in a separate announcement. */ public announceInput(buffer: string): void { if (!this.inputRegion) { return; } + // Detect what changed from lastAnnouncedBuffer to current buffer + const lastLength = this.lastAnnouncedBuffer.length; + const currentLength = buffer.length; + + let announcement = ''; + + if (currentLength > lastLength) { + // Character(s) added + const addedChars = buffer.substring(lastLength); + for (const char of addedChars) { + announcement += char + ' '; + } + } + + console.debug('[ScreenReader] Announcing input delta:', { + lastLength, + currentLength, + announcement, + }); + + if (announcement) { + this.inputRegion.textContent = announcement; + } + + 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 input:', { + console.debug('[ScreenReader] Announcing committed input:', { raw: buffer.substring(0, 100), normalized: normalized.substring(0, 100), }); if (!normalized) { - this.inputRegion.textContent = ''; return; } - // Show buffer with cursor indicator (helpful for users to know where they are) - // Format: "typed text (cursor at position X)" - const display = `${normalized}`; - this.inputRegion.textContent = display; + this.inputCommittedRegion.textContent = `${normalized}`; + // Reset buffer tracker since we're starting fresh after commit + this.lastAnnouncedBuffer = ''; } private scheduleClear(): void { From 35f15b929e1a464a6ca81766313501c87323f34d Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 23:19:39 +0100 Subject: [PATCH 25/77] test(frontend): .. and timing for input announcements --- .../mud-client/mud-client.component.html | 3 +- .../app/features/terminal/mud-screenreader.ts | 48 +++++++++++++------ 2 files changed, 34 insertions(+), 17 deletions(-) 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 a53b3f8..7628d63 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 @@ -3,9 +3,8 @@
diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 59e3484..b29eb8c 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -12,6 +12,7 @@ const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g; */ export class MudScreenReaderAnnouncer { private clearTimer: number | undefined; + private inputClearTimer: number | undefined; private sessionStartedAt: number; private lastAnnouncedBuffer = ''; @@ -83,6 +84,7 @@ export class MudScreenReaderAnnouncer { */ public dispose(): void { this.clear(); + this.cancelInputClearTimer(); } /** @@ -119,6 +121,7 @@ export class MudScreenReaderAnnouncer { /** * Announces only the change in the input buffer (delta). * Each character typed results in a separate announcement. + * The region is auto-cleared after a short delay to prevent double announcements. */ public announceInput(buffer: string): void { if (!this.inputRegion) { @@ -129,24 +132,18 @@ export class MudScreenReaderAnnouncer { const lastLength = this.lastAnnouncedBuffer.length; const currentLength = buffer.length; - let announcement = ''; - if (currentLength > lastLength) { - // Character(s) added - const addedChars = buffer.substring(lastLength); - for (const char of addedChars) { - announcement += char + ' '; - } - } + // Character(s) added - announce only the newest character + const newestChar = buffer[currentLength - 1]; - console.debug('[ScreenReader] Announcing input delta:', { - lastLength, - currentLength, - announcement, - }); + console.debug('[ScreenReader] Announcing input delta:', { + lastLength, + currentLength, + newestChar, + }); - if (announcement) { - this.inputRegion.textContent = announcement; + this.inputRegion.textContent = newestChar; + this.scheduleInputClear(); } this.lastAnnouncedBuffer = buffer; @@ -185,6 +182,14 @@ export class MudScreenReaderAnnouncer { }, this.clearDelayMs); } + private scheduleInputClear(): void { + this.cancelInputClearTimer(); + + this.inputClearTimer = window.setTimeout(() => { + this.clearInputRegion(); + }, 100); + } + private cancelClearTimer(): void { if (this.clearTimer !== undefined) { window.clearTimeout(this.clearTimer); @@ -192,6 +197,19 @@ export class MudScreenReaderAnnouncer { } } + private cancelInputClearTimer(): void { + if (this.inputClearTimer !== undefined) { + window.clearTimeout(this.inputClearTimer); + this.inputClearTimer = undefined; + } + } + + private clearInputRegion(): void { + if (this.inputRegion) { + this.inputRegion.textContent = ''; + } + } + private normalize(raw: string): string { if (!raw) { return ''; From edfa84e0800eeff2411ba1296391f18c63cc54fe Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 00:01:44 +0100 Subject: [PATCH 26/77] test(frontend): .. with more timing.. --- .../components/mud-client/mud-client.component.html | 2 +- .../components/mud-client/mud-client.component.scss | 12 ++++++++++++ .../src/app/features/terminal/mud-screenreader.ts | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) 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 7628d63..1c5f55f 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 @@ -3,7 +3,7 @@
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 b252bee..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 @@ -35,6 +35,18 @@ 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; } diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index b29eb8c..89a2f95 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -187,7 +187,7 @@ export class MudScreenReaderAnnouncer { this.inputClearTimer = window.setTimeout(() => { this.clearInputRegion(); - }, 100); + }, 250); } private cancelClearTimer(): void { From da23a006ff7b16df11ec95eaecf08506aefd2a68 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 00:18:24 +0100 Subject: [PATCH 27/77] test(frontend): .. with less timing --- .../src/app/features/terminal/mud-screenreader.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 89a2f95..f72c6ce 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -1,4 +1,5 @@ const DEFAULT_CLEAR_DELAY_MS = 300; +const INPUT_CLEAR_DELAY_MS = 150; const ANSI_ESCAPE_PATTERN = /\x1B\[[0-9;?]*[ -\/]*[@-~]/g; const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g; @@ -121,7 +122,7 @@ export class MudScreenReaderAnnouncer { /** * Announces only the change in the input buffer (delta). * Each character typed results in a separate announcement. - * The region is auto-cleared after a short delay to prevent double announcements. + * Uses appendChild instead of textContent to ensure VoiceOver detects DOM mutations. */ public announceInput(buffer: string): void { if (!this.inputRegion) { @@ -142,7 +143,12 @@ export class MudScreenReaderAnnouncer { newestChar, }); - this.inputRegion.textContent = newestChar; + // Create a new span element for better VoiceOver detection + // appendChild triggers DOM mutation events that VoiceOver responds to better + const charSpan = this.inputRegion.ownerDocument.createElement('span'); + charSpan.textContent = newestChar; + this.inputRegion.appendChild(charSpan); + this.scheduleInputClear(); } @@ -187,7 +193,7 @@ export class MudScreenReaderAnnouncer { this.inputClearTimer = window.setTimeout(() => { this.clearInputRegion(); - }, 250); + }, INPUT_CLEAR_DELAY_MS); } private cancelClearTimer(): void { From c619f96311dc8b468ca9d2f8588a85bd5ee14fbc Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 00:33:44 +0100 Subject: [PATCH 28/77] test(frontend): .. and no spans.. --- .../mud-client/mud-client.component.html | 1 + .../app/features/terminal/mud-screenreader.ts | 37 +++++++------------ 2 files changed, 15 insertions(+), 23 deletions(-) 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 1c5f55f..a53b3f8 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 @@ -5,6 +5,7 @@ role="textbox" aria-live="polite" aria-readonly="true" + aria-atomic="true" #inputRegionRef > diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index f72c6ce..d91e1e7 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -1,5 +1,5 @@ const DEFAULT_CLEAR_DELAY_MS = 300; -const INPUT_CLEAR_DELAY_MS = 150; +const INPUT_CLEAR_DELAY_MS = 400; const ANSI_ESCAPE_PATTERN = /\x1B\[[0-9;?]*[ -\/]*[@-~]/g; const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g; @@ -120,38 +120,29 @@ export class MudScreenReaderAnnouncer { } /** - * Announces only the change in the input buffer (delta). - * Each character typed results in a separate announcement. - * Uses appendChild instead of textContent to ensure VoiceOver detects DOM mutations. + * Announces the full current input buffer (not just delta). + * Uses textContent so VoiceOver can read the entire buffer, aided by aria-atomic. */ public announceInput(buffer: string): void { if (!this.inputRegion) { return; } - // Detect what changed from lastAnnouncedBuffer to current buffer - const lastLength = this.lastAnnouncedBuffer.length; - const currentLength = buffer.length; - - if (currentLength > lastLength) { - // Character(s) added - announce only the newest character - const newestChar = buffer[currentLength - 1]; - - console.debug('[ScreenReader] Announcing input delta:', { - lastLength, - currentLength, - newestChar, - }); + const normalized = this.normalize(buffer); - // Create a new span element for better VoiceOver detection - // appendChild triggers DOM mutation events that VoiceOver responds to better - const charSpan = this.inputRegion.ownerDocument.createElement('span'); - charSpan.textContent = newestChar; - this.inputRegion.appendChild(charSpan); + console.debug('[ScreenReader] Announcing input buffer:', { + raw: buffer.substring(0, 100), + normalized: normalized.substring(0, 100), + }); - this.scheduleInputClear(); + if (!normalized) { + this.clearInputRegion(); + this.lastAnnouncedBuffer = buffer; + return; } + this.inputRegion.textContent = normalized; + this.scheduleInputClear(); this.lastAnnouncedBuffer = buffer; } From 5c555677dff84119fbfb4df0af9a2a876c66f67f Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 00:49:56 +0100 Subject: [PATCH 29/77] test(frontend): .. more tests .. --- .../mud-client/mud-client.component.html | 5 +-- .../app/features/terminal/mud-screenreader.ts | 37 +++++++++++++------ 2 files changed, 27 insertions(+), 15 deletions(-) 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 a53b3f8..e89117f 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 @@ -2,9 +2,8 @@
diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index d91e1e7..e5b391c 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -1,5 +1,5 @@ const DEFAULT_CLEAR_DELAY_MS = 300; -const INPUT_CLEAR_DELAY_MS = 400; +const INPUT_CLEAR_DELAY_MS = 700; const ANSI_ESCAPE_PATTERN = /\x1B\[[0-9;?]*[ -\/]*[@-~]/g; const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g; @@ -122,27 +122,28 @@ export class MudScreenReaderAnnouncer { /** * Announces the full current input buffer (not just delta). * Uses textContent so VoiceOver can read the entire buffer, aided by aria-atomic. + * For VoiceOver iOS: we do NOT auto-clear here to avoid dropping queued speech. */ public announceInput(buffer: string): void { if (!this.inputRegion) { return; } - const normalized = this.normalize(buffer); + const normalized = this.normalizeInput(buffer); console.debug('[ScreenReader] Announcing input buffer:', { raw: buffer.substring(0, 100), normalized: normalized.substring(0, 100), }); - if (!normalized) { - this.clearInputRegion(); + if (normalized.length === 0) { + // Avoid announcing empty string; leave prior text as-is this.lastAnnouncedBuffer = buffer; return; } this.inputRegion.textContent = normalized; - this.scheduleInputClear(); + // No auto-clear: let the screen reader finish reading. this.lastAnnouncedBuffer = buffer; } @@ -179,6 +180,14 @@ export class MudScreenReaderAnnouncer { }, 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(); @@ -187,13 +196,6 @@ export class MudScreenReaderAnnouncer { }, INPUT_CLEAR_DELAY_MS); } - private cancelClearTimer(): void { - if (this.clearTimer !== undefined) { - window.clearTimeout(this.clearTimer); - this.clearTimer = undefined; - } - } - private cancelInputClearTimer(): void { if (this.inputClearTimer !== undefined) { window.clearTimeout(this.inputClearTimer); @@ -207,6 +209,17 @@ export class MudScreenReaderAnnouncer { } } + 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 ''; From 2a46b62bb9899a4477ec699e3756a1c3eae05c89 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 01:04:13 +0100 Subject: [PATCH 30/77] test(frontend): .. next try ... --- .../mud-client/mud-client.component.html | 8 +---- .../app/features/terminal/mud-screenreader.ts | 35 +++++++++++-------- 2 files changed, 22 insertions(+), 21 deletions(-) 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 e89117f..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,12 +1,6 @@
-
+
lastLength) { + const newestChar = buffer[currentLength - 1]; + const normalized = this.normalizeInput(newestChar); - if (normalized.length === 0) { - // Avoid announcing empty string; leave prior text as-is - this.lastAnnouncedBuffer = buffer; - return; + console.debug('[ScreenReader] Announcing input char:', { + newestChar, + normalized, + lastLength, + currentLength, + }); + + if (normalized.length === 0) { + this.lastAnnouncedBuffer = buffer; + return; + } + + this.inputRegion.textContent = normalized; } - this.inputRegion.textContent = normalized; - // No auto-clear: let the screen reader finish reading. this.lastAnnouncedBuffer = buffer; } From 541990eefb825446c45552bb14fb07024cca7d6f Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 01:32:20 +0100 Subject: [PATCH 31/77] test(frontend): .. and with random shit ... --- .../mud-client/mud-client.component.html | 18 ++++++++++++++- .../mud-client/mud-client.component.ts | 5 +++++ .../app/features/terminal/mud-screenreader.ts | 22 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) 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 a35d0fa..6bb71f9 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,14 +1,30 @@
-
+
+
+
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 6e2c0ad..8492780 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 @@ -88,6 +88,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { @ViewChild('historyRegionRef', { static: true }) private readonly historyRegionRef!: ElementRef; + @ViewChild('inputBufferRegionRef', { static: true }) + private readonly inputBufferRegionRef!: ElementRef; + protected readonly isConnected$ = this.mudService.connectedToMud$; protected readonly showEcho$ = this.mudService.showEcho$; @@ -128,6 +131,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.historyRegionRef.nativeElement, this.inputRegionRef.nativeElement, this.inputCommittedRegionRef.nativeElement, + this.inputBufferRegionRef.nativeElement, ); console.debug( '[MudClient] Screenreader announcer initialized, live region:', @@ -250,6 +254,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { */ private announceInputToScreenReader(buffer: string): void { this.screenReader?.announceInput(buffer); + this.screenReader?.updateInputBuffer(buffer); } /** diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 192e272..7bebd99 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -22,6 +22,7 @@ export class MudScreenReaderAnnouncer { private readonly historyRegion?: HTMLElement, private readonly inputRegion?: HTMLElement, private readonly inputCommittedRegion?: HTMLElement, + private readonly inputBufferRegion?: HTMLElement, private readonly clearDelayMs: number = DEFAULT_CLEAR_DELAY_MS, ) { this.sessionStartedAt = Date.now(); @@ -34,6 +35,7 @@ export class MudScreenReaderAnnouncer { this.sessionStartedAt = timestamp; this.clear(); this.clearHistory(); + this.lastAnnouncedBuffer = ''; } /** @@ -149,11 +151,31 @@ export class MudScreenReaderAnnouncer { } this.inputRegion.textContent = normalized; + } else if (currentLength < lastLength) { + // Provide deletion feedback when buffer shrinks + console.debug('[ScreenReader] Announcing deletion (backspace/delete):', { + lastLength, + currentLength, + }); + this.inputRegion.textContent = 'gelöscht'; } this.lastAnnouncedBuffer = buffer; } + /** + * Mirrors the full current input buffer into a non-live, navigable region + * so users can review their input via rotor without live announcements. + */ + public updateInputBuffer(buffer: string): void { + if (!this.inputBufferRegion) { + return; + } + + const normalized = this.normalizeInput(buffer); + this.inputBufferRegion.textContent = normalized; + } + /** * Announces the complete, committed input after user presses Enter. * This reads back the entire line so the user can verify what they typed. From 5406a0a240c3d57602283bff72b85c50c7ce27d6 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 18:59:46 +0100 Subject: [PATCH 32/77] test(frontend): .. and without random shit... --- .../mud-client/mud-client.component.html | 17 ---- .../mud-client/mud-client.component.ts | 9 -- .../app/features/terminal/mud-screenreader.ts | 92 ++++++++++++++----- 3 files changed, 69 insertions(+), 49 deletions(-) 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 6bb71f9..90cce72 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 @@ -8,23 +8,6 @@ #inputRegionRef >
-
- -
-
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 8492780..b185c8d 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 @@ -82,15 +82,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { @ViewChild('inputRegionRef', { static: true }) private readonly inputRegionRef!: ElementRef; - @ViewChild('inputCommittedRegionRef', { static: true }) - private readonly inputCommittedRegionRef!: ElementRef; - @ViewChild('historyRegionRef', { static: true }) private readonly historyRegionRef!: ElementRef; - @ViewChild('inputBufferRegionRef', { static: true }) - private readonly inputBufferRegionRef!: ElementRef; - protected readonly isConnected$ = this.mudService.connectedToMud$; protected readonly showEcho$ = this.mudService.showEcho$; @@ -130,8 +124,6 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.liveRegionRef.nativeElement, this.historyRegionRef.nativeElement, this.inputRegionRef.nativeElement, - this.inputCommittedRegionRef.nativeElement, - this.inputBufferRegionRef.nativeElement, ); console.debug( '[MudClient] Screenreader announcer initialized, live region:', @@ -254,7 +246,6 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { */ private announceInputToScreenReader(buffer: string): void { this.screenReader?.announceInput(buffer); - this.screenReader?.updateInputBuffer(buffer); } /** diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 7bebd99..09075be 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -21,8 +21,6 @@ export class MudScreenReaderAnnouncer { private readonly liveRegion: HTMLElement, private readonly historyRegion?: HTMLElement, private readonly inputRegion?: HTMLElement, - private readonly inputCommittedRegion?: HTMLElement, - private readonly inputBufferRegion?: HTMLElement, private readonly clearDelayMs: number = DEFAULT_CLEAR_DELAY_MS, ) { this.sessionStartedAt = Date.now(); @@ -122,9 +120,12 @@ export class MudScreenReaderAnnouncer { } /** - * 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. + * Announces input changes with three levels: + * (a) Per-character: announce the newest character + * (b) Per-word: when whitespace is encountered, announce the complete word + * (c) On commit: full line is announced via announceInputCommitted() + * + * No "gelöscht" feedback; backspace silently updates the buffer tracker. */ public announceInput(buffer: string): void { if (!this.inputRegion) { @@ -145,43 +146,83 @@ export class MudScreenReaderAnnouncer { currentLength, }); - if (normalized.length === 0) { - this.lastAnnouncedBuffer = buffer; - return; + // (a) Announce the newest character + if (normalized.length > 0) { + this.inputRegion.textContent = normalized; } - this.inputRegion.textContent = normalized; + // (b) Check if we just completed a word (whitespace as delimiter) + if (/\s/.test(newestChar)) { + const lastWord = this.extractLastWord(buffer); + if (lastWord) { + const normalizedWord = this.normalizeInput(lastWord); + console.debug( + '[ScreenReader] Word boundary detected, announcing word:', + { + lastWord, + normalizedWord, + }, + ); + this.inputRegion.textContent = normalizedWord; + } + } } else if (currentLength < lastLength) { - // Provide deletion feedback when buffer shrinks - console.debug('[ScreenReader] Announcing deletion (backspace/delete):', { + // Backspace/delete: silently track, no "gelöscht" announcement + console.debug('[ScreenReader] Buffer shortened (backspace/delete):', { lastLength, currentLength, }); - this.inputRegion.textContent = 'gelöscht'; } this.lastAnnouncedBuffer = buffer; } /** - * Mirrors the full current input buffer into a non-live, navigable region - * so users can review their input via rotor without live announcements. + * Extracts the last word from the buffer (text before the last whitespace). + * Used for per-word announcements when user types a space. */ - public updateInputBuffer(buffer: string): void { - if (!this.inputBufferRegion) { - return; + private extractLastWord(buffer: string): string { + if (!buffer) return ''; + + // Find the last whitespace + const trimmedFromRight = buffer.trimEnd(); + if (trimmedFromRight === buffer) { + // No trailing whitespace, return empty + return ''; + } + + // Find position of last word (before trailing whitespace) + let lastNonWhitespace = -1; + for (let i = trimmedFromRight.length - 1; i >= 0; i--) { + if (/\S/.test(trimmedFromRight[i])) { + lastNonWhitespace = i; + break; + } + } + + if (lastNonWhitespace === -1) { + return ''; + } + + // Find the start of the last word (after previous whitespace) + let wordStart = 0; + for (let i = lastNonWhitespace; i >= 0; i--) { + if (/\s/.test(trimmedFromRight[i])) { + wordStart = i + 1; + break; + } } - const normalized = this.normalizeInput(buffer); - this.inputBufferRegion.textContent = normalized; + return trimmedFromRight.slice(wordStart, lastNonWhitespace + 1); } /** - * Announces the complete, committed input after user presses Enter. - * This reads back the entire line so the user can verify what they typed. + * (c) Announces the complete, committed input after user presses Enter. + * Reads back the entire line so the user can verify what they typed. + * Auto-clears after a delay to reset for the next input line. */ public announceInputCommitted(buffer: string): void { - if (!this.inputCommittedRegion) { + if (!this.inputRegion) { return; } @@ -193,10 +234,15 @@ export class MudScreenReaderAnnouncer { }); if (!normalized) { + this.lastAnnouncedBuffer = ''; return; } - this.inputCommittedRegion.textContent = `${normalized}`; + this.inputRegion.textContent = normalized; + + // Auto-clear after delay so user gets confirmation but next input starts fresh + this.scheduleInputClear(); + // Reset buffer tracker since we're starting fresh after commit this.lastAnnouncedBuffer = ''; } From 5bb91f44702f4d230bf1134129db6411c854667a Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 20:49:30 +0100 Subject: [PATCH 33/77] test(frontend): .. and with strange requests from users... --- .../app/features/terminal/mud-screenreader.ts | 54 ++++++++++++++----- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 09075be..85f0d64 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -153,25 +153,35 @@ export class MudScreenReaderAnnouncer { // (b) Check if we just completed a word (whitespace as delimiter) if (/\s/.test(newestChar)) { + // First announce the whitespace token to ensure SR picks up the change + this.inputRegion.textContent = this.describeChar(newestChar); + const lastWord = this.extractLastWord(buffer); - if (lastWord) { - const normalizedWord = this.normalizeInput(lastWord); - console.debug( - '[ScreenReader] Word boundary detected, announcing word:', - { - lastWord, - normalizedWord, - }, - ); - this.inputRegion.textContent = normalizedWord; - } + const normalizedWord = lastWord ? this.normalizeInput(lastWord) : ''; + + console.debug('[ScreenReader] Word boundary detected:', { + lastWord, + normalizedWord, + }); + + // Announce the word (or fallback to the whitespace token if empty) + this.inputRegion.textContent = + normalizedWord || this.describeChar(newestChar); } } else if (currentLength < lastLength) { - // Backspace/delete: silently track, no "gelöscht" announcement + // Backspace/delete: announce the removed character (best-effort diff) console.debug('[ScreenReader] Buffer shortened (backspace/delete):', { lastLength, currentLength, }); + + const removedSegment = this.lastAnnouncedBuffer.slice(currentLength); + const removedChar = removedSegment[0]; + const token = removedChar ? this.describeChar(removedChar) : ''; + + if (token && this.inputRegion) { + this.inputRegion.textContent = token; + } } this.lastAnnouncedBuffer = buffer; @@ -216,6 +226,26 @@ export class MudScreenReaderAnnouncer { return trimmedFromRight.slice(wordStart, lastNonWhitespace + 1); } + /** + * Maps characters to speakable tokens for screen readers. + */ + private describeChar(char: string): string { + if (char === ' ') { + return 'Leerzeichen'; + } + + if (char === '\n') { + return 'Zeilenumbruch'; + } + + if (char === '\t') { + return 'Tab'; + } + + const normalized = this.normalizeInput(char); + return normalized || ''; + } + /** * (c) Announces the complete, committed input after user presses Enter. * Reads back the entire line so the user can verify what they typed. From bc469eabd63a37cf077cb5533a62a53eef56921a Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 20:56:40 +0100 Subject: [PATCH 34/77] test(frontend): .. and with strange requests from users 2... --- frontend/src/app/features/terminal/mud-screenreader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 85f0d64..dd65b90 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -194,8 +194,8 @@ export class MudScreenReaderAnnouncer { private extractLastWord(buffer: string): string { if (!buffer) return ''; - // Find the last whitespace - const trimmedFromRight = buffer.trimEnd(); + // Find the last whitespace (remove trailing whitespace) + const trimmedFromRight = buffer.replace(/\s+$/, ''); if (trimmedFromRight === buffer) { // No trailing whitespace, return empty return ''; From 0987ad2cbe5423285ba094a300e74b027fce69da Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 21:38:59 +0100 Subject: [PATCH 35/77] test(frontend): add input buffer for screen reader announcements --- .../mud-client/mud-client.component.html | 15 +++++++++++++++ .../components/mud-client/mud-client.component.ts | 4 ++++ 2 files changed, 19 insertions(+) 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 90cce72..b4f8f5d 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 @@ -8,6 +8,21 @@ #inputRegionRef > + +
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 b185c8d..6e3452a 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 @@ -82,6 +82,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { @ViewChild('inputRegionRef', { static: true }) private readonly inputRegionRef!: ElementRef; + @ViewChild('inputBufferRef', { static: true }) + private readonly inputBufferRef!: ElementRef; + @ViewChild('historyRegionRef', { static: true }) private readonly historyRegionRef!: ElementRef; @@ -246,6 +249,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { */ private announceInputToScreenReader(buffer: string): void { this.screenReader?.announceInput(buffer); + this.inputBufferRef.nativeElement.value = buffer; } /** From 213680cbc562611b19236b2c1368b69d91beca38 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 22:19:57 +0100 Subject: [PATCH 36/77] test(frontend): remove unused input buffer and implement helper textarea for screen reader --- .../mud-client/mud-client.component.html | 15 --------- .../mud-client/mud-client.component.ts | 33 ++++++++++++++++--- .../features/terminal/mud-prompt.manager.ts | 7 ++++ 3 files changed, 36 insertions(+), 19 deletions(-) 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 b4f8f5d..90cce72 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 @@ -8,21 +8,6 @@ #inputRegionRef > - -
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 6e3452a..861e56e 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 @@ -82,12 +82,11 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { @ViewChild('inputRegionRef', { static: true }) private readonly inputRegionRef!: ElementRef; - @ViewChild('inputBufferRef', { static: true }) - private readonly inputBufferRef!: ElementRef; - @ViewChild('historyRegionRef', { static: true }) private readonly historyRegionRef!: ElementRef; + private helperTextarea: HTMLTextAreaElement | null = null; + protected readonly isConnected$ = this.mudService.connectedToMud$; protected readonly showEcho$ = this.mudService.showEcho$; @@ -149,6 +148,12 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.terminal.loadAddon(this.terminalAttachAddon); this.terminal.focus(); + // Cache helper textarea created by xterm (used to mirror prompt + input) + this.helperTextarea = + (this.terminalRef.nativeElement.querySelector( + '.xterm-helper-textarea', + ) as HTMLTextAreaElement | null) ?? null; + this.terminalDisposables.push( this.terminal.onData((data) => this.handleInput(data)), ); @@ -240,6 +245,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { } this.mudService.sendMessage(payload); + + // Clear helper textarea after commit + this.updateHelperTextarea(''); } /** @@ -249,7 +257,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { */ private announceInputToScreenReader(buffer: string): void { this.screenReader?.announceInput(buffer); - this.inputBufferRef.nativeElement.value = buffer; + this.updateHelperTextarea(buffer); } /** @@ -320,6 +328,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.promptManager.afterServerOutput(data, this.getPromptContext()); this.announceToScreenReader(data); this.screenReader?.appendToHistory(data); + this.updateHelperTextarea(); } /** @@ -377,4 +386,20 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { return data; } + + /** + * Mirrors the current prompt + buffer into xterm's helper textarea + * so screen readers can inspect the input line. + */ + private updateHelperTextarea(buffer?: string): void { + if (!this.helperTextarea) { + return; + } + + const prompt = this.promptManager.getCurrentPrompt(); + const effectiveBuffer = + buffer !== undefined ? buffer : this.inputController.getSnapshot().buffer; + + this.helperTextarea.value = `${prompt}${effectiveBuffer ?? ''}`; + } } diff --git a/frontend/src/app/features/terminal/mud-prompt.manager.ts b/frontend/src/app/features/terminal/mud-prompt.manager.ts index bafe5d8..a951ec8 100644 --- a/frontend/src/app/features/terminal/mud-prompt.manager.ts +++ b/frontend/src/app/features/terminal/mud-prompt.manager.ts @@ -105,6 +105,13 @@ export class MudPromptManager { this.lineHidden = false; } + /** + * Exposes the currently tracked prompt (ANSI included). + */ + public getCurrentPrompt(): string { + return this.currentPrompt; + } + /** * Strips leading CRLF sequence from server output after a line was hidden. * From 91dd19f06c6f747650f2235d524112e6beb48b05 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 23:02:26 +0100 Subject: [PATCH 37/77] test(frontend): update announceInput to leverage automatic screen reader announcements --- .../app/features/terminal/mud-screenreader.ts | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index dd65b90..4204550 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -121,11 +121,13 @@ export class MudScreenReaderAnnouncer { /** * Announces input changes with three levels: - * (a) Per-character: announce the newest character + * (a) Per-character: textarea is read automatically by SR; no manual announcement * (b) Per-word: when whitespace is encountered, announce the complete word * (c) On commit: full line is announced via announceInputCommitted() * - * No "gelöscht" feedback; backspace silently updates the buffer tracker. + * Note: Per-character feedback is handled by the helper textarea being read + * by the screen reader automatically, so we skip manual textContent updates + * for individual chars to avoid double announcements. */ public announceInput(buffer: string): void { if (!this.inputRegion) { @@ -137,25 +139,15 @@ export class MudScreenReaderAnnouncer { if (currentLength > lastLength) { const newestChar = buffer[currentLength - 1]; - const normalized = this.normalizeInput(newestChar); - console.debug('[ScreenReader] Announcing input char:', { + console.debug('[ScreenReader] Input changed:', { newestChar, - normalized, lastLength, currentLength, }); - // (a) Announce the newest character - if (normalized.length > 0) { - this.inputRegion.textContent = normalized; - } - // (b) Check if we just completed a word (whitespace as delimiter) if (/\s/.test(newestChar)) { - // First announce the whitespace token to ensure SR picks up the change - this.inputRegion.textContent = this.describeChar(newestChar); - const lastWord = this.extractLastWord(buffer); const normalizedWord = lastWord ? this.normalizeInput(lastWord) : ''; @@ -169,19 +161,11 @@ export class MudScreenReaderAnnouncer { normalizedWord || this.describeChar(newestChar); } } else if (currentLength < lastLength) { - // Backspace/delete: announce the removed character (best-effort diff) + // Backspace/delete: silently track, textarea is read by SR automatically console.debug('[ScreenReader] Buffer shortened (backspace/delete):', { lastLength, currentLength, }); - - const removedSegment = this.lastAnnouncedBuffer.slice(currentLength); - const removedChar = removedSegment[0]; - const token = removedChar ? this.describeChar(removedChar) : ''; - - if (token && this.inputRegion) { - this.inputRegion.textContent = token; - } } this.lastAnnouncedBuffer = buffer; From 33e1700ac394956caa2878deb570ad2e7be757a6 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 24 Jan 2026 23:42:31 +0100 Subject: [PATCH 38/77] test(frontend): add screenreader test checklist and improve aria-label for input --- SCREENREADER_TESTS.md | 383 ++++++++++++++++++ .../mud-client/mud-client.component.scss | 12 + .../mud-client/mud-client.component.ts | 5 + 3 files changed, 400 insertions(+) create mode 100644 SCREENREADER_TESTS.md diff --git a/SCREENREADER_TESTS.md b/SCREENREADER_TESTS.md new file mode 100644 index 0000000..94ac198 --- /dev/null +++ b/SCREENREADER_TESTS.md @@ -0,0 +1,383 @@ +# Screenreader Test-Checkliste für WebMUD3 + +## Übersicht + +Diese Testliste überprüft die Barrierefreiheit der Terminal-Eingabe für Screenreader-Nutzer. Alle Tests sollten mit aktiviertem Screenreader (z.B. NVDA, JAWS oder VoiceOver) durchgeführt werden. + +--- + +## Vorbereitung + +1. Öffne die WebMUD3-Anwendung im Browser +2. Stelle sicher, dass dein Screenreader aktiv ist +3. Fokus sollte automatisch im Terminal-Eingabefeld sein + +Hinweis: Die Tests lassen sich direkt im Login Screen durchführen. + +--- + +## Test 1: Einzelne Zeichen eingeben und hören + +### Ziel + +Überprüfen, dass jedes getippte Zeichen einzeln vorgelesen wird. + +### Schritte + +1. Tippe langsam die Buchstaben: `h` `e` `l` `l` `o` +2. Achte darauf, dass nach jedem Tastendruck der Buchstabe vorgelesen wird + +### Erwartetes Ergebnis + +- Nach jedem Buchstaben sollte der Screenreader den Buchstaben ansagen +- Beispiel: "h", "e", "l", "l", "o" +- Kein doppeltes Vorlesen (nicht "h h" oder ähnliches) + +### Notizen: + +``` + + +``` + +--- + +## Test 2: Wörter nach Leerzeichen hören + +### Ziel + +Überprüfen, dass nach einem Leerzeichen das komplette getippte Wort vorgelesen wird. + +### Schritte + +1. Tippe: `hallo` (ohne Enter) +2. Tippe ein Leerzeichen +3. Achte darauf, ob das Wort "hallo" komplett vorgelesen wird + +### Erwartetes Ergebnis + +- Während des Tippens: einzelne Buchstaben werden angesagt ("h", "a", "l", "l", "o") +- Nach dem Leerzeichen: das komplette Wort wird nochmal angesagt ("hallo") +- Zusätzlich sollte "Leerzeichen" angesagt werden + +### Notizen: + +``` + + +``` + +--- + +## Test 3: Mehrere Wörter mit Leerzeichen + +### Ziel + +Überprüfen, dass bei mehreren Wörtern jedes Wort nach dem Leerzeichen vorgelesen wird. + +### Schritte + +1. Tippe: `schau` (Leerzeichen) `nach` (Leerzeichen) `norden` +2. Achte auf die Ansagen nach jedem Leerzeichen + +### Erwartetes Ergebnis + +- Nach dem ersten Leerzeichen: "schau" wird vorgelesen +- Nach dem zweiten Leerzeichen: "nach" wird vorgelesen +- "norden" wird erst vorgelesen, wenn du Enter drückst oder ein weiteres Leerzeichen tippst + +### Notizen: + +``` + + +``` + +--- + +## Test 4: Vollständige Eingabe nach Enter hören + +### Ziel + +Überprüfen, dass die komplette Eingabezeile nach dem Absenden (Enter) nochmal vorgelesen wird. + +### Schritte + +1. Tippe: `betrachte mich` +2. Drücke Enter +3. Achte darauf, ob die komplette Eingabe vorgelesen wird + +### Erwartetes Ergebnis + +- Während des Tippens: einzelne Zeichen und Wörter werden wie erwartet angesagt +- Nach Enter: die komplette Eingabe "schau dich um" wird nochmal vorgelesen +- Danach sollte die Serverantwort kommen (z.B. Charakterbeschreibung) + +### Notizen: + +``` + + +``` + +--- + +## Test 5: Backspace - gelöschte Zeichen hören + +### Ziel + +Überprüfen, dass gelöschte Zeichen (via Backspace) vorgelesen werden. + +### Schritte + +1. Tippe: `tiss` +2. Drücke Backspace einmal (um das zweite 's' zu löschen) +3. Achte darauf, ob der gelöschte Buchstabe angesagt wird + +### Erwartetes Ergebnis + +- Der Screenreader sollte "s" ansagen (der gelöschte Buchstabe) +- Nach dem Löschen kannst du weiterschreiben: `ch` → sollte "tisch" ergeben + +### Notizen: + +``` + + +``` + +--- + +## Test 6: Leerzeichen löschen mit Backspace + +### Ziel + +Überprüfen, dass gelöschte Leerzeichen als "Leerzeichen" angesagt werden. + +### Schritte + +1. Tippe: `hallo` (Leerzeichen) `welt` +2. Drücke Backspace viermal (um "welt" zu löschen) +3. Drücke Backspace nochmal (um das Leerzeichen zu löschen) + +### Erwartetes Ergebnis + +- Beim Löschen von "w", "e", "l", "t": jeweiliger Buchstabe wird angesagt +- Beim Löschen des Leerzeichen: "Leerzeichen" wird angesagt + +### Notizen: + +``` + + +``` + +--- + +## Test 7: Eingabe navigieren und vorlesen lassen + +### Ziel + +Überprüfen, dass die aktuelle Eingabe jederzeit vorgelesen werden kann, ohne sie abzuschicken. + +### Schritte + +1. Tippe: `betrachte mich` +2. **Ohne Enter zu drücken**: Nutze die Screenreader-Funktion "Aktuelle Zeile vorlesen" + - NVDA: NVDA+L + - JAWS: Insert+Pfeil oben + - VoiceOver: VO+A (aktuelle Zeile) +3. Alternativ: Nutze die Screenreader-Navigation, um zur Eingabezeile zu navigieren + +### Erwartetes Ergebnis + +- Der Screenreader sollte den Prompt (falls vorhanden, z.B. "> ") plus deine Eingabe vorlesen +- Beispiel: "> betrachte mich" +- Du solltest die Eingabe vollständig hören können, ohne Enter zu drücken + +### Notizen: + +``` + + +``` + +--- + +## Test 8: Prompt wird mitgelesen + +### Vorbereitung + +Um den Prompt überhaupt zu sehen (ist standardmäßig bei allen neuen Charakteren aktiviert), kann man +`einst client prompt an` zum aktivieren, bzw. `einst client prompt aus` zum deaktivieren nutzen. + +Der Prompt ist ein Zeichen, was anzeigt, dass man "hier" etwas eingeben kann. In Unitopia ist das das > Zeichen. + +### Ziel + +Überprüfen, dass der Server-Prompt ">" in der Eingabezeile sichtbar/hörbar ist. + +### Schritte + +1. Warte, bis der Server einen neuen Prompt sendet (nach jeder Aktion) +2. Tippe einen Buchstaben, z.B. `l` +3. Nutze die Screenreader-Funktion "Aktuelle Zeile vorlesen" + +### Erwartetes Ergebnis + +- Der Screenreader sollte den Prompt UND deine Eingabe vorlesen +- Beispiel: "> l" oder "HP:100> l" +- Der Prompt sollte ANSI-Farbcodes nicht enthalten (nur der reine Text) + +### Hinweise + +Der Prompt ist für blinde Spieler eher unnötig und nervig, dennoch ist er Teil eines komplexen Systemes und sollte dementsprechend +ebenfalls funktionieren, wenn aktiviert. + +### Notizen: + +``` + + +``` + +--- + +## Test 9: Serverausgabe wird vorgelesen + +### Ziel + +Überprüfen, dass Server-Nachrichten (z.B. Raumbeschreibungen, Kämpfe) vorgelesen werden. + +### Schritte + +1. Tippe einen Befehl, z.B. `schau` +2. Drücke Enter +3. Achte darauf, ob die Serverantwort vorgelesen wird + +### Erwartetes Ergebnis + +- Die Serverantwort sollte automatisch vorgelesen werden +- Die Ansage sollte ANSI-Farbcodes nicht enthalten (nur reiner Text), daher es sollten keine merkwürdigen Zeichen, wie [ zu hören sein +- Nach der Serverausgabe sollte direkt weitere Eingaben möglich sein. + +### Notizen: + +``` + + +``` + +--- + +## Test 10: Schnelles Tippen + +### Ziel + +Überprüfen, dass auch bei schnellem Tippen alle Zeichen korrekt vorgelesen werden. + +### Schritte + +1. Tippe so schnell wie möglich: `untersuche alles hier` +2. Achte darauf, ob alle Zeichen vorgelesen werden + +### Erwartetes Ergebnis + +- Alle Zeichen sollten vorgelesen werden (evtl. leicht verzögert) +- Kein Buchstabe sollte "verschluckt" werden +- Nach Leerzeichen sollten Wörter vorgelesen werden + +### Notizen: + +``` + + +``` + +--- + +## Test 11: History/Verlauf navigieren + +### Ziel + +Überprüfen, dass alte Server-Ausgaben über die Screenreader-Navigation erreichbar sind. + +### Schritte + +1. Führe mehrere Befehle aus (z.B. `schau`, `inventar`, `wer`) +2. Nutze die Screenreader-Navigation, um durch die vergangenen Ausgaben zu scrollen +3. Versuche, eine alte Nachricht nochmal vorlesen zu lassen + +### Erwartetes Ergebnis + +- Du solltest mit dem Screenreader durch alte Nachrichten navigieren können +- Alte Nachrichten sollten in einem History-Bereich verfügbar sein +- Jede Nachricht sollte einzeln lesbar sein + +### Notizen: + +``` + + +``` + +--- + +## Test 12: Mehrfaches Enter auf leerer Zeile + +### Ziel + +Überprüfen, dass leere Eingaben korrekt behandelt werden. + +### Schritte + +1. Drücke Enter ohne etwas zu tippen +2. Achte auf Screenreader-Feedback + +### Erwartetes Ergebnis + +- Der Screenreader sollte nicht endlos wiederholen +- Evtl. eine kurze Ansage oder gar keine Ansage +- Die Anwendung sollte nicht abstürzen oder hängen bleiben + +### Notizen: + +``` + + +``` + +--- + +## Test 13: Sonderzeichen eingeben + +### Ziel + +Überprüfen, dass Sonderzeichen korrekt vorgelesen werden. + +### Schritte + +1. Tippe Sonderzeichen wie: `!` `?` `.` `,` `:` `;` +2. Achte darauf, wie sie angesagt werden + +### Erwartetes Ergebnis + +- Jedes Sonderzeichen sollte korrekt benannt werden +- Beispiel: "Ausrufezeichen", "Fragezeichen", "Punkt", etc. +- Keine seltsamen ANSI-Codes oder Steuerzeichen + +### Notizen: + +``` + + +``` + +--- + +## Zusätzliche Hinweise für Tester + +- **Browser-Empfehlung**: Chrome oder Firefox mit aktiviertem Screenreader +- **Screenreader-Einstellungen**: Stelle sicher, dass "Getippte Zeichen ansagen" aktiviert ist +- **Fokus**: Der Fokus sollte immer im Terminal bleiben, damit Eingaben funktionieren +- **Verbindung**: Bei Verbindungsabbruch bitte neu verbinden und Tests wiederholen 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 1f42a1b..722c1ad 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 @@ -23,6 +23,18 @@ border: 0; } + .sr-input { + 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; 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 861e56e..49327dc 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 @@ -154,6 +154,11 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { '.xterm-helper-textarea', ) as HTMLTextAreaElement | null) ?? null; + // Set aria-label on helper textarea for better screen reader context + if (this.helperTextarea) { + this.helperTextarea.setAttribute('aria-label', 'Eingabe'); + } + this.terminalDisposables.push( this.terminal.onData((data) => this.handleInput(data)), ); From 18a8c46baba7560e81ccf236fc79c116501ea34d Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 01:32:28 +0100 Subject: [PATCH 39/77] test(frontend): enhance sr-history accessibility by updating aria-label and styling --- .../mud-client/mud-client.component.html | 7 ++++++- .../mud-client/mud-client.component.scss | 17 +++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) 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 90cce72..0ab124e 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 @@ -8,7 +8,12 @@ #inputRegionRef > -
+
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 722c1ad..9798918 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 @@ -37,14 +37,15 @@ .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; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: pre-wrap; + border: 0; } // .sr-input-committed { From 31b29f6c5c7561fc5bc048d0382b19c222265137 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 13:09:07 +0100 Subject: [PATCH 40/77] test(frontend): update sr-history styles for improved accessibility --- .../mud-client/mud-client.component.scss | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 9798918..bc487ed 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 @@ -37,15 +37,20 @@ .sr-history { position: absolute; - width: 1px; - height: 1px; + left: 0; + top: 0; + width: 100%; + height: 40vh; /* oder 100% */ + opacity: 0.001; /* nahezu unsichtbar */ + pointer-events: none; + overflow: auto; + z-index: 0; + + /* Wichtig: KEIN clip/clip-path/1px */ + margin: 0; padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - clip-path: inset(50%); - white-space: pre-wrap; - border: 0; + clip: auto; + clip-path: none; } // .sr-input-committed { From 0e80510c023a5cf0c1700f0a5bc53c1e2293fb4e Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 13:25:21 +0100 Subject: [PATCH 41/77] test(frontend): update sr-history position and styles for improved accessibility --- .../mud-client/mud-client.component.scss | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 bc487ed..3932b4c 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 @@ -36,21 +36,26 @@ } .sr-history { - position: absolute; + position: fixed; left: 0; top: 0; width: 100%; - height: 40vh; /* oder 100% */ - opacity: 0.001; /* nahezu unsichtbar */ - pointer-events: none; + height: 40vh; overflow: auto; - z-index: 0; - /* Wichtig: KEIN clip/clip-path/1px */ - margin: 0; - padding: 0; + /* KEIN opacity */ + opacity: 1; + + /* KEIN clip/clip-path */ clip: auto; clip-path: none; + + /* Text unsichtbar, Box bleibt „sichtbar“ */ + color: transparent; + background: transparent; + + /* Damit es nicht klickt */ + pointer-events: none; } // .sr-input-committed { From 7de62dca27b2413cd38946e332273edf823dac6a Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 13:44:36 +0100 Subject: [PATCH 42/77] test(frontend): refactor sr-history styles for improved accessibility --- .../mud-client/mud-client.component.scss | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) 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 3932b4c..c4a090c 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 @@ -36,26 +36,15 @@ } .sr-history { - position: fixed; - left: 0; - top: 0; - width: 100%; - height: 40vh; - overflow: auto; - - /* KEIN opacity */ - opacity: 1; - - /* KEIN clip/clip-path */ - clip: auto; - clip-path: none; - - /* Text unsichtbar, Box bleibt „sichtbar“ */ - color: transparent; - background: transparent; - - /* Damit es nicht klickt */ - pointer-events: none; + 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-input-committed { From 2d4ab0d243e8c458270b0f2b4517c07c1d46121d Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 14:09:49 +0100 Subject: [PATCH 43/77] test(frontend): refactor sr-history styles for improved accessibility --- .../mud-client/mud-client.component.scss | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) 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 c4a090c..094ddda 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 @@ -37,28 +37,16 @@ .sr-history { position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: pre-wrap; - border: 0; + left: 0; + top: 0; + width: 100%; + height: 40vh; + visibility: hidden; + pointer-events: none; + 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; } From 86d8f38e4aff36a340d172d00c6d16cb6e5a45a9 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 14:26:49 +0100 Subject: [PATCH 44/77] test(frontend): update sr-history visibility for improved accessibility --- .../core/mud/components/mud-client/mud-client.component.scss | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 094ddda..68db47c 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 @@ -41,10 +41,9 @@ top: 0; width: 100%; height: 40vh; - visibility: hidden; + opacity: 0.001; /* SR-accessible, visually hidden */ pointer-events: none; overflow: auto; - z-index: 0; } .sr-log-item { From f0220ee0a37d69b27b63da61625e96bb0b5fc2ce Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 14:52:36 +0100 Subject: [PATCH 45/77] test(frontend): testing without hiding --- .../mud/components/mud-client/mud-client.component.html | 2 ++ .../mud/components/mud-client/mud-client.component.scss | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) 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 0ab124e..5455d65 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 @@ -12,6 +12,8 @@ class="sr-history" role="region" aria-label="Ausgabe" + aria-live="off" + aria-readonly="true" #historyRegionRef > 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 68db47c..32e096c 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 @@ -36,14 +36,14 @@ } .sr-history { - position: absolute; + // position: absolute; left: 0; top: 0; width: 100%; height: 40vh; - opacity: 0.001; /* SR-accessible, visually hidden */ - pointer-events: none; - overflow: auto; + // opacity: 0.001; /* SR-accessible, visually hidden */ + // pointer-events: none; + // overflow: auto; } .sr-log-item { From 379a732c9cfb7dfebf5cf92a6aebbb7c01345a63 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 15:12:11 +0100 Subject: [PATCH 46/77] test(frontend): testing overflow behavior --- .../core/mud/components/mud-client/mud-client.component.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 32e096c..959a1d1 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 @@ -43,7 +43,7 @@ height: 40vh; // opacity: 0.001; /* SR-accessible, visually hidden */ // pointer-events: none; - // overflow: auto; + overflow: auto; } .sr-log-item { From 3a04b0dcbe2446d16433611b082d619aa2d355fc Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 15:21:20 +0100 Subject: [PATCH 47/77] test(frontend): comment out height property for sr-history for testing --- .../core/mud/components/mud-client/mud-client.component.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 959a1d1..93191a1 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 @@ -40,7 +40,7 @@ left: 0; top: 0; width: 100%; - height: 40vh; + // height: 40vh; // opacity: 0.001; /* SR-accessible, visually hidden */ // pointer-events: none; overflow: auto; From 89faffb0ee91946cd3233b3ed9779f61ccab6da6 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 15:43:28 +0100 Subject: [PATCH 48/77] test(frontend): testing with list items --- .../components/mud-client/mud-client.component.html | 2 +- .../components/mud-client/mud-client.component.scss | 12 ++++++------ .../src/app/features/terminal/mud-screenreader.ts | 1 + 3 files changed, 8 insertions(+), 7 deletions(-) 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 5455d65..1badf3d 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 @@ -10,7 +10,7 @@ -
+>
diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 6278d0e..1979077 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -103,8 +103,7 @@ export class MudScreenReaderAnnouncer { const doc = this.historyRegion.ownerDocument; - const item = doc.createElement('div'); - item.role = 'listitem'; + const item = doc.createElement('li'); item.className = 'sr-log-item'; item.textContent = normalized; From 3cff4390e781abcd13ebd782d3aacab3b31904c8 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 21:48:07 +0100 Subject: [PATCH 53/77] style(frontend): adjust .sr-history styles for improved accessibility and layout --- .../mud-client/mud-client.component.html | 1 + .../mud-client/mud-client.component.scss | 14 +++++--------- 2 files changed, 6 insertions(+), 9 deletions(-) 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 8daf335..0a35058 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 @@ -12,6 +12,7 @@ class="sr-history" aria-label="Ausgabe" aria-live="off" + tabindex="-1" #historyRegionRef > 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 0616b88..d732c73 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 @@ -38,15 +38,11 @@ .sr-history { flex: 0 0 30vh; overflow: auto; - z-index: 10; - // position: absolute; - // left: 0; - // top: 0; - // width: 100%; - // height: 40vh; - // opacity: 0.001; /* SR-accessible, visually hidden */ - // pointer-events: none; - // overflow: auto; + position: relative; + z-index: 9999; + background: white; + color: black; + border: 3px solid red; } // .sr-log-item { From 8c3c2d27c8802d8a566d8376f71084f1e3faa153 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 22:00:40 +0100 Subject: [PATCH 54/77] fix(frontend): update sr-history structure to use
and change log item to

for better semantics --- .../mud/components/mud-client/mud-client.component.html | 6 +++--- .../mud/components/mud-client/mud-client.component.scss | 8 ++++---- frontend/src/app/features/terminal/mud-screenreader.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) 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 0a35058..81322c7 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 @@ -8,13 +8,13 @@ #inputRegionRef >

-
    +>
    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 d732c73..8253c2f 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 @@ -38,11 +38,11 @@ .sr-history { flex: 0 0 30vh; overflow: auto; - position: relative; + // position: relative; z-index: 9999; - background: white; - color: black; - border: 3px solid red; + // background: white; + // color: black; + // border: 3px solid red; } // .sr-log-item { diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 1979077..3e4d15c 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -103,7 +103,7 @@ export class MudScreenReaderAnnouncer { const doc = this.historyRegion.ownerDocument; - const item = doc.createElement('li'); + const item = doc.createElement('p'); item.className = 'sr-log-item'; item.textContent = normalized; From 11c90f6c2bf662995ba8b01e278a2973772b2662 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 22:39:17 +0100 Subject: [PATCH 55/77] fix(frontend): mini test --- .../core/mud/components/mud-client/mud-client.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 81322c7..d11c76d 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 @@ -12,7 +12,7 @@ class="sr-history" aria-label="Ausgabe" role="region" - tabindex="-1" + tabindex="0" #historyRegionRef > From 1d211a5e005c6a4871cce5d2ba89ef162ab74546 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 22:46:36 +0100 Subject: [PATCH 56/77] fix(frontend): update sr-history role to textbox for better accessibility --- .../mud/components/mud-client/mud-client.component.html | 2 +- .../mud/components/mud-client/mud-client.component.scss | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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 d11c76d..f0939f1 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 @@ -11,7 +11,7 @@
    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 8253c2f..2b9c126 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 @@ -45,9 +45,9 @@ // border: 3px solid red; } - // .sr-log-item { - // white-space: pre-wrap; - // } + .sr-log-item { + white-space: pre-wrap; + } /* Optionales Styling */ .disconnected-panel { From 8b6959ce6b79da92f8cc57b1685da2e3ac74ab52 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 22:52:52 +0100 Subject: [PATCH 57/77] fix(frontend): comment out .sr-log-item styles for cleanup --- .../mud/components/mud-client/mud-client.component.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 2b9c126..8253c2f 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 @@ -45,9 +45,9 @@ // border: 3px solid red; } - .sr-log-item { - white-space: pre-wrap; - } + // .sr-log-item { + // white-space: pre-wrap; + // } /* Optionales Styling */ .disconnected-panel { From f6b770be704af5f3dfbc1297d465c547166f9abb Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 23:00:43 +0100 Subject: [PATCH 58/77] fix(frontend): remove tabindex --- .../app/core/mud/components/mud-client/mud-client.component.html | 1 - 1 file changed, 1 deletion(-) 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 f0939f1..e4e9865 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 @@ -12,7 +12,6 @@ class="sr-history" aria-label="Ausgabe" role="textbox" - tabindex="0" #historyRegionRef > From 937ca05d784a57ebde1397f9d389625c8e03dc24 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 23:14:24 +0100 Subject: [PATCH 59/77] fix(frontend): add aria-multiline attribute to sr-history for improved accessibility --- .../app/core/mud/components/mud-client/mud-client.component.html | 1 + 1 file changed, 1 insertion(+) 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 e4e9865..ad37cac 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 @@ -11,6 +11,7 @@
    From 0616c3335056bcc1cbe4d9411575401fc77301be Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 23:34:49 +0100 Subject: [PATCH 60/77] fix(frontend): update sr-history flex size and enhance sr-log-item accessibility --- .../core/mud/components/mud-client/mud-client.component.scss | 4 +++- frontend/src/app/features/terminal/mud-screenreader.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 8253c2f..525ea57 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 @@ -36,9 +36,11 @@ } .sr-history { - flex: 0 0 30vh; + flex: 0 0 80vh; + min-height: 0; overflow: auto; // position: relative; + -webkit-overflow-scrolling: touch; z-index: 9999; // background: white; // color: black; diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 3e4d15c..2472520 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -106,6 +106,8 @@ export class MudScreenReaderAnnouncer { const item = doc.createElement('p'); item.className = 'sr-log-item'; item.textContent = normalized; + item.role = 'text'; + item.tabIndex = 0; this.historyRegion.appendChild(item); } From a1dc8fdb4059048917904b918ce58757c1330cf2 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 23:43:44 +0100 Subject: [PATCH 61/77] fix(frontend): revert sr-history role to log and remove aria-multiline attribute --- .../core/mud/components/mud-client/mud-client.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ad37cac..5c8144a 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 @@ -11,8 +11,8 @@
    From 003b9f3e9e92cbe2d6368a268fad4fa1bea39624 Mon Sep 17 00:00:00 2001 From: myst Date: Mon, 26 Jan 2026 00:00:29 +0100 Subject: [PATCH 62/77] fix(frontend): update sr-history styles for improved layout and accessibility --- .../mud-client/mud-client.component.scss | 15 ++++++++------- .../src/app/features/terminal/mud-screenreader.ts | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) 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 525ea57..c759bfb 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 @@ -36,21 +36,22 @@ } .sr-history { - flex: 0 0 80vh; + // flex: 0 0 100vh; min-height: 0; + width: 100%; + height: 100vh; overflow: auto; - // position: relative; - -webkit-overflow-scrolling: touch; + position: absolute; + top: 0; + left: 0; z-index: 9999; + opacity: 0.001; + pointer-events: none; // background: white; // color: black; // border: 3px solid red; } - // .sr-log-item { - // white-space: pre-wrap; - // } - /* Optionales Styling */ .disconnected-panel { flex: 0 0 auto; diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 2472520..f023220 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -107,7 +107,7 @@ export class MudScreenReaderAnnouncer { item.className = 'sr-log-item'; item.textContent = normalized; item.role = 'text'; - item.tabIndex = 0; + // item.tabIndex = 0; this.historyRegion.appendChild(item); } From 62823a293199a9209b1c8e228ab9646a7b62ae34 Mon Sep 17 00:00:00 2001 From: myst Date: Mon, 26 Jan 2026 00:11:34 +0100 Subject: [PATCH 63/77] fix(frontend): add tabindex to sr-log-item for improved accessibility --- frontend/src/app/features/terminal/mud-screenreader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index f023220..2472520 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -107,7 +107,7 @@ export class MudScreenReaderAnnouncer { item.className = 'sr-log-item'; item.textContent = normalized; item.role = 'text'; - // item.tabIndex = 0; + item.tabIndex = 0; this.historyRegion.appendChild(item); } From 1e72d73c9324ff671f1c55bc8052d167e4dc6e31 Mon Sep 17 00:00:00 2001 From: myst Date: Mon, 26 Jan 2026 00:22:49 +0100 Subject: [PATCH 64/77] fix(frontend): adjust z-index for sr-history and mud-output for improved layering --- .../components/mud-client/mud-client.component.scss | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 c759bfb..13f6d13 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 @@ -9,6 +9,7 @@ background-color: black; min-height: 0; overflow: hidden; + z-index: 1; } .sr-announcer { @@ -44,9 +45,13 @@ position: absolute; top: 0; left: 0; - z-index: 9999; - opacity: 0.001; - pointer-events: none; + z-index: 0; + + // opacity: 0.001; + // pointer-events: none; + + color: transparent; + background: transparent; // background: white; // color: black; // border: 3px solid red; From 9fd23dca42d2e234ed6b5748f21d6b4e3646ac1f Mon Sep 17 00:00:00 2001 From: myst Date: Mon, 26 Jan 2026 00:29:26 +0100 Subject: [PATCH 65/77] fix(frontend): this is the important stuff - it breaks screen readers --- frontend/src/app/features/terminal/mud-screenreader.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 2472520..0e703ff 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -107,7 +107,6 @@ export class MudScreenReaderAnnouncer { item.className = 'sr-log-item'; item.textContent = normalized; item.role = 'text'; - item.tabIndex = 0; this.historyRegion.appendChild(item); } From a0ab92c4fc0ffc908ad3857077cbdc50adb4e615 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 18:07:16 +0100 Subject: [PATCH 66/77] feat: implement session token handling and output history service for MUD connections --- backend/src/core/sockets/socket-manager.ts | 311 +++++++++++++----- .../src/core/sockets/types/mud-connections.ts | 58 +++- backend/src/shared/utils/logger.ts | 2 +- .../mud-client/mud-client.component.scss | 1 - .../mud-client/mud-client.component.ts | 27 ++ .../src/app/core/mud/services/mud.service.ts | 3 + .../app/features/sockets/sockets.service.ts | 151 ++++++++- .../shared/services/output-history.service.ts | 160 +++++++++ shared/package.json | 1 - shared/src/sockets/client-to-server-events.ts | 7 +- shared/src/sockets/server-to-client-events.ts | 4 +- 11 files changed, 621 insertions(+), 104 deletions(-) create mode 100644 frontend/src/app/shared/services/output-history.service.ts diff --git a/backend/src/core/sockets/socket-manager.ts b/backend/src/core/sockets/socket-manager.ts index e06f8a0..e326c7d 100644 --- a/backend/src/core/sockets/socket-manager.ts +++ b/backend/src/core/sockets/socket-manager.ts @@ -3,6 +3,7 @@ import type { LinemodeState, ServerToClientEvents, } from '@webmud3/shared'; +import { randomUUID } from 'crypto'; import { Server as HttpServer } from 'http'; import { Server as HttpsServer } from 'https'; import { Server, Socket } from 'socket.io'; @@ -15,6 +16,7 @@ import { logger } from '../../shared/utils/logger.js'; import { mapToServerEncodings } from '../../shared/utils/supported-encodings.js'; import { Environment } from '../environment/environment.js'; import type { MudConnections } from './types/mud-connections.js'; +import { OutputLineBuffer } from './types/mud-connections.js'; export class SocketManager extends Server< ClientToServerEvents, @@ -43,56 +45,61 @@ export class SocketManager extends Server< this.on('connection', (socket) => { logger.info(`[${socket.id}] [Socket-Manager] Client connected`, { socketId: socket.id, + recovered: socket.recovered, }); - this.handleClientConnection(socket); + const handshakeSessionToken = this.getSessionTokenFromSocket(socket); - if (this.mudConnections[socket.id] !== undefined) { - logger.info(`[${socket.id}] [Socket-Manager] Client was reconnecting`, { - socketId: socket.id, - }); + if (handshakeSessionToken) { + const existingConnection = this.mudConnections[handshakeSessionToken]; - const existingTelnet = this.mudConnections[socket.id].telnet; + if (existingConnection !== undefined) { + existingConnection.socketId = socket.id; - if (existingTelnet !== undefined) { - logger.info( - `[${socket.id}] [Socket-Manager] Client already got an established telnet connection. Emitting 'mudConnected'`, - { - socketId: socket.id, - }, - ); + if (existingConnection.connectionTimer !== undefined) { + clearTimeout(existingConnection.connectionTimer); - socket.emit('mudConnected'); + existingConnection.connectionTimer = undefined; + } - this.emitCurrentOptionStates(existingTelnet, socket); - } + const existingTelnet = existingConnection.telnet; - logger.info( - `[${socket.id}] [Socket-Manager] Client resetting logout (statue in mud) timer`, - { - socketId: socket.id, - }, - ); + if (existingTelnet !== undefined && existingTelnet.isConnected) { + logger.info( + `[${socket.id}] [Socket-Manager] Client reattached via handshake token. Emitting 'mudConnected'`, + { + sessionToken: handshakeSessionToken, + }, + ); - if (this.mudConnections[socket.id].connectionTimer !== undefined) { - clearTimeout(this.mudConnections[socket.id].connectionTimer); + socket.emit('mudConnected', false, handshakeSessionToken); // isNewConnection = false - this.mudConnections[socket.id].connectionTimer = undefined; + this.emitCurrentOptionStates(existingTelnet, socket); + + const bufferedLines = + existingConnection.outputLineBuffer.getLines(); + + if (bufferedLines.length > 0) { + logger.info( + `[${socket.id}] [Socket-Manager] Sending ${bufferedLines.length} buffered lines to reconnected client`, + { + socketId: socket.id, + }, + ); + + socket.emit('mudOutput', bufferedLines.join('')); + } + } } } + + this.handleClientConnection(socket); }); } private handleClientConnection( socket: Socket, ) { - if (this.mudConnections[socket.id] === undefined) { - this.mudConnections[socket.id] = { - telnet: undefined, - connectionTimer: undefined, - }; - } - socket.on('error', (error: Error) => { logger.error(`[${socket.id}] [Socket-Manager] Client error`, { socketId: socket.id, @@ -112,19 +119,32 @@ export class SocketManager extends Server< }, ); - const connection = this.mudConnections[socket.id]; + const existing = this.getConnectionBySocketId(socket.id); - if (connection === undefined) { + if (existing === undefined) { return; } - connection.connectionTimer = setTimeout(() => { - this.closeTelnetConnections(socket.id); + existing.connection.connectionTimer = setTimeout(() => { + this.closeTelnetConnections(existing.sessionToken); }, Environment.getInstance().socketTimeout); }); socket.on('mudInput', (data: string) => { - const telnetClient = this.mudConnections[socket.id].telnet; + const existing = this.getConnectionBySocketId(socket.id); + + if (existing === undefined) { + logger.error( + `[${socket.id}] [Socket-Manager] Client has no session - can not send message to mud!`, + { + socketId: socket.id, + }, + ); + + return; + } + + const telnetClient = existing.connection.telnet; if (telnetClient === undefined || telnetClient.isConnected === false) { logger.error( @@ -161,47 +181,94 @@ export class SocketManager extends Server< }); socket.on('mudViewportSize', (columns: number, rows: number) => { - const connection = this.mudConnections[socket.id]; + const existing = this.getConnectionBySocketId(socket.id); - if (connection === undefined) { + if (existing === undefined) { return; } - if (connection.telnet !== undefined) { - connection.telnet.updateViewportSize(columns, rows); + if (existing.connection.telnet !== undefined) { + existing.connection.telnet.updateViewportSize(columns, rows); } }); socket.on( 'mudConnect', - (initialViewPort: { columns: number; rows: number }) => { + ( + initialViewPort: { columns: number; rows: number }, + sessionToken: string, + ) => { + const resolvedSessionToken = + sessionToken || + this.getSessionTokenFromSocket(socket) || + randomUUID(); + logger.info( `[${socket.id}] [Socket-Manager] Client want to connect to mud`, + { + sessionToken: resolvedSessionToken, + }, ); - const existingClient = this.mudConnections[socket.id]?.telnet; + const existingConnection = this.mudConnections[resolvedSessionToken]; - if (existingClient !== undefined && existingClient.isConnected) { - logger.info( - `[${socket.id}] [Socket-Manager] Client is reusing existing telnet connection. Emitting 'mudConnected'`, - ); + if (existingConnection !== undefined) { + existingConnection.socketId = socket.id; - existingClient.updateViewportSize( - initialViewPort.columns, - initialViewPort.rows, - ); + if (existingConnection.connectionTimer !== undefined) { + clearTimeout(existingConnection.connectionTimer); + + existingConnection.connectionTimer = undefined; + } + + const existingClient = existingConnection.telnet; + + if (existingClient !== undefined && existingClient.isConnected) { + logger.info( + `[${socket.id}] [Socket-Manager] Client is reusing existing telnet connection. Emitting 'mudConnected'`, + { + sessionToken: resolvedSessionToken, + }, + ); + + existingClient.updateViewportSize( + initialViewPort.columns, + initialViewPort.rows, + ); + + socket.emit('mudConnected', false, resolvedSessionToken); // isNewConnection = false - socket.emit('mudConnected'); + this.emitCurrentOptionStates(existingClient, socket); - this.emitCurrentOptionStates(existingClient, socket); + const bufferedLines = + existingConnection.outputLineBuffer.getLines(); - return; + if (bufferedLines.length > 0) { + logger.info( + `[${socket.id}] [Socket-Manager] Sending ${bufferedLines.length} buffered lines to reconnected client`, + { + socketId: socket.id, + }, + ); + + socket.emit('mudOutput', bufferedLines.join('')); + } + + return; + } } logger.info( `[${socket.id}] [Socket-Manager] Client had no active telnet connection .. creating new one..`, + { + sessionToken: resolvedSessionToken, + }, ); + // Create output buffer BEFORE telnet client so all data is captured from the start + const outputBuffer = + existingConnection?.outputLineBuffer ?? new OutputLineBuffer(); + const telnetClient = new TelnetClient( socket.id, this.managerOptions.telnetHost, @@ -213,46 +280,54 @@ export class SocketManager extends Server< }, ); - const previousConnection = this.mudConnections[socket.id]; - - if (previousConnection?.connectionTimer !== undefined) { - clearTimeout(previousConnection.connectionTimer); - } - - this.mudConnections[socket.id] = { + this.mudConnections[resolvedSessionToken] = { telnet: telnetClient, connectionTimer: undefined, + outputLineBuffer: outputBuffer, + socketId: socket.id, }; + // Register buffer listener IMMEDIATELY to capture all telnet data + // This ensures buffering happens even when socket is disconnected telnetClient.on('data', (data: string | Buffer) => { const mudCharset = - this.mudConnections[socket.id].telnet?.negotiations[ + this.mudConnections[resolvedSessionToken]?.telnet?.negotiations[ TelnetOptions.TELOPT_CHARSET ]?.subnegotiation?.clientOption; + let outputString: string; + if (mudCharset === undefined) { logger.warn( `[${socket.id}] [Socket-Manager] Client has no charset negotiated before sending data. Default to utf-8`, ); - socket.emit('mudOutput', data.toString('utf-8')); - - return; - } - - const charset = mapToServerEncodings(mudCharset); + outputString = data.toString('utf-8'); + } else { + const charset = mapToServerEncodings(mudCharset); - if (charset !== null) { - socket.emit('mudOutput', data.toString(charset)); + if (charset !== null) { + outputString = data.toString(charset); + } else { + logger.warn( + `[Socket-Manager] [Client] ${socket.id} unknown charset ${mudCharset}. Default to utf-8`, + ); - return; + outputString = data.toString('utf-8'); + } } - logger.warn( - `[Socket-Manager] [Client] ${socket.id} unknown charset ${mudCharset}. Default to utf-8`, + // ALWAYS buffer the output, regardless of socket connection status + outputBuffer.addLine(outputString); + + // Emit to socket only if connected + const currentSocket = this.getSocketById( + this.mudConnections[resolvedSessionToken]?.socketId, ); - socket.emit('mudOutput', data.toString('utf-8')); + if (currentSocket !== undefined) { + currentSocket.emit('mudOutput', outputString); + } }); telnetClient.on('close', () => { @@ -260,9 +335,17 @@ export class SocketManager extends Server< `[${socket.id}] [Socket-Manager] Client telnet connection closed. Emitting 'mudDisconnected'`, ); - this.mudConnections[socket.id].telnet = undefined; + const connection = this.mudConnections[resolvedSessionToken]; - socket.emit('mudDisconnected'); + if (connection !== undefined) { + connection.telnet = undefined; + + const targetSocket = this.getSocketById(connection.socketId); + + if (targetSocket !== undefined) { + targetSocket.emit('mudDisconnected'); + } + } }); telnetClient.on('negotiationChanged', (negotiation) => { @@ -270,9 +353,17 @@ export class SocketManager extends Server< negotiation.option === TelnetOptions.TELOPT_TM && negotiation.server === TelnetControlSequences.DO ) { - socket.emit('requestTimingMark', () => { - this.mudConnections[socket.id].telnet?.sendTimingMark(); - }); + const connection = this.mudConnections[resolvedSessionToken]; + + const targetSocket = connection + ? this.getSocketById(connection.socketId) + : undefined; + + if (connection !== undefined && targetSocket !== undefined) { + targetSocket.emit('requestTimingMark', () => { + connection.telnet?.sendTimingMark(); + }); + } } }); @@ -319,7 +410,7 @@ export class SocketManager extends Server< `[${socket.id}] [Socket-Manager] Client .. telnet connection established. Emitting 'mudConnected'`, ); - socket.emit('mudConnected'); + socket.emit('mudConnected', true, resolvedSessionToken); // isNewConnection = true this.emitCurrentOptionStates(telnetClient, socket); }, @@ -330,23 +421,27 @@ export class SocketManager extends Server< `[${socket.id}] [Socket-Manager] Client disconnecting from mud`, ); - this.closeTelnetConnections(socket.id); + const existing = this.getConnectionBySocketId(socket.id); + + if (existing !== undefined) { + this.closeTelnetConnections(existing.sessionToken); + } }); } - private closeTelnetConnections(socketId: string) { - const telnetClient = this.mudConnections[socketId]?.telnet; + private closeTelnetConnections(sessionToken: string) { + const telnetClient = this.mudConnections[sessionToken]?.telnet; if (telnetClient !== undefined && telnetClient.isConnected) { telnetClient.disconnect(); - if (this.mudConnections[socketId].connectionTimer !== undefined) { - clearTimeout(this.mudConnections[socketId].connectionTimer); + if (this.mudConnections[sessionToken].connectionTimer !== undefined) { + clearTimeout(this.mudConnections[sessionToken].connectionTimer); - this.mudConnections[socketId].connectionTimer = undefined; + this.mudConnections[sessionToken].connectionTimer = undefined; } - this.mudConnections[socketId].telnet = undefined; + this.mudConnections[sessionToken].telnet = undefined; } } @@ -371,4 +466,46 @@ export class SocketManager extends Server< socket.emit('setLinemode', linemodeState); } } + + /** + * Buffers output in complete lines (separated by \n) and emits to client. + * Only complete lines are added to the buffer. + * + * @deprecated This method is no longer needed. Output buffering happens directly + * in the telnet 'data' event listener registered in mudConnect handler. + */ + + private getConnectionBySocketId( + socketId: string, + ): { sessionToken: string; connection: MudConnections[string] } | undefined { + for (const [sessionToken, connection] of Object.entries( + this.mudConnections, + )) { + if (connection.socketId === socketId) { + return { sessionToken, connection }; + } + } + + return undefined; + } + + private getSocketById(socketId: string | undefined) { + if (!socketId) { + return undefined; + } + + return this.sockets.sockets.get(socketId); + } + + private getSessionTokenFromSocket( + socket: Socket, + ): string | undefined { + const authToken = socket.handshake.auth?.sessionToken; + + if (typeof authToken === 'string' && authToken.trim().length > 0) { + return authToken.trim(); + } + + return undefined; + } } diff --git a/backend/src/core/sockets/types/mud-connections.ts b/backend/src/core/sockets/types/mud-connections.ts index 42a50cc..dbd506e 100644 --- a/backend/src/core/sockets/types/mud-connections.ts +++ b/backend/src/core/sockets/types/mud-connections.ts @@ -1,8 +1,64 @@ import { TelnetClient } from '../../../features/telnet/telnet-client.js'; +/** + * Ringbuffer für MUD-Output (rohe Telnet-Daten) + * Speichert bis zu 10MB und verwirft die ältesten Daten, wenn das Limit überschritten wird + */ +export class OutputLineBuffer { + private buffer: string[] = []; + private readonly maxBytes = 10 * 1024 * 1024; // 10MB + private currentSizeBytes = 0; + + /** + * Fügt Daten zum Buffer hinzu. + * Wenn das Größenlimit überschritten wird, werden die ältesten Einträge gelöscht. + */ + public addLine(data: string): void { + const dataBytes = Buffer.byteLength(data, 'utf-8'); + + this.buffer.push(data); + + this.currentSizeBytes += dataBytes; + + // Entferne älteste Einträge, bis wir wieder unter dem Limit sind + while (this.currentSizeBytes > this.maxBytes && this.buffer.length > 0) { + const removed = this.buffer.shift(); + + if (removed !== undefined) { + this.currentSizeBytes -= Buffer.byteLength(removed, 'utf-8'); + } + } + } + + /** + * Gibt alle gepufferten Daten als String zurück + */ + public getLines(): string[] { + return [...this.buffer]; + } + + /** + * Gibt die aktuelle Größe des Buffers in Bytes zurück + */ + public getSizeBytes(): number { + return this.currentSizeBytes; + } + + /** + * Löscht den kompletten Buffer + */ + public clear(): void { + this.buffer = []; + + this.currentSizeBytes = 0; + } +} + export type MudConnections = { - [socketId: string]: { + [sessionToken: string]: { telnet: TelnetClient | undefined; connectionTimer: NodeJS.Timeout | undefined; + outputLineBuffer: OutputLineBuffer; + socketId: string | undefined; // Current socket.id for emit/broadcast }; }; diff --git a/backend/src/shared/utils/logger.ts b/backend/src/shared/utils/logger.ts index 2493c70..5574d85 100644 --- a/backend/src/shared/utils/logger.ts +++ b/backend/src/shared/utils/logger.ts @@ -33,7 +33,7 @@ const logger = winston.createLogger({ winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'label'], }), - logMetadata(false), + logMetadata(true), winston.format.printf( (info) => `[${info.timestamp}] [${info.level}] ${info.message}`, ), 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 13f6d13..07da8d0 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 @@ -37,7 +37,6 @@ } .sr-history { - // flex: 0 0 100vh; min-height: 0; width: 100%; height: 100vh; 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 49327dc..b900fc2 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 @@ -14,6 +14,7 @@ import { Subscription } from 'rxjs'; import { MudService } from '../../services/mud.service'; import { SecureString } from '@webmud3/frontend/shared/types/secure-string'; +import { OutputHistoryService } from '@webmud3/frontend/shared/services/output-history.service'; import type { LinemodeState } from '@webmud3/shared'; import { MudInputController, @@ -49,6 +50,7 @@ type MudClientState = { }) export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly mudService = inject(MudService); + private readonly outputHistoryService = inject(OutputHistoryService); private readonly terminal: Terminal; private readonly inputController: MudInputController; @@ -174,6 +176,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.resizeObs.observe(this.terminalRef.nativeElement); this.setState({ terminalReady: true }); + // Load history BEFORE connecting to MUD to ensure it appears before new output + this.loadHistoryIfAvailable(); + const columns = this.terminal.cols; const rows = this.terminal.rows + 1; @@ -378,6 +383,28 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.state = { ...this.state, ...patch }; } + /** + * Loads and displays saved output history if available. + */ + private loadHistoryIfAvailable(): void { + console.log('[MudClient] Loading history from localStorage'); + + const historyLines = this.outputHistoryService.loadLines(); + + if (historyLines.length === 0) { + console.log('[MudClient] No history found'); + return; + } + + console.log( + `[MudClient] Restoring ${historyLines.length} lines from history`, + ); + + // Write all history lines to terminal + const historyData = historyLines.join(''); + this.terminal.write(historyData); + } + /** * Maps DEL to BACKSPACE for non-edit mode */ diff --git a/frontend/src/app/core/mud/services/mud.service.ts b/frontend/src/app/core/mud/services/mud.service.ts index 6932201..b4237b2 100644 --- a/frontend/src/app/core/mud/services/mud.service.ts +++ b/frontend/src/app/core/mud/services/mud.service.ts @@ -18,6 +18,9 @@ export class MudService { /** Roh-Ausgabe-Stream vom Server (ANSI-Bytes/String) */ public readonly mudOutput$ = this.sockets.onMudOutput.asObservable(); + /** Emittiert isNewConnection wenn MUD-Verbindung hergestellt wurde */ + public readonly mudConnect$ = this.sockets.onMudConnect.asObservable(); + public connect(initialViewPort: { columns: number; rows: number }) { this.sockets.connectToMud(initialViewPort); } diff --git a/frontend/src/app/features/sockets/sockets.service.ts b/frontend/src/app/features/sockets/sockets.service.ts index 7182789..cde1c66 100644 --- a/frontend/src/app/features/sockets/sockets.service.ts +++ b/frontend/src/app/features/sockets/sockets.service.ts @@ -1,10 +1,11 @@ -import { EventEmitter, Injectable } from '@angular/core'; +import { EventEmitter, inject, Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { Manager, Socket } from 'socket.io-client'; import { ServerConfigService } from '../../features/serverconfig/server-config.service'; import { SecureString } from '@webmud3/frontend/shared/types/secure-string'; import { isSecureString } from '@webmud3/frontend/shared/utils/is-secure-string'; +import { OutputHistoryService } from '@webmud3/frontend/shared/services/output-history.service'; import type { ClientToServerEvents, @@ -20,12 +21,16 @@ type MudOutputEventArgs = { providedIn: 'root', }) export class SocketsService { + private readonly outputHistoryService = inject(OutputHistoryService); private readonly manager: Manager; private readonly socket: Socket; private readonly connectedToServer = new BehaviorSubject(false); private readonly connectedToMud = new BehaviorSubject(false); + private readonly inputQueue: string[] = []; + private isReconnecting = false; + private sessionToken: string; - public onMudConnect = new EventEmitter(); + public onMudConnect = new EventEmitter(); // Emits isNewConnection public onMudDisconnect = new EventEmitter(); public onMudOutput = new EventEmitter(); public onSetEchoMode = new EventEmitter(); @@ -38,9 +43,13 @@ export class SocketsService { const socketUrl = serverConfigService.getBackendUrl(); const socketNamespace = serverConfigService.getSocketNamespace(); + // Initialize or retrieve persistent session token + this.sessionToken = this.initializeSessionToken(); + console.log('[Sockets] Socket Service init socket', { socketUrl, socketNamespace, + sessionToken: this.sessionToken, }); this.manager = new Manager(socketUrl, { @@ -78,7 +87,11 @@ export class SocketsService { this.handlePing(); }); - this.socket = this.manager.socket('/'); + this.socket = this.manager.socket('/', { + auth: { + sessionToken: this.sessionToken, + }, + }); this.socket.on('connect', () => { this.handleConnect(); @@ -88,9 +101,12 @@ export class SocketsService { this.handleDisconnect(reason); }); - this.socket.on('mudConnected', () => { - this.handleMudConnect(); - }); + this.socket.on( + 'mudConnected', + (isNewConnection: boolean, sessionToken: string) => { + this.handleMudConnect(isNewConnection, sessionToken); + }, + ); this.socket.on('mudDisconnected', () => { this.handleMudDisconnect(); @@ -117,8 +133,10 @@ export class SocketsService { columns: number; rows: number; }): void { - console.log(`[Sockets] Sockets-Service: 'connectToMud'`); - this.socket.emit('mudConnect', initialViewPort); + console.log( + `[Sockets] Sockets-Service: 'connectToMud' with sessionToken: ${this.sessionToken}`, + ); + this.socket.emit('mudConnect', initialViewPort, this.sessionToken); } public disconnectFromMud() { @@ -127,6 +145,16 @@ export class SocketsService { } public sendMessage(message: string | SecureString) { + // Queue the message if disconnected or reconnecting + if (!this.connectedToServer.value || this.isReconnecting) { + const messageToQueue = !isSecureString(message) ? message : message.value; + this.inputQueue.push(messageToQueue); + console.log( + `[Sockets] Sockets-Service: Message queued (${this.inputQueue.length} in queue)`, + ); + return; + } + if (!isSecureString(message)) { console.log(`[Sockets] Sockets-Service: 'sendMessage'`, { message }); this.socket.emit('mudInput', message); @@ -149,15 +177,39 @@ export class SocketsService { throw new Error('Method not implemented.'); } - private handleMudConnect = () => { - this.connectedToMud.next(true); + private handleMudConnect = ( + isNewConnection: boolean, + sessionToken: string, + ) => { + console.log( + '[Sockets] Sockets-Service: mudConnected, isNewConnection:', + isNewConnection, + 'sessionToken:', + sessionToken, + ); + + // Update session token if received from server + if (sessionToken) { + this.sessionToken = sessionToken; + this.saveSessionToken(sessionToken); + this.socket.auth = { sessionToken }; + } - this.onMudConnect.emit(); + // Don't clear history here - it would clear on every page reload + // since isNewConnection=true even when just the socket-id changed + // History is only cleared on explicit mudDisconnect + + this.connectedToMud.next(true); + this.onMudConnect.emit(isNewConnection); }; private handleMudDisconnect = () => { console.log(`[Sockets] Sockets-Service: received 'mudDisconnected'`); + // Clear history when MUD connection is closed + console.log('[Sockets] Clearing history after MUD disconnect'); + this.outputHistoryService.clearLines(); + this.connectedToMud.next(false); this.onMudDisconnect.emit(); @@ -167,6 +219,14 @@ export class SocketsService { this.onMudOutput.emit({ data: output, }); + + // Save output to localStorage + this.outputHistoryService.appendLines([output]); + + // Flush queued input after receiving output (including buffered output after reconnect) + if (this.inputQueue.length > 0) { + this.flushInputQueue(); + } }; private handleClose() { @@ -183,10 +243,15 @@ export class SocketsService { private handleReconnect = (attempt: number) => { console.info('[Sockets] Sockets-Service: Reconnect:', attempt); + this.isReconnecting = false; + + // Flush queued input after reconnection + this.flushInputQueue(); }; private handleReconnectAttempt = (attempt: number) => { console.info('[Sockets] Sockets-Service: Reconnect Attempt:', attempt); + this.isReconnecting = true; }; private handleReconnectError = (error: Error) => { @@ -232,4 +297,68 @@ export class SocketsService { callback(); }; + + /** + * Flushes the input queue by sending all queued messages to the server + */ + private flushInputQueue = () => { + if (this.inputQueue.length === 0) { + return; + } + + console.log( + `[Sockets] Sockets-Service: Flushing ${this.inputQueue.length} queued messages`, + ); + + while (this.inputQueue.length > 0) { + const message = this.inputQueue.shift(); + if (message !== undefined) { + this.socket.emit('mudInput', message); + } + } + }; + + /** + * Initializes or retrieves the persistent session token from localStorage + */ + private initializeSessionToken(): string { + const STORAGE_KEY = 'webmud3-session-token'; + let token = localStorage.getItem(STORAGE_KEY); + + if (!token) { + // Generate new UUID v4 + token = this.generateUUID(); + this.saveSessionToken(token); + } + + return token; + } + + /** + * Saves the session token to localStorage + */ + private saveSessionToken(token: string): void { + try { + localStorage.setItem('webmud3-session-token', token); + } catch (error) { + console.error( + '[Sockets] Failed to save session token to localStorage:', + error, + ); + } + } + + /** + * Generates a UUID v4 string + */ + private generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + function (c) { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }, + ); + } } diff --git a/frontend/src/app/shared/services/output-history.service.ts b/frontend/src/app/shared/services/output-history.service.ts new file mode 100644 index 0000000..a7d0c02 --- /dev/null +++ b/frontend/src/app/shared/services/output-history.service.ts @@ -0,0 +1,160 @@ +import { Injectable } from '@angular/core'; + +const MAX_STORAGE_BYTES = 30 * 1024 * 1024; // 30MB +const STORAGE_KEY = 'webmud3-history'; + +/** + * Service for persisting MUD output history to localStorage. + * Stores output as a simple string array. + */ +@Injectable({ + providedIn: 'root', +}) +export class OutputHistoryService { + /** + * Saves output lines to localStorage. + * Enforces a ~30MB limit by removing oldest lines if needed. + */ + public saveLines(lines: string[]): void { + if (!this.isStorageAvailable()) { + console.warn('[OutputHistory] localStorage not available'); + return; + } + + try { + const serialized = JSON.stringify(lines); + + // Check size limit + const sizeBytes = new Blob([serialized]).size; + if (sizeBytes > MAX_STORAGE_BYTES) { + console.warn( + `[OutputHistory] Data exceeds ${MAX_STORAGE_BYTES} bytes, trimming...`, + ); + const trimmedLines = this.trimToSize(lines, MAX_STORAGE_BYTES); + localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmedLines)); + } else { + localStorage.setItem(STORAGE_KEY, serialized); + } + + console.debug( + `[OutputHistory] Saved ${lines.length} lines (${sizeBytes} bytes)`, + ); + } catch (error) { + console.error('[OutputHistory] Failed to save lines:', error); + // If QuotaExceededError, try to trim + if ( + error instanceof DOMException && + error.name === 'QuotaExceededError' + ) { + this.handleQuotaExceeded(lines); + } + } + } + + /** + * Loads output lines from localStorage. + * Returns an empty array if no data exists or an error occurs. + */ + public loadLines(): string[] { + if (!this.isStorageAvailable()) { + return []; + } + + try { + const stored = localStorage.getItem(STORAGE_KEY); + + if (!stored) { + console.debug('[OutputHistory] No stored lines found'); + return []; + } + + const lines = JSON.parse(stored) as string[]; + console.debug(`[OutputHistory] Loaded ${lines.length} lines`); + return lines; + } catch (error) { + console.error('[OutputHistory] Failed to load lines:', error); + return []; + } + } + + /** + * Clears all stored output lines. + */ + public clearLines(): void { + if (!this.isStorageAvailable()) { + return; + } + + try { + localStorage.removeItem(STORAGE_KEY); + console.debug('[OutputHistory] Cleared all lines'); + } catch (error) { + console.error('[OutputHistory] Failed to clear lines:', error); + } + } + + /** + * Appends new lines to existing stored lines. + */ + public appendLines(newLines: string[]): void { + const existingLines = this.loadLines(); + const combinedLines = [...existingLines, ...newLines]; + this.saveLines(combinedLines); + } + + /** + * Checks if localStorage is available. + */ + private isStorageAvailable(): boolean { + try { + const test = '__storage_test__'; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return true; + } catch { + return false; + } + } + + /** + * Trims lines array to fit within the specified byte size. + * Removes oldest lines (from the beginning) until size is acceptable. + */ + private trimToSize(lines: string[], maxBytes: number): string[] { + let trimmedLines = [...lines]; + + while (trimmedLines.length > 0) { + const serialized = JSON.stringify(trimmedLines); + const sizeBytes = new Blob([serialized]).size; + + if (sizeBytes <= maxBytes) { + break; + } + + // Remove oldest 10% of lines at a time for efficiency + const removeCount = Math.max(1, Math.floor(trimmedLines.length * 0.1)); + trimmedLines = trimmedLines.slice(removeCount); + } + + console.debug( + `[OutputHistory] Trimmed from ${lines.length} to ${trimmedLines.length} lines`, + ); + return trimmedLines; + } + + /** + * Handles QuotaExceededError by trimming lines and retrying. + */ + private handleQuotaExceeded(lines: string[]): void { + console.warn( + '[OutputHistory] Quota exceeded, attempting to trim and retry', + ); + const trimmedLines = this.trimToSize(lines, MAX_STORAGE_BYTES * 0.8); // Use 80% of limit + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmedLines)); + console.debug('[OutputHistory] Successfully saved after trimming'); + } catch (error) { + console.error('[OutputHistory] Failed even after trimming:', error); + } + } +} diff --git a/shared/package.json b/shared/package.json index ee2fb0e..4c0c782 100644 --- a/shared/package.json +++ b/shared/package.json @@ -26,7 +26,6 @@ "dist" ], "scripts": { - "prebuild": "npm run clean:dist", "build": "tsc -p tsconfig.json", "clean": "npm run clean:dist && npm run clean:packages", "clean:dist": "rimraf dist", diff --git a/shared/src/sockets/client-to-server-events.ts b/shared/src/sockets/client-to-server-events.ts index 8f5d52a..653ede6 100644 --- a/shared/src/sockets/client-to-server-events.ts +++ b/shared/src/sockets/client-to-server-events.ts @@ -4,8 +4,13 @@ export interface ClientToServerEvents { /** * Requests a telnet connection using the provided initial viewport size. + * @param initialViewPort - Client's terminal dimensions + * @param sessionToken - Persistent session identifier (UUID) across socket reconnections */ - mudConnect: (initialViewPort: { columns: number; rows: number }) => void; + mudConnect: ( + initialViewPort: { columns: number; rows: number }, + sessionToken: string, + ) => void; /** * Requests that the server tears down the active telnet connection. */ diff --git a/shared/src/sockets/server-to-client-events.ts b/shared/src/sockets/server-to-client-events.ts index b781f08..5a27d28 100644 --- a/shared/src/sockets/server-to-client-events.ts +++ b/shared/src/sockets/server-to-client-events.ts @@ -14,8 +14,10 @@ export interface ServerToClientEvents { mudDisconnected: () => void; /** * Signals that the MUD connection was successfully established. + * @param isNewConnection - true if this is a new telnet connection, false if reconnected to existing + * @param sessionToken - The session token confirmed by the server */ - mudConnected: () => void; + mudConnected: (isNewConnection: boolean, sessionToken: string) => void; /** * Instructs the client to enable or disable local echo mode. */ From df0f7ebd35c7ec272b6c232988be1e12858b5c55 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 25 Jan 2026 20:56:41 +0100 Subject: [PATCH 67/77] feat: enhance output handling with sequence numbers and batch support for reconnections --- backend/src/core/sockets/socket-manager.ts | 38 ++- .../src/core/sockets/types/mud-connections.ts | 19 +- .../mud-client/mud-client.component.ts | 27 +- .../app/features/sockets/sockets.service.ts | 53 +++- .../shared/services/output-history.service.ts | 230 +++++++++++------- shared/src/sockets/server-to-client-events.ts | 6 +- 6 files changed, 245 insertions(+), 128 deletions(-) diff --git a/backend/src/core/sockets/socket-manager.ts b/backend/src/core/sockets/socket-manager.ts index e326c7d..81f1fb1 100644 --- a/backend/src/core/sockets/socket-manager.ts +++ b/backend/src/core/sockets/socket-manager.ts @@ -76,18 +76,23 @@ export class SocketManager extends Server< this.emitCurrentOptionStates(existingTelnet, socket); - const bufferedLines = - existingConnection.outputLineBuffer.getLines(); + const bufferedEntries = + existingConnection.outputLineBuffer.getEntries(); - if (bufferedLines.length > 0) { + if (bufferedEntries.length > 0) { logger.info( - `[${socket.id}] [Socket-Manager] Sending ${bufferedLines.length} buffered lines to reconnected client`, + `[${socket.id}] [Socket-Manager] Sending ${bufferedEntries.length} buffered entries to reconnected client`, { socketId: socket.id, }, ); - socket.emit('mudOutput', bufferedLines.join('')); + // Send buffered entries as batch with sequence numbers + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (socket as unknown as any).emit( + 'mudOutputBatch', + bufferedEntries, + ); } } } @@ -240,18 +245,22 @@ export class SocketManager extends Server< this.emitCurrentOptionStates(existingClient, socket); - const bufferedLines = - existingConnection.outputLineBuffer.getLines(); + const bufferedEntries = + existingConnection.outputLineBuffer.getEntries(); - if (bufferedLines.length > 0) { + if (bufferedEntries.length > 0) { logger.info( - `[${socket.id}] [Socket-Manager] Sending ${bufferedLines.length} buffered lines to reconnected client`, + `[${socket.id}] [Socket-Manager] Sending ${bufferedEntries.length} buffered entries to reconnected client`, { socketId: socket.id, }, ); - socket.emit('mudOutput', bufferedLines.join('')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (socket as unknown as any).emit( + 'mudOutputBatch', + bufferedEntries, + ); } return; @@ -318,7 +327,7 @@ export class SocketManager extends Server< } // ALWAYS buffer the output, regardless of socket connection status - outputBuffer.addLine(outputString); + const seq = outputBuffer.addData(outputString); // Emit to socket only if connected const currentSocket = this.getSocketById( @@ -326,7 +335,12 @@ export class SocketManager extends Server< ); if (currentSocket !== undefined) { - currentSocket.emit('mudOutput', outputString); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (currentSocket as unknown as any).emit( + 'mudOutput', + outputString, + seq, + ); } }); diff --git a/backend/src/core/sockets/types/mud-connections.ts b/backend/src/core/sockets/types/mud-connections.ts index dbd506e..3a768a3 100644 --- a/backend/src/core/sockets/types/mud-connections.ts +++ b/backend/src/core/sockets/types/mud-connections.ts @@ -4,19 +4,24 @@ import { TelnetClient } from '../../../features/telnet/telnet-client.js'; * Ringbuffer für MUD-Output (rohe Telnet-Daten) * Speichert bis zu 10MB und verwirft die ältesten Daten, wenn das Limit überschritten wird */ +export type OutputEntry = { seq: number; data: string }; + export class OutputLineBuffer { - private buffer: string[] = []; + private buffer: OutputEntry[] = []; private readonly maxBytes = 10 * 1024 * 1024; // 10MB private currentSizeBytes = 0; + private nextSeq = 1; /** * Fügt Daten zum Buffer hinzu. * Wenn das Größenlimit überschritten wird, werden die ältesten Einträge gelöscht. */ - public addLine(data: string): void { + public addData(data: string): number { const dataBytes = Buffer.byteLength(data, 'utf-8'); - this.buffer.push(data); + const entry: OutputEntry = { seq: this.nextSeq++, data }; + + this.buffer.push(entry); this.currentSizeBytes += dataBytes; @@ -25,15 +30,17 @@ export class OutputLineBuffer { const removed = this.buffer.shift(); if (removed !== undefined) { - this.currentSizeBytes -= Buffer.byteLength(removed, 'utf-8'); + this.currentSizeBytes -= Buffer.byteLength(removed.data, 'utf-8'); } } + + return entry.seq; } /** * Gibt alle gepufferten Daten als String zurück */ - public getLines(): string[] { + public getEntries(): OutputEntry[] { return [...this.buffer]; } @@ -51,6 +58,8 @@ export class OutputLineBuffer { this.buffer = []; this.currentSizeBytes = 0; + + this.nextSeq = 1; } } 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 b900fc2..91d670c 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 @@ -256,6 +256,18 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.mudService.sendMessage(payload); + // Save the current prompt to history for session persistence + const currentPrompt = this.promptManager.getCurrentPrompt(); + + if (typeof payload === 'string') { + // Persist with CRLF to match server formatting faithfully + const storedMessage = `${currentPrompt}${payload}\r\n`; + console.debug( + `[MudClient] Saving input with prompt: ${currentPrompt} ${message}`, + ); + this.outputHistoryService.appendInputLine(storedMessage); + } + // Clear helper textarea after commit this.updateHelperTextarea(''); } @@ -389,20 +401,19 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private loadHistoryIfAvailable(): void { console.log('[MudClient] Loading history from localStorage'); - const historyLines = this.outputHistoryService.loadLines(); + const entries = this.outputHistoryService.loadEntries(); - if (historyLines.length === 0) { + if (entries.length === 0) { console.log('[MudClient] No history found'); return; } - console.log( - `[MudClient] Restoring ${historyLines.length} lines from history`, - ); + console.log(`[MudClient] Restoring ${entries.length} entries from history`); - // Write all history lines to terminal - const historyData = historyLines.join(''); - this.terminal.write(historyData); + // Write all history entries to terminal in order + for (const entry of entries) { + this.terminal.write(entry.data); + } } /** diff --git a/frontend/src/app/features/sockets/sockets.service.ts b/frontend/src/app/features/sockets/sockets.service.ts index cde1c66..ac5279f 100644 --- a/frontend/src/app/features/sockets/sockets.service.ts +++ b/frontend/src/app/features/sockets/sockets.service.ts @@ -112,9 +112,22 @@ export class SocketsService { this.handleMudDisconnect(); }); - this.socket.on('mudOutput', (output: string) => { - this.handleMudOutput(output); - }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.socket as unknown as any).on( + 'mudOutput', + (output: string, seq: number) => { + this.handleMudOutput(output, seq); + }, + ); + + // Optional batch replay on reconnect + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.socket as unknown as any).on( + 'mudOutputBatch', + (entries: Array<{ data: string; seq: number }>) => { + this.handleMudOutputBatch(entries); + }, + ); this.socket.on('setEchoMode', (showEchos: boolean) => { this.handleSetEchoMode(showEchos); @@ -208,27 +221,45 @@ export class SocketsService { // Clear history when MUD connection is closed console.log('[Sockets] Clearing history after MUD disconnect'); - this.outputHistoryService.clearLines(); + this.outputHistoryService.clearAll(); this.connectedToMud.next(false); this.onMudDisconnect.emit(); }; - private handleMudOutput = (output: string) => { - this.onMudOutput.emit({ - data: output, - }); + private handleMudOutput = (output: string, seq?: number) => { + // Accept only if seq gating passes (or seq missing for compatibility) + if (typeof seq === 'number') { + const last = this.outputHistoryService.getLastSeqSeen(this.sessionToken); + if (seq <= last) { + // Old replay; drop silently + return; + } + // Persist and emit + this.outputHistoryService.appendServerEntry( + this.sessionToken, + output, + seq, + ); + } - // Save output to localStorage - this.outputHistoryService.appendLines([output]); + this.onMudOutput.emit({ data: output }); - // Flush queued input after receiving output (including buffered output after reconnect) if (this.inputQueue.length > 0) { this.flushInputQueue(); } }; + private handleMudOutputBatch = ( + entries: Array<{ data: string; seq: number }>, + ) => { + // Process in order; persist and emit only new ones + for (const { data, seq } of entries) { + this.handleMudOutput(data, seq); + } + }; + private handleClose() { console.log('[Sockets] Sockets-Service: Close'); diff --git a/frontend/src/app/shared/services/output-history.service.ts b/frontend/src/app/shared/services/output-history.service.ts index a7d0c02..2f9d5ff 100644 --- a/frontend/src/app/shared/services/output-history.service.ts +++ b/frontend/src/app/shared/services/output-history.service.ts @@ -3,6 +3,17 @@ import { Injectable } from '@angular/core'; const MAX_STORAGE_BYTES = 30 * 1024 * 1024; // 30MB const STORAGE_KEY = 'webmud3-history'; +export type HistoryEntry = + | { type: 'server'; data: string; seq: number; sessionToken: string } + | { type: 'input'; data: string }; + +type HistoryStore = { + entries: HistoryEntry[]; + meta: { + lastSeqSeenBySession: Record; + }; +}; + /** * Service for persisting MUD output history to localStorage. * Stores output as a simple string array. @@ -11,95 +22,84 @@ const STORAGE_KEY = 'webmud3-history'; providedIn: 'root', }) export class OutputHistoryService { - /** - * Saves output lines to localStorage. - * Enforces a ~30MB limit by removing oldest lines if needed. - */ - public saveLines(lines: string[]): void { - if (!this.isStorageAvailable()) { - console.warn('[OutputHistory] localStorage not available'); + // Public API for structured history + public loadEntries(): HistoryEntry[] { + const store = this.loadStore(); + return store.entries; + } + + public appendServerEntry( + sessionToken: string, + data: string, + seq: number, + ): void { + const store = this.loadStore(); + const last = store.meta.lastSeqSeenBySession[sessionToken] ?? 0; + + if (seq <= last) { + // Duplicate or old entry; ignore return; } - try { - const serialized = JSON.stringify(lines); + store.entries.push({ type: 'server', data, seq, sessionToken }); + store.meta.lastSeqSeenBySession[sessionToken] = seq; + this.saveStore(store); + } - // Check size limit - const sizeBytes = new Blob([serialized]).size; - if (sizeBytes > MAX_STORAGE_BYTES) { - console.warn( - `[OutputHistory] Data exceeds ${MAX_STORAGE_BYTES} bytes, trimming...`, - ); - const trimmedLines = this.trimToSize(lines, MAX_STORAGE_BYTES); - localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmedLines)); - } else { - localStorage.setItem(STORAGE_KEY, serialized); - } + public appendInputLine(line: string): void { + const store = this.loadStore(); + store.entries.push({ type: 'input', data: line }); + this.saveStore(store); + } - console.debug( - `[OutputHistory] Saved ${lines.length} lines (${sizeBytes} bytes)`, - ); - } catch (error) { - console.error('[OutputHistory] Failed to save lines:', error); - // If QuotaExceededError, try to trim - if ( - error instanceof DOMException && - error.name === 'QuotaExceededError' - ) { - this.handleQuotaExceeded(lines); - } - } + public getLastSeqSeen(sessionToken: string): number { + const store = this.loadStore(); + return store.meta.lastSeqSeenBySession[sessionToken] ?? 0; } - /** - * Loads output lines from localStorage. - * Returns an empty array if no data exists or an error occurs. - */ - public loadLines(): string[] { - if (!this.isStorageAvailable()) { - return []; - } + public setLastSeqSeen(sessionToken: string, seq: number): void { + const store = this.loadStore(); + store.meta.lastSeqSeenBySession[sessionToken] = seq; + this.saveStore(store); + } + public clearAll(): void { + if (!this.isStorageAvailable()) return; try { - const stored = localStorage.getItem(STORAGE_KEY); - - if (!stored) { - console.debug('[OutputHistory] No stored lines found'); - return []; - } - - const lines = JSON.parse(stored) as string[]; - console.debug(`[OutputHistory] Loaded ${lines.length} lines`); - return lines; + localStorage.removeItem(STORAGE_KEY); + console.debug('[OutputHistory] Cleared all entries'); } catch (error) { - console.error('[OutputHistory] Failed to load lines:', error); - return []; + console.error('[OutputHistory] Failed to clear entries:', error); } } - /** - * Clears all stored output lines. - */ - public clearLines(): void { - if (!this.isStorageAvailable()) { - return; - } + // Backward-compat wrappers (no-ops or adapters) + public saveLines(_lines: string[]): void { + // Deprecated: use structured API + console.warn('[OutputHistory] saveLines is deprecated'); + } - try { - localStorage.removeItem(STORAGE_KEY); - console.debug('[OutputHistory] Cleared all lines'); - } catch (error) { - console.error('[OutputHistory] Failed to clear lines:', error); - } + public loadLines(): string[] { + // Map structured entries back to flat strings + const entries = this.loadEntries(); + return entries.map((e) => e.data); + } + + public clearLines(): void { + this.clearAll(); } - /** - * Appends new lines to existing stored lines. - */ public appendLines(newLines: string[]): void { - const existingLines = this.loadLines(); - const combinedLines = [...existingLines, ...newLines]; - this.saveLines(combinedLines); + const store = this.loadStore(); + for (const line of newLines) { + store.entries.push({ + type: 'server', + data: line, + seq: 0, + sessionToken: '', + }); + } + this.saveStore(store); } /** @@ -120,41 +120,89 @@ export class OutputHistoryService { * Trims lines array to fit within the specified byte size. * Removes oldest lines (from the beginning) until size is acceptable. */ - private trimToSize(lines: string[], maxBytes: number): string[] { - let trimmedLines = [...lines]; - - while (trimmedLines.length > 0) { - const serialized = JSON.stringify(trimmedLines); - const sizeBytes = new Blob([serialized]).size; - - if (sizeBytes <= maxBytes) { - break; - } - - // Remove oldest 10% of lines at a time for efficiency - const removeCount = Math.max(1, Math.floor(trimmedLines.length * 0.1)); - trimmedLines = trimmedLines.slice(removeCount); + private trimStoreToSize(store: HistoryStore, maxBytes: number): HistoryStore { + let entries = [...store.entries]; + let serialized = JSON.stringify({ entries, meta: store.meta }); + let sizeBytes = new Blob([serialized]).size; + + while (entries.length > 0 && sizeBytes > maxBytes) { + const removeCount = Math.max(1, Math.floor(entries.length * 0.1)); + entries = entries.slice(removeCount); + serialized = JSON.stringify({ entries, meta: store.meta }); + sizeBytes = new Blob([serialized]).size; } console.debug( - `[OutputHistory] Trimmed from ${lines.length} to ${trimmedLines.length} lines`, + `[OutputHistory] Trimmed store to ${entries.length} entries (${sizeBytes} bytes)`, ); - return trimmedLines; + return { entries, meta: store.meta }; } /** * Handles QuotaExceededError by trimming lines and retrying. */ - private handleQuotaExceeded(lines: string[]): void { + private handleQuotaExceeded(store: HistoryStore): void { console.warn( '[OutputHistory] Quota exceeded, attempting to trim and retry', ); - const trimmedLines = this.trimToSize(lines, MAX_STORAGE_BYTES * 0.8); // Use 80% of limit + const trimmedStore = this.trimStoreToSize(store, MAX_STORAGE_BYTES * 0.8); // Use 80% of limit try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmedLines)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmedStore)); console.debug('[OutputHistory] Successfully saved after trimming'); } catch (error) { console.error('[OutputHistory] Failed even after trimming:', error); } } + + private loadStore(): HistoryStore { + if (!this.isStorageAvailable()) { + return { entries: [], meta: { lastSeqSeenBySession: {} } }; + } + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) { + return { entries: [], meta: { lastSeqSeenBySession: {} } }; + } + const parsed = JSON.parse(stored) as HistoryStore | string[]; + if (Array.isArray(parsed)) { + // Migrate from legacy string[] format + return { + entries: parsed.map((data) => ({ + type: 'server', + data, + seq: 0, + sessionToken: '', + })), + meta: { lastSeqSeenBySession: {} }, + }; + } + return parsed; + } catch (error) { + console.error('[OutputHistory] Failed to load store:', error); + return { entries: [], meta: { lastSeqSeenBySession: {} } }; + } + } + + private saveStore(store: HistoryStore): void { + if (!this.isStorageAvailable()) return; + try { + let toSave = store; + const serialized = JSON.stringify(toSave); + const sizeBytes = new Blob([serialized]).size; + if (sizeBytes > MAX_STORAGE_BYTES) { + console.warn('[OutputHistory] Store exceeds limit, trimming...'); + toSave = this.trimStoreToSize(store, MAX_STORAGE_BYTES); + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); + } catch (error) { + console.error('[OutputHistory] Failed to save store:', error); + if ( + error instanceof DOMException && + error.name === 'QuotaExceededError' + ) { + this.handleQuotaExceeded(store); + } + } + } } diff --git a/shared/src/sockets/server-to-client-events.ts b/shared/src/sockets/server-to-client-events.ts index 5a27d28..3d97875 100644 --- a/shared/src/sockets/server-to-client-events.ts +++ b/shared/src/sockets/server-to-client-events.ts @@ -7,7 +7,11 @@ export interface ServerToClientEvents { /** * Sends rendered MUD output to the connected client. */ - mudOutput: (data: string) => void; + mudOutput: (data: string, seq: number) => void; + /** + * Sends a batch of buffered outputs (used on reconnect recovery). + */ + mudOutputBatch?: (entries: Array<{ data: string; seq: number }>) => void; /** * Signals that the MUD connection was closed. */ From 3c0257e73050e5f94d9cde9864fbe04105167bd1 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 30 Jan 2026 23:39:17 +0100 Subject: [PATCH 68/77] feat(frontend): implement responsive font size adjustments for terminal based on viewport width --- .../mud-client/mud-client.component.html | 7 --- .../mud-client/mud-client.component.ts | 49 +++++++++++++++---- 2 files changed, 39 insertions(+), 17 deletions(-) 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 5c8144a..dee04d7 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 @@ -17,10 +17,3 @@ >
    - -@if (!(isConnected$ | async)) { -
    -

    Disconnected!

    - -
    -} 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 91d670c..8ae1b3a 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 @@ -6,7 +6,6 @@ import { OnDestroy, ViewChild, } from '@angular/core'; -import { AsyncPipe } from '@angular/common'; import { AttachAddon } from '@xterm/addon-attach'; import { FitAddon } from '@xterm/addon-fit'; import { IDisposable, Terminal } from '@xterm/xterm'; @@ -44,7 +43,6 @@ type MudClientState = { @Component({ selector: 'app-mud-client', standalone: true, - imports: [AsyncPipe], templateUrl: './mud-client.component.html', styleUrls: ['./mud-client.component.scss'], }) @@ -52,6 +50,18 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly mudService = inject(MudService); private readonly outputHistoryService = inject(OutputHistoryService); + private readonly fontSizeBreakpoints = [ + { minWidth: 0, fontSize: 8.5 }, // bis 360px + { minWidth: 380, fontSize: 9 }, + { minWidth: 420, fontSize: 10 }, + { minWidth: 470, fontSize: 11 }, + { minWidth: 520, fontSize: 12 }, + { minWidth: 570, fontSize: 13 }, + { minWidth: 620, fontSize: 14 }, + { minWidth: 670, fontSize: 15 }, + { minWidth: 720, fontSize: 16 }, + ]; + private readonly terminal: Terminal; private readonly inputController: MudInputController; private readonly promptManager: MudPromptManager; @@ -129,6 +139,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.historyRegionRef.nativeElement, this.inputRegionRef.nativeElement, ); + console.debug( '[MudClient] Screenreader announcer initialized, live region:', this.liveRegionRef.nativeElement, @@ -140,11 +151,14 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { beforeMessage: (data) => this.beforeMudOutput(data), afterMessage: (data) => this.afterMudOutput(data), }); + this.terminalAttachAddon = new AttachAddon( this.socketAdapter as unknown as WebSocket, { bidirectional: false }, ); + this.applyResponsiveFontSize(window.innerWidth); + this.terminal.open(this.terminalRef.nativeElement); this.terminal.loadAddon(this.terminalFitAddon); this.terminal.loadAddon(this.terminalAttachAddon); @@ -201,19 +215,12 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { 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.applyResponsiveFontSize(window.innerWidth); this.terminalFitAddon.fit(); const columns = this.terminal.cols; @@ -240,6 +247,28 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.mudService.updateViewportSize(columns, rows); } + private applyResponsiveFontSize(viewportWidth: number): void { + const nextFontSize = this.getFontSizeForWidth(viewportWidth); + + if (this.terminal.options.fontSize === nextFontSize) { + return; + } + + this.terminal.options.fontSize = nextFontSize; + } + + private getFontSizeForWidth(viewportWidth: number): number { + let match = this.fontSizeBreakpoints[0]?.fontSize ?? 14; + + for (const breakpoint of this.fontSizeBreakpoints) { + if (viewportWidth >= breakpoint.minWidth) { + match = breakpoint.fontSize; + } + } + + return match; + } + /** * Sends a committed line (or secure string) to the server. */ From 656ace02f46e815ef8407c9f1e461bcc4818214b Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 31 Jan 2026 00:33:56 +0100 Subject: [PATCH 69/77] feat(frontend): add audio feedback for terminal bell and unlock audio context on user interaction fixes #147 --- .../mud-client/mud-client.component.ts | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) 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 8ae1b3a..01d9f62 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 @@ -51,7 +51,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly outputHistoryService = inject(OutputHistoryService); private readonly fontSizeBreakpoints = [ - { minWidth: 0, fontSize: 8.5 }, // bis 360px + { minWidth: 0, fontSize: 8.5 }, { minWidth: 380, fontSize: 9 }, { minWidth: 420, fontSize: 10 }, { minWidth: 470, fontSize: 11 }, @@ -75,6 +75,10 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.handleTerminalResize(); }); + private audioContext?: AudioContext; + private audioUnlocked = false; + private lastBellTime = 0; + private showEchoSubscription?: Subscription; private linemodeSubscription?: Subscription; private state: MudClientState = { @@ -177,6 +181,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.terminalDisposables.push( this.terminal.onData((data) => this.handleInput(data)), + this.terminal.onBell(() => this.playBell()), ); this.showEchoSubscription = this.showEcho$.subscribe((showEcho) => { @@ -212,6 +217,13 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.terminalAttachAddon?.dispose(); this.socketAdapter?.dispose(); this.terminal.dispose(); + + // Close audio context if it was created + if (this.audioContext && this.audioContext.state !== 'closed') { + this.audioContext.close().catch(() => { + // Ignore errors on close + }); + } this.screenReader?.dispose(); } @@ -316,6 +328,11 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { * or through the {@link MudInputController}. */ private handleInput(data: string) { + // Unlock audio context on first user interaction (browser autoplay policy) + if (!this.audioUnlocked) { + this.unlockAudio(); + } + if (!this.state.isEditMode) { if (data.length > 0) { const rewritten = this.rewriteBackspaceToDelete(data); @@ -445,6 +462,72 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { } } + /** + * Plays a short synthesized beep using AudioContext. + * Implements debouncing to prevent bell spam (100ms minimum interval). + */ + private playBell(): void { + const BELL_DEBOUNCE_MS = 100; + const now = Date.now(); + + // Ignore bells within the debounce window + if (now - this.lastBellTime < BELL_DEBOUNCE_MS) { + return; + } + + this.lastBellTime = now; + + if (!this.audioContext || this.audioContext.state === 'closed') { + return; + } + + try { + const oscillator = this.audioContext.createOscillator(); + const gainNode = this.audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(this.audioContext.destination); + + oscillator.frequency.value = 800; // Frequency in Hz + gainNode.gain.value = 0.3; // Volume (0-1) + + const startTime = this.audioContext.currentTime; + oscillator.start(startTime); + oscillator.stop(startTime + 0.1); // Duration 100ms + } catch (err) { + console.debug('[MudClient] Bell playback failed:', err); + } + } + + /** + * Initializes the AudioContext to comply with browser autoplay policy. + * Must be called in response to a user gesture (e.g., first keypress). + */ + private unlockAudio(): void { + try { + if (!this.audioContext) { + this.audioContext = new (window.AudioContext || + (window as any).webkitAudioContext)(); + } + + if (this.audioContext.state === 'suspended') { + this.audioContext.resume().catch((err) => { + console.debug('[MudClient] Audio context resume failed:', err); + }); + } + + this.audioUnlocked = true; + } catch (err) { + console.debug('[MudClient] Audio context initialization failed:', err); + this.audioUnlocked = false; + } + } + + /** + * this.terminal.write(entry.data); + } + } + /** * Maps DEL to BACKSPACE for non-edit mode */ From 38256014aa2d8d2288a08f9da6a65c86a8dfaea4 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 31 Jan 2026 00:51:46 +0100 Subject: [PATCH 70/77] feat(frontend): add visibility change listener to resume audio context when tab is active fixes #147 --- .../mud-client/mud-client.component.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 01d9f62..dd64d11 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 @@ -79,6 +79,14 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private audioUnlocked = false; private lastBellTime = 0; + private readonly handleVisibilityChange = (): void => { + if (!document.hidden && this.audioContext?.state === 'interrupted') { + this.audioContext.resume().catch(() => { + // Ignore errors on resume + }); + } + }; + private showEchoSubscription?: Subscription; private linemodeSubscription?: Subscription; private state: MudClientState = { @@ -195,6 +203,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.resizeObs.observe(this.terminalRef.nativeElement); this.setState({ terminalReady: true }); + // Register visibility change listener to resume audio context when tab becomes visible + document.addEventListener('visibilitychange', this.handleVisibilityChange); + // Load history BEFORE connecting to MUD to ensure it appears before new output this.loadHistoryIfAvailable(); @@ -210,6 +221,12 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { ngOnDestroy() { this.resizeObs.disconnect(); + // Unregister visibility change listener + document.removeEventListener( + 'visibilitychange', + this.handleVisibilityChange, + ); + this.terminalDisposables.forEach((disposable) => disposable.dispose()); this.showEchoSubscription?.unsubscribe(); this.linemodeSubscription?.unsubscribe(); From baca149ba106ddcd89e510755dd630a783d34bcc Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 31 Jan 2026 01:01:59 +0100 Subject: [PATCH 71/77] feat(frontend): enhance bell playback with audio context resume handling and improved volume control --- .../mud-client/mud-client.component.ts | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) 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 dd64d11..2829349 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 @@ -483,34 +483,46 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { * Plays a short synthesized beep using AudioContext. * Implements debouncing to prevent bell spam (100ms minimum interval). */ - private playBell(): void { + private async playBell(): Promise { const BELL_DEBOUNCE_MS = 100; const now = Date.now(); - - // Ignore bells within the debounce window - if (now - this.lastBellTime < BELL_DEBOUNCE_MS) { - return; - } - + if (now - this.lastBellTime < BELL_DEBOUNCE_MS) return; this.lastBellTime = now; - if (!this.audioContext || this.audioContext.state === 'closed') { - return; - } + const ctx = this.audioContext; + if (!ctx || ctx.state === 'closed') return; try { - const oscillator = this.audioContext.createOscillator(); - const gainNode = this.audioContext.createGain(); + // Wichtig: suspended behandeln + if (ctx.state === 'suspended') { + await ctx.resume(); // kann in manchen Browsern ohne User-Geste fehlschlagen + } + + // Falls resume nicht geklappt hat + if (ctx.state !== 'running') return; + + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); oscillator.connect(gainNode); - gainNode.connect(this.audioContext.destination); + gainNode.connect(ctx.destination); + + oscillator.frequency.value = 800; + + const t = ctx.currentTime; + + // Kleine Lautstärke-Hüllkurve (Attack/Decay), um Klickgeräusche beim Starten/Stoppen zu vermeiden + gainNode.gain.setValueAtTime(0.0001, t); // leise starten + gainNode.gain.exponentialRampToValueAtTime(0.3, t + 0.005); // schneller Attack + gainNode.gain.exponentialRampToValueAtTime(0.0001, t + 0.1); // sanfter Fade-out - oscillator.frequency.value = 800; // Frequency in Hz - gainNode.gain.value = 0.3; // Volume (0-1) + oscillator.start(t); + oscillator.stop(t + 0.11); - const startTime = this.audioContext.currentTime; - oscillator.start(startTime); - oscillator.stop(startTime + 0.1); // Duration 100ms + oscillator.onended = () => { + oscillator.disconnect(); + gainNode.disconnect(); + }; } catch (err) { console.debug('[MudClient] Bell playback failed:', err); } From ca0c346694d62dab7add594d5db9891f60a27457 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 31 Jan 2026 01:19:27 +0100 Subject: [PATCH 72/77] feat(frontend): enhance history loading by updating screenreader history and resetting session timestamp --- .../mud/components/mud-client/mud-client.component.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 2829349..5f9ebed 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 @@ -460,6 +460,8 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { /** * Loads and displays saved output history if available. + * Fills the screenreader history region with old entries (silently, no announcement). + * Resets the screenreader session timestamp so new output isn't filtered as "too old". */ private loadHistoryIfAvailable(): void { console.log('[MudClient] Loading history from localStorage'); @@ -476,7 +478,16 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { // Write all history entries to terminal in order for (const entry of entries) { this.terminal.write(entry.data); + + // Also append to screenreader history region (silent, no live announcement) + this.screenReader?.appendToHistory(entry.data); } + + // Reset screenreader session timestamp after history load + // This ensures new incoming data won't be filtered as "too old" + this.screenReader?.markSessionStart(); + + console.log('[MudClient] History loaded and screenreader session reset'); } /** From b8ab0c91438c18491b7bad9cf05427c70e68ca2f Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 31 Jan 2026 01:49:05 +0100 Subject: [PATCH 73/77] fix(frontend): clearing the log erases the SR --- .../mud/components/mud-client/mud-client.component.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 5f9ebed..8800d19 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 @@ -483,11 +483,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.screenReader?.appendToHistory(entry.data); } - // Reset screenreader session timestamp after history load - // This ensures new incoming data won't be filtered as "too old" - this.screenReader?.markSessionStart(); - - console.log('[MudClient] History loaded and screenreader session reset'); + console.log( + '[MudClient] History loaded to terminal and screenreader history', + ); } /** From 9673a1475642017327a481891d5b6b65590933c2 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 31 Jan 2026 02:15:55 +0100 Subject: [PATCH 74/77] test(frontend): validation without recovery --- .../app/core/mud/components/mud-client/mud-client.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8800d19..0fd030e 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 @@ -480,7 +480,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.terminal.write(entry.data); // Also append to screenreader history region (silent, no live announcement) - this.screenReader?.appendToHistory(entry.data); + // this.screenReader?.appendToHistory(entry.data); } console.log( From d8d4b6e143afa374c1d4c05d4d0e5196bc886cab Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 31 Jan 2026 22:26:15 +0100 Subject: [PATCH 75/77] feat(frontend): add clipboard support to terminal for enhanced paste functionality --- frontend/package.json | 6 +- .../mud-client/mud-client.component.ts | 101 ++++++++++++++++++ package-lock.json | 37 +++++-- 3 files changed, 136 insertions(+), 8 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index d6e0684..37ebbd4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,7 +3,10 @@ "version": "1.0.0-alpha", "description": "Webmud3 Frontend", "license": "MIT", - "authors": ["Myonara", "Felag"], + "authors": [ + "Myonara", + "Felag" + ], "engines": { "node": "~22.20.0" }, @@ -34,6 +37,7 @@ "@angular/platform-browser-dynamic": "~20.3.4", "@angular/router": "~20.3.4", "@xterm/addon-attach": "~0.11.0", + "@xterm/addon-clipboard": "~0.2.0", "@xterm/addon-fit": "~0.10.0", "@xterm/xterm": "~5.5.0", "normalize.css": "~8.0.1", 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 0fd030e..e2a9c1a 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 @@ -7,6 +7,7 @@ import { ViewChild, } from '@angular/core'; import { AttachAddon } from '@xterm/addon-attach'; +import { ClipboardAddon } from '@xterm/addon-clipboard'; import { FitAddon } from '@xterm/addon-fit'; import { IDisposable, Terminal } from '@xterm/xterm'; import { Subscription } from 'rxjs'; @@ -66,6 +67,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly inputController: MudInputController; private readonly promptManager: MudPromptManager; private screenReader?: MudScreenReaderAnnouncer; + private readonly terminalClipboardAddon = new ClipboardAddon(); private readonly terminalFitAddon = new FitAddon(); private socketAdapter?: MudSocketAdapter; private terminalAttachAddon?: AttachAddon; @@ -89,6 +91,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private showEchoSubscription?: Subscription; private linemodeSubscription?: Subscription; + private pasteHandler?: (event: ClipboardEvent) => void; private state: MudClientState = { isEditMode: true, showEcho: true, @@ -172,6 +175,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.applyResponsiveFontSize(window.innerWidth); this.terminal.open(this.terminalRef.nativeElement); + this.terminal.loadAddon(this.terminalClipboardAddon); this.terminal.loadAddon(this.terminalFitAddon); this.terminal.loadAddon(this.terminalAttachAddon); this.terminal.focus(); @@ -187,6 +191,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.helperTextarea.setAttribute('aria-label', 'Eingabe'); } + // Set up paste handler on terminal container (for native paste events) + this.setupPasteHandler(this.terminalRef.nativeElement); + this.terminalDisposables.push( this.terminal.onData((data) => this.handleInput(data)), this.terminal.onBell(() => this.playBell()), @@ -227,10 +234,22 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.handleVisibilityChange, ); + // Unregister paste handler + if (this.pasteHandler) { + if (this.terminalRef?.nativeElement) { + this.terminalRef.nativeElement.removeEventListener( + 'paste', + this.pasteHandler, + ); + } + document.removeEventListener('paste', this.pasteHandler); + } + this.terminalDisposables.forEach((disposable) => disposable.dispose()); this.showEchoSubscription?.unsubscribe(); this.linemodeSubscription?.unsubscribe(); + this.terminalClipboardAddon.dispose(); this.terminalAttachAddon?.dispose(); this.socketAdapter?.dispose(); this.terminal.dispose(); @@ -343,8 +362,23 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { /** * Routes terminal keystrokes either directly to the socket (when not in edit mode) * or through the {@link MudInputController}. + * Special handling for Ctrl+V: intercepts clipboard content and injects it properly. */ private handleInput(data: string) { + console.log('[PASTE-DEBUG] Edit mode:', this.state.isEditMode); + console.log('[PASTE-DEBUG] Data received:', JSON.stringify(data)); + console.log('[PASTE-DEBUG] Data length:', data.length); + + // Special handling for Ctrl+V (paste): xterm converts paste to \u0016 in onData() + // We need to read the clipboard and inject the actual content + if (data === '\u0016') { + console.log( + '[MudClient] Ctrl+V detected, reading clipboard from native event...', + ); + this.handlePasteFromClipboard(); + return; + } + // Unlock audio context on first user interaction (browser autoplay policy) if (!this.audioUnlocked) { this.unlockAudio(); @@ -595,4 +629,71 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.helperTextarea.value = `${prompt}${effectiveBuffer ?? ''}`; } + + /** + * Sets up a paste event handler to intercept native clipboard paste operations. + * This method listens on the terminal container for paste events that come + * directly from the browser (Ctrl+V, right-click paste, etc.) without requiring + * explicit clipboard API permissions. + * + * The paste event automatically includes clipboard access through event.clipboardData, + * so no navigator.clipboard.readText() call is needed. + */ + private setupPasteHandler(element: HTMLElement): void { + this.pasteHandler = (event: ClipboardEvent) => { + console.log('[MudClient] Native paste event intercepted'); + + // Don't prevent default for now - let xterm handle the visual part + // We'll just read the clipboard data and inject it properly + const pastedText = event.clipboardData?.getData('text/plain'); + + console.log('[MudClient] Clipboard content:', { + length: pastedText?.length ?? 0, + preview: pastedText?.substring(0, 50), + }); + + if (pastedText) { + // Prevent the default onData behavior (which sends \u0016 only) + event.preventDefault(); + + if (!this.state.isEditMode) { + // In non-edit mode, send paste content directly to server + this.mudService.sendMessage(pastedText); + } else { + // In edit mode, route through input controller for buffering and echo + this.inputController.handleData(pastedText); + } + } + }; + + element.addEventListener('paste', this.pasteHandler); + } + + /** + * Handles paste by reading clipboard content when Ctrl+V is detected. + * Uses the Clipboard API which is safe to call here since it's triggered + * by a user gesture (Ctrl+V keypress). + */ + private async handlePasteFromClipboard(): Promise { + try { + const pastedText = await navigator.clipboard.readText(); + + console.log('[MudClient] Clipboard content read:', { + length: pastedText.length, + preview: pastedText.substring(0, 50), + }); + + if (pastedText) { + if (!this.state.isEditMode) { + // In non-edit mode, send paste content directly to server + this.mudService.sendMessage(pastedText); + } else { + // In edit mode, route through input controller for buffering and echo + this.inputController.handleData(pastedText); + } + } + } catch (err) { + console.error('[MudClient] Failed to read clipboard:', err); + } + } } diff --git a/package-lock.json b/package-lock.json index 258e792..af53992 100644 --- a/package-lock.json +++ b/package-lock.json @@ -246,6 +246,7 @@ "@angular/router": "~20.3.4", "@webmud3/shared": "1.0.0-alpha", "@xterm/addon-attach": "~0.11.0", + "@xterm/addon-clipboard": "~0.2.0", "@xterm/addon-fit": "~0.10.0", "@xterm/xterm": "~5.5.0", "normalize.css": "~8.0.1", @@ -286,13 +287,6 @@ "undici-types": "~7.14.0" } }, - "frontend/node_modules/@xterm/xterm": { - "version": "5.5.0", - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, "frontend/node_modules/eslint": { "version": "9.37.0", "dev": true, @@ -6007,10 +6001,25 @@ "version": "0.11.0", "license": "MIT" }, + "node_modules/@xterm/addon-clipboard": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz", + "integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.10.0", "license": "MIT" }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "dev": true, @@ -14165,6 +14174,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "dev": true, @@ -20752,6 +20767,14 @@ "version": "1.0.0-alpha", "license": "MIT", "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" } From 0e1041bdf0131e4fa7e441e8cc90624d113b4891 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 31 Jan 2026 22:36:50 +0100 Subject: [PATCH 76/77] feat(frontend): enhance history logging by splitting multiline output into separate entries (screenreader) --- .../mud-client/mud-client.component.scss | 1 + .../terminal/mud-screenreader.spec.ts | 101 ++++++++++++++++++ .../app/features/terminal/mud-screenreader.ts | 20 +++- 3 files changed, 117 insertions(+), 5 deletions(-) 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 07da8d0..1f269d2 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 @@ -10,6 +10,7 @@ min-height: 0; overflow: hidden; z-index: 1; + padding-left: 8px; } .sr-announcer { diff --git a/frontend/src/app/features/terminal/mud-screenreader.spec.ts b/frontend/src/app/features/terminal/mud-screenreader.spec.ts index ed2f376..035b5b2 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.spec.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.spec.ts @@ -70,3 +70,104 @@ describe('MudScreenReaderAnnouncer', () => { expect(liveRegion.textContent).toBe(''); }); }); + +describe('MudScreenReaderAnnouncer - appendToHistory', () => { + let historyRegion: HTMLElement; + let announcer: MudScreenReaderAnnouncer; + + beforeEach(() => { + historyRegion = document.createElement('div'); + announcer = new MudScreenReaderAnnouncer( + document.createElement('div'), + historyRegion, + ); + }); + + afterEach(() => { + announcer.dispose(); + }); + + it('splits multiline output into separate log items', () => { + announcer.appendToHistory('Line 1\nLine 2\nLine 3'); + + const items = historyRegion.querySelectorAll('p.sr-log-item'); + expect(items.length).toBe(3); + expect(items[0].textContent).toBe('Line 1'); + expect(items[1].textContent).toBe('Line 2'); + expect(items[2].textContent).toBe('Line 3'); + }); + + it('skips empty lines', () => { + announcer.appendToHistory('Line 1\n\nLine 3\n'); + + const items = historyRegion.querySelectorAll('p.sr-log-item'); + expect(items.length).toBe(2); + expect(items[0].textContent).toBe('Line 1'); + expect(items[1].textContent).toBe('Line 3'); + }); + + it('skips lines with only whitespace', () => { + announcer.appendToHistory('Line 1\n \n\t\nLine 4'); + + const items = historyRegion.querySelectorAll('p.sr-log-item'); + expect(items.length).toBe(2); + expect(items[0].textContent).toBe('Line 1'); + expect(items[1].textContent).toBe('Line 4'); + }); + + it('handles CRLF line endings correctly', () => { + announcer.appendToHistory('Line 1\r\nLine 2\r\nLine 3'); + + const items = historyRegion.querySelectorAll('p.sr-log-item'); + expect(items.length).toBe(3); + expect(items[0].textContent).toBe('Line 1'); + expect(items[1].textContent).toBe('Line 2'); + expect(items[2].textContent).toBe('Line 3'); + }); + + it('strips ANSI escape sequences from each line', () => { + announcer.appendToHistory( + 'Line 1 \x1b[31mRed\x1b[0m\nLine 2 \x1b[32mGreen\x1b[0m', + ); + + const items = historyRegion.querySelectorAll('p.sr-log-item'); + expect(items.length).toBe(2); + expect(items[0].textContent).toBe('Line 1 Red'); + expect(items[1].textContent).toBe('Line 2 Green'); + }); + + it('sets role="text" on each log item', () => { + announcer.appendToHistory('Line 1\nLine 2'); + + const items = historyRegion.querySelectorAll('p.sr-log-item'); + expect(items[0].getAttribute('role')).toBe('text'); + expect(items[1].getAttribute('role')).toBe('text'); + }); + + it('ignores empty normalized output', () => { + announcer.appendToHistory('\x1b[31m\x1b[0m\r\n\x1b[32m\x1b[0m'); + + const items = historyRegion.querySelectorAll('p.sr-log-item'); + expect(items.length).toBe(0); + }); + + it('does nothing when history region is not provided', () => { + const announcer2 = new MudScreenReaderAnnouncer( + document.createElement('div'), + undefined, // no history region + ); + + // Should not throw + announcer2.appendToHistory('Line 1\nLine 2'); + }); + + it('collapses excessive blank lines before splitting', () => { + announcer.appendToHistory('Line 1\n\n\n\nLine 5'); + + const items = historyRegion.querySelectorAll('p.sr-log-item'); + // Should be 3 items: 'Line 1', '', 'Line 5' -> after filtering empty: 2 items + expect(items.length).toBe(2); + expect(items[0].textContent).toBe('Line 1'); + expect(items[1].textContent).toBe('Line 5'); + }); +}); diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 0e703ff..c9e37b8 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -90,6 +90,8 @@ export class MudScreenReaderAnnouncer { /** * Appends sanitized text to the history region so users can navigate it later. + * Splits text by newlines to create separate entries for each line, allowing screen readers + * to announce each line individually rather than reading the entire block as one. */ public appendToHistory(raw: string): void { if (!this.historyRegion) { @@ -102,13 +104,21 @@ export class MudScreenReaderAnnouncer { } const doc = this.historyRegion.ownerDocument; + const lines = normalized.split('\n'); - const item = doc.createElement('p'); - item.className = 'sr-log-item'; - item.textContent = normalized; - item.role = 'text'; + for (const line of lines) { + // Skip empty lines to avoid cluttering the history + if (!line.trim()) { + continue; + } + + const item = doc.createElement('p'); + item.className = 'sr-log-item'; + item.textContent = line; + item.setAttribute('role', 'text'); - this.historyRegion.appendChild(item); + this.historyRegion.appendChild(item); + } } /** From fc628e2ac21c2633fc9a6d8df66e3bfe47b4add2 Mon Sep 17 00:00:00 2001 From: myst Date: Sat, 31 Jan 2026 22:43:08 +0100 Subject: [PATCH 77/77] feat(frontend): fixed smaller css issues --- .../mud-client/mud-client.component.scss | 2 +- .../mud-client/mud-client.component.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) 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 1f269d2..6fa35f5 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 @@ -40,7 +40,7 @@ .sr-history { min-height: 0; width: 100%; - height: 100vh; + height: 100%; overflow: auto; position: absolute; top: 0; 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 e2a9c1a..4ac544c 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 @@ -53,14 +53,14 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly fontSizeBreakpoints = [ { minWidth: 0, fontSize: 8.5 }, - { minWidth: 380, fontSize: 9 }, - { minWidth: 420, fontSize: 10 }, - { minWidth: 470, fontSize: 11 }, - { minWidth: 520, fontSize: 12 }, - { minWidth: 570, fontSize: 13 }, - { minWidth: 620, fontSize: 14 }, - { minWidth: 670, fontSize: 15 }, - { minWidth: 720, fontSize: 16 }, + { minWidth: 420, fontSize: 9 }, + { minWidth: 470, fontSize: 10 }, + { minWidth: 520, fontSize: 11 }, + { minWidth: 570, fontSize: 12 }, + { minWidth: 620, fontSize: 13 }, + { minWidth: 670, fontSize: 14 }, + { minWidth: 720, fontSize: 15 }, + { minWidth: 770, fontSize: 16 }, ]; private readonly terminal: Terminal;