Compare commits

..
Author SHA1 Message Date
arkonandClaude Opus 4.6 0c4aac8029 chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:05:16 +01:00
arkonandClaude Opus 4.6 d436c6375f fix: strip Ink spinner bloat from terminal buffer before tailing
During long thinking phases, Ink's TUI rewrites the spinner/status bar
thousands of times via absolute cursor positioning (VPA/CUP). These
500KB+ of redraw frames pushed real content out of the 128KB tail
window, making the terminal appear empty when switching tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:55:24 +01:00
7 changed files with 82 additions and 26 deletions

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# aicodeman # aicodeman
## 0.4.5
### Patch Changes
- Fix mobile keyboard toolbar positioning on iOS Safari: toolbar (Run/Stop/Run Shell) was hidden behind the accessory bar when virtual keyboard was active due to overlapping CSS positions. Remove the aggressive safety check in `updateLayoutForKeyboard()` that incorrectly dismissed keyboard state when iOS scrolled the visual viewport during typing. Add Safari-bar CSS offset to accessory bar so it properly stacks above the toolbar. Remove the double-counted Safari-bar offset when keyboard is visible since the JS transform already covers the full distance.
## 0.4.4 ## 0.4.4
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -52,7 +52,7 @@ When user says "COM":
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json` 4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` 5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.4.4 (must match `package.json`) **Version**: 0.4.5 (must match `package.json`)
## Project Overview ## Project Overview
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.4.4", "version": "0.4.5",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+17 -21
View File
@@ -272,35 +272,31 @@ const KeyboardHandler = {
const main = document.querySelector('.main'); const main = document.querySelector('.main');
if (this.keyboardVisible) { if (this.keyboardVisible) {
// Calculate keyboard offset // Calculate how far the toolbar (position:fixed, bottom:0) needs to
// translate up so it sits at the bottom of the visual viewport.
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
// when the user types in xterm's hidden textarea.
const layoutHeight = window.innerHeight; const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = layoutHeight - visualBottom; const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
// Safety: if keyboard is supposedly visible but offset is 0 or negative, // Move toolbar and accessory bar above keyboard.
// the keyboard is actually gone — force dismiss. This catches cases where // When keyboardOffset is 0 (viewport scrolled to layout bottom),
// visualViewport.resize fires late or with intermediate values on iOS. // the bars are naturally positioned via their CSS bottom values —
if (keyboardOffset <= 0) { // just clear the transforms. Never dismiss keyboard state here;
this.keyboardVisible = false; // that's handleViewportResize's job.
document.body.classList.remove('keyboard-visible');
this.onKeyboardHide();
return;
}
// Move toolbar up above keyboard
if (toolbar) { if (toolbar) {
toolbar.style.transform = `translateY(${-keyboardOffset}px)`; toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
} }
// Move accessory bar up (it sits above toolbar)
if (accessoryBar) { if (accessoryBar) {
accessoryBar.style.transform = `translateY(${-keyboardOffset}px)`; accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
} }
// Shrink main content area so terminal doesn't extend behind keyboard // Shrink main content area so terminal doesn't extend behind keyboard.
// Account for keyboard height + toolbar height (40px) + accessory bar (44px) // Use stable keyboard height (not scroll-dependent) for padding.
if (main) { const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
main.style.paddingBottom = `${keyboardOffset + 94}px`; if (main && keyboardHeight > 0) {
main.style.paddingBottom = `${keyboardHeight + 94}px`;
} }
} else { } else {
this.resetLayout(); this.resetLayout();
+11
View File
@@ -534,6 +534,17 @@ html.mobile-init .file-browser-panel {
bottom: calc(var(--safe-area-bottom) + (100vh - var(--app-height, 100vh))); bottom: calc(var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
} }
/* When keyboard is visible the JS translateY already accounts for the full
distance from the visual-viewport bottom to the layout-viewport bottom
(keyboard + Safari bar). Remove the CSS Safari-bar offset to avoid
double-counting, which otherwise creates a visible gap above the keyboard. */
.keyboard-visible.ios-device.safari-browser .toolbar {
bottom: var(--safe-area-bottom);
}
.keyboard-visible.ios-device.safari-browser .keyboard-accessory-bar {
bottom: calc(var(--safe-area-bottom) + 40px);
}
/* Show case selector in center */ /* Show case selector in center */
.toolbar-center { .toolbar-center {
display: flex !important; display: flex !important;
+42 -3
View File
@@ -51,6 +51,39 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
const CTRL_L_PATTERN = /\x0c/g; const CTRL_L_PATTERN = /\x0c/g;
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/; const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
/**
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA, CSI n;m H = CUP)
* to animate the spinner and update the status bar. During long thinking phases, these frames
* accumulate to 500KB+ of repeated overwrites to the same rows. When the buffer is tailed,
* only spinner frames are returned, making the terminal appear empty.
*
* Strategy: find where absolute-positioned redraws begin (first VPA sequence), then keep
* only the last ~4KB of redraw frames (the final visual state) and discard the rest.
*/
function stripInkRedrawBloat(buffer: string): string {
// Find where Ink's absolute-positioned redraws start (first CSI n d = VPA)
// eslint-disable-next-line no-control-regex
const firstVPA = buffer.search(/\x1b\[\d+d/);
if (firstVPA === -1) return buffer; // No Ink redraws
const contentPart = buffer.slice(0, firstVPA);
const redrawPart = buffer.slice(firstVPA);
// If the redraw section is small (<16KB), not worth stripping
if (redrawPart.length < 16384) return buffer;
// Keep only the last 4KB of redraw frames — this preserves the final visual state
// (spinner position, status bar text, token count, etc.)
const tail = redrawPart.slice(-4096);
// Avoid starting mid-escape: find first complete frame boundary
// eslint-disable-next-line no-control-regex
const frameStart = tail.search(/\x1b\(B\x1b\[m|\x1b\[\d+d|\x1b\[\d+;\d+H/);
const cleanTail = frameStart > 0 ? tail.slice(frameStart) : tail;
return contentPart + cleanTail;
}
export function registerSessionRoutes( export function registerSessionRoutes(
app: FastifyInstance, app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
@@ -534,10 +567,16 @@ export function registerSessionRoutes(
let truncated = false; let truncated = false;
let cleanBuffer: string; let cleanBuffer: string;
if (tailBytes > 0 && fullSize > tailBytes) { // Strip redundant Ink spinner/status redraws BEFORE tailing.
// During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs.
const strippedBuffer = stripInkRedrawBloat(session.terminalBuffer);
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer. // Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
// Banner is near the top and gets discarded by tail anyway. // Banner is near the top and gets discarded by tail anyway.
cleanBuffer = session.terminalBuffer.slice(-tailBytes); cleanBuffer = strippedBuffer.slice(-tailBytes);
truncated = true; truncated = true;
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB // 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 // and start from there. This prevents xterm.js from parsing a partial escape
@@ -548,7 +587,7 @@ export function registerSessionRoutes(
} }
} else { } else {
// Full buffer: clean junk before actual Claude content // Full buffer: clean junk before actual Claude content
cleanBuffer = session.terminalBuffer; cleanBuffer = strippedBuffer;
// Find where Claude banner starts (has color codes before "Claude") // Find where Claude banner starts (has color codes before "Claude")
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN); const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}