chore: bump version to 0.1545

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-18 21:53:34 +01:00
co-authored by Claude Opus 4.6
parent 989f8386b1
commit d7ad617d7f
7 changed files with 541 additions and 16 deletions
+1 -1
View File
@@ -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.1544 (must match `package.json` for npm publish)
**Version**: 0.1545 (must match `package.json` for npm publish)
## Project Overview
+231
View File
@@ -0,0 +1,231 @@
# Local Echo Overlay — Implementation Plan
## Context
User accesses Claudeman remotely from Thailand to Switzerland over Tailscale (~200-300ms RTT).
Every keystroke is invisible for 200-300ms before the server echoes it back. This makes typing
painfully slow on mobile. Previous attempts to write directly to xterm.js buffer failed because
Ink (Claude Code's terminal framework) does full-screen redraws that corrupt injected characters.
## Approach: DOM Overlay (Mosh-inspired)
A single absolutely-positioned `<span>` inside xterm.js's `.xterm-screen` element that shows
typed characters at the cursor position. This completely avoids buffer conflicts with Ink because
we never write to xterm.js's buffer — the overlay is a pure DOM element sitting on top.
**Why this works when buffer writes don't:** Ink owns the terminal buffer and does full-line
redraws. A DOM overlay sits in a separate rendering layer (z-index 7) and doesn't interfere
with Ink's cursor management or screen redraws at all. When Ink redraws (server output arrives),
we simply hide the overlay.
**Why it will look indistinguishable:** We use the DOM renderer (not canvas/WebGL) in our
xterm.js v5.3.0, so both terminal text and overlay text are rendered by the same browser
font engine with identical sub-pixel rendering.
## Key Technical Details (from research)
### Pixel Positioning Formula
```js
// Same formula used by BufferDecorationRenderer, CompositionHelper, Terminal._syncTextArea
const dims = terminal._core._renderService.dimensions;
const left = cursorX * dims.css.cell.width; // CSS pixels, relative to .xterm-screen
const top = cursorY * dims.css.cell.height; // CSS pixels, relative to .xterm-screen
```
- `cursorX` = `terminal.buffer.active.cursorX` (0 to terminal.cols)
- `cursorY` = `terminal.buffer.active.cursorY` (0 to terminal.rows-1, ALREADY viewport-relative)
- No scroll offset math needed
### Cell Dimensions (v5.3.0 — no public API, use internal)
```js
const dims = terminal._core._renderService.dimensions;
dims.css.cell.width // e.g., 8.4px
dims.css.cell.height // e.g., 17px
```
Public `terminal.dimensions` only available in v7.0.0+.
### xterm.js DOM Structure
```
div.terminal.xterm
├── div.xterm-viewport (overflow-y: scroll)
└── div.xterm-screen (position: relative) ← INSERT OVERLAY HERE
├── div.xterm-helpers (z-index: 5)
├── div.xterm-rows (the actual text) (z-index: auto/0)
├── div.xterm-selection (z-index: 1)
└── div.xterm-decoration-container (z-index: 6-7)
```
### Z-Index Layers
| Layer | Z-Index |
|-------|---------|
| textarea | -5 |
| row content (DOM renderer) | auto (0) |
| selection | 1 |
| composition (IME) | 1 |
| helpers | 5 |
| decorations | 6 |
| decorations (top layer) | 7 ← OUR OVERLAY |
| overview ruler | 8 |
| accessibility | 10 |
### Font Matching CSS
```css
.local-echo-overlay {
position: absolute;
z-index: 7;
pointer-events: none;
white-space: pre;
font-kerning: none;
overflow: hidden;
display: none;
/* Set dynamically: left, top, height, line-height, font-family, font-size, color, letter-spacing */
}
```
Critical: match `letter-spacing` from `.xterm-rows` container (DPR rounding compensation).
### Font Properties from Terminal
```js
terminal.options.fontFamily // '"Fira Code", "Cascadia Code", ...'
terminal.options.fontSize // 14 (10 on mobile)
terminal.options.fontWeight // 'normal'
terminal.options.letterSpacing // 0
terminal.options.lineHeight // 1.2
```
Use actual `dims.css.cell.height` for line-height (not the multiplier).
## Files to Modify
### `src/web/public/app.js` — All logic
1. **Constructor** (~line 1455): Initialize overlay state variables
2. **After terminal creation** (in `setupTerminal` or similar): Create overlay DOM element
3. **`terminal.onData` handler** (~line 1801): Echo printable chars to overlay when idle
4. **`flushPendingWrites`** (~line 2083): Hide overlay when server output arrives
5. **SSE event handlers**: Update overlay state on session:idle/working/exit
6. **`selectSession`**: Clear overlay on tab switch
7. **`handleInit`**: Clear overlay on SSE reconnect
8. **Settings load/save** (`openAppSettings`/`saveAppSettings`): Toggle checkbox
### `src/web/public/index.html` — Settings toggle
After Image Watcher section (~line 878), add "Input" section with checkbox.
## Implementation Details
### Overlay Class (inline in app.js, near extractSyncSegments)
```js
class LocalEchoOverlay {
constructor(terminal) {
this.terminal = terminal;
this.overlay = document.createElement('span');
// ... CSS setup ...
const screen = terminal.element.querySelector('.xterm-screen');
screen.appendChild(this.overlay);
this.pendingText = '';
this.timeout = null;
}
addChar(char) {
this.pendingText += char;
this._render();
this._resetTimeout();
}
removeChar() {
if (this.pendingText.length > 0) {
this.pendingText = this.pendingText.slice(0, -1);
this._render();
if (this.pendingText.length > 0) this._resetTimeout();
else this._clearTimeout();
}
}
clear() {
this.pendingText = '';
this.overlay.textContent = '';
this.overlay.style.display = 'none';
this._clearTimeout();
}
_render() {
if (!this.pendingText) { this.clear(); return; }
const dims = this.terminal._core._renderService.dimensions;
const cellW = dims.css.cell.width;
const cellH = dims.css.cell.height;
const cursorX = this.terminal.buffer.active.cursorX;
const cursorY = this.terminal.buffer.active.cursorY;
this.overlay.style.left = (cursorX * cellW) + 'px';
this.overlay.style.top = (cursorY * cellH) + 'px';
this.overlay.style.height = cellH + 'px';
this.overlay.style.lineHeight = cellH + 'px';
this.overlay.textContent = this.pendingText;
this.overlay.style.display = '';
}
_resetTimeout() {
this._clearTimeout();
this.timeout = setTimeout(() => this.clear(), 2000);
}
_clearTimeout() {
if (this.timeout) { clearTimeout(this.timeout); this.timeout = null; }
}
get hasPending() { return this.pendingText.length > 0; }
dispose() {
this.clear();
this.overlay.remove();
}
}
```
### Integration Points
**Input handler (`terminal.onData`):**
- Backspace (`\x7f`): if overlay has pending + echo enabled → `overlay.removeChar()`
- Enter (`\r`/`\n`): `overlay.clear()`, disable echo (session goes busy)
- Other control chars / multi-char (paste): `overlay.clear()`
- Single printable char (charCode >= 32, length === 1): if echo enabled → `overlay.addChar(data)`
**Output handler (`flushPendingWrites`):**
- After writing segments: if overlay has pending text → `overlay.clear()` (server confirmed)
**State management:**
- `_localEchoEnabled` boolean, updated on session status change + settings change
- Only enabled when: setting on + active session is idle
- On idle→busy transition: clear overlay
- On tab switch: clear overlay
- On SSE reconnect: clear overlay
### Settings
**index.html:** Checkbox `appSettingsLocalEcho` under "Input" section header
**openAppSettings:** Load `settings.localEchoEnabled ?? false`
**saveAppSettings:** Save checkbox + call `_updateLocalEchoState()`
Default: **disabled** (opt-in)
## Edge Cases
| Case | Handling |
|---|---|
| Paste (multi-char onData) | data.length > 1 → NOT echoed. Server echoes it. |
| Misprediction | Server output arrives → overlay cleared → server redraws correctly |
| Idle→busy race | _updateLocalEchoState() disables + clears overlay |
| Server unresponsive | 2s timeout → overlay cleared |
| Tab switch | selectSession() clears overlay |
| SSE reconnect | handleInit() clears overlay |
| Terminal resize | Overlay position recalculated on next _render() |
| Scrolled back | cursorY is viewport-relative, position stays correct |
| Unicode/emoji | data.length > 1 → not echoed (ASCII-only) |
## What NOT to Do
- Do NOT write to `terminal.write()` — Ink conflicts
- Do NOT use `registerDecoration` — requires markers, can't follow cursor smoothly
- Do NOT try to match predictions against server output — Ink's full-line redraws make this impossible
- Do NOT use `stripAnsiForMatch` / `findEscapeEnd` — removed, not needed for overlay approach
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claudeman",
"version": "0.1544",
"version": "0.1545",
"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",
+8 -1
View File
@@ -176,7 +176,14 @@ export class BufferAccumulator {
private trim(): void {
const full = this.chunks.join('');
const trimmedBytes = full.length - this.trimSize;
const trimmed = full.slice(-this.trimSize);
let trimmed = full.slice(-this.trimSize);
// Avoid starting mid-ANSI-escape: advance to first newline within 4KB.
// A partial escape at the buffer start causes xterm.js to misparse
// subsequent cursor movements, corrupting Ink's redraw rendering.
const firstNewline = trimmed.indexOf('\n');
if (firstNewline > 0 && firstNewline < 4096) {
trimmed = trimmed.slice(firstNewline + 1);
}
this.chunks = [trimmed];
this.totalLength = trimmed.length;
+275 -12
View File
@@ -837,6 +837,141 @@ class FocusTrap {
}
}
/**
* Local echo DOM overlay — shows typed characters instantly at the visible prompt
* position for high-latency connections. Positioned by scanning the terminal buffer
* for the ❯ prompt character, not by trusting buffer.cursorY (which reflects Ink's
* internal cursor near the status bar).
*/
class LocalEchoOverlay {
constructor(terminal) {
this.terminal = terminal;
this.overlay = document.createElement('span');
this.overlay.style.cssText = 'position:absolute;z-index:7;pointer-events:none;white-space:pre;font-kerning:none;overflow:hidden;display:none';
this.overlay.style.fontFamily = terminal.options.fontFamily;
this.overlay.style.fontSize = terminal.options.fontSize + 'px';
this.overlay.style.fontWeight = terminal.options.fontWeight || 'normal';
// Match letter-spacing and colors from xterm-rows for DPR compensation
const screen = terminal.element.querySelector('.xterm-screen');
const rows = terminal.element.querySelector('.xterm-rows');
if (rows) {
this.overlay.style.letterSpacing = getComputedStyle(rows).letterSpacing;
this.overlay.style.color = getComputedStyle(rows).color;
}
// Opaque background so overlay covers old buffer text underneath.
// xterm.js applies bg via internal rendering, not CSS — use theme option.
this.overlay.style.backgroundColor = terminal.options?.theme?.background || '#0d0d0d';
// Solid bar cursor (no blink — matches Claude Code's input cursor)
this._cursor = document.createElement('span');
this._cursor.style.cssText = 'display:inline-block;width:1.5px;height:1em;vertical-align:text-bottom;background:currentColor;margin-left:1px';
screen.appendChild(this.overlay);
this.pendingText = '';
this._storageKey = 'claudeman_local_echo_pending';
// Restore unsent input from previous session (survives reload/disconnect)
try {
const saved = localStorage.getItem(this._storageKey);
if (saved) {
this.pendingText = saved;
this._render();
}
} catch {}
}
_persist() {
try {
if (this.pendingText) {
localStorage.setItem(this._storageKey, this.pendingText);
} else {
localStorage.removeItem(this._storageKey);
}
} catch {}
}
// Scan terminal buffer to find the visible ❯ prompt position (bottom-up)
_findPrompt() {
try {
const buffer = this.terminal.buffer.active;
const viewportTop = buffer.viewportY;
for (let row = this.terminal.rows - 1; row >= 0; row--) {
const line = buffer.getLine(viewportTop + row);
if (!line) continue;
const text = line.translateToString(true);
const idx = text.lastIndexOf('\u276f');
if (idx >= 0) {
return { row, col: idx };
}
}
} catch {}
return null;
}
addChar(char) {
this.pendingText += char;
this._persist();
this._render();
}
removeChar() {
if (this.pendingText.length > 0) {
this.pendingText = this.pendingText.slice(0, -1);
this._persist();
if (this.pendingText.length > 0) {
this._render();
} else {
this.clear();
}
}
}
clear() {
this.pendingText = '';
this._persist();
this.overlay.textContent = '';
if (this._cursor.parentNode === this.overlay) {
this.overlay.removeChild(this._cursor);
}
this.overlay.style.display = 'none';
}
_render() {
if (!this.pendingText) {
this.overlay.style.display = 'none';
return;
}
try {
// Re-scan for prompt on EVERY render — Ink can move it between redraws
const prompt = this._findPrompt();
if (!prompt) { this.overlay.style.display = 'none'; return; }
const dims = this.terminal._core._renderService.dimensions;
const cellW = dims.css.cell.width;
const cellH = dims.css.cell.height;
// Position right after "❯ " (prompt col + 2)
const col = prompt.col + 2;
const leftPx = col * cellW;
this.overlay.style.left = leftPx + 'px';
this.overlay.style.top = (prompt.row * cellH) + 'px';
this.overlay.style.height = cellH + 'px';
this.overlay.style.lineHeight = cellH + 'px';
// Extend to right edge to cover old buffer text with opaque bg
this.overlay.style.width = (this.terminal.cols * cellW - leftPx) + 'px';
this.overlay.textContent = this.pendingText;
// Solid cursor after text
this.overlay.appendChild(this._cursor);
this.overlay.style.display = '';
} catch {
this.clear();
}
}
get hasPending() { return this.pendingText.length > 0; }
dispose() {
this.clear();
this.overlay.remove();
}
}
/**
* Process data containing DEC 2026 sync markers.
* Strips markers and returns segments that should be written atomically.
@@ -1418,6 +1553,8 @@ class ClaudemanApp {
this.writeFrameScheduled = false;
this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes
this._loadBufferQueue = null; // queued SSE events during buffer load
// Flicker filter state (buffers output after screen clears)
this.flickerFilterBuffer = '';
@@ -1452,10 +1589,10 @@ class ClaudemanApp {
// Sequential input send chain — ensures keystroke ordering across async fetches
this._inputSendChain = Promise.resolve();
// No local echo — let PTY/Ink handle all character echoing.
// Local echo was removed because it's fundamentally incompatible with Ink's
// terminal management (causes chars to leak onto Ink's status bar rows below the prompt,
// even when only echoing during idle state).
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
this._localEchoOverlay = null; // created after terminal.open()
this._localEchoEnabled = false; // true when setting on + session active
// Accessibility: Focus trap for modals
this.activeFocusTrap = null;
@@ -1549,16 +1686,19 @@ class ClaudemanApp {
// Remove mobile-init class now that JS has applied visibility settings.
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
document.documentElement.classList.remove('mobile-init');
// Defer heavy terminal canvas creation to next frame — lets browser paint header/skeleton first
// Defer heavy terminal canvas creation to next frame — lets browser paint header/skeleton first.
// IMPORTANT: connectSSE must run AFTER initTerminal to prevent a race where SSE data
// arrives before the terminal exists, orphaning data in pendingWrites and corrupting
// escape sequence boundaries when later concatenated with fresh data.
requestAnimationFrame(() => {
this.initTerminal();
this.loadFontSize();
this.connectSSE();
// Only fetch state if SSE init event hasn't arrived within 3s (avoids duplicate handleInit)
this._initFallbackTimer = setTimeout(() => {
if (this._initGeneration === 0) this.loadState();
}, 3000);
});
this.connectSSE();
// Only fetch state if SSE init event hasn't arrived within 3s (avoids duplicate handleInit)
this._initFallbackTimer = setTimeout(() => {
if (this._initGeneration === 0) this.loadState();
}, 3000);
// Share a single settings fetch between both consumers
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null);
this.loadQuickStartCases(null, settingsPromise);
@@ -1628,6 +1768,7 @@ class ClaudemanApp {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
// On mobile Safari, delay initial fit() to allow layout to settle
// This prevents 0-column terminals caused by fit() running before container is sized
@@ -1806,6 +1947,47 @@ class ClaudemanApp {
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
// ── Local Echo Mode ──
// When enabled, keystrokes are captured locally in the overlay.
// Nothing is sent to the server until Enter is pressed.
// This guarantees what the user sees is exactly what gets sent.
if (this._localEchoEnabled) {
if (data === '\x7f') {
// Backspace: remove last char from overlay (not sent to server)
this._localEchoOverlay?.removeChar();
return;
}
if (data === '\r' || data === '\n' || data === '\r\n') {
// Enter: send the final edited text + Enter to server
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
this._pendingInput += text + '\r';
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
}
flushInput();
return;
}
if (data.charCodeAt(0) < 32 || data.length > 1) {
// Other control chars (Ctrl+C, escape sequences): send raw, clear overlay
this._localEchoOverlay?.clear();
this._pendingInput += data;
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
}
flushInput();
return;
}
if (data.length === 1 && data.charCodeAt(0) >= 32) {
// Printable char: add to overlay only (not sent to server)
this._localEchoOverlay?.addChar(data);
return;
}
}
// ── Normal Mode (echo disabled) ──
this._pendingInput += data;
// Control chars (Enter, Ctrl+C, escape sequences) — flush immediately
@@ -1966,6 +2148,14 @@ class ClaudemanApp {
}
batchTerminalWrite(data) {
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
// to prevent interleaving historical buffer data with live SSE data.
// This is critical: interleaving causes cursor position chaos with Ink redraws.
if (this._isLoadingBuffer) {
if (this._loadBufferQueue) this._loadBufferQueue.push(data);
return;
}
// Check if at bottom BEFORE adding data (captures user's scroll position)
// Only update if not already scheduled (preserve the first check's result)
if (!this.writeFrameScheduled) {
@@ -2076,6 +2266,21 @@ class ClaudemanApp {
}
}
/**
* Update local echo overlay state based on settings.
* Enabled whenever the setting is on — works during idle AND busy.
* Position is tracked dynamically by _findPrompt() on every render.
*/
_updateLocalEchoState() {
const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const shouldEnable = !!(settings.localEchoEnabled && session);
if (this._localEchoEnabled && !shouldEnable) {
this._localEchoOverlay?.clear();
}
this._localEchoEnabled = shouldEnable;
}
/**
* Flush pending writes to terminal, processing DEC 2026 sync markers.
* Strips markers and writes content atomically within a single frame.
@@ -2112,18 +2317,28 @@ class ClaudemanApp {
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
return new Promise((resolve) => {
if (!buffer || buffer.length === 0) {
this._finishBufferLoad();
resolve();
return;
}
// Block live SSE writes during buffer load to prevent interleaving
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
// Strip any DEC 2026 markers that might be in the buffer
// (from historical SSE data that was stored with markers)
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
const finish = () => {
this._finishBufferLoad();
resolve();
};
// For small buffers, write directly
if (cleanBuffer.length <= chunkSize) {
this.terminal.write(cleanBuffer);
resolve();
finish();
return;
}
@@ -2131,7 +2346,7 @@ class ClaudemanApp {
const writeChunk = () => {
if (offset >= cleanBuffer.length) {
// Wait one more frame for xterm to finish rendering before resolving
requestAnimationFrame(() => resolve());
requestAnimationFrame(finish);
return;
}
@@ -2148,6 +2363,21 @@ class ClaudemanApp {
});
}
/**
* Complete a buffer load: unblock live SSE writes and flush any queued events.
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
*/
_finishBufferLoad() {
const queue = this._loadBufferQueue;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
if (queue && queue.length > 0) {
for (const data of queue) {
this.batchTerminalWrite(data);
}
}
}
setupEventListeners() {
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
@@ -2371,6 +2601,25 @@ class ClaudemanApp {
}
});
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
addListener('session:needsRefresh', async () => {
if (!this.activeSessionId || !this.terminal) return;
try {
const tailSize = 256 * 1024;
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${tailSize}`);
const data = await res.json();
if (data.terminalBuffer) {
this.terminal.clear();
this.terminal.reset();
await this.chunkedTerminalWrite(data.terminalBuffer);
this.terminal.scrollToBottom();
}
} catch (err) {
console.error('needsRefresh reload failed:', err);
}
});
addListener('session:clearTerminal', async (e) => {
const data = JSON.parse(e.data);
if (data.id === this.activeSessionId) {
@@ -2429,6 +2678,7 @@ class ClaudemanApp {
if (session) {
session.status = 'stopped';
this.renderSessionTabs();
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Notify on unexpected exit (non-zero code)
if (data.code && data.code !== 0) {
@@ -2451,6 +2701,7 @@ class ClaudemanApp {
session.status = 'idle';
this.renderSessionTabs();
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Start stuck detection timer (only if no respawn running)
if (!this.respawnStatus[data.id]?.enabled) {
@@ -2483,6 +2734,7 @@ class ClaudemanApp {
}
this.renderSessionTabs();
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Clear stuck detection timer
const timer = this.idleTimers.get(data.id);
@@ -3431,6 +3683,10 @@ class ClaudemanApp {
}
this.pendingWrites = [];
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
// Clear local echo overlay on SSE reconnect (re-enable via _updateLocalEchoState)
this._localEchoOverlay?.clear();
// Clear pending hooks
this.pendingHooks.clear();
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
@@ -4157,6 +4413,9 @@ class ClaudemanApp {
}
this.pendingWrites = [];
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._localEchoOverlay?.clear();
this.activeSessionId = sessionId;
try { localStorage.setItem('claudeman-active-session', sessionId); } catch {}
this.hideWelcome();
@@ -4165,6 +4424,7 @@ class ClaudemanApp {
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
this._updateActiveTabImmediate(sessionId);
this.renderSessionTabs();
this._updateLocalEchoState();
// Glow the newly-active tab
const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`);
@@ -9449,6 +9709,7 @@ class ClaudemanApp {
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
@@ -9575,6 +9836,7 @@ class ClaudemanApp {
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
@@ -9590,6 +9852,7 @@ class ClaudemanApp {
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
// Save notification preferences separately
const notifPrefsToSave = {
+11
View File
@@ -877,6 +877,17 @@
<span class="slider"></span>
</label>
</div>
<!-- Input Options Section -->
<div class="settings-section-header">Input</div>
<div class="settings-item" title="Show typed characters instantly via overlay for high-latency connections. Only active when session is idle.">
<span class="settings-item-label">Local Echo</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsLocalEcho">
<span class="slider"></span>
</label>
</div>
</div>
</div>
<!-- Tab-Switch Tab -->
+14 -1
View File
@@ -1730,6 +1730,13 @@ export class WebServer extends EventEmitter {
if (tailBytes > 0 && cleanBuffer.length > tailBytes) {
cleanBuffer = cleanBuffer.slice(-tailBytes);
truncated = true;
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
// and start from there. This prevents xterm.js from parsing a partial escape
// sequence which corrupts cursor position for all subsequent Ink redraws.
const firstNewline = cleanBuffer.indexOf('\n');
if (firstNewline > 0 && firstNewline < 4096) {
cleanBuffer = cleanBuffer.slice(firstNewline + 1);
}
}
return {
@@ -4799,7 +4806,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Optimized: send pre-formatted SSE message to a client
// Returns false if client is backpressured or dead
private sendSSEPreformatted(reply: FastifyReply, message: string): void {
// Skip backpressured clients to prevent unbounded memory growth
// Skip backpressured clients to prevent unbounded memory growth.
// Terminal data dropped here is recovered via session:needsRefresh on drain.
if (this.backpressuredClients.has(reply)) return;
try {
@@ -4809,6 +4817,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.backpressuredClients.add(reply);
reply.raw.once('drain', () => {
this.backpressuredClients.delete(reply);
// Client may have missed terminal data during backpressure.
// Tell it to reload the active session's buffer to recover.
try {
reply.raw.write(`event: session:needsRefresh\ndata: {}\n\n`);
} catch { /* client gone */ }
});
}
} catch {