diff --git a/CLAUDE.md b/CLAUDE.md index ae294e9a..800830d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,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.1585 (must match `package.json` for npm publish) +**Version**: 0.1586 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 2704fb81..a5a4818c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1585", + "version": "0.1586", "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 147683d0..3f5b515c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -894,6 +894,7 @@ class LocalEchoOverlay { } screen.appendChild(this.overlay); this.pendingText = ''; + this._flushedOffset = 0; // chars flushed to PTY during tab switch (echo may not have arrived yet) this._storageKey = 'claudeman_local_echo_pending'; // Restore unsent input from previous session (survives reload/disconnect) try { @@ -960,6 +961,7 @@ class LocalEchoOverlay { clear() { this.pendingText = ''; + this._flushedOffset = 0; this._persist(); this._lastRenderKey = ''; this._lastPromptPos = null; @@ -1015,12 +1017,29 @@ class LocalEchoOverlay { const cellW = dims.css.cell.width; const cellH = dims.css.cell.height; const totalCols = this.terminal.cols; - // Position right after "❯ " (prompt col + 2) - const startCol = Math.min(activePrompt.col + 2, totalCols - 1); + // Position after existing text on the prompt line. Normally this is + // just "❯ " (col+2), but after a tab switch the flushed text is echoed + // by the PTY and sits in the terminal buffer — the overlay must start + // AFTER it to avoid covering it with its opaque background. + // _flushedOffset provides immediate offset even before echo arrives. + let startCol = activePrompt.col + 2 + (this._flushedOffset || 0); + try { + const buf = this.terminal.buffer.active; + const promptLine = buf.getLine(buf.viewportY + activePrompt.row); + if (promptLine) { + const existingEnd = promptLine.translateToString(true).trimEnd().length; + if (existingEnd > startCol) startCol = existingEnd; + // Once echo has arrived in the buffer, we can drop the offset + if (this._flushedOffset && existingEnd >= activePrompt.col + 2 + this._flushedOffset) { + this._flushedOffset = 0; + } + } + } catch {} + startCol = Math.min(startCol, totalCols - 1); const firstLineCols = Math.max(1, totalCols - startCol); // Skip redundant re-renders (e.g. from flushPendingWrites at 60fps) - const renderKey = `${this.pendingText.length}:${activePrompt.row}:${activePrompt.col}:${totalCols}`; + const renderKey = `${this.pendingText.length}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}`; if (renderKey === this._lastRenderKey && this.overlay.style.display !== 'none') return; this._lastRenderKey = renderKey; @@ -2067,33 +2086,10 @@ class ClaudemanApp { } }; - // Background keystroke forwarding for local echo mode. - // Sends keystrokes to the PTY in the background (50ms debounce) so Tab - // completion works and tab-switching doesn't lose PTY state. - this._localEchoBgBuffer = ''; - this._localEchoBgTimer = null; - - const drainBgBuffer = () => { - if (this._localEchoBgTimer) { - clearTimeout(this._localEchoBgTimer); - this._localEchoBgTimer = null; - } - const buf = this._localEchoBgBuffer; - this._localEchoBgBuffer = ''; - return buf; - }; - - const scheduleBgFlush = () => { - if (this._localEchoBgTimer) return; - this._localEchoBgTimer = setTimeout(() => { - this._localEchoBgTimer = null; - const buf = this._localEchoBgBuffer; - this._localEchoBgBuffer = ''; - if (buf && this.activeSessionId) { - this._sendInputAsync(this.activeSessionId, buf); - } - }, 50); - }; + // Local echo mode: buffer keystrokes locally (shown in overlay) and only + // send to PTY on Enter. Avoids out-of-order delivery on high-latency + // mobile connections. The overlay + localStorage persistence ensure input + // survives tab switches and reconnects. this.terminal.onData((data) => { if (this.activeSessionId) { @@ -2104,55 +2100,47 @@ class ClaudemanApp { if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return; // ── Local Echo Mode ── - // When enabled, keystrokes are captured locally in the overlay for - // instant visual feedback AND sent to the PTY in the background - // (50ms debounce) so Tab completion and tab-switching work. + // When enabled, keystrokes are buffered locally in the overlay for + // instant visual feedback. Nothing is sent to the PTY until Enter + // (or a control char) is pressed — avoids out-of-order char delivery. if (this._localEchoEnabled) { if (data === '\x7f') { - // Backspace: remove last char from overlay, send \x7f to PTY in background + // Backspace: remove last char from overlay only (nothing sent to PTY yet) this._localEchoOverlay?.removeChar(); - this._localEchoBgBuffer += '\x7f'; - scheduleBgFlush(); return; } if (/^[\r\n]+$/.test(data)) { - // Enter: drain any background-buffered chars, then send \r after 120ms. - // PTY already has the text from background sends; drain catches any remainder. + // Enter: send full buffered text + \r to PTY in one shot + const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); + // Clear flushed offset — Enter commits all text + this._flushedOffsets?.delete(this.activeSessionId); if (this._inputFlushTimeout) { clearTimeout(this._inputFlushTimeout); this._inputFlushTimeout = null; } - const remainder = drainBgBuffer(); - if (remainder) { - this._pendingInput += remainder; + if (text) { + this._pendingInput += text; flushInput(); } + // Send \r after a short delay so text arrives first setTimeout(() => { this._pendingInput += '\r'; flushInput(); - }, 120); + }, 80); return; } if (data.length > 1 && data.charCodeAt(0) >= 32) { - // Paste: append to overlay + send to PTY immediately + // Paste: append to overlay only (sent on Enter) this._localEchoOverlay?.appendText(data); - const remainder = drainBgBuffer(); - if (remainder) this._pendingInput += remainder; - this._pendingInput += data; - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - flushInput(); return; } if (data.charCodeAt(0) < 32) { - // Control chars (Ctrl+C, escape sequences): clear overlay, drain bg, send immediately + // Control chars (Ctrl+C, escape sequences): send buffered text + control char immediately + const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); - const remainder = drainBgBuffer(); - if (remainder) { - this._pendingInput += remainder; + if (text) { + this._pendingInput += text; } this._pendingInput += data; if (this._inputFlushTimeout) { @@ -2163,10 +2151,8 @@ class ClaudemanApp { return; } if (data.length === 1 && data.charCodeAt(0) >= 32) { - // Printable char: add to overlay + queue for background send to PTY + // Printable char: add to overlay only (sent on Enter) this._localEchoOverlay?.addChar(data); - this._localEchoBgBuffer += data; - scheduleBgFlush(); return; } } @@ -2469,16 +2455,6 @@ class ClaudemanApp { const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); const shouldEnable = !!(echoEnabled && session); if (this._localEchoEnabled && !shouldEnable) { - // Flush background-buffered keystrokes before disabling - if (this.activeSessionId && this._localEchoBgBuffer) { - const remainder = this._localEchoBgBuffer; - if (this._localEchoBgTimer) { - clearTimeout(this._localEchoBgTimer); - this._localEchoBgTimer = null; - } - this._localEchoBgBuffer = ''; - this._sendInputAsync(this.activeSessionId, remainder); - } this._localEchoOverlay?.clear(); } this._localEchoEnabled = shouldEnable; @@ -4652,23 +4628,33 @@ class ClaudemanApp { this.writeFrameScheduled = false; this._isLoadingBuffer = false; this._loadBufferQueue = null; - // Flush any background-buffered keystrokes to the outgoing session's PTY - if (this.activeSessionId && this._localEchoBgBuffer) { - const remainder = this._localEchoBgBuffer; - if (this._localEchoBgTimer) { - clearTimeout(this._localEchoBgTimer); - this._localEchoBgTimer = null; + // End any in-flight IME composition. + // iOS Safari keeps autocorrect composing; switching tabs without ending it + // leaves xterm's _compositionHelper._isComposing stuck true, which blocks + // keyboard input when the user returns to this tab. + try { + const ch = this.terminal?._core?._compositionHelper; + if (ch?._isComposing) { + ch._isComposing = false; + // Also fire compositionend on the textarea so any other listeners reset + const ta = this.terminal?.element?.querySelector('.xterm-helper-textarea'); + if (ta) ta.dispatchEvent(new CompositionEvent('compositionend', { data: '' })); } - this._localEchoBgBuffer = ''; - this._sendInputAsync(this.activeSessionId, remainder); - } - // Save local echo text for outgoing session before clear destroys it + } catch {} + + // Flush local echo text to PTY before switching tabs. + // Send as a single batch (no Enter) so it lands in the session's readline + // input buffer — avoids "old text resent on Enter" and overlay render bugs. + // Track flushed length so _render() offsets the overlay correctly even before + // the PTY echo arrives in the terminal buffer. if (this.activeSessionId) { const echoText = this._localEchoOverlay?.pendingText || ''; if (echoText) { - this.localEchoTextCache.set(this.activeSessionId, echoText); - } else { - this.localEchoTextCache.delete(this.activeSessionId); + this._sendInputAsync(this.activeSessionId, echoText); + // Store flushed length PER SESSION so it's available when switching back + if (!this._flushedOffsets) this._flushedOffsets = new Map(); + const prev = this._flushedOffsets.get(this.activeSessionId) || 0; + this._flushedOffsets.set(this.activeSessionId, prev + echoText.length); } } this._localEchoOverlay?.clear(); @@ -4755,20 +4741,10 @@ class ClaudemanApp { this.terminal.reset(); } - // Restore local echo text for incoming session (saved during previous tab switch). - // Retry rerender at increasing delays — terminal render service may not be ready - // immediately after buffer load (especially on slower mobile devices). - const savedEcho = this.localEchoTextCache.get(sessionId); - if (savedEcho && this._localEchoOverlay) { - this._localEchoOverlay.pendingText = savedEcho; - this._localEchoOverlay._persist(); - for (const delay of [150, 500, 1000]) { - setTimeout(() => { - if (this.activeSessionId === sessionId && this._localEchoOverlay?.pendingText) { - this._localEchoOverlay.rerender(); - } - }, delay); - } + // Restore flushed offset for this session so the overlay positions correctly + // even before the PTY echo arrives in the terminal buffer. + if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) { + this._localEchoOverlay._flushedOffset = this._flushedOffsets.get(sessionId); } // Fire-and-forget resize — don't await to avoid blocking UI. @@ -4852,12 +4828,7 @@ class ClaudemanApp { this.terminalBuffers.delete(sessionId); this.terminalBufferCache.delete(sessionId); this.localEchoTextCache.delete(sessionId); - // Cancel orphaned background echo timer (discard — session is gone) - if (this._localEchoBgTimer) { - clearTimeout(this._localEchoBgTimer); - this._localEchoBgTimer = null; - this._localEchoBgBuffer = ''; - } + this._flushedOffsets?.delete(sessionId); this._inputQueue.delete(sessionId); this.ralphStates.delete(sessionId); this.ralphClosedSessions.delete(sessionId);