mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1567
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`)
|
||||
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.1566 (must match `package.json` for npm publish)
|
||||
**Version**: 0.1567 (must match `package.json` for npm publish)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
@@ -282,6 +282,81 @@ batchTerminalWrite(data) {
|
||||
|
||||
---
|
||||
|
||||
### ⌨️ Lag-Free Terminal Input — Local Echo with Background Forwarding
|
||||
|
||||
**The problem:** When accessing Claude Code remotely (VPN, Tailscale, SSH tunnel), every keystroke takes 200-300ms to echo back from the server. You type blind, make mistakes you can't see, and the whole experience feels broken. Traditional terminal apps have no solution for this — you're stuck with the round-trip latency.
|
||||
|
||||
**The solution:** Claudeman implements a **Mosh-inspired local echo system** that makes typing feel instant, no matter how far away the server is. Keystrokes appear immediately in a pixel-perfect DOM overlay while simultaneously being forwarded to the PTY in the background.
|
||||
|
||||
#### Why This Is Hard (and Why Nobody Else Does It)
|
||||
|
||||
Claude Code uses [Ink](https://github.com/vadimdemedes/ink) (React for terminals), which **redraws the entire screen on every state change**. This makes the standard approach of writing directly to the terminal buffer impossible — Ink would immediately corrupt any injected characters on its next redraw cycle. Two separate attempts to use `terminal.write()` for local echo both failed for this reason.
|
||||
|
||||
#### How It Works
|
||||
|
||||
The system has three components working together:
|
||||
|
||||
| Component | What It Does |
|
||||
|-----------|-------------|
|
||||
| **DOM Overlay** | A `<span>` positioned inside xterm.js's `.xterm-screen` at z-index 7. Shows typed characters with pixel-perfect font matching. Completely invisible to Ink because it never touches the terminal buffer. |
|
||||
| **Background Forwarding** | Every keystroke is also sent to the PTY in the background (50ms debounced batches). The server's terminal state stays in sync — Tab completion, history, and all shell features work normally. |
|
||||
| **Enter Split** | When you press Enter, any remaining buffered keystrokes are flushed first, then Enter is sent separately after 120ms. This ensures the PTY has the complete text before submission. |
|
||||
|
||||
#### The Overlay: Why a DOM Element Beats Buffer Writes
|
||||
|
||||
```
|
||||
xterm.js DOM structure:
|
||||
├── .xterm-viewport (scrolling)
|
||||
└── .xterm-screen (position: relative)
|
||||
├── .xterm-rows (z-index: 0) ← Ink owns this, redraws constantly
|
||||
├── .xterm-selection (z-index: 1)
|
||||
├── .xterm-helpers (z-index: 5)
|
||||
└── LOCAL ECHO OVERLAY (z-index: 7) ← Our overlay, Ink can't touch it
|
||||
```
|
||||
|
||||
The overlay scans the terminal buffer bottom-up to find Claude Code's `❯` prompt marker, then positions itself right after it. It handles multi-line wrapping when input exceeds the terminal width, renders a block cursor at the end, and uses an opaque background that cleanly covers the server's stale cursor position.
|
||||
|
||||
Font matching is critical: the overlay reads `fontFamily`, `fontSize`, `fontWeight`, and `letterSpacing` directly from xterm.js's `.xterm-rows` computed style, so the echo text is visually indistinguishable from real terminal text.
|
||||
|
||||
#### Background Forwarding: The Key Innovation
|
||||
|
||||
Previous local echo implementations (including our own earlier attempts) buffered input locally and only sent it on Enter. This broke Tab completion, shell history, and any interactive feature that depends on the PTY seeing keystrokes in real-time.
|
||||
|
||||
Background forwarding solves this: every character is queued into a 50ms debounce buffer and sent to the PTY silently. The server processes keystrokes normally — Tab completion works, `Ctrl+R` history search works, everything works. The overlay just provides instant visual feedback while the round-trip happens invisibly.
|
||||
|
||||
```
|
||||
Keystroke Flow:
|
||||
┌─── DOM overlay (instant, 0ms)
|
||||
User types 'h' ─── onData('h') ───┤
|
||||
└─── Background buffer ──[50ms]──→ PTY
|
||||
│
|
||||
Server echoes 'h' ←────────────────────────────────────────────────────┘
|
||||
│ (200-300ms RTT)
|
||||
└──→ flushPendingWrites() ──→ overlay.clear()
|
||||
(server output replaces overlay — seamless transition)
|
||||
```
|
||||
|
||||
#### Edge Cases Handled
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| **Paste** (multi-char) | Appended to overlay + sent to PTY immediately (no debounce) |
|
||||
| **Backspace** | Removes last overlay char + sends `\x7f` to PTY in background |
|
||||
| **Misprediction** | Server output arrives → overlay clears → server redraws correctly |
|
||||
| **Tab completion** | Background forwarding means PTY receives Tab keystroke normally |
|
||||
| **Ctrl+C / escape** | Overlay clears, control char sent immediately |
|
||||
| **Page reload** | Unsent input persisted to `localStorage`, restored on reconnect |
|
||||
| **Ink screen redraw** | Overlay is a separate DOM layer — completely unaffected |
|
||||
| **Tab switch** | Background buffer flushed to outgoing session before switching |
|
||||
|
||||
#### The Result
|
||||
|
||||
Typing over a 200-300ms connection feels identical to localhost. The overlay provides instant character feedback while background forwarding keeps the PTY perfectly synchronized. When the server's echo arrives (200-300ms later), the overlay seamlessly disappears and the real terminal text takes over — the transition is invisible.
|
||||
|
||||
Enabled by default. Works during both idle and busy sessions.
|
||||
|
||||
---
|
||||
|
||||
### 📈 Run Summary ("What Happened While You Were Away")
|
||||
|
||||
Click the chart icon on any session tab to see a complete timeline of what happened:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudeman",
|
||||
"version": "0.1566",
|
||||
"version": "0.1567",
|
||||
"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",
|
||||
|
||||
+12
-5
@@ -958,8 +958,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
*
|
||||
* Uses tmux send-keys for reliable input delivery:
|
||||
* - `-l` flag sends literal text (no key interpretation)
|
||||
* - `Enter` key is sent as a separate argument
|
||||
* - Single command, no delay needed
|
||||
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
|
||||
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
|
||||
*/
|
||||
async sendInput(sessionId: string, input: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
@@ -985,10 +985,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
||||
|
||||
if (textPart && hasCarriageReturn) {
|
||||
// Send text + Enter in a single tmux invocation (one child process).
|
||||
// tmux's \; separator chains commands within one process spawn.
|
||||
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
|
||||
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
|
||||
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
|
||||
// character in the input buffer rather than as form submission.
|
||||
await execAsync(
|
||||
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)} \\; send-keys -t "${session.muxName}" Enter`,
|
||||
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
await execAsync(
|
||||
`tmux send-keys -t "${session.muxName}" Enter`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
} else if (textPart) {
|
||||
|
||||
+47
-17
@@ -48,13 +48,14 @@ const BUILTIN_RESPAWN_PRESETS = [
|
||||
{
|
||||
id: 'solo-work',
|
||||
name: 'Solo',
|
||||
description: 'Claude working alone — fast respawn cycles',
|
||||
description: 'Claude working alone — fast respawn cycles with context reset',
|
||||
config: {
|
||||
idleTimeoutMs: 3000,
|
||||
updatePrompt: 'continue',
|
||||
updatePrompt: 'summarize your progress so far before the context reset.',
|
||||
interStepDelayMs: 2000,
|
||||
sendClear: false,
|
||||
sendInit: false,
|
||||
sendClear: true,
|
||||
sendInit: true,
|
||||
kickstartPrompt: 'continue working. Pick up where you left off based on the context above.',
|
||||
autoAcceptPrompts: true,
|
||||
},
|
||||
durationMinutes: 60,
|
||||
@@ -67,10 +68,11 @@ const BUILTIN_RESPAWN_PRESETS = [
|
||||
description: 'Lead session with Task tool subagents — longer idle tolerance',
|
||||
config: {
|
||||
idleTimeoutMs: 45000,
|
||||
updatePrompt: 'check on your running subagents and continue coordinating their work. If all subagents have finished, summarize their results and proceed with the next step.',
|
||||
updatePrompt: 'check on your running subagents and summarize their results before the context reset. If all subagents have finished, note what was completed and what remains.',
|
||||
interStepDelayMs: 3000,
|
||||
sendClear: false,
|
||||
sendInit: false,
|
||||
sendClear: true,
|
||||
sendInit: true,
|
||||
kickstartPrompt: 'check on your running subagents and continue coordinating their work. If all subagents have finished, summarize their results and proceed with the next step.',
|
||||
autoAcceptPrompts: true,
|
||||
},
|
||||
durationMinutes: 240,
|
||||
@@ -83,10 +85,11 @@ const BUILTIN_RESPAWN_PRESETS = [
|
||||
description: 'Leading an agent team via TeamCreate — tolerates long silences',
|
||||
config: {
|
||||
idleTimeoutMs: 90000,
|
||||
updatePrompt: 'check on your teammates by reviewing the task list and any messages in your inbox. Assign new tasks if teammates are idle, or continue coordinating the team effort.',
|
||||
updatePrompt: 'review the task list and teammate progress. Summarize the current state before the context reset.',
|
||||
interStepDelayMs: 5000,
|
||||
sendClear: false,
|
||||
sendInit: false,
|
||||
sendClear: true,
|
||||
sendInit: true,
|
||||
kickstartPrompt: 'check on your teammates by reviewing the task list and any messages in your inbox. Assign new tasks if teammates are idle, or continue coordinating the team effort.',
|
||||
autoAcceptPrompts: true,
|
||||
},
|
||||
durationMinutes: 480,
|
||||
@@ -922,10 +925,18 @@ class LocalEchoOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
appendText(text) {
|
||||
if (!text) return;
|
||||
this.pendingText += text;
|
||||
this._persist();
|
||||
this._render();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.pendingText = '';
|
||||
this._persist();
|
||||
this._lastRenderKey = '';
|
||||
this._lastPromptPos = null;
|
||||
this.overlay.innerHTML = '';
|
||||
this.overlay.style.display = 'none';
|
||||
}
|
||||
@@ -955,20 +966,26 @@ class LocalEchoOverlay {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Re-scan for prompt on EVERY render — Ink can move it between redraws
|
||||
// Re-scan for prompt on EVERY render — Ink can move it between redraws.
|
||||
// Cache last known position as fallback during Ink redraw flicker.
|
||||
const prompt = this._findPrompt();
|
||||
if (!prompt) { this.overlay.style.display = 'none'; return; }
|
||||
if (prompt) {
|
||||
this._lastPromptPos = prompt;
|
||||
} else if (!this._lastPromptPos) {
|
||||
this.overlay.style.display = 'none'; return;
|
||||
}
|
||||
const activePrompt = this._lastPromptPos;
|
||||
|
||||
const dims = this.terminal._core._renderService.dimensions;
|
||||
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(prompt.col + 2, totalCols - 1);
|
||||
const startCol = Math.min(activePrompt.col + 2, totalCols - 1);
|
||||
const firstLineCols = Math.max(1, totalCols - startCol);
|
||||
|
||||
// Skip redundant re-renders (e.g. from flushPendingWrites at 60fps)
|
||||
const renderKey = `${this.pendingText.length}:${prompt.row}:${prompt.col}:${totalCols}`;
|
||||
const renderKey = `${this.pendingText.length}:${activePrompt.row}:${activePrompt.col}:${totalCols}`;
|
||||
if (renderKey === this._lastRenderKey && this.overlay.style.display !== 'none') return;
|
||||
this._lastRenderKey = renderKey;
|
||||
|
||||
@@ -986,7 +1003,7 @@ class LocalEchoOverlay {
|
||||
|
||||
// Position overlay container at prompt row
|
||||
this.overlay.style.left = '0px';
|
||||
this.overlay.style.top = (prompt.row * cellH) + 'px';
|
||||
this.overlay.style.top = (activePrompt.row * cellH) + 'px';
|
||||
this.overlay.style.boxShadow = 'none';
|
||||
|
||||
// Build line elements — recreate on each render (typically 1-3 divs, negligible cost)
|
||||
@@ -2075,8 +2092,21 @@ class ClaudemanApp {
|
||||
}, 120);
|
||||
return;
|
||||
}
|
||||
if (data.charCodeAt(0) < 32 || data.length > 1) {
|
||||
// Other control chars (Ctrl+C, escape, paste): clear overlay, drain bg, send immediately
|
||||
if (data.length > 1 && data.charCodeAt(0) >= 32) {
|
||||
// Paste: append to overlay + send to PTY immediately
|
||||
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
|
||||
this._localEchoOverlay?.clear();
|
||||
const remainder = drainBgBuffer();
|
||||
if (remainder) {
|
||||
|
||||
@@ -780,6 +780,19 @@
|
||||
<!-- Display Tab -->
|
||||
<div class="modal-tab-content" id="settings-display">
|
||||
<div class="settings-grid">
|
||||
<!-- Input Section -->
|
||||
<div class="settings-section-header">Input</div>
|
||||
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Local Echo</span>
|
||||
<span class="settings-item-desc">Instant typing feedback with local echo</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsLocalEcho">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Header Displays Section -->
|
||||
<div class="settings-section-header">Header Displays</div>
|
||||
<div class="settings-item" title="Show A-/A+ font size buttons in header">
|
||||
@@ -886,15 +899,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Input Options Section -->
|
||||
<div class="settings-section-header">Input</div>
|
||||
<div class="settings-item" title="Buffer keystrokes locally and show them instantly via overlay. Text is sent to the server on Enter. Useful for high-latency connections or to compose input before sending.">
|
||||
<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>
|
||||
|
||||
@@ -2523,6 +2523,25 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.settings-item-multiline {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-item-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-item-desc {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.settings-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user