perf: optimize terminal buffer rendering for smoother tab switches

- Reduce buffer limits (terminal: 5MB→2MB, text: 2MB→1MB) to decrease
  render payload
- Add chunkedTerminalWrite() that writes in 64KB chunks via
  requestAnimationFrame to avoid UI jank
- Add tail mode to /api/sessions/:id/terminal endpoint (?tail=256KB)
  for faster initial load on tab switch
- Show truncation indicator when earlier output is cut
- Update CLAUDE.md with new buffer limits and performance notes

Fixes jittery behavior when switching tabs with large (2MB+) buffers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-21 23:55:16 +01:00
co-authored by Claude Opus 4.5
parent 50150befef
commit 5bd1e9e536
4 changed files with 111 additions and 14 deletions
+38 -4
View File
@@ -19,7 +19,9 @@ Claudeman is a Claude Code session manager with a web interface and autonomous R
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
**Requirements**: Node.js 18+, Claude CLI (`claude`) installed and available in PATH
**Key Dependencies**: fastify (REST API), node-pty (PTY spawning), ink/react (TUI), xterm.js (web terminal)
**Requirements**: Node.js 18+, Claude CLI (`claude`) in PATH, GNU Screen (`apt install screen` / `brew install screen`)
## First-Time Setup
@@ -90,6 +92,33 @@ screen -X -S <name> quit # Graceful quit
pkill -f "SCREEN.*claudeman" # Force kill all claudeman screens
```
## CLI Commands
```bash
claudeman session [s] # Manage Claude sessions
start # Start new session
stop <id> # Stop session
list [ls] # List all
logs <id> # View output
claudeman task [t] # Manage tasks
add <prompt> # Add task
list [ls] # List tasks
status <id> # Task details
remove [rm] <id> # Remove task
clear # Clear completed/failed
claudeman ralph [r] # Control Ralph loop
start # Start loop
stop # Stop loop
status # Show status
claudeman web # Start web interface
claudeman tui # Start TUI
claudeman status # Overall status
claudeman reset # Reset all state
```
## Architecture
### Key Files
@@ -385,7 +414,7 @@ TUI uses React JSX (`jsxImportSource: react`) for Ink components.
- **API endpoint**: Add types in `types.ts`, route in `server.ts:buildServer()`, use `createErrorResponse()` for errors
- **SSE event**: Emit via `broadcast()` in server.ts, handle in `app.js:handleSSEEvent()` switch
- **Session event**: Add to `SessionEvents` interface in `session.ts`, emit via `this.emit()`, subscribe in server.ts, handle in frontend
- **New test file**: Create `test/<name>.test.ts`, pick unique port (next available: 3122+), add to port allocation comment above
- **New test file**: Create `test/<name>.test.ts`, pick unique port (next available: 3127+), add to port allocation comment above
### API Error Codes
@@ -439,12 +468,17 @@ Long-running sessions are supported with automatic trimming:
| Buffer | Max Size | Trim To |
|--------|----------|---------|
| Terminal | 5MB | 4MB |
| Text output | 2MB | 1.5MB |
| Terminal | 2MB | 1.5MB |
| Text output | 1MB | 768KB |
| Messages | 1000 | 800 |
| Line buffer | 64KB | (flushed every 100ms) |
| Respawn buffer | 1MB | 512KB |
**Performance optimizations:**
- Tab switch uses `tail=256KB` for fast initial load, then chunked writes
- Large buffers written in 64KB chunks via `requestAnimationFrame` to avoid UI jank
- Truncation indicator shown when earlier output is cut
## API Routes Quick Reference
| Method | Endpoint | Description |
+8 -8
View File
@@ -31,17 +31,17 @@ export { withTimeout };
// Buffer Size Constants
// ============================================================================
/** Maximum terminal buffer size in characters (5MB) */
const MAX_TERMINAL_BUFFER_SIZE = 5 * 1024 * 1024;
/** Maximum terminal buffer size in characters (2MB) - reduced from 5MB for better render performance */
const MAX_TERMINAL_BUFFER_SIZE = 2 * 1024 * 1024;
/** When trimming terminal buffer, keep the most recent portion (4MB) */
const TERMINAL_BUFFER_TRIM_SIZE = 4 * 1024 * 1024;
/** When trimming terminal buffer, keep the most recent portion (1.5MB) */
const TERMINAL_BUFFER_TRIM_SIZE = 1.5 * 1024 * 1024;
/** Maximum text output buffer size (2MB) - ANSI-stripped text */
const MAX_TEXT_OUTPUT_SIZE = 2 * 1024 * 1024;
/** Maximum text output buffer size (1MB) - ANSI-stripped text */
const MAX_TEXT_OUTPUT_SIZE = 1 * 1024 * 1024;
/** When trimming text output, keep the most recent portion (1.5MB) */
const TEXT_OUTPUT_TRIM_SIZE = 1.5 * 1024 * 1024;
/** When trimming text output, keep the most recent portion (768KB) */
const TEXT_OUTPUT_TRIM_SIZE = 768 * 1024;
/** Maximum number of Claude JSON messages to keep in memory */
const MAX_MESSAGES = 1000;
+50 -2
View File
@@ -207,6 +207,47 @@ class ClaudemanApp {
}
}
/**
* Write large buffer to terminal in chunks to avoid UI jank.
* Uses requestAnimationFrame to spread work across frames.
* @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 64KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written
*/
chunkedTerminalWrite(buffer, chunkSize = 64 * 1024) {
return new Promise((resolve) => {
if (!buffer || buffer.length === 0) {
resolve();
return;
}
// For small buffers, write directly
if (buffer.length <= chunkSize) {
this.terminal.write(buffer);
resolve();
return;
}
let offset = 0;
const writeChunk = () => {
if (offset >= buffer.length) {
resolve();
return;
}
const chunk = buffer.slice(offset, offset + chunkSize);
this.terminal.write(chunk);
offset += chunkSize;
// Schedule next chunk on next frame
requestAnimationFrame(writeChunk);
};
// Start writing
requestAnimationFrame(writeChunk);
});
}
setupEventListeners() {
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
@@ -772,14 +813,21 @@ class ClaudemanApp {
}
// Load terminal buffer for this session
// Use tail mode for faster initial load (256KB is enough for recent visible content)
try {
const res = await fetch(`/api/sessions/${sessionId}/terminal`);
const tailSize = 256 * 1024;
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`);
const data = await res.json();
this.terminal.clear();
this.terminal.reset();
if (data.terminalBuffer) {
this.terminal.write(data.terminalBuffer);
// Show truncation indicator if buffer was cut
if (data.truncated) {
this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n');
}
// Use chunked write for large buffers to avoid UI jank
await this.chunkedTerminalWrite(data.terminalBuffer);
}
// Send resize and Ctrl+L to trigger Claude to redraw at correct size
+15
View File
@@ -505,8 +505,11 @@ export class WebServer extends EventEmitter {
});
// Get session terminal buffer (for reconnecting)
// Query params:
// tail=<bytes> - Only return last N bytes (faster initial load)
this.app.get('/api/sessions/:id/terminal', async (req) => {
const { id } = req.params as { id: string };
const query = req.query as { tail?: string };
const session = this.sessions.get(id);
if (!session) {
@@ -534,9 +537,21 @@ export class WebServer extends EventEmitter {
.replace(/\x0c/g, '')
.replace(/^[\s\r\n]+/, '');
// Optionally truncate to last N bytes for faster initial load
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const fullSize = cleanBuffer.length;
let truncated = false;
if (tailBytes > 0 && cleanBuffer.length > tailBytes) {
cleanBuffer = cleanBuffer.slice(-tailBytes);
truncated = true;
}
return {
terminalBuffer: cleanBuffer,
status: session.status,
fullSize,
truncated,
};
});