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 dee04d7..20b818b 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,4 +1,11 @@
-
+
{
@@ -326,9 +327,13 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
: { value: message };
if (typeof payload === 'string') {
+ const normalizedInput =
+ this.screenReader?.normalizeForComparison(payload);
+
+ this.pendingEchoSuppression = normalizedInput?.length
+ ? normalizedInput
+ : null;
this.screenReader?.appendToHistory(payload);
- // Announce the complete input so user can verify what they typed
- this.screenReader?.announceInputCommitted(payload);
}
this.mudService.sendMessage(payload);
@@ -477,6 +482,20 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
return;
}
+ const normalizedOutput = this.screenReader.normalizeForComparison(data);
+
+ if (
+ this.pendingEchoSuppression &&
+ normalizedOutput === this.pendingEchoSuppression
+ ) {
+ this.pendingEchoSuppression = null;
+ return;
+ }
+
+ if (normalizedOutput) {
+ this.pendingEchoSuppression = null;
+ }
+
console.debug('[MudClient] Announcing to screenreader:', {
rawLength: data.length,
raw: data,
diff --git a/frontend/src/app/features/terminal/mud-screenreader.spec.ts b/frontend/src/app/features/terminal/mud-screenreader.spec.ts
index 035b5b2..b859902 100644
--- a/frontend/src/app/features/terminal/mud-screenreader.spec.ts
+++ b/frontend/src/app/features/terminal/mud-screenreader.spec.ts
@@ -5,32 +5,18 @@ describe('MudScreenReaderAnnouncer', () => {
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,
- undefined,
- 100,
- );
+ announcer = new MudScreenReaderAnnouncer(liveRegion);
});
afterEach(() => {
announcer.dispose();
- jest.useRealTimers();
});
- it('announces sanitized text and clears after delay', () => {
+ it('announces sanitized text by appending to the live region', () => {
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('');
+ expect(liveRegion.textContent).toBe('Hello World\n');
});
it('ignores announcements older than the current session', () => {
@@ -43,19 +29,6 @@ describe('MudScreenReaderAnnouncer', () => {
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');
@@ -64,6 +37,16 @@ describe('MudScreenReaderAnnouncer', () => {
expect(liveRegion.textContent).toBe('');
});
+ it('stopAnnouncements() clears live region and backlog', () => {
+ announcer.announce('First');
+ announcer.announce('Second');
+
+ expect(liveRegion.textContent).toBe('First\nSecond\n');
+
+ announcer.stopAnnouncements();
+ expect(liveRegion.textContent).toBe('');
+ });
+
it('ignores empty output after normalization', () => {
announcer.announce('\x1b[31m\x1b[0m');
diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts
index c9e37b8..4fdb852 100644
--- a/frontend/src/app/features/terminal/mud-screenreader.ts
+++ b/frontend/src/app/features/terminal/mud-screenreader.ts
@@ -1,4 +1,3 @@
-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;
@@ -12,7 +11,6 @@ const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g;
* - 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 = '';
@@ -21,7 +19,6 @@ export class MudScreenReaderAnnouncer {
private readonly liveRegion: HTMLElement,
private readonly historyRegion?: HTMLElement,
private readonly inputRegion?: HTMLElement,
- private readonly clearDelayMs: number = DEFAULT_CLEAR_DELAY_MS,
) {
this.sessionStartedAt = Date.now();
}
@@ -31,7 +28,7 @@ export class MudScreenReaderAnnouncer {
*/
public markSessionStart(timestamp: number = Date.now()): void {
this.sessionStartedAt = timestamp;
- this.clear();
+ this.stopAnnouncements();
this.clearHistory();
this.lastAnnouncedBuffer = '';
}
@@ -64,12 +61,7 @@ export class MudScreenReaderAnnouncer {
return;
}
- this.liveRegion.textContent = normalized;
- console.debug(
- '[ScreenReader] Live region updated:',
- this.liveRegion.textContent,
- );
- this.scheduleClear();
+ this.appendToLiveRegion(normalized);
}
/**
@@ -77,14 +69,20 @@ export class MudScreenReaderAnnouncer {
*/
public clear(): void {
this.liveRegion.textContent = '';
- this.cancelClearTimer();
+ }
+
+ /**
+ * Stops any in-flight announcements and drops the queued backlog.
+ */
+ public stopAnnouncements(): void {
+ this.clear();
}
/**
* Disposes internal timers.
*/
public dispose(): void {
- this.clear();
+ this.stopAnnouncements();
this.cancelInputClearTimer();
}
@@ -272,19 +270,13 @@ export class MudScreenReaderAnnouncer {
this.lastAnnouncedBuffer = '';
}
- private scheduleClear(): void {
- this.cancelClearTimer();
-
- this.clearTimer = window.setTimeout(() => {
- this.clear();
- }, this.clearDelayMs);
+ private appendToLiveRegion(normalized: string): void {
+ const doc = this.liveRegion.ownerDocument;
+ this.liveRegion.appendChild(doc.createTextNode(`${normalized}\n`));
}
- private cancelClearTimer(): void {
- if (this.clearTimer !== undefined) {
- window.clearTimeout(this.clearTimer);
- this.clearTimer = undefined;
- }
+ public normalizeForComparison(raw: string): string {
+ return this.normalize(raw);
}
// Input clear helpers are retained for potential future use (currently unused)