diff --git a/CLAUDE.md b/CLAUDE.md index 804fb5ef..22cb4164 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1497 (must match `package.json` for npm publish) +**Version**: 0.1498 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index c59fbadf..ea6eabdc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1497", + "version": "0.1498", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index 1abf3599..0ecf9638 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1242,6 +1242,11 @@ class ClaudemanApp { // Sequential input send chain — ensures keystroke ordering across async fetches this._inputSendChain = Promise.resolve(); + // Speculative local echo (mosh-style) — echo printable chars immediately, + // strip redundant server echo when it arrives. Only active during idle state. + this._localEchoBuffer = ''; + this._localEchoTimeout = null; + // Accessibility: Focus trap for modals this.activeFocusTrap = null; @@ -1571,6 +1576,24 @@ class ClaudemanApp { // Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR) if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return; + // Speculative local echo: show printable chars instantly during idle state + const session = this.sessions.get(this.activeSessionId); + const isIdle = session && session.status === 'idle'; + if (isIdle && data.length === 1) { + const code = data.charCodeAt(0); + if (code >= 32 && code < 127) { + // Printable ASCII — echo immediately, strip server echo later + this.terminal.write(data); + this._localEchoBuffer += data; + this._resetLocalEchoTimeout(); + } else if ((code === 0x7f || code === 0x08) && this._localEchoBuffer.length > 0) { + // Backspace — undo last local echo char + this._localEchoBuffer = this._localEchoBuffer.slice(0, -1); + this.terminal.write('\b \b'); + this._resetLocalEchoTimeout(); + } + } + this._pendingInput += data; // Flush immediately for control characters (Enter, Ctrl+C, etc.) @@ -1843,7 +1866,13 @@ class ClaudemanApp { // xterm.js internally batches multiple write() calls within same frame for (const segment of segments) { if (segment && !segment.startsWith(DEC_SYNC_START)) { - this.terminal.write(segment); + // Strip locally-echoed characters from server output to prevent doubles + if (this._localEchoBuffer.length > 0) { + const stripped = this._stripLocalEcho(segment); + if (stripped) this.terminal.write(stripped); + } else { + this.terminal.write(segment); + } } } @@ -2250,6 +2279,11 @@ class ClaudemanApp { const session = this.sessions.get(data.id); if (session) { session.status = 'busy'; + // Clear local echo buffer — server is now generating output + if (data.id === this.activeSessionId) { + this._localEchoBuffer = ''; + this._clearLocalEchoTimeout(); + } // Only clear tab alert if no pending hooks (permission_prompt, elicitation_dialog, etc.) if (!this.pendingHooks.has(data.id)) { this.tabAlerts.delete(data.id); @@ -2994,6 +3028,67 @@ class ClaudemanApp { }); } + /** + * Strip locally-echoed characters from server output to prevent doubles. + * Walks output char-by-char, skipping ANSI escape sequences (passed through), + * and consuming matching chars from the echo buffer. + */ + _stripLocalEcho(serverOutput) { + let echoIdx = 0; + let outIdx = 0; + let result = ''; + + while (outIdx < serverOutput.length && echoIdx < this._localEchoBuffer.length) { + // Skip ANSI escape sequences — pass them through to output + if (serverOutput[outIdx] === '\x1b') { + const seqStart = outIdx; + outIdx++; // skip ESC + // CSI sequence: ESC [ ... + if (outIdx < serverOutput.length && serverOutput[outIdx] === '[') { + outIdx++; + while (outIdx < serverOutput.length && !/[A-Za-z]/.test(serverOutput[outIdx])) outIdx++; + if (outIdx < serverOutput.length) outIdx++; // include final letter + } + result += serverOutput.slice(seqStart, outIdx); + continue; + } + + if (serverOutput[outIdx] === this._localEchoBuffer[echoIdx]) { + // Match — consume from echo buffer, skip in output (already displayed) + echoIdx++; + outIdx++; + } else { + // Mismatch — stop stripping, pass rest through + break; + } + } + + // Consumed echoIdx chars from buffer + this._localEchoBuffer = this._localEchoBuffer.slice(echoIdx); + if (this._localEchoBuffer.length === 0) this._clearLocalEchoTimeout(); + + // Append any remaining server output + result += serverOutput.slice(outIdx); + return result; + } + + /** + * Reset the local echo safety timeout. If the echo buffer isn't consumed + * within 3 seconds (e.g., dropped connection), clear it to prevent stuck state. + */ + _resetLocalEchoTimeout() { + clearTimeout(this._localEchoTimeout); + this._localEchoTimeout = setTimeout(() => { + this._localEchoBuffer = ''; + this._localEchoTimeout = null; + }, 3000); + } + + _clearLocalEchoTimeout() { + clearTimeout(this._localEchoTimeout); + this._localEchoTimeout = null; + } + _enqueueInput(sessionId, input) { const existing = this._inputQueue.get(sessionId) || ''; let combined = existing + input;