Compare commits

...
Author SHA1 Message Date
Codeman maintainer 3172befd5d fix(terminal): keep Claude scrollback reachable — strip alt-screen/3J/mouse for claude mode (v1.1.7)
Terminal scroll-up intermittently broke for Claude sessions (most visible on
iPhone). Claude Code periodically emits alt-screen switches (?1049h/?47h/?1047h),
scrollback-erase (3J), and mouse-tracking enables for full-screen UIs, which move
xterm.js to the scrollback-less alt buffer / wipe saved lines / hijack the wheel.
Codeman stripped these but only for codex mode.

Share the strip via isAltScreenStripMode(mode) = codex || claude, applied at both
sites that were codex-only: the live PTY stream (Session._handleTerminalOutput,
incl. the chunk-boundary carry) and the /terminal buffer replay. shell stays
excluded (vim/less/htop need the alt screen); opencode unchanged.

Tests: test/claude-scrollback-strip.test.ts (8 new); codex strip tests unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:08:45 +02:00
Codeman maintainer 29ffc62536 fix(ultracode): pop floating windows on fresh devices loading mid-run (v1.1.6)
Re-run syncAllUltracodeFloatingWindows() after server settings load so a
first-time device whose getLightState run snapshot arrives before the async
settings fetch resolves still pops an already-active run's window immediately,
instead of waiting for the next ~10s watcher tick. Also fixes a stale
@fileoverview comment that named the wrong gating setting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:27:10 +02:00
Codeman maintainer 4cb3a4aac8 fix(ultracode): (x) Close fully hides the Ultracode Agents panel
closeUltracodeAgentsPanel() only removed `open`, leaving the drawer in its
collapsed peek state (header strip still visible) — so (x) looked like a no-op.
Now also adds `hidden` (display:none), mirroring closeSubagentsPanel; does NOT
flip showUltracodeAgents (that gates the watcher + floating windows). Verified in
a real browser (post-close computed display:none). Bumps 1.1.4 -> 1.1.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:46:53 +02:00
10 changed files with 195 additions and 30 deletions
+30
View File
@@ -1,5 +1,35 @@
# aicodeman
## 1.1.7
### Patch Changes
- Fix: terminal scroll-up (scrollback) intermittently breaking for **Claude** sessions — most visible on iPhone, where you suddenly "can't scroll up the Claude console."
Root cause: Claude Code periodically emits alternate-screen switches (`\x1b[?1049h`/`\x1b[?47h`/`\x1b[?1047h`), scrollback-erase (`\x1b[3J`), and mouse-tracking enables — typically when it draws a full-screen UI (pickers/dialogs, the boot welcome). xterm.js obeys these by moving to the scrollback-less alternate buffer (or wiping saved lines / hijacking the wheel), so the conversation history becomes unreachable until Claude returns to its normal view. Codeman already stripped these sequences so history stays scrollable, but the strip was gated to **Codex mode only** — Claude (and the equivalent buffer-replay path) let them through.
The strip is now shared via a single `isAltScreenStripMode(mode)` predicate (`codex || claude`) applied at BOTH sites that were Codex-only: the live PTY stream (`Session._handleTerminalOutput`, including the split-across-chunks carry reassembly) and the `/terminal` buffer replay used on tab-switch/reconnect. `shell` is deliberately excluded so full-screen TUIs run from a shell (vim/less/htop) keep their alternate screen; `opencode` is also unchanged.
Verified end-to-end on an isolated instance against a real Claude session: the replayed buffer and live stream now carry zero alt-screen/scrollback-erase/mouse sequences, the terminal stays in the normal buffer with scrollback intact, and touch swipe-up scrolls correctly. Covered by new unit tests (`test/claude-scrollback-strip.test.ts`); the existing Codex strip tests are unchanged.
## 1.1.6
### Patch Changes
- Fix: ultracode floating run windows now pop on a fresh device/browser that loads while a run is already active.
`ultracodeFloatingWindows` syncs from the server (it's a non-display setting), but on a first-time device the SSE `getLightState` run snapshot can seed the run list BEFORE the async settings load resolves — so the floating-window gate read `false` at that instant and skipped any already-active run, leaving the window un-popped until the next ~10s watcher tick. The app now re-runs `syncAllUltracodeFloatingWindows()` once server settings finish loading (in the `loadAppSettingsFromServer().then()` callback), so an in-flight run pops its window immediately. Idempotent: open windows are left as-is, and if the setting is off any premature windows are torn down. Verified end-to-end against a real in-flight run on an isolated instance — a pristine browser (empty localStorage) seeds the setting from the server and pops the active run's window ~0.4s after first paint.
Also corrected a stale `@fileoverview` comment in `ultracode-windows.js` that claimed the floating windows are gated on `showUltracodeAgents`; they are gated on the dedicated `ultracodeFloatingWindows` toggle (only the docked "Ultracode Agents" panel uses `showUltracodeAgents`).
## 1.1.5
### Patch Changes
- Fix: the Ultracode Agents panel's (×) Close button now fully hides the panel.
`closeUltracodeAgentsPanel()` only removed the `open` class, which drops the bottom-docked drawer to its collapsed _peek_ state (the 36px header strip stays visible) rather than closing it — so clicking (×) looked like it did nothing. It now also adds the `hidden` class (`display:none`), mirroring `closeSubagentsPanel()`. It deliberately does NOT flip the `showUltracodeAgents` setting (that also gates the run watcher and floating windows); the header launcher button reopens the panel. Verified in a real browser: after (×) the panel computes `display:none`.
## 1.1.4
### Patch Changes
+1 -1
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.1.4 (must match `package.json`)
**Version**: 1.1.7 (must match `package.json`)
## Project Overview
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.1.4",
"version": "1.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.1.4",
"version": "1.1.7",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.1.4",
"version": "1.1.7",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+34 -13
View File
@@ -136,6 +136,21 @@ export function isExternalCliMode(mode: SessionMode): boolean {
return mode === 'opencode' || mode === 'codex';
}
/**
* Modes whose TUI emits alt-screen / scrollback-erase / mouse-tracking sequences
* that we strip so the browser keeps everything in the main buffer with scrollback
* reachable (the strip runs on both the live stream and the buffer replay).
*
* Codex and Claude Code are known, controlled TUIs that repaint via cursor
* positioning, so dropping the alt-screen switch is safe — content stays in the
* normal buffer. Excluded: `shell` (arbitrary programs like vim/less/htop
* legitimately need the alt screen) and `opencode` (renders its own TUI that
* may rely on it). Keep parity with the replay-side strip in session-routes.ts.
*/
export function isAltScreenStripMode(mode: SessionMode): boolean {
return mode === 'codex' || mode === 'claude';
}
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
@@ -265,9 +280,10 @@ export class Session extends EventEmitter {
private _messages: ClaudeMessage[] = [];
private _lineBuffer: string = '';
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
// Codex only: trailing partial CSI held back so sequences split across PTY
// chunks can't slip past the alt-screen/scrollback strip (see _handleTerminalOutput)
private _codexSeqCarry: string = '';
// Alt-screen-strip modes (Codex/Claude): trailing partial CSI held back so
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
// strip (see _handleTerminalOutput / isAltScreenStripMode)
private _altScreenSeqCarry: string = '';
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
@@ -1134,35 +1150,40 @@ export class Session extends EventEmitter {
}
private _handleTerminalOutput(data: string): void {
// Codex emits sequences that wipe xterm.js scrollback, plus mouse-tracking
// enables that hijack the scroll wheel so the user can't reach scrollback:
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
// scrollback. Claude Code does this intermittently (e.g. full-screen pickers /
// dialogs), which is why terminal scroll-up "randomly" breaks for Claude
// sessions on mobile and desktop until the dialog closes:
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
// scrollback) — \x1b[?...l switches back.
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
// the visible viewport — are left intact; the TUI repaints those rows.)
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
// xterm.js forwards wheel events to codex instead of scrolling the
// xterm.js forwards wheel events to the CLI instead of scrolling the
// viewport, so the conversation is in scrollback but unreachable.
// (Focus events at ?1004 are left alone — codeman uses them for
// active-tab detection.)
// Strip them at the source so neither the persisted buffer nor the live
// SSE/WS stream carries them, keeping everything in the main buffer with
// scrollback intact. Codex's cursor-positioned redraws overwrite only the
// cells they actually target, so the non-erased rows keep their content.
if (this.mode === 'codex') {
// scrollback intact. These are controlled TUIs whose cursor-positioned
// redraws overwrite only the cells they target, so non-erased rows keep
// their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must
// keep the alt screen for vim/less/htop.
if (isAltScreenStripMode(this.mode)) {
// Reassemble sequences split across PTY chunk boundaries first: a chunk
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
// strip below and leave xterm stuck in the scrollback-less alt buffer
// until the next buffer replay. Hold back an incomplete digit-only CSI
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
// prepend it to the next chunk; complete sequences are never held.
data = this._codexSeqCarry + data;
this._codexSeqCarry = '';
data = this._altScreenSeqCarry + data;
this._altScreenSeqCarry = '';
// eslint-disable-next-line no-control-regex
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
if (splitTail) {
this._codexSeqCarry = splitTail[0];
this._altScreenSeqCarry = splitTail[0];
data = data.slice(0, -splitTail[0].length);
if (!data) return;
}
@@ -1809,7 +1830,7 @@ export class Session extends EventEmitter {
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._codexSeqCarry = '';
this._altScreenSeqCarry = '';
this._lastActivityAt = Date.now();
}
+10
View File
@@ -665,6 +665,16 @@ class CodemanApp {
this.applySkin();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
// FRESH device the getLightState run snapshot can seed workflowRuns BEFORE this
// async settings load resolves — so the floating-window gate read false then and
// skipped any already-active run. Re-sync now that the real setting is loaded so
// an in-flight run pops its window immediately instead of waiting for the next
// ~10s SSE tick. Idempotent: open windows are left as-is; if the setting is off
// it tears any premature windows down.
if (typeof this.syncAllUltracodeFloatingWindows === 'function') {
this.syncAllUltracodeFloatingWindows();
}
});
// Hide loading skeleton now that the app shell is ready
document.body.classList.add('app-loaded');
+8 -1
View File
@@ -78,8 +78,15 @@ Object.assign(CodemanApp.prototype, {
if (panel.classList.contains('open')) this.renderUltracodeAgentsPanel();
},
closeUltracodeAgentsPanel() {
// The X must FULLY hide the panel. Removing only `open` drops it to the
// collapsed peek state (header strip still visible), so add `hidden`
// (display:none) too — mirrors closeSubagentsPanel. Not the showUltracodeAgents
// setting: that also gates the watcher + floating windows; the launcher reopens.
const panel = document.getElementById('ultracodeAgentsPanel');
if (panel) panel.classList.remove('open');
if (panel) {
panel.classList.remove('open');
panel.classList.add('hidden');
}
},
// ----- Selection -----
+3 -1
View File
@@ -4,7 +4,9 @@
*
* This is the "floating thing" companion to the docked master-detail panel in
* `ultracode-panel.js` (the dock panel stays — these windows are ADDITIONAL).
* When the `showUltracodeAgents` setting is on, a small floating window pops up
* When the `ultracodeFloatingWindows` setting is on (a DEDICATED toggle, separate
* from the dock panel's `showUltracodeAgents` — see `_ultracodeFloatingEnabled`),
* a small floating window pops up
* for each ACTIVE ultracode/Workflow run (status not completed/killed/failed),
* mirroring the live agent grid, and is connected by a glowing line to the
* Codeman tab whose `claudeSessionId` matches the run's `sessionUuid` — the same
+13 -11
View File
@@ -18,7 +18,7 @@ import {
type ApiResponse,
type SessionColor,
} from '../../types.js';
import { Session } from '../../session.js';
import { Session, isAltScreenStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
import {
CreateSessionSchema,
@@ -79,12 +79,13 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
* (1049 also saves cursor and clears the alt buffer).
* - CSI 3 J = erase saved lines (scrollback).
*
* Codex emits `\x1b[?1049h` and clear-scrollback sequences during startup and
* on repaint. xterm.js obeys them by switching to the alt buffer (no native
* scrollback) and wiping saved lines, so the user's conversation history
* disappears on every tab switch / pane refresh. Stripping these from the
* replayed byte stream keeps everything in the main buffer with scrollback
* intact. Mirrors the live-stream strip in Session._handleTerminalOutput.
* Codex AND Claude Code emit `\x1b[?1049h` and clear-scrollback sequences (the
* latter intermittently, e.g. full-screen pickers/dialogs). xterm.js obeys them
* by switching to the alt buffer (no native scrollback) and wiping saved lines,
* so the user's conversation history disappears on every tab switch / pane
* refresh (and scroll-up breaks live). Stripping these from the replayed byte
* stream keeps everything in the main buffer with scrollback intact. Mirrors the
* live-stream strip in Session._handleTerminalOutput (isAltScreenStripMode).
*/
// eslint-disable-next-line no-control-regex
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
@@ -989,10 +990,11 @@ export function registerSessionRoutes(
// the terminal appears empty when switching tabs.
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from codex byte streams.
// xterm.js obeys them by switching to its scrollback-less alt buffer and
// wiping saved lines, so conversation history disappears on tab switch.
if (session.mode === 'codex') {
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
// streams. xterm.js obeys them by switching to its scrollback-less alt
// buffer and wiping saved lines, so conversation history disappears on tab
// switch. Same gate as the live-stream strip in session.ts.
if (isAltScreenStripMode(session.mode)) {
strippedBuffer = strippedBuffer
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
.replace(ERASE_SCROLLBACK_PATTERN, '')
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { Session, isAltScreenStripMode } from '../src/session.js';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
};
function handleOutput(session: Session, data: string): void {
(session as unknown as SessionInternals)._handleTerminalOutput(data);
}
describe('isAltScreenStripMode', () => {
it('strips for the controlled TUIs (codex + claude), not shell/opencode', () => {
expect(isAltScreenStripMode('codex')).toBe(true);
expect(isAltScreenStripMode('claude')).toBe(true);
expect(isAltScreenStripMode('shell')).toBe(false);
expect(isAltScreenStripMode('opencode')).toBe(false);
});
});
describe('Claude terminal scrollback strip', () => {
it('strips alt-screen toggles, scrollback-erase, and mouse-tracking', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(session, '\x1b[?1049h\x1b[55;1Hdialog\x1b[3J\x1b[?1006h\x1b[?1049l');
expect(emitted[0]).toBe('\x1b[55;1Hdialog');
expect(session.terminalBuffer).toBe('\x1b[55;1Hdialog');
});
it('keeps the visible-screen erase (2J / [J) — only scrollback-erase (3J) is dropped', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
});
it('preserves an ordinary erase-display redraw (no scrollback sequences)', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
handleOutput(session, '\x1b[H\x1b[Jclaude redraw');
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jclaude redraw');
});
it('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(session, 'before\x1b[?104');
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
handleOutput(session, 'Jtail');
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
});
it('emits nothing for a chunk that is only a partial CSI, then completes it', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
handleOutput(session, '6h done'); // completes ?1006h (stripped); rest passes
expect(emitted).toEqual([' done']);
expect(session.terminalBuffer).toBe(' done');
});
it('does not touch ordinary Claude conversation output', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const text = 'Here is line one\r\nHere is line two\r\n\x1b[2mdim status\x1b[0m';
handleOutput(session, text);
expect(session.terminalBuffer).toBe(text);
});
});
describe('Shell terminal output is NOT stripped (vim/less/htop need the alt screen)', () => {
it('leaves alt-screen toggles, scrollback-erase, and mouse-tracking intact for shell', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const vimLike = '\x1b[?1049h\x1b[?1002h\x1b[2J~ editing\x1b[3J\x1b[?1002l\x1b[?1049l';
handleOutput(session, vimLike);
expect(session.terminalBuffer).toBe(vimLike);
});
});