chore: bump version to 0.1502

This commit is contained in:
arkon
2026-02-13 20:25:56 +01:00
parent 3523bbe3af
commit d63fe2063f
8 changed files with 334 additions and 125 deletions
+1 -1
View File
@@ -25,7 +25,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.1501 (must match `package.json` for npm publish)
**Version**: 0.1502 (must match `package.json` for npm publish)
## Project Overview
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claudeman",
"version": "0.1501",
"version": "0.1502",
"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",
+61
View File
@@ -0,0 +1,61 @@
/**
* Take screenshots of the typing-visible-below bug.
* Intercepts input to prevent damage to live sessions.
*/
import { chromium } from 'playwright';
async function main() {
const browser = await chromium.launch({ headless: true, args: ['--window-size=1280,800'] });
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
// Intercept input to prevent sending to real sessions
await page.route('**/api/sessions/*/input', route => route.fulfill({
status: 200, contentType: 'application/json', body: '{"success":true}'
}));
console.log('Opening Claudeman...');
await page.goto('http://localhost:3000');
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(3000);
// Screenshot 1: Before typing
await page.screenshot({ path: '/tmp/before-typing.png' });
console.log('Screenshot 1: /tmp/before-typing.png');
// Click on the terminal to focus it
await page.click('.xterm-screen');
await page.waitForTimeout(500);
// Type a short string
await page.keyboard.type('hello world this is a test', { delay: 30 });
await page.waitForTimeout(500);
await page.screenshot({ path: '/tmp/after-short-typing.png' });
console.log('Screenshot 2: /tmp/after-short-typing.png');
// Type a LONG string that approaches the right edge
await page.keyboard.type(' and now I am typing a very long line that should approach the right edge of the terminal to test whether text wraps incorrectly below the status bar area which would be the typing visible below bug', { delay: 15 });
await page.waitForTimeout(500);
await page.screenshot({ path: '/tmp/after-long-typing.png' });
console.log('Screenshot 3: /tmp/after-long-typing.png');
// Wait for flush + SIGWINCH to settle
await page.waitForTimeout(1000);
await page.screenshot({ path: '/tmp/after-settle.png' });
console.log('Screenshot 4: /tmp/after-settle.png (after 1s settle)');
// Try typing even MORE to really push past the edge
await page.keyboard.type(' AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH IIII JJJJ KKKK LLLL MMMM NNNN OOOO PPPP', { delay: 10 });
await page.waitForTimeout(500);
await page.screenshot({ path: '/tmp/after-overflow.png' });
console.log('Screenshot 5: /tmp/after-overflow.png (past right edge)');
await page.waitForTimeout(1000);
await page.screenshot({ path: '/tmp/after-overflow-settle.png' });
console.log('Screenshot 6: /tmp/after-overflow-settle.png (settled)');
await browser.close();
console.log('Done');
}
main().catch(err => { console.error(err); process.exit(1); });
+3 -8
View File
@@ -883,15 +883,10 @@ 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 command
// -l flag = literal text (no special key interpretation)
// 'Enter' after -l text = Enter key
// Send text + Enter in a single tmux invocation (one child process).
// tmux's \; separator chains commands within one process spawn.
await execAsync(
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
{ timeout: EXEC_TIMEOUT_MS }
);
await execAsync(
`tmux send-keys -t "${session.muxName}" Enter`,
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)} \\; send-keys -t "${session.muxName}" Enter`,
{ timeout: EXEC_TIMEOUT_MS }
);
} else if (textPart) {
+10 -102
View File
@@ -1242,15 +1242,10 @@ class ClaudemanApp {
// Sequential input send chain — ensures keystroke ordering across async fetches
this._inputSendChain = Promise.resolve();
// Local echo with output hold — shows typed chars instantly on high-latency connections.
// Instead of stripping echoed chars from Ink output (fragile), we simply hold ALL server
// output while typing and flush it when the user pauses. Ink's final redraw replaces
// any locally-echoed chars with the correct server state.
this._localEchoActive = false; // Whether we have unconfirmed locally-echoed chars
this._localEchoCount = 0; // Number of unconfirmed chars
this._localEchoHoldBuffer = []; // Server output held during typing
this._localEchoFlushTimer = null; // Timer to flush after typing pause
this._localEchoFlushDelay = 200; // ms after last keystroke before flushing server output
// No local echo — let PTY/Ink handle all character echoing.
// Local echo was removed because it's fundamentally incompatible with Ink's
// terminal management (caused "typing visible below" bug where locally-echoed
// chars leaked onto Ink's status bar rows).
// Accessibility: Focus trap for modals
this.activeFocusTrap = null;
@@ -1550,25 +1545,17 @@ class ClaudemanApp {
this.terminalResizeObserver = new ResizeObserver(throttledResize);
this.terminalResizeObserver.observe(container);
// Handle keyboard input with batching for rapid keystrokes
// Handle keyboard input — send to PTY immediately, no local echo.
// PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug).
this._pendingInput = '';
this._inputFlushTimeout = null;
this._inputFlushDelay = 8; // Flush at ~120fps for snappy input
const flushInput = () => {
// Clear timeout immediately so new keystrokes can schedule fresh flushes.
// Previously this was set AFTER the await fetch(), which meant the stale
// timer ID blocked new keystrokes from scheduling — serializing all input
// behind the previous fetch's full network round-trip.
this._inputFlushTimeout = null;
if (this._pendingInput && this.activeSessionId) {
const input = this._pendingInput;
const sessionId = this.activeSessionId;
this._pendingInput = '';
// Fire-and-forget: don't block the flush cycle waiting for network.
// This prevents keystroke serialization behind fetch RTT.
this._sendInputAsync(sessionId, input);
}
};
@@ -1581,36 +1568,9 @@ class ClaudemanApp {
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
// Local echo: show printable chars instantly during idle state.
// Reduces perceived latency on remote connections (e.g., 100-300ms RTT).
// Server output is held while typing and flushed when user pauses.
const session = this.sessions.get(this.activeSessionId);
const isIdle = session && session.status === 'idle';
if (isIdle && data.length === 1) {
const code = data.charCodeAt(0);
if (code >= 32 && code < 127) {
// Printable ASCII — echo immediately
this.terminal.write(data);
this._localEchoActive = true;
this._localEchoCount++;
this._resetLocalEchoFlush();
} else if ((code === 0x7f || code === 0x08) && this._localEchoCount > 0) {
// Backspace — undo last local echo char
this.terminal.write('\b \b');
this._localEchoCount--;
if (this._localEchoCount === 0) this._localEchoActive = false;
this._resetLocalEchoFlush();
}
}
// Control chars (Enter, Ctrl+C) clear local echo immediately
if (data.charCodeAt(0) < 32 || data.length > 1) {
this._clearLocalEcho();
}
this._pendingInput += data;
// Flush immediately for control characters (Enter, Ctrl+C, etc.)
// Control chars (Enter, Ctrl+C, escape sequences) — flush immediately
if (data.charCodeAt(0) < 32 || data.length > 1) {
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
@@ -1620,9 +1580,10 @@ class ClaudemanApp {
return;
}
// Batch regular input at high frequency for responsiveness
// Regular chars — flush on next microtask to coalesce rapid keystrokes
// (e.g. paste) while keeping latency near-zero for single keystrokes.
if (!this._inputFlushTimeout) {
this._inputFlushTimeout = setTimeout(flushInput, this._inputFlushDelay);
this._inputFlushTimeout = setTimeout(flushInput, 0);
}
}
});
@@ -1754,15 +1715,6 @@ class ClaudemanApp {
}
batchTerminalWrite(data) {
// Local echo output hold: while user is actively typing (local echo active),
// buffer server output instead of writing it. This prevents Ink's intermediate
// redraws from overwriting locally-echoed characters. The buffer is flushed
// when typing pauses (200ms), giving the server time to catch up.
if (this._localEchoActive) {
this._localEchoHoldBuffer.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) {
@@ -2296,10 +2248,6 @@ class ClaudemanApp {
const session = this.sessions.get(data.id);
if (session) {
session.status = 'busy';
// Clear local echo — server is now generating output
if (data.id === this.activeSessionId) {
this._clearLocalEcho();
}
// Only clear tab alert if no pending hooks (permission_prompt, elicitation_dialog, etc.)
if (!this.pendingHooks.has(data.id)) {
this.tabAlerts.delete(data.id);
@@ -3044,46 +2992,6 @@ class ClaudemanApp {
});
}
/**
* Reset the local echo flush timer. Called on each keystroke.
* After the user stops typing for _localEchoFlushDelay ms, flushes
* held server output to the terminal.
*/
_resetLocalEchoFlush() {
if (this._localEchoFlushTimer) clearTimeout(this._localEchoFlushTimer);
this._localEchoFlushTimer = setTimeout(() => {
this._flushLocalEcho();
}, this._localEchoFlushDelay);
}
/**
* Flush held server output and clear local echo state.
* Called when typing pauses, session goes busy, or control char sent.
*/
_flushLocalEcho() {
if (this._localEchoFlushTimer) {
clearTimeout(this._localEchoFlushTimer);
this._localEchoFlushTimer = null;
}
this._localEchoActive = false;
this._localEchoCount = 0;
// Flush all held server output through the normal write path
if (this._localEchoHoldBuffer.length > 0) {
const held = this._localEchoHoldBuffer;
this._localEchoHoldBuffer = [];
for (const chunk of held) {
this.batchTerminalWrite(chunk);
}
}
}
/**
* Clear local echo state immediately (on Enter, Ctrl+C, session busy).
*/
_clearLocalEcho() {
this._flushLocalEcho();
}
_enqueueInput(sessionId, input) {
const existing = this._inputQueue.get(sessionId) || '';
+5 -3
View File
@@ -1669,9 +1669,11 @@ body {
flex: 1;
background: #0d0d0d;
overflow: hidden;
/* Performance: isolate layout and paint operations */
contain: strict;
will-change: contents;
position: relative;
/* Performance: isolate layout and paint operations.
Do NOT use contain:strict — size containment breaks xterm.js
FitAddon dimension calculations. */
contain: layout paint;
}
/* Tab switch: green-tinted overlay fades in over old content, fades out to reveal new.
Animated via Web Animations API (not CSS keyframes) for reliable GPU compositing.
+12 -10
View File
@@ -1526,21 +1526,23 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
}
// Use writeViaScreen for programmatic input (more reliable for mux sessions)
let success = false;
// Write input to PTY. Direct write is synchronous; writeViaScreen
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
if (useScreen) {
success = await session.writeViaScreen(inputStr);
if (!success) {
console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`);
// Fallback to direct write if screen write fails
// Fire-and-forget: don't block HTTP response on tmux child process.
// Fallback to direct write on failure.
session.writeViaScreen(inputStr).then(ok => {
if (!ok) {
console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`);
session.write(inputStr);
}
}).catch(() => {
session.write(inputStr);
success = true; // Direct write doesn't return status, assume success
}
});
} else {
session.write(inputStr);
success = true;
}
return { success };
return { success: true };
});
// Resize session terminal
+241
View File
@@ -0,0 +1,241 @@
/**
* Input echo test: Verifies that typing in the web UI doesn't produce
* "visible below" artifacts (text mirrored onto Ink's status bar rows).
*
* Test approach:
* 1. Creates a SHELL session (bash, not Claude) to avoid Ink complexity
* 2. Types various input patterns and captures terminal state
* 3. Verifies echo appears at correct cursor position, not duplicated
*
* Usage: node test/input-echo-test.mjs [--live]
* --live Use existing sessions instead of creating new ones
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
async function createShellSession() {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'shell', name: 'input-test' })
});
if (!resp.ok) throw new Error(`Failed to create session: ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForSessionReady(sessionId, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const resp = await fetch(`${BASE}/api/sessions/${sessionId}`);
if (resp.ok) {
const data = await resp.json();
if (data.status === 'idle' || data.status === 'busy') return true;
}
await new Promise(r => setTimeout(r, 200));
}
return false;
}
async function getTerminalContent(page) {
return page.evaluate(() => {
const app = window.app;
if (!app?.terminal) return null;
const buf = app.terminal.buffer.active;
const lines = [];
for (let i = 0; i <= buf.baseY + buf.viewportY + app.terminal.rows; i++) {
const line = buf.getLine(i);
if (line) lines.push(line.translateToString(true));
}
return {
lines,
cursorX: buf.cursorX,
cursorY: buf.cursorY,
rows: app.terminal.rows,
cols: app.terminal.cols,
};
});
}
async function main() {
const isLive = process.argv.includes('--live');
let sessionId = null;
console.log('=== Input Echo Test ===\n');
// Create a shell session for testing
if (!isLive) {
console.log('Creating shell session...');
sessionId = await createShellSession();
console.log(`Session created: ${sessionId}`);
const ready = await waitForSessionReady(sessionId);
if (!ready) {
console.error('Session failed to become ready');
await deleteSession(sessionId);
process.exit(1);
}
console.log('Session ready\n');
}
const browser = await chromium.launch({
headless: true,
args: ['--window-size=1280,800']
});
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
// Collect console output for debugging
const consoleLogs = [];
page.on('console', msg => consoleLogs.push(`[${msg.type()}] ${msg.text()}`));
try {
console.log('Opening Claudeman...');
await page.goto(BASE);
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Switch to our test session if not live
if (sessionId) {
// Click the test session tab
const tabClicked = await page.evaluate((id) => {
const tabs = document.querySelectorAll('.session-tab');
for (const tab of tabs) {
if (tab.dataset.sessionId === id) {
tab.click();
return true;
}
}
return false;
}, sessionId);
if (tabClicked) {
await page.waitForTimeout(1000);
console.log('Switched to test session');
}
}
// Focus the terminal
await page.click('.xterm-screen');
await page.waitForTimeout(500);
// Take before screenshot
await page.screenshot({ path: '/tmp/input-test-before.png' });
console.log('Screenshot: /tmp/input-test-before.png (before typing)');
// Get terminal state before typing
const beforeState = await getTerminalContent(page);
const beforeCursorY = beforeState?.cursorY ?? -1;
console.log(`Before: cursor at row ${beforeCursorY}, col ${beforeState?.cursorX}`);
// TEST 1: Type a short string
console.log('\n--- Test 1: Short string ---');
const testStr = 'echo hello-world-test-123';
await page.keyboard.type(testStr, { delay: 30 });
await page.waitForTimeout(600); // Wait for PTY echo round-trip
const afterShort = await getTerminalContent(page);
await page.screenshot({ path: '/tmp/input-test-short.png' });
console.log('Screenshot: /tmp/input-test-short.png');
// Check: cursor should still be on same row (or +1 for wrap)
// and text should appear at/near the cursor position
const cursorRowAfter = afterShort?.cursorY ?? -1;
console.log(`After: cursor at row ${cursorRowAfter}, col ${afterShort?.cursorX}`);
// The echo should be on the cursor's row, not below
const echoLine = afterShort?.lines?.[afterShort.cursorY + (afterShort.lines.length - afterShort.rows)] ?? '';
console.log(`Echo line: "${echoLine.trim()}"`);
if (echoLine.includes(testStr)) {
console.log('PASS: Echo found at cursor row');
} else {
console.log('INFO: Echo may be on a different row (check screenshots)');
}
// TEST 2: Press Enter and check response
console.log('\n--- Test 2: Enter key ---');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
const afterEnter = await getTerminalContent(page);
await page.screenshot({ path: '/tmp/input-test-enter.png' });
console.log('Screenshot: /tmp/input-test-enter.png');
// Check for "hello-world-test-123" in the output
const allText = afterEnter?.lines?.join('\n') ?? '';
if (allText.includes('hello-world-test-123')) {
console.log('PASS: Command output visible');
} else {
console.log('WARN: Command output not found');
}
// TEST 3: Type a LONG string that approaches right edge
console.log('\n--- Test 3: Long string (near right edge) ---');
const longStr = 'echo ' + 'A'.repeat(afterShort?.cols ? afterShort.cols - 10 : 150);
await page.keyboard.type(longStr, { delay: 10 });
await page.waitForTimeout(800);
const afterLong = await getTerminalContent(page);
await page.screenshot({ path: '/tmp/input-test-long.png' });
console.log('Screenshot: /tmp/input-test-long.png');
console.log(`After long: cursor at row ${afterLong?.cursorY}, col ${afterLong?.cursorX}`);
// Check that no text leaked below the cursor row
const cursorAbsRow = (afterLong?.lines?.length ?? 0) - (afterLong?.rows ?? 0) + (afterLong?.cursorY ?? 0);
const rowsBelow = afterLong?.lines?.slice(cursorAbsRow + 1) ?? [];
const leakedText = rowsBelow.filter(l => l.trim().length > 0 && l.includes('AAA'));
if (leakedText.length === 0) {
console.log('PASS: No text leaked below cursor row');
} else {
console.log(`FAIL: Text leaked below cursor: ${leakedText.map(l => `"${l.trim()}"`).join(', ')}`);
}
// TEST 4: Rapid typing (paste-like)
console.log('\n--- Test 4: Rapid input (paste simulation) ---');
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
// Type rapidly (no delay between chars)
await page.keyboard.type('echo rapid-test-no-delay-abcdefghij', { delay: 0 });
await page.waitForTimeout(800);
await page.screenshot({ path: '/tmp/input-test-rapid.png' });
console.log('Screenshot: /tmp/input-test-rapid.png');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
const afterRapid = await getTerminalContent(page);
const rapidText = afterRapid?.lines?.join('\n') ?? '';
if (rapidText.includes('rapid-test-no-delay-abcdefghij')) {
console.log('PASS: Rapid input command output visible');
} else {
console.log('WARN: Rapid input output not found (check screenshots)');
}
// Final screenshot
await page.screenshot({ path: '/tmp/input-test-final.png' });
console.log('\nScreenshot: /tmp/input-test-final.png (final state)');
// Summary
console.log('\n=== Test Complete ===');
console.log('Check screenshots in /tmp/input-test-*.png');
console.log('Key check: no text should appear below the active cursor row');
} finally {
await browser.close();
if (sessionId && !isLive) {
console.log(`\nCleaning up session ${sessionId}...`);
await deleteSession(sessionId);
}
}
}
main().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});