mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
chore: bump version to 0.1545
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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`)
|
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`
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"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",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -176,7 +176,14 @@ export class BufferAccumulator {
|
|||||||
private trim(): void {
|
private trim(): void {
|
||||||
const full = this.chunks.join('');
|
const full = this.chunks.join('');
|
||||||
const trimmedBytes = full.length - this.trimSize;
|
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.chunks = [trimmed];
|
||||||
this.totalLength = trimmed.length;
|
this.totalLength = trimmed.length;
|
||||||
|
|
||||||
|
|||||||
+275
-12
@@ -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.
|
* Process data containing DEC 2026 sync markers.
|
||||||
* Strips markers and returns segments that should be written atomically.
|
* Strips markers and returns segments that should be written atomically.
|
||||||
@@ -1418,6 +1553,8 @@ class ClaudemanApp {
|
|||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll
|
this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll
|
||||||
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
|
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)
|
// Flicker filter state (buffers output after screen clears)
|
||||||
this.flickerFilterBuffer = '';
|
this.flickerFilterBuffer = '';
|
||||||
@@ -1452,10 +1589,10 @@ class ClaudemanApp {
|
|||||||
// Sequential input send chain — ensures keystroke ordering across async fetches
|
// Sequential input send chain — ensures keystroke ordering across async fetches
|
||||||
this._inputSendChain = Promise.resolve();
|
this._inputSendChain = Promise.resolve();
|
||||||
|
|
||||||
// No local echo — let PTY/Ink handle all character echoing.
|
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
|
||||||
// Local echo was removed because it's fundamentally incompatible with Ink's
|
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
|
||||||
// terminal management (causes chars to leak onto Ink's status bar rows below the prompt,
|
this._localEchoOverlay = null; // created after terminal.open()
|
||||||
// even when only echoing during idle state).
|
this._localEchoEnabled = false; // true when setting on + session active
|
||||||
|
|
||||||
// Accessibility: Focus trap for modals
|
// Accessibility: Focus trap for modals
|
||||||
this.activeFocusTrap = null;
|
this.activeFocusTrap = null;
|
||||||
@@ -1549,16 +1686,19 @@ class ClaudemanApp {
|
|||||||
// Remove mobile-init class now that JS has applied visibility settings.
|
// 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.
|
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
|
||||||
document.documentElement.classList.remove('mobile-init');
|
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(() => {
|
requestAnimationFrame(() => {
|
||||||
this.initTerminal();
|
this.initTerminal();
|
||||||
this.loadFontSize();
|
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
|
// Share a single settings fetch between both consumers
|
||||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null);
|
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
this.loadQuickStartCases(null, settingsPromise);
|
this.loadQuickStartCases(null, settingsPromise);
|
||||||
@@ -1628,6 +1768,7 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
const container = document.getElementById('terminalContainer');
|
const container = document.getElementById('terminalContainer');
|
||||||
this.terminal.open(container);
|
this.terminal.open(container);
|
||||||
|
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
|
||||||
|
|
||||||
// On mobile Safari, delay initial fit() to allow layout to settle
|
// On mobile Safari, delay initial fit() to allow layout to settle
|
||||||
// This prevents 0-column terminals caused by fit() running before container is sized
|
// 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)
|
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
|
||||||
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
|
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;
|
this._pendingInput += data;
|
||||||
|
|
||||||
// Control chars (Enter, Ctrl+C, escape sequences) — flush immediately
|
// Control chars (Enter, Ctrl+C, escape sequences) — flush immediately
|
||||||
@@ -1966,6 +2148,14 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
batchTerminalWrite(data) {
|
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)
|
// Check if at bottom BEFORE adding data (captures user's scroll position)
|
||||||
// Only update if not already scheduled (preserve the first check's result)
|
// Only update if not already scheduled (preserve the first check's result)
|
||||||
if (!this.writeFrameScheduled) {
|
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.
|
* Flush pending writes to terminal, processing DEC 2026 sync markers.
|
||||||
* Strips markers and writes content atomically within a single frame.
|
* Strips markers and writes content atomically within a single frame.
|
||||||
@@ -2112,18 +2317,28 @@ class ClaudemanApp {
|
|||||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
|
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!buffer || buffer.length === 0) {
|
if (!buffer || buffer.length === 0) {
|
||||||
|
this._finishBufferLoad();
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
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
|
// Strip any DEC 2026 markers that might be in the buffer
|
||||||
// (from historical SSE data that was stored with markers)
|
// (from historical SSE data that was stored with markers)
|
||||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
this._finishBufferLoad();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
// For small buffers, write directly
|
// For small buffers, write directly
|
||||||
if (cleanBuffer.length <= chunkSize) {
|
if (cleanBuffer.length <= chunkSize) {
|
||||||
this.terminal.write(cleanBuffer);
|
this.terminal.write(cleanBuffer);
|
||||||
resolve();
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2131,7 +2346,7 @@ class ClaudemanApp {
|
|||||||
const writeChunk = () => {
|
const writeChunk = () => {
|
||||||
if (offset >= cleanBuffer.length) {
|
if (offset >= cleanBuffer.length) {
|
||||||
// Wait one more frame for xterm to finish rendering before resolving
|
// Wait one more frame for xterm to finish rendering before resolving
|
||||||
requestAnimationFrame(() => resolve());
|
requestAnimationFrame(finish);
|
||||||
return;
|
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() {
|
setupEventListeners() {
|
||||||
// Use capture to handle before terminal
|
// Use capture to handle before terminal
|
||||||
document.addEventListener('keydown', (e) => {
|
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) => {
|
addListener('session:clearTerminal', async (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.id === this.activeSessionId) {
|
if (data.id === this.activeSessionId) {
|
||||||
@@ -2429,6 +2678,7 @@ class ClaudemanApp {
|
|||||||
if (session) {
|
if (session) {
|
||||||
session.status = 'stopped';
|
session.status = 'stopped';
|
||||||
this.renderSessionTabs();
|
this.renderSessionTabs();
|
||||||
|
if (data.id === this.activeSessionId) this._updateLocalEchoState();
|
||||||
}
|
}
|
||||||
// Notify on unexpected exit (non-zero code)
|
// Notify on unexpected exit (non-zero code)
|
||||||
if (data.code && data.code !== 0) {
|
if (data.code && data.code !== 0) {
|
||||||
@@ -2451,6 +2701,7 @@ class ClaudemanApp {
|
|||||||
session.status = 'idle';
|
session.status = 'idle';
|
||||||
this.renderSessionTabs();
|
this.renderSessionTabs();
|
||||||
this.sendPendingCtrlL(data.id);
|
this.sendPendingCtrlL(data.id);
|
||||||
|
if (data.id === this.activeSessionId) this._updateLocalEchoState();
|
||||||
}
|
}
|
||||||
// Start stuck detection timer (only if no respawn running)
|
// Start stuck detection timer (only if no respawn running)
|
||||||
if (!this.respawnStatus[data.id]?.enabled) {
|
if (!this.respawnStatus[data.id]?.enabled) {
|
||||||
@@ -2483,6 +2734,7 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
this.renderSessionTabs();
|
this.renderSessionTabs();
|
||||||
this.sendPendingCtrlL(data.id);
|
this.sendPendingCtrlL(data.id);
|
||||||
|
if (data.id === this.activeSessionId) this._updateLocalEchoState();
|
||||||
}
|
}
|
||||||
// Clear stuck detection timer
|
// Clear stuck detection timer
|
||||||
const timer = this.idleTimers.get(data.id);
|
const timer = this.idleTimers.get(data.id);
|
||||||
@@ -3431,6 +3683,10 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
this.pendingWrites = [];
|
this.pendingWrites = [];
|
||||||
this.writeFrameScheduled = false;
|
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
|
// Clear pending hooks
|
||||||
this.pendingHooks.clear();
|
this.pendingHooks.clear();
|
||||||
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
|
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
|
||||||
@@ -4157,6 +4413,9 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
this.pendingWrites = [];
|
this.pendingWrites = [];
|
||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
|
this._isLoadingBuffer = false;
|
||||||
|
this._loadBufferQueue = null;
|
||||||
|
this._localEchoOverlay?.clear();
|
||||||
this.activeSessionId = sessionId;
|
this.activeSessionId = sessionId;
|
||||||
try { localStorage.setItem('claudeman-active-session', sessionId); } catch {}
|
try { localStorage.setItem('claudeman-active-session', sessionId); } catch {}
|
||||||
this.hideWelcome();
|
this.hideWelcome();
|
||||||
@@ -4165,6 +4424,7 @@ class ClaudemanApp {
|
|||||||
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
|
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
|
||||||
this._updateActiveTabImmediate(sessionId);
|
this._updateActiveTabImmediate(sessionId);
|
||||||
this.renderSessionTabs();
|
this.renderSessionTabs();
|
||||||
|
this._updateLocalEchoState();
|
||||||
|
|
||||||
// Glow the newly-active tab
|
// Glow the newly-active tab
|
||||||
const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`);
|
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('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
|
||||||
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
|
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
|
||||||
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
|
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;
|
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||||
@@ -9575,6 +9836,7 @@ class ClaudemanApp {
|
|||||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||||
|
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||||
@@ -9590,6 +9852,7 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
this.saveAppSettingsToStorage(settings);
|
this.saveAppSettingsToStorage(settings);
|
||||||
|
this._updateLocalEchoState();
|
||||||
|
|
||||||
// Save notification preferences separately
|
// Save notification preferences separately
|
||||||
const notifPrefsToSave = {
|
const notifPrefsToSave = {
|
||||||
|
|||||||
@@ -877,6 +877,17 @@
|
|||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tab-Switch Tab -->
|
<!-- Tab-Switch Tab -->
|
||||||
|
|||||||
+14
-1
@@ -1730,6 +1730,13 @@ export class WebServer extends EventEmitter {
|
|||||||
if (tailBytes > 0 && cleanBuffer.length > tailBytes) {
|
if (tailBytes > 0 && cleanBuffer.length > tailBytes) {
|
||||||
cleanBuffer = cleanBuffer.slice(-tailBytes);
|
cleanBuffer = cleanBuffer.slice(-tailBytes);
|
||||||
truncated = true;
|
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 {
|
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
|
// Optimized: send pre-formatted SSE message to a client
|
||||||
// Returns false if client is backpressured or dead
|
// Returns false if client is backpressured or dead
|
||||||
private sendSSEPreformatted(reply: FastifyReply, message: string): void {
|
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;
|
if (this.backpressuredClients.has(reply)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -4809,6 +4817,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
this.backpressuredClients.add(reply);
|
this.backpressuredClients.add(reply);
|
||||||
reply.raw.once('drain', () => {
|
reply.raw.once('drain', () => {
|
||||||
this.backpressuredClients.delete(reply);
|
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 {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user