mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
fix: clear screen initialization blank space after attaching
GNU screen creates blank space at the top when initializing sessions. This is now handled after attaching to the screen: - Claude sessions: emit clearTerminal event after 100ms, client clears xterm - Shell sessions: send 'clear' command after 100ms Also updates CLAUDE.md with documentation of the fix. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,10 +22,10 @@ npx tsx src/index.ts web -p 8080 # Dev mode with custom port
|
|||||||
node dist/index.js web # After npm run build
|
node dist/index.js web # After npm run build
|
||||||
claudeman web # After npm link
|
claudeman web # After npm link
|
||||||
|
|
||||||
# NOTE: `npm run dev` runs the CLI (shows help), NOT the web server
|
# IMPORTANT: `npm run dev` runs the CLI help, NOT the web server
|
||||||
# You must specify the `web` subcommand to start the server
|
# Always use `npx tsx src/index.ts web` for development
|
||||||
|
|
||||||
# Testing
|
# Testing (vitest)
|
||||||
npm run test # Run all tests once
|
npm run test # Run all tests once
|
||||||
npm run test:watch # Watch mode
|
npm run test:watch # Watch mode
|
||||||
npm run test:coverage # With coverage report
|
npm run test:coverage # With coverage report
|
||||||
@@ -33,6 +33,20 @@ npx vitest run test/session.test.ts # Single file
|
|||||||
npx vitest run -t "should create session" # By pattern
|
npx vitest run -t "should create session" # By pattern
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Test Files
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|------|----------|
|
||||||
|
| `session.test.ts` | Session creation, PTY modes, token tracking |
|
||||||
|
| `respawn-controller.test.ts` | State machine transitions, config updates |
|
||||||
|
| `scheduled-runs.test.ts` | Timed runs, iteration cleanup |
|
||||||
|
| `quick-start.test.ts` | Case creation + session startup |
|
||||||
|
| `sse-events.test.ts` | Event broadcasting, client reconnection |
|
||||||
|
| `integration-flows.test.ts` | Multi-step workflows |
|
||||||
|
| `edge-cases.test.ts` | Error handling, boundary conditions |
|
||||||
|
| `session-cleanup.test.ts` | Process termination, buffer management |
|
||||||
|
| `pty-interactive.test.ts` | Terminal resize, input handling |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -64,7 +78,7 @@ src/
|
|||||||
|
|
||||||
### Key Components
|
### Key Components
|
||||||
|
|
||||||
- **Session** (`src/session.ts`): Wraps Claude CLI as PTY subprocess. Two modes: `runPrompt(prompt)` for one-shot execution, `startInteractive()` for persistent terminal. Emits `output`, `terminal`, `message`, `completion`, `exit`, `idle`, `working`, `autoClear` events. Maintains terminal buffer for reconnections. Includes buffer management for long-running sessions (12-24+ hours) with automatic trimming. Tracks input/output tokens and supports auto-clear at configurable threshold.
|
- **Session** (`src/session.ts`): Wraps Claude CLI as PTY subprocess. Two modes: `runPrompt(prompt)` for one-shot execution, `startInteractive()` for persistent terminal. Emits `output`, `terminal`, `message`, `completion`, `exit`, `idle`, `working`, `autoClear`, `clearTerminal` events. Maintains terminal buffer for reconnections. Includes buffer management for long-running sessions (12-24+ hours) with automatic trimming. Tracks input/output tokens and supports auto-clear at configurable threshold.
|
||||||
|
|
||||||
- **TaskTracker** (`src/task-tracker.ts`): Detects Claude's background Task tool usage from JSON output. Builds a tree of parent-child task relationships. Emits `taskCreated`, `taskUpdated`, `taskCompleted`, `taskFailed` events. Used by Session to track background work.
|
- **TaskTracker** (`src/task-tracker.ts`): Detects Claude's background Task tool usage from JSON output. Builds a tree of parent-child task relationships. Emits `taskCreated`, `taskUpdated`, `taskCompleted`, `taskFailed` events. Used by Session to track background work.
|
||||||
|
|
||||||
@@ -208,11 +222,18 @@ Session tracks input/output tokens differently depending on mode:
|
|||||||
|
|
||||||
When enabled, auto-clear waits for idle state, sends `/clear`, and resets token counts.
|
When enabled, auto-clear waits for idle state, sends `/clear`, and resets token counts.
|
||||||
|
|
||||||
|
### Screen Session Initialization
|
||||||
|
|
||||||
|
GNU screen creates blank space at the top when initializing a session. This is handled after attaching to the screen:
|
||||||
|
|
||||||
|
- **Claude sessions**: After 100ms, clears terminal buffer and emits `clearTerminal` event. Client receives `session:clearTerminal` via SSE and clears/resets its xterm.
|
||||||
|
- **Shell sessions**: After 100ms, clears terminal buffer and sends `clear\n` command to the shell.
|
||||||
|
|
||||||
### SSE Events
|
### SSE Events
|
||||||
|
|
||||||
All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`.
|
All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`.
|
||||||
|
|
||||||
Event categories (prefixes): `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `init`. Key events include `session:idle`, `session:working`, `session:terminal`, `session:completion`, `respawn:stateChanged`. See `src/web/server.ts` for the full event catalog.
|
Event categories (prefixes): `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `init`. Key events include `session:idle`, `session:working`, `session:terminal`, `session:clearTerminal`, `session:completion`, `respawn:stateChanged`. See `src/web/server.ts` for the full event catalog.
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -303,8 +324,30 @@ Key files:
|
|||||||
- `styles.css` - All CSS styles
|
- `styles.css` - All CSS styles
|
||||||
- Libraries loaded from CDN (xterm.js, addons)
|
- Libraries loaded from CDN (xterm.js, addons)
|
||||||
|
|
||||||
|
## Adding New Features
|
||||||
|
|
||||||
|
### New API Endpoint
|
||||||
|
|
||||||
|
1. Add types to `src/types.ts` (request/response interfaces)
|
||||||
|
2. Add route in `src/web/server.ts` within the `buildServer()` function
|
||||||
|
3. Follow existing patterns: use `createErrorResponse()` for errors
|
||||||
|
|
||||||
|
### New SSE Event
|
||||||
|
|
||||||
|
1. Add event type constant in `src/web/server.ts` (see `broadcast()` calls)
|
||||||
|
2. Emit from appropriate component (Session, RespawnController, etc.)
|
||||||
|
3. Handle in `src/web/public/app.js` `handleSSEEvent()` switch
|
||||||
|
|
||||||
|
### New Session Event
|
||||||
|
|
||||||
|
1. Add to `SessionEvents` interface in `src/session.ts`
|
||||||
|
2. Emit in `src/session.ts` via `this.emit()`
|
||||||
|
3. Subscribe in `src/web/server.ts` when wiring session to SSE
|
||||||
|
4. Handle in `src/web/public/app.js` SSE event listener
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- State persists to `~/.claudeman/state.json` and `~/.claudeman/screens.json`
|
- State persists to `~/.claudeman/state.json` and `~/.claudeman/screens.json`
|
||||||
- Cases are created in `~/claudeman-cases/` by default
|
- Cases are created in `~/claudeman-cases/` by default
|
||||||
- Sessions are wrapped in GNU screen for persistence across server restarts
|
- Sessions are wrapped in GNU screen for persistence across server restarts
|
||||||
|
- Tests use vitest with mocking via `vi.mock()` - no real Claude CLI spawned
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export class ScreenManager extends EventEmitter {
|
|||||||
const screenName = `claudeman-${sessionId.slice(0, 8)}`;
|
const screenName = `claudeman-${sessionId.slice(0, 8)}`;
|
||||||
|
|
||||||
// Create screen in detached mode with the appropriate command
|
// Create screen in detached mode with the appropriate command
|
||||||
const cmd = mode === 'claude' ? 'claude --dangerously-skip-permissions' : '$SHELL';
|
const cmd = mode === 'claude'
|
||||||
|
? 'claude --dangerously-skip-permissions'
|
||||||
|
: '$SHELL';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start screen in detached mode
|
// Start screen in detached mode
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface SessionEvents {
|
|||||||
exit: (code: number | null) => void;
|
exit: (code: number | null) => void;
|
||||||
completion: (result: string, cost: number) => void;
|
completion: (result: string, cost: number) => void;
|
||||||
terminal: (data: string) => void; // Raw terminal data
|
terminal: (data: string) => void; // Raw terminal data
|
||||||
|
clearTerminal: () => void; // Signal client to clear terminal (after screen attach)
|
||||||
// Background task events
|
// Background task events
|
||||||
taskCreated: (task: BackgroundTask) => void;
|
taskCreated: (task: BackgroundTask) => void;
|
||||||
taskUpdated: (task: BackgroundTask) => void;
|
taskUpdated: (task: BackgroundTask) => void;
|
||||||
@@ -327,6 +328,13 @@ export class Session extends EventEmitter {
|
|||||||
cwd: this.workingDir,
|
cwd: this.workingDir,
|
||||||
env: { ...process.env, TERM: 'xterm-256color' },
|
env: { ...process.env, TERM: 'xterm-256color' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Screen creates blank space when initializing. After attaching, wait for
|
||||||
|
// the initial burst then clear the buffer and tell clients to clear their terminal.
|
||||||
|
setTimeout(() => {
|
||||||
|
this._terminalBuffer = '';
|
||||||
|
this.emit('clearTerminal');
|
||||||
|
}, 100);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
|
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
|
||||||
this._useScreen = false;
|
this._useScreen = false;
|
||||||
@@ -448,6 +456,15 @@ export class Session extends EventEmitter {
|
|||||||
cwd: this.workingDir,
|
cwd: this.workingDir,
|
||||||
env: { ...process.env, TERM: 'xterm-256color' },
|
env: { ...process.env, TERM: 'xterm-256color' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Screen creates blank space when initializing. After attaching, wait for
|
||||||
|
// the initial burst then clear by sending 'clear' command to the shell.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.ptyProcess) {
|
||||||
|
this._terminalBuffer = '';
|
||||||
|
this.ptyProcess.write('clear\n');
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
|
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
|
||||||
this._useScreen = false;
|
this._useScreen = false;
|
||||||
|
|||||||
+45
-2
@@ -254,6 +254,15 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('session:clearTerminal', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.id === this.activeSessionId) {
|
||||||
|
// Clear terminal after screen attach to remove initialization blank space
|
||||||
|
this.terminal.clear();
|
||||||
|
this.terminal.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.eventSource.addEventListener('session:completion', (e) => {
|
this.eventSource.addEventListener('session:completion', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
this.totalCost += data.cost || 0;
|
this.totalCost += data.cost || 0;
|
||||||
@@ -552,7 +561,12 @@ class ClaudemanApp {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${sessionId}/terminal`);
|
const res = await fetch(`/api/sessions/${sessionId}/terminal`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Aggressively clear the terminal before switching sessions
|
||||||
|
// This ensures no residual content from previous display
|
||||||
|
this.terminal.clear();
|
||||||
this.terminal.reset();
|
this.terminal.reset();
|
||||||
|
|
||||||
if (data.terminalBuffer) {
|
if (data.terminalBuffer) {
|
||||||
// Strip leading ANSI escape sequences and whitespace to prevent gaps
|
// Strip leading ANSI escape sequences and whitespace to prevent gaps
|
||||||
// This handles:
|
// This handles:
|
||||||
@@ -560,8 +574,16 @@ class ClaudemanApp {
|
|||||||
// - OSC sequences: ESC ] ... BEL or ESC \
|
// - OSC sequences: ESC ] ... BEL or ESC \
|
||||||
// - Simple sequences: ESC followed by single char
|
// - Simple sequences: ESC followed by single char
|
||||||
// - Whitespace, CR, LF
|
// - Whitespace, CR, LF
|
||||||
|
// - Screen initialization sequences
|
||||||
let cleanBuffer = data.terminalBuffer;
|
let cleanBuffer = data.terminalBuffer;
|
||||||
cleanBuffer = cleanBuffer.replace(/^(\x1b\[[0-9;?]*[A-Za-z@`]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[DEMNOP78>=c]|\s|\r|\n)*/g, '');
|
|
||||||
|
// First, strip any leading screen/terminal initialization artifacts
|
||||||
|
// Including cursor positioning that could leave blank space
|
||||||
|
cleanBuffer = cleanBuffer.replace(/^(\x1b\[[0-9;?]*[A-Za-z@`]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[=>DEMNOP78c]|\x1b[^\x1b]|\s|\r|\n)*/g, '');
|
||||||
|
|
||||||
|
// Also strip any DCS sequences (screen might send these)
|
||||||
|
cleanBuffer = cleanBuffer.replace(/^\x1bP[^\x1b]*\x1b\\/g, '');
|
||||||
|
|
||||||
this.terminal.write(cleanBuffer);
|
this.terminal.write(cleanBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +819,28 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
// Auto-switch to the new session
|
// Auto-switch to the new session
|
||||||
if (firstSessionId) {
|
if (firstSessionId) {
|
||||||
await this.selectSession(firstSessionId);
|
// IMPORTANT: Clear terminal BEFORE setting activeSessionId
|
||||||
|
// This prevents SSE events from writing during the transition
|
||||||
|
this.terminal.clear();
|
||||||
|
this.terminal.reset();
|
||||||
|
|
||||||
|
// Small delay to ensure terminal is fully reset before SSE events can write
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// NOW set the active session so SSE events start populating
|
||||||
|
this.activeSessionId = firstSessionId;
|
||||||
|
this.renderSessionTabs();
|
||||||
|
|
||||||
|
// Send resize to the new session
|
||||||
|
const dims = this.fitAddon.proposeDimensions();
|
||||||
|
if (dims) {
|
||||||
|
fetch(`/api/sessions/${firstSessionId}/resize`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cols: dims.cols, rows: dims.rows })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.loadQuickStartCases();
|
this.loadQuickStartCases();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -813,6 +813,11 @@ export class WebServer extends EventEmitter {
|
|||||||
this.batchTerminalData(session.id, data);
|
this.batchTerminalData(session.id, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
session.on('clearTerminal', () => {
|
||||||
|
// Tell clients to clear their terminal (after screen attach)
|
||||||
|
this.broadcast('session:clearTerminal', { id: session.id });
|
||||||
|
});
|
||||||
|
|
||||||
session.on('message', (msg: ClaudeMessage) => {
|
session.on('message', (msg: ClaudeMessage) => {
|
||||||
this.broadcast('session:message', { id: session.id, message: msg });
|
this.broadcast('session:message', { id: session.id, message: msg });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user