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