From db69a613a69a2da80869ea03ab04a61c69c64ef8 Mon Sep 17 00:00:00 2001 From: myst Date: Sun, 19 Oct 2025 01:48:35 +0200 Subject: [PATCH 01/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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 3a5916484d80dcbeb30bdf952706d32b70a5c0d7 Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 23 Jan 2026 16:37:27 +0100 Subject: [PATCH 15/28] 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 16/28] 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 17/28] 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 18/28] 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 19/28] 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 20/28] 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 21/28] 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 22/28] 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 23/28] 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 24/28] 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 25/28] 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 26/28] 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 27/28] 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 28/28] 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; }