Compare commits

...

38 commits

Author SHA1 Message Date
b28283bf34
Fix db types 2026-01-31 12:08:23 -05:00
0a17b61bf3
Update docs to reflect deleted ANSI files
Replace reference to deprecated ansi.ts and ansi-carryover.ts
with description of current trimHtmlOutput() functionality.
2026-01-31 12:02:48 -05:00
f2a60658cf
Document xterm HTML structure dependency in trimHtmlOutput
Add warning about dependency on xterm's HTML format and the risk
if xterm changes it. Suggest filing an upstream issue for a native
trimEmpty option.
2026-01-31 12:02:23 -05:00
4502be8d06
Add integration tests for reconnect scenarios
Test that initial_state provides full terminal state, multiple
sessions are independent, and the CSI 'T' artifact bug is fixed.
2026-01-31 12:02:00 -05:00
baa6ef9d70
Delete deprecated ANSI processing files
Remove ansi.ts, ansi-carryover.ts, and their test files. Terminal
emulation is now handled by @xterm/headless via terminal.ts.
2026-01-31 12:01:24 -05:00
6342c7a699
Add error handling to terminal creation
Wrap terminal creation in try-catch to prevent session creation
from failing if the terminal emulator fails to initialize. The
session will still work, just without ANSI processing.
2026-01-31 12:01:07 -05:00
58996a51f8
Add periodic cleanup for orphaned terminals
Every 5 minutes, check for terminals whose sessions have ended
but weren't properly disposed. This handles cases where a server
crash or network issue prevented normal WebSocket close.
2026-01-31 12:00:48 -05:00
22c5c3d102
Fix dashboard race condition in initial_state handler
When a new session's initial_state arrives, we now set the output
before calling renderSessions() rather than after. This prevents
renderSessionOutput() from being called before the DOM exists.
2026-01-31 12:00:23 -05:00
1b72d1e4e4
Trim empty rows 2026-01-31 11:53:38 -05:00
ab61445bcc
Use css for wrapping text 2026-01-31 11:43:56 -05:00
31340fe0a8
Fix async terminal.write() causing garbled dashboard output
The terminal.write() method from @xterm/headless is async - the data
isn't in the buffer yet when we call serializeAsHTML() immediately
after. This caused empty or partially rendered output in the browser.

Now using the callback form of terminal.write(data, callback) to wait
for the write to complete before serializing and broadcasting to SSE
clients. This ensures the terminal buffer is fully updated before we
generate HTML from it.
2026-01-31 11:06:37 -05:00
9bc77292cd
Remove browser resize triggers 2026-01-31 10:58:22 -05:00
116847ab58
Add multiple fixes 2026-01-31 10:14:25 -05:00
0ac5eec30d
Update docs for terminal emulation 2026-01-31 10:04:29 -05:00
6bd599d47b
Fix dashboard to handle terminal serialization output
The server's serializeAsHTML() returns the full terminal screen state, not incremental chunks. Updated the dashboard to:

1. Handle initial_state event to receive current terminal state on connection
2. Replace output instead of appending (output event now replaces session.output)
3. Simplify renderSessionOutput() to always do full innerHTML replacement

This fixes the issue where output was being duplicated/appended incorrectly.
2026-01-31 09:58:18 -05:00
130f01a19f
Clean up deprecated ANSI processing code (Phase 4)
Removed the ansiCarryovers Map from server.ts since terminal emulation
now handles all ANSI sequence buffering internally. Marked ansi.ts and
ansi-carryover.ts as deprecated with JSDoc comments, but kept them for
backward compatibility and potential rollback.
2026-01-31 09:53:22 -05:00
43648f7d60
Add initial_state SSE event for reconnect sync (Phase 3)
When a dashboard connects via SSE, it now receives the current terminal
state for all active sessions. This allows dashboards to immediately
display the full terminal content without waiting for new output.
2026-01-31 09:51:13 -05:00
88afb7249d
Replace ansiToHtml with terminal serialization (Phase 2)
The output handler now:
- Writes raw data to terminal emulator
- Stores raw data in DB (unchanged)
- Serializes terminal state as HTML
- Broadcasts serialized HTML via SSE

This replaces the ansiToHtml() + splitAnsiCarryover() approach with
proper terminal emulation. The terminal emulator handles all ANSI
sequences internally, including incomplete sequences across chunks.
2026-01-31 09:49:53 -05:00
6c7de2332b
Add terminal.test.ts unit tests 2026-01-31 09:47:54 -05:00
0366e459f5
Integrate terminal emulator in server.ts (Phase 1) 2026-01-31 09:45:38 -05:00
c9908c87c3
Add terminal.ts module for headless terminal emulation
Creates new module providing terminal session management using @xterm/headless:
- createTerminal(): spawn headless terminal emulator instances
- serializeAsHTML(): export terminal state as HTML
- disposeTerminal(): clean up resources

This replaces stateless ANSI processing with proper VT emulation that tracks
cursor position, screen buffer, and terminal attributes across output chunks.
2026-01-31 09:43:46 -05:00
5016cd9960
Add terminal emulation design doc 2026-01-31 09:40:31 -05:00
a14decf2bc
Fix HTML truncation to avoid cutting inside tags 2026-01-31 09:12:20 -05:00
3e5afbd5a8
Clean up ansiCarryovers on WebSocket close 2026-01-31 09:12:05 -05:00
42ba893ea5
Add tests for splitAnsiCarryover function 2026-01-31 09:11:49 -05:00
0a3bfa6092
Add debug logging for ANSI carryover events 2026-01-31 09:10:19 -05:00
c09654c6c7
Add JSDoc to splitAnsiCarryover function 2026-01-31 09:10:07 -05:00
a8eea4e694
Add SSE cleanup 2026-01-31 09:06:16 -05:00
721bff81d0
Cleanup dom 2026-01-31 09:06:08 -05:00
f2c3f6f067
Avoid stray control characters 2026-01-31 09:05:33 -05:00
a8a73aad4e
Add dynamic favicon state system
Embed SVG path data in TypeScript to enable runtime color switching based on app state (idle/processing/error) without maintaining multiple static SVG files.
2026-01-31 08:42:58 -05:00
0df63961e0
Update todo 2026-01-31 08:42:58 -05:00
aeebe863e9
Speed up docker builds 2026-01-31 08:33:40 -05:00
33cf28d643
Add reload to header click 2026-01-30 13:55:34 -05:00
5597502a8c
Keep terminal scrolled to bottom 2026-01-30 13:55:26 -05:00
9e8a275831
Display --yolo instead of --dangerously-skip-permissions 2026-01-30 13:15:45 -05:00
b67247e340
Replace pin button with floating scroll-to-bottom button
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:47:47 -05:00
386c1e74cc
Update TODO 2026-01-30 10:14:52 -05:00
18 changed files with 1643 additions and 1059 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
node_modules
dist
.git
*.test.ts
*.md
.env*
data

View file

@ -13,6 +13,16 @@ Core tech:
- Bun.serve() for HTTP + WebSocket + SSE
- plain text output (no xterm.js) for mobile-friendly display
## Terminal Emulation
Output processing uses @xterm/headless for proper VT100 emulation:
- src/terminal.ts - headless terminal wrapper
- One terminal instance per session (created on auth, disposed on close)
- serializeAsHTML() produces HTML for SSE broadcast
- trimHtmlOutput() removes empty trailing rows for cleaner display
See docs/terminal-emulation.md for design rationale.
## References
Inspiration and patterns to steal from:

View file

@ -7,7 +7,9 @@ COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
# Copy source files
COPY . .
COPY src ./src
COPY public ./public
COPY tsconfig.json ./
# Build CLI binary (smallest possible)
RUN mkdir -p dist/bin && \

View file

@ -110,6 +110,7 @@ Pure Bun stack:
- bun-pty for PTY wrapper
- bun:sqlite for persistence
- Bun.serve() for HTTP + WebSocket + SSE
- @xterm/headless for terminal emulation and state tracking
- plain text output (mobile-friendly)
Target: ~1000-1500 lines total.

View file

@ -1,9 +1,21 @@
TODO
- [ ] keep the remote client scrolled to bottom, unless user scrolls up, but
- [ ] i lose control of site sometimes, like i cant click on things. how bad is
our frontend? could things lock up?
- [ ] refresh shouldnt show https://cesspit.dungeon.red/i/Wwwdeg.png
i swear that when it does same line updates, our webapp is doing newlines for all of them
http://localhost:7200/
i return to page sometimes and i cant click our h1, doesnt turn into a pointer. soomething is fucked up
- [ ] escape should close the gear menu, like how clicking off it works
- [ ] i want better icons, not emojis for the gear
- [x] i cant pull down to refresh when i export to "desktop icon" on ios, weirdly. hm.. explore this, i mean, we could make the header "clarc" a link, so i can juist click it, your call
- [x] keep the remote client scrolled to bottom, unless user scrolls up, but
EASILY give them a way to .. resume keeping it on bottom, with a single
click?
- [x] i just wanna render this as claude --yolo <span>claude --dangerously-skip-permissions</span>
aka, if --dangerously-skip-permissions used to run, dont show that all as its too long
https://cesspit.dungeon.red/i/bucNrP.png
IDEAS
- [ ] spin up claude from the UI
@ -12,3 +24,10 @@ IDEAS
- can it still @ file refs?
----
i sent a prompt still queued, wtf is "pending prompts" i nvr see anything appear there
http://cesspit.dungeon.red/i/YaACEY.png
----
i want to make the padding black too, the inside of the card, so the terminal bg doesnt stand out
https://cesspit.dungeon.red/i/uLfyxL.png

View file

@ -5,6 +5,8 @@
"": {
"name": "clarc",
"dependencies": {
"@xterm/addon-serialize": "^0.14.0",
"@xterm/headless": "^6.0.0",
"bun-pty": "^0.4.8",
},
"devDependencies": {
@ -39,6 +41,10 @@
"@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
"@xterm/addon-serialize": ["@xterm/addon-serialize@0.14.0", "", {}, "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA=="],
"@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],

836
docs/terminal-emulation.md Normal file
View file

@ -0,0 +1,836 @@
# Terminal Emulation Design
## Problem Statement
### User-Visible Symptoms
When a dashboard client reconnects to an active Claude Code session or when the server restarts, the terminal output is garbled. The browser shows fragments like just "T" characters repeatedly, or incomplete output. Resizing the terminal temporarily fixes the problem because it triggers Claude Code to redraw the entire screen.
### Technical Root Cause
clarc currently processes ANSI escape sequences in a stateless manner:
1. **No terminal state tracking** - Each chunk of ANSI output is processed independently without maintaining cursor position, screen buffer, or terminal attributes across chunks
2. **CSI final bytes leaking** - The ansiCarryover system detects incomplete sequences like `ESC [ params` but doesn't handle the final command byte (T, H, J, etc.), allowing these to leak through as literal characters
3. **Incorrect CR handling** - Carriage returns (`\r`) truncate to the last newline instead of properly moving the cursor to column 0, losing overwritten content
4. **No reconnect state** - When a client reconnects, they receive nothing or stale data because there's no terminal state to serialize and send
The core issue: we're treating a stateful protocol (terminal emulation) as stateless string processing.
### Why This Matters
Terminal applications like Claude Code use cursor positioning, line clearing, and character overwrites extensively. Without proper state tracking:
- Progress indicators get garbled
- Interactive prompts break
- Reconnecting clients see corrupted output
- Screen redraws don't work correctly
## Solution Overview
### High-Level Architecture Change
Replace the current stateless ANSI processing pipeline with a proper terminal emulator using `@xterm/headless` + `@xterm/addon-serialize`:
**Current (broken):**
```
PTY output → splitAnsiCarryover() → ansiToHtml() → Browser
appendOutput(DB)
```
**New (correct):**
```
PTY output → Terminal.write() → Terminal state (buffer, cursor, attrs)
↓ ↓
appendOutput(DB) SerializeAddon.serializeAsHTML()
Browser
```
### Why @xterm/headless
`@xterm/headless` is the official xterm.js headless terminal emulator designed for exactly this use case:
- **Proper VT emulation** - Handles all ANSI/VT sequences correctly (CSI, OSC, cursor movement, scrollback)
- **Maintains state** - Tracks cursor position, screen buffer, attributes, scrollback history
- **Serialization support** - `@xterm/addon-serialize` can export terminal state as HTML or ANSI
- **Battle-tested** - Used by VS Code, Hyper, and other major projects
- **Server-friendly** - No DOM dependencies, runs in Node.js/Bun
This is exactly what crabigator does with the `vt100` crate in Rust.
### Comparison: Current vs New
| Aspect | Current | New |
|--------|---------|-----|
| ANSI processing | Stateless SGR-only parser | Full VT emulator |
| Cursor tracking | None | Full cursor position state |
| Screen buffer | Raw ANSI chunks | Complete screen buffer |
| Reconnect | Sends nothing or stale data | Serializes current screen state |
| CR/LF handling | Buggy truncation | Proper cursor movement |
| CSI sequences | Strips params, leaks final bytes | Full CSI command handling |
| Dependencies | Custom ansi.ts | @xterm/headless + addon-serialize |
## Architecture Changes
### New Data Flow
```
┌─────────────┐
│ CLI (PTY) │
└──────┬──────┘
│ WebSocket: {type:"output", data}
┌─────────────────────────────────────────────┐
│ Server (server.ts) │
│ │
│ ┌────────────────────────────────────┐ │
│ │ Terminal Emulator (per session) │ │
│ │ - Terminal (headless) │ │
│ │ - SerializeAddon │ │
│ │ - Screen buffer + cursor state │ │
│ └────────┬──────────────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ appendOutput(DB) serializeAsHTML() │
│ (raw ANSI) (rendered state) │
│ │ │ │
└───────────┼──────────────────┼──────────────┘
│ │
▼ ▼
Database SSE: {type:"output"}
┌───────────────┐
│ Dashboard │
│ (innerHTML) │
└───────────────┘
```
### What Gets Added
1. **Terminal emulator per session** (new `src/terminal.ts`)
- Create `Terminal` instance when session starts
- Create `SerializeAddon` instance
- Store in `sessionTerminals` Map
2. **Dependencies** (package.json)
- `@xterm/headless` - Terminal emulator
- `@xterm/addon-serialize` - HTML/ANSI serialization
### What Gets Changed
1. **server.ts**
- Add `sessionTerminals` Map alongside `sessionWebSockets`
- In WebSocket `message` handler for `type:"output"`:
- Remove `splitAnsiCarryover()` call
- Remove `ansiCarryovers` Map usage
- Call `terminal.write(msg.data)` instead
- Call `serializeAddon.serializeAsHTML()` for SSE broadcast
- In WebSocket `message` handler for `type:"auth"`:
- Create new Terminal + SerializeAddon
- Send initial serialized state to client
- In WebSocket `close` handler:
- Dispose terminal instance
- Remove from `sessionTerminals` Map
2. **ansi.ts**
- Keep `ansiToHtml()` for backward compatibility during migration
- Mark as deprecated
- Eventually remove after Phase 4
3. **db.ts**
- Keep storing raw ANSI in `output_log` for now
- Later: consider storing screen snapshots instead
### What Gets Removed
1. **ansi-carryover.ts** - No longer needed with proper emulator
2. **ansiCarryovers Map** - Terminal emulator handles buffering
3. **splitAnsiCarryover() calls** - Terminal emulator handles partial sequences
### Storage Changes
For now: keep storing raw ANSI chunks in `output_log` table. This preserves backward compatibility and allows us to rebuild terminal state if needed.
Future consideration: switch to storing periodic screen snapshots instead of raw ANSI. This would improve reconnect performance for long-running sessions.
## Implementation Plan
### Phase 1: Add @xterm/headless and Basic Integration
**Goal:** Feed PTY output into terminal emulator, no visible changes yet.
**Files to change:**
- `package.json` - Add dependencies
- `src/terminal.ts` (new) - Terminal manager module
- `src/server.ts` - Create terminals on session start
**Code patterns:**
```typescript
// src/terminal.ts
import { Terminal } from "@xterm/headless";
import { SerializeAddon } from "@xterm/addon-serialize";
export interface TerminalSession {
terminal: Terminal;
serialize: SerializeAddon;
}
export function createTerminal(cols: number, rows: number): TerminalSession {
const terminal = new Terminal({
cols,
rows,
allowProposedApi: true, // Required for some addons
});
const serialize = new SerializeAddon();
terminal.loadAddon(serialize);
return { terminal, serialize };
}
export function disposeTerminal(session: TerminalSession): void {
session.serialize.dispose();
session.terminal.dispose();
}
```
```typescript
// src/server.ts
import { createTerminal, disposeTerminal, type TerminalSession } from "./terminal";
const sessionTerminals = new Map<number, TerminalSession>();
// In WebSocket message handler for "auth":
const { cols, rows } = getTerminalSize(); // Get from initial resize or default
const termSession = createTerminal(cols, rows);
sessionTerminals.set(session.id, termSession);
// In WebSocket message handler for "output":
const termSession = sessionTerminals.get(sessionId);
if (termSession) {
termSession.terminal.write(msg.data);
}
// In WebSocket close handler:
const termSession = sessionTerminals.get(ws.data.sessionId);
if (termSession) {
disposeTerminal(termSession);
sessionTerminals.delete(ws.data.sessionId);
}
```
**Tests:**
- Unit test: `terminal.test.ts` - Create terminal, write data, verify buffer exists
- Integration test: Start session, send output, verify terminal contains data
### Phase 2: Replace ansiToHtml with Terminal Serialization
**Goal:** Use terminal serialization for all output rendering.
**Files to change:**
- `src/server.ts` - Replace `ansiToHtml()` with `serializeAsHTML()`
- `src/terminal.ts` - Add serialization helpers
**Code patterns:**
```typescript
// src/terminal.ts
export function serializeAsHTML(session: TerminalSession): string {
return session.serialize.serializeAsHTML({
excludeAltBuffer: false,
excludeModes: false,
onlySelection: false,
});
}
```
```typescript
// src/server.ts - WebSocket message handler for "output"
if (msg.type === "output") {
const sessionId = ws.data.sessionId;
const termSession = sessionTerminals.get(sessionId);
if (!termSession) {
console.error(`No terminal for session ${sessionId}`);
return;
}
// Write to terminal emulator
termSession.terminal.write(msg.data);
// Store raw ANSI in DB
appendOutput(sessionId, msg.data);
// Broadcast serialized HTML to dashboards
broadcastSSE({
type: "output",
session_id: sessionId,
data: serializeAsHTML(termSession), // Changed from ansiToHtml(body)
});
return;
}
```
**Tests:**
- Unit test: Write ANSI sequences, verify HTML output is correct
- Integration test: Send cursor movement sequences, verify final HTML shows correct result
- Regression test: Verify CR handling works (overwrites instead of truncates)
### Phase 3: Add Reconnect State Sync
**Goal:** Send terminal state to clients on reconnect.
**Files to change:**
- `src/server.ts` - Send initial state on SSE connect
- `src/types.ts` - Add new SSE event type
- `public/index.html` or dashboard - Handle initial state event
**Code patterns:**
```typescript
// src/types.ts - Add new SSE event type
export type SSEEvent =
| { type: "initial_state"; session_id: number; html: string }
| ... // existing events
// src/server.ts - SSE endpoint
if (url.pathname === "/events") {
let ctrl: ReadableStreamDefaultController<string>;
const stream = new ReadableStream<string>({
start(controller) {
ctrl = controller;
sseClients.add(controller);
// Send initial headers
controller.enqueue(": connected\n\n");
// Send current state for all active sessions
for (const [sessionId, termSession] of sessionTerminals.entries()) {
const html = serializeAsHTML(termSession);
const event: SSEEvent = {
type: "initial_state",
session_id: sessionId,
html,
};
const eventStr = `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
controller.enqueue(eventStr);
}
},
cancel() {
sseClients.delete(ctrl);
},
});
return new Response(stream, { headers: { ... } });
}
```
**Dashboard handling:**
```typescript
// public/index.html (or frontend.tsx if using React)
eventSource.addEventListener("initial_state", (e) => {
const data = JSON.parse(e.data);
const sessionEl = document.querySelector(`[data-session="${data.session_id}"]`);
if (sessionEl) {
sessionEl.innerHTML = data.html; // Replace entire content
}
});
eventSource.addEventListener("output", (e) => {
const data = JSON.parse(e.data);
const sessionEl = document.querySelector(`[data-session="${data.session_id}"]`);
if (sessionEl) {
sessionEl.innerHTML += data.html; // Append incremental updates
}
});
```
**Tests:**
- Integration test: Start session with output, reconnect dashboard, verify state is sent
- Integration test: Verify "T" artifact bug is fixed (output doesn't corrupt on reconnect)
### Phase 4: Clean Up Old Code
**Goal:** Remove deprecated ANSI processing code.
**Files to change:**
- `src/ansi-carryover.ts` - Delete file
- `src/ansi.ts` - Delete file (or keep minimal version for other uses)
- `src/server.ts` - Remove ansiCarryovers Map and related code
- `src/server.ts` - Remove imports of deleted modules
**Tests:**
- Run full test suite to ensure nothing broke
- Verify bundle size reduction
## API Changes
### New SSE Event Type
```typescript
// types.ts
export type SSEEvent =
| { type: "initial_state"; session_id: number; html: string }
| ... // existing events
```
### No WebSocket Message Changes
The WebSocket protocol between CLI and server remains unchanged - CLI still sends `{type:"output", data}` chunks.
### Dashboard Receives Terminal State
Instead of receiving incremental ANSI-to-HTML chunks, dashboards now receive:
1. **On connect:** Full terminal state as HTML via `initial_state` event
2. **On updates:** Incremental updates as HTML via `output` event
## Testing Strategy
### Unit Tests
```typescript
// src/terminal.test.ts
import { test, expect } from "bun:test";
import { createTerminal, serializeAsHTML } from "./terminal";
test("creates terminal with correct dimensions", () => {
const term = createTerminal(80, 24);
expect(term.terminal.cols).toBe(80);
expect(term.terminal.rows).toBe(24);
});
test("writes data to terminal buffer", () => {
const term = createTerminal(80, 24);
term.terminal.write("Hello, world!");
const html = serializeAsHTML(term);
expect(html).toContain("Hello, world!");
});
test("handles ANSI cursor movement", () => {
const term = createTerminal(80, 24);
term.terminal.write("AAA\x1b[3D"); // Write AAA, move cursor back 3
term.terminal.write("BBB"); // Overwrite with BBB
const html = serializeAsHTML(term);
expect(html).toContain("BBB");
expect(html).not.toContain("AAA");
});
test("handles carriage return correctly", () => {
const term = createTerminal(80, 24);
term.terminal.write("Old text\rNew"); // CR should move to col 0
const html = serializeAsHTML(term);
expect(html).toContain("New");
expect(html).not.toContain("Old");
});
test("handles incomplete ANSI sequences across writes", () => {
const term = createTerminal(80, 24);
term.terminal.write("Hello\x1b["); // Incomplete CSI
term.terminal.write("31mRed\x1b[0m"); // Complete it
const html = serializeAsHTML(term);
expect(html).toContain("Red");
expect(html).toMatch(/color.*red/i); // Check for red styling
});
```
### Integration Tests
```typescript
// src/integration.test.ts
import { test, expect } from "bun:test";
import { WebSocket } from "ws";
test("reconnect sends initial terminal state", async () => {
// Start server, create session, send output
const ws1 = new WebSocket("ws://localhost:7200/ws");
await new Promise(resolve => ws1.once("open", resolve));
ws1.send(JSON.stringify({ type: "auth", secret: "test" }));
await new Promise(resolve => ws1.once("message", resolve)); // authenticated
ws1.send(JSON.stringify({ type: "output", data: "Test output\n" }));
await Bun.sleep(100);
// Reconnect with SSE, expect initial_state event
const sse = new EventSource("http://localhost:7200/events");
const events: any[] = [];
sse.addEventListener("initial_state", (e) => {
events.push(JSON.parse(e.data));
});
await Bun.sleep(100);
expect(events.length).toBeGreaterThan(0);
expect(events[0].html).toContain("Test output");
});
test("T artifact bug is fixed", async () => {
// Reproduce original bug: send CSI sequence, reconnect, verify no "T" leak
const ws = new WebSocket("ws://localhost:7200/ws");
await new Promise(resolve => ws.once("open", resolve));
ws.send(JSON.stringify({ type: "auth", secret: "test" }));
await new Promise(resolve => ws.once("message", resolve));
// Send cursor movement CSI (final byte T)
ws.send(JSON.stringify({ type: "output", data: "\x1b[5;10H" })); // CUP - cursor position
await Bun.sleep(100);
// Reconnect and check state
const sse = new EventSource("http://localhost:7200/events");
let html = "";
sse.addEventListener("initial_state", (e) => {
html = JSON.parse(e.data).html;
});
await Bun.sleep(100);
expect(html).not.toContain("T"); // Final byte should not leak
});
```
### How to Test "T" Artifact Fix
The original bug shows "T" characters because CSI final bytes leak through. To verify it's fixed:
1. Start a session
2. Send ANSI sequences with various final bytes (H, J, K, T, etc.)
3. Reconnect a dashboard client
4. Verify the HTML contains proper output, not the final bytes as literal characters
## Migration Path
### Incremental Rollout
Yes, we can do incremental rollout:
1. **Phase 1:** Add terminal emulator alongside existing code (no behavior change)
2. **Phase 2:** Switch to terminal serialization (behavior change, but backward compatible)
3. **Phase 3:** Add reconnect state (new feature, backward compatible)
4. **Phase 4:** Remove old code (cleanup, no API changes)
### Backwards Compatibility
**Database:** No schema changes required. We continue storing raw ANSI in `output_log`.
**WebSocket protocol:** No changes to CLI ↔ Server messages.
**SSE protocol:** Additive only - new `initial_state` event, existing `output` event structure unchanged.
**Dashboard:** Needs update to handle `initial_state` event, but can ignore it initially (degrades gracefully).
### Rollback Plan
If we need to rollback during migration:
**After Phase 1:** Just remove terminal creation, keep using ansiToHtml
**After Phase 2:** Revert serializeAsHTML calls back to ansiToHtml
**After Phase 3:** Remove initial_state event handling
**After Phase 4:** Cannot easily rollback (code deleted), but could revert entire commit
Safety: Keep git tags at each phase boundary for easy rollback.
## Code Examples
### Creating Headless Terminal Per Session
```typescript
// src/terminal.ts
import { Terminal } from "@xterm/headless";
import { SerializeAddon } from "@xterm/addon-serialize";
export interface TerminalSession {
terminal: Terminal;
serialize: SerializeAddon;
}
/**
* Create a new headless terminal emulator instance
* @param cols - Terminal width in columns
* @param rows - Terminal height in rows
* @returns Terminal session with emulator and serialization addon
*/
export function createTerminal(cols: number, rows: number): TerminalSession {
const terminal = new Terminal({
cols,
rows,
scrollback: 1000, // Keep 1000 lines of scrollback
allowProposedApi: true,
});
const serialize = new SerializeAddon();
terminal.loadAddon(serialize);
return { terminal, serialize };
}
/**
* Serialize terminal screen buffer as HTML
*/
export function serializeAsHTML(session: TerminalSession): string {
return session.serialize.serializeAsHTML({
excludeAltBuffer: false,
excludeModes: false,
onlySelection: false,
});
}
/**
* Clean up terminal resources
*/
export function disposeTerminal(session: TerminalSession): void {
session.serialize.dispose();
session.terminal.dispose();
}
```
### Feeding PTY Data Into Terminal
```typescript
// src/server.ts (WebSocket message handler)
// Map to store terminal emulators per session
const sessionTerminals = new Map<number, TerminalSession>();
// On session creation (auth message):
if (msg.type === "auth") {
// ... existing auth logic ...
// Create terminal emulator for this session
const termSession = createTerminal(80, 24); // Use actual terminal size
sessionTerminals.set(session.id, termSession);
// ... rest of auth handler ...
}
// On output message:
if (msg.type === "output") {
const sessionId = ws.data.sessionId;
const termSession = sessionTerminals.get(sessionId);
if (!termSession) {
console.error(`No terminal for session ${sessionId}`);
return;
}
// Write PTY output to terminal emulator
// Terminal handles all ANSI sequences, cursor movement, buffering, etc.
termSession.terminal.write(msg.data);
// Store raw ANSI in database (unchanged)
appendOutput(sessionId, msg.data);
// Serialize current terminal state as HTML
const html = serializeAsHTML(termSession);
// Broadcast to dashboards
broadcastSSE({
type: "output",
session_id: sessionId,
data: html,
});
return;
}
// On WebSocket close:
close(ws) {
if (ws.data.sessionId) {
// ... existing cleanup ...
// Dispose terminal emulator
const termSession = sessionTerminals.get(ws.data.sessionId);
if (termSession) {
disposeTerminal(termSession);
sessionTerminals.delete(ws.data.sessionId);
}
}
}
```
### Serializing State for Reconnect
```typescript
// src/server.ts (SSE endpoint)
if (url.pathname === "/events") {
let ctrl: ReadableStreamDefaultController<string>;
const stream = new ReadableStream<string>({
start(controller) {
ctrl = controller;
sseClients.add(controller);
// Send initial connection acknowledgment
controller.enqueue(": connected\n\n");
// Send current terminal state for all active sessions
for (const [sessionId, termSession] of sessionTerminals.entries()) {
const session = getSession(sessionId);
if (!session || session.ended_at) continue;
// Serialize full terminal state as HTML
const html = serializeAsHTML(termSession);
// Send as initial_state event
const event: SSEEvent = {
type: "initial_state",
session_id: sessionId,
html,
};
const eventStr = `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
controller.enqueue(eventStr);
}
},
cancel() {
sseClients.delete(ctrl);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}
```
### Dashboard Receiving and Rendering State
```typescript
// public/index.html or frontend.tsx
// Connect to SSE endpoint
const eventSource = new EventSource("/events");
// Handle initial state (sent on connect)
eventSource.addEventListener("initial_state", (event) => {
const data = JSON.parse(event.data);
const sessionId = data.session_id;
const html = data.html;
// Find or create terminal display element
let terminalEl = document.querySelector(`[data-session="${sessionId}"]`);
if (!terminalEl) {
terminalEl = document.createElement("pre");
terminalEl.setAttribute("data-session", sessionId);
terminalEl.className = "terminal-output";
document.getElementById("terminals").appendChild(terminalEl);
}
// Replace entire content with current terminal state
terminalEl.innerHTML = html;
});
// Handle incremental output updates
eventSource.addEventListener("output", (event) => {
const data = JSON.parse(event.data);
const sessionId = data.session_id;
const html = data.html;
const terminalEl = document.querySelector(`[data-session="${sessionId}"]`);
if (!terminalEl) {
console.warn(`No terminal element for session ${sessionId}`);
return;
}
// For now, replace content (later: optimize to append)
// Note: Full replace is safest because terminal emulator handles all state
terminalEl.innerHTML = html;
});
// Styling for terminal output
const style = `
.terminal-output {
background: #0d1117;
color: #c9d1d9;
font-family: 'Courier New', monospace;
font-size: 14px;
padding: 10px;
overflow-x: auto;
white-space: pre;
}
`;
```
## Open Questions / Future Work
### Should We Keep Raw ANSI in DB or Switch to Screen Snapshots?
**Current:** Store raw ANSI chunks in `output_log` table.
**Pros:**
- Preserves full command history
- Can rebuild terminal state from any point
- Debugging-friendly (can see exact sequences)
**Cons:**
- Large storage for long sessions
- Reconnect requires replaying all chunks
- Not efficient for random access
**Alternative:** Store periodic screen snapshots (every N seconds or N bytes).
**Pros:**
- Fast reconnect (just load latest snapshot)
- Constant-size storage per session
- Efficient random access to session state
**Cons:**
- Loses command history between snapshots
- More complex migration path
- Need snapshot management (cleanup old ones)
**Recommendation:** Keep raw ANSI for now (Phase 1-4), evaluate snapshots later based on performance data.
### Scrollback Handling - How Much History?
Terminal emulator supports configurable scrollback. Questions:
1. **How many lines?** Currently set to 1000 in example. Is this enough for Claude Code sessions?
2. **Dashboard scrolling:** Do dashboards need to display scrollback or just current screen?
3. **Memory concerns:** Each terminal instance keeps scrollback in memory. For many concurrent sessions, this could add up.
**Recommendation:** Start with 1000 lines, monitor memory usage, make configurable via environment variable.
### Performance Considerations
**Serialization cost:** Calling `serializeAsHTML()` on every PTY output chunk could be expensive. Considerations:
1. **Throttling:** Only serialize every N milliseconds or N bytes
2. **Incremental updates:** Send diffs instead of full HTML (requires custom serialization)
3. **Caching:** Cache last serialization, only re-serialize if terminal changed
**Memory usage:** One Terminal instance per session. For 100 concurrent sessions:
- ~100 terminal buffers in memory
- Each buffer: ~80 cols × 24 rows × 1000 scrollback = ~2MB per session
- Total: ~200MB for 100 sessions (acceptable)
**Recommendation:** Start simple (serialize on every output), optimize later if needed. Add metrics to track serialization time.
### Terminal Size Tracking
Current code doesn't track initial terminal size properly. Need to:
1. Get terminal size from CLI on auth (add to auth message?)
2. Track resize events properly (update terminal.resize())
3. Handle missing/invalid sizes gracefully
**Recommendation:** Add `cols` and `rows` to auth message, default to 80×24 if missing.
### Alternative: Use @xterm/addon-fit for Auto-sizing?
The `@xterm/addon-fit` addon can auto-calculate terminal size based on container dimensions. But this requires a DOM, which we don't have server-side.
**Recommendation:** Keep manual size tracking, not applicable for headless.
### Can We Use This for Replay/Playback?
Having full terminal state opens up interesting possibilities:
1. **Session replay:** Store terminal state snapshots, replay session history
2. **Time travel debugging:** Jump to any point in session timeline
3. **Export to video:** Render terminal state as frames, create video
**Recommendation:** Out of scope for initial implementation, but good future feature.

View file

@ -19,6 +19,8 @@
"typescript": "^5"
},
"dependencies": {
"@xterm/addon-serialize": "^0.14.0",
"@xterm/headless": "^6.0.0",
"bun-pty": "^0.4.8"
}
}

View file

@ -4,12 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>clarc</title>
<link rel="icon" id="favicon" type="image/svg+xml">
<style>
:root {
--columns: 1;
--font-size: 12px;
--terminal-height: 400px;
--wrap-mode: pre-wrap;
}
* {
@ -38,6 +38,7 @@
h1 {
font-size: 20px;
font-weight: 600;
cursor: pointer;
}
.status {
@ -201,47 +202,68 @@
display: block;
}
.pin-scroll-btn {
.scroll-to-bottom-btn {
position: absolute;
bottom: 12px;
right: 12px;
background: rgba(42, 42, 42, 0.9);
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 8px 12px;
font-size: 16px;
border-radius: 50%;
width: 44px;
height: 44px;
font-size: 20px;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
transition: background 0.2s, opacity 0.2s, transform 0.2s;
z-index: 10;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
}
.pin-scroll-btn:hover {
.session-output.scrolled-up .scroll-to-bottom-btn {
opacity: 1;
pointer-events: auto;
}
.scroll-to-bottom-btn:hover {
background: rgba(58, 58, 58, 0.95);
transform: translateY(-2px);
}
.pin-scroll-btn:active {
.scroll-to-bottom-btn:active {
opacity: 0.7;
}
.pin-scroll-btn.pinned {
background: rgba(255, 152, 0, 0.2);
border-color: #ff9800;
transform: translateY(0);
}
.terminal {
font-family: "SF Mono", Monaco, "Cascadia Code", "Courier New", monospace;
font-size: var(--font-size);
line-height: 1.5;
white-space: var(--wrap-mode);
word-wrap: break-word;
line-height: 1.3;
color: #e0e0e0;
}
/* Default: wrap mode - text wraps to fit container */
.terminal.wrap-mode {
white-space: break-spaces;
word-break: break-word;
overflow-wrap: anywhere;
}
/* Scroll mode - horizontal scroll, no wrapping */
.terminal.scroll-mode {
white-space: pre;
overflow-x: auto;
}
/* Box-drawing lines (detected via JS) should never wrap */
.terminal .rule-line {
white-space: pre;
display: block;
}
.prompt {
background: #2a2a2a;
border: 2px solid #ff9800;
@ -869,7 +891,7 @@
</head>
<body>
<header>
<h1>clarc</h1>
<h1 onclick="location.reload()">clarc</h1>
<div style="display: flex; align-items: center; gap: 16px;">
<button class="settings-btn header-text-btn" id="sessions-filter-btn" onclick="openSessionFilter()" title="Session Filter">
<span id="sessions-count-text">0 sessions</span>
@ -968,7 +990,40 @@
</div>
</div>
<script>
<script type="module">
// Dynamic favicon state system
const FAVICON_COLORS = { idle: "#4abe4a", processing: "#e6a835", error: "#e63946" };
const FAVICON_PATH = "M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z";
function setFaviconState(state) {
const link = document.querySelector('link[rel="icon"]');
if (!link) return;
const color = FAVICON_COLORS[state] || FAVICON_COLORS.idle;
const svg = `<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg"><path fill="${color}" stroke="none" d="${FAVICON_PATH}"/></svg>`;
link.href = `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
function updateFaviconFromState() {
// Check for pending prompts first
const hasPending = Array.from(state.prompts.values()).some(p => p);
if (hasPending) {
setFaviconState('processing');
return;
}
// Check session states
const states = Array.from(state.sessions.values()).map(s => s.state);
if (states.includes('thinking') || states.includes('permission') || states.includes('question')) {
setFaviconState('processing');
} else if (states.includes('interrupted')) {
setFaviconState('error');
} else {
setFaviconState('idle');
}
}
// Set initial favicon state
setFaviconState('idle');
const state = {
sessions: new Map(),
prompts: new Map(),
@ -980,7 +1035,7 @@
columns: 1, // 1, 2, 3, or 'fit'
fontSize: 12, // px value
terminalHeight: 400, // px value
wrapText: true, // true = pre-wrap, false = pre (horizontal scroll)
wrapText: true, // true = wrap (mobile-friendly), false = scroll
},
};
@ -1011,27 +1066,30 @@
}
const sessions = await res.json();
sessions.forEach(session => {
state.sessions.set(session.id, {
const existing = state.sessions.get(session.id);
const merged = {
id: session.id,
cwd: session.cwd,
command: session.command,
output: '',
outputRenderedLength: 0,
expanded: false,
state: 'ready',
prompts: 0,
completions: 0,
tools: 0,
compressions: 0,
thinking_seconds: 0,
work_seconds: 0,
mode: 'normal',
model: null,
idle_since: null,
git_branch: null,
git_files_json: null,
autoScroll: true,
});
// Preserve any output already received via SSE before fetch completed
output: existing?.output ?? '',
outputRenderedLength: existing?.outputRenderedLength ?? 0,
// Preserve expansion state
expanded: existing?.expanded ?? false,
state: existing?.state ?? 'ready',
prompts: existing?.prompts ?? 0,
completions: existing?.completions ?? 0,
tools: existing?.tools ?? 0,
compressions: existing?.compressions ?? 0,
thinking_seconds: existing?.thinking_seconds ?? 0,
work_seconds: existing?.work_seconds ?? 0,
mode: existing?.mode ?? 'normal',
model: existing?.model ?? null,
idle_since: existing?.idle_since ?? null,
git_branch: existing?.git_branch ?? null,
git_files_json: existing?.git_files_json ?? null,
};
state.sessions.set(session.id, merged);
});
renderSessions();
} catch (error) {
@ -1040,9 +1098,14 @@
}
function connectSSE() {
// Clean up any prior connection attempt
if (state.eventSource) {
state.eventSource.close();
}
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
const es = new EventSource('/events');
@ -1060,6 +1123,7 @@
console.debug('SSE error, reconnecting...');
setStatus(false);
es.close();
state.eventSource = null;
if (!state.reconnectTimeout) {
state.reconnectTimeout = setTimeout(() => connectSSE(), 2000);
}
@ -1086,10 +1150,6 @@
idle_since: null,
git_branch: null,
git_files_json: null,
// Auto-scroll state is per-session and ephemeral - not persisted to localStorage.
// This is intentional: users may want different auto-scroll settings for different
// sessions, and persisting would require tracking state by session ID which is complex.
autoScroll: true,
});
renderSessions();
});
@ -1100,11 +1160,48 @@
renderSessions();
});
es.addEventListener('initial_state', (e) => {
const data = JSON.parse(e.data);
let session = state.sessions.get(data.session_id);
if (!session) {
// Create a placeholder session so we don't drop the state
session = {
id: data.session_id,
cwd: '',
command: 'claude',
output: data.html,
outputRenderedLength: 0,
expanded: false,
state: 'ready',
prompts: 0,
completions: 0,
tools: 0,
compressions: 0,
thinking_seconds: 0,
work_seconds: 0,
mode: 'normal',
model: null,
idle_since: null,
git_branch: null,
git_files_json: null,
};
state.sessions.set(data.session_id, session);
// renderSessions() will render both the session and its output
renderSessions();
} else {
// Session already exists, update output and re-render it
session.output = data.html;
session.outputRenderedLength = 0; // Force re-render
renderSessionOutput(data.session_id);
}
});
es.addEventListener('output', (e) => {
const data = JSON.parse(e.data);
const session = state.sessions.get(data.session_id);
if (session) {
session.output += data.data;
session.output = data.data; // Replace, not append
session.outputRenderedLength = 0; // Force full re-render
renderSessionOutput(data.session_id);
}
});
@ -1118,12 +1215,14 @@
json: data.prompt_json ? JSON.parse(data.prompt_json) : null,
});
renderPrompts();
updateFaviconFromState();
});
es.addEventListener('prompt_response', (e) => {
const data = JSON.parse(e.data);
state.prompts.delete(data.prompt_id);
renderPrompts();
updateFaviconFromState();
});
es.addEventListener('state', (e) => {
@ -1132,6 +1231,7 @@
if (session) {
session.state = data.state;
updateSessionCard(data.session_id);
updateFaviconFromState();
}
});
@ -1173,11 +1273,12 @@
session.outputRenderedLength = 0;
renderSessions();
// If expanding, resize the PTY to fit the viewport
if (session.expanded) {
// Wait for DOM to update before measuring
// Wait for DOM to update before initializing UI state
setTimeout(() => {
handleSessionResize(Number(sessionId));
scrollToBottom(Number(sessionId));
updateScrollButton(Number(sessionId));
initSessionOutputUI(Number(sessionId));
}, 0);
}
}
@ -1360,7 +1461,7 @@
<div class="session-header" onclick="toggleSession('${s.id}')">
<div class="session-info">
<div class="session-command">
<span>${escapeHtml(s.command || 'claude')}</span>
<span>${escapeHtml((s.command || 'claude').replace('--dangerously-skip-permissions', '--yolo'))}</span>
${renderStateBadge(s.state || 'ready')}
</div>
<div class="session-cwd">${escapeHtml(s.cwd || '~')}</div>
@ -1369,21 +1470,54 @@
</div>
${renderStatsWidget(s)}
<div class="session-output" id="session-output-${s.id}">
<button class="pin-scroll-btn ${s.autoScroll ? 'pinned' : ''}"
onclick="toggleAutoScroll(${s.id}); event.stopPropagation();"
title="${s.autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'}">
${s.autoScroll ? '📌' : '📍'}
<button class="scroll-to-bottom-btn"
onclick="scrollToBottom(${s.id}); event.stopPropagation();"
title="Scroll to bottom">
</button>
<div class="terminal" id="output-${s.id}">${s.output}</div>
<div class="terminal ${state.settings.wrapText ? 'wrap-mode' : 'scroll-mode'}" id="output-${s.id}">${s.output}</div>
</div>
</div>
`).join('');
// Attach scroll listeners after rendering
// Initialize scroll handlers, button visibility, and mark rule lines
sessionsToRender.forEach(s => {
const $output = document.getElementById(`session-output-${s.id}`);
if ($output) {
attachScrollListener(s.id, $output);
initSessionOutputUI(s.id);
updateScrollButton(s.id);
// Mark box-drawing lines on initial render
if (s.expanded) {
const $output = document.getElementById(`output-${s.id}`);
if ($output) markRuleLines($output);
}
});
}
// Box-drawing character range: U+2500 to U+257F
const BOX_DRAWING_REGEX = /[\u2500-\u257F]/g;
/**
* Check if a line is primarily box-drawing characters (>= 60%)
* Used to prevent wrapping on separator/border lines
*/
function isRuleLine(text) {
if (!text || text.length < 3) return false;
const visibleChars = text.replace(/\s/g, '');
if (visibleChars.length < 3) return false;
const boxChars = (visibleChars.match(BOX_DRAWING_REGEX) || []).length;
return boxChars / visibleChars.length >= 0.6;
}
/**
* Process terminal output to mark box-drawing lines as rule-lines
* This prevents wrapping on separator/border lines
*/
function markRuleLines($terminal) {
// xterm serializeAsHTML outputs content in divs (one per row)
// Each div may contain spans for styling
const rows = $terminal.querySelectorAll(':scope > div');
rows.forEach(row => {
if (isRuleLine(row.textContent)) {
row.classList.add('rule-line');
}
});
}
@ -1394,19 +1528,49 @@
const $output = document.getElementById(`output-${sessionId}`);
if ($output) {
// Append only new content since last render
const newChunk = session.output.slice(session.outputRenderedLength);
if (newChunk) {
$output.innerHTML += newChunk;
session.outputRenderedLength = session.output.length;
const $outputContainer = document.getElementById(`session-output-${sessionId}`);
const shouldStickToBottom =
!!($outputContainer && session.expanded && isAtBottom($outputContainer));
// Only mutate DOM when expanded to reduce work
if (session.expanded) {
$output.innerHTML = session.output;
// Mark box-drawing lines to prevent wrapping
markRuleLines($output);
}
// Only auto-scroll if session is expanded and autoScroll is enabled
if (session.expanded && session.autoScroll) {
$output.parentElement.scrollTop = $output.parentElement.scrollHeight;
session.outputRenderedLength = session.output.length;
if ($outputContainer && session.expanded && shouldStickToBottom) {
$outputContainer.scrollTop = $outputContainer.scrollHeight;
}
// Update scroll-to-bottom button visibility
updateScrollButton(sessionId);
}
}
function isAtBottom(el, threshold = 8) {
return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
}
function updateScrollButton(sessionId) {
const $outputContainer = document.getElementById(`session-output-${sessionId}`);
if (!$outputContainer) return;
const nearBottom = isAtBottom($outputContainer);
if (nearBottom) {
$outputContainer.classList.remove('scrolled-up');
} else {
$outputContainer.classList.add('scrolled-up');
}
}
function initSessionOutputUI(sessionId) {
const $outputContainer = document.getElementById(`session-output-${sessionId}`);
if (!$outputContainer) return;
// Attach scroll listener to toggle the button visibility
$outputContainer.addEventListener('scroll', () => updateScrollButton(sessionId));
}
function updateSessionCard(sessionId) {
const session = state.sessions.get(sessionId);
if (!session) return;
@ -1416,7 +1580,7 @@
const $commandDiv = $sessionCard.querySelector('.session-command');
if ($commandDiv) {
$commandDiv.innerHTML = `
<span>${escapeHtml(session.command || 'claude')}</span>
<span>${escapeHtml((session.command || 'claude').replace('--dangerously-skip-permissions', '--yolo'))}</span>
${renderStateBadge(session.state || 'ready')}
`;
}
@ -1869,8 +2033,16 @@
// Apply terminal height
root.style.setProperty('--terminal-height', `${state.settings.terminalHeight}px`);
// Apply wrap mode
root.style.setProperty('--wrap-mode', state.settings.wrapText ? 'pre-wrap' : 'pre');
// Apply wrap mode to all terminal elements
document.querySelectorAll('.terminal').forEach(el => {
if (state.settings.wrapText) {
el.classList.add('wrap-mode');
el.classList.remove('scroll-mode');
} else {
el.classList.add('scroll-mode');
el.classList.remove('wrap-mode');
}
});
}
function updateSettingsUI() {
@ -2012,7 +2184,7 @@
return `
<div class="session-filter-item ${isSelected ? 'selected' : ''}" onclick="setSessionFilter(${s.id})">
<div class="session-filter-command">
<span>${escapeHtml(s.command || 'claude')}</span>
<span>${escapeHtml((s.command || 'claude').replace('--dangerously-skip-permissions', '--yolo'))}</span>
${renderStateBadge(s.state || 'ready')}
</div>
<div class="session-filter-cwd">${escapeHtml(s.cwd || '~')}</div>
@ -2021,143 +2193,17 @@
}).join('');
}
// Auto-scroll control
const scrollListeners = new Map();
// Viewport-based PTY resize
let resizeDebounceTimer = null;
function measureTerminalCols() {
// Create a hidden span with monospace font to measure character width
const $measure = document.createElement('span');
$measure.style.fontFamily = '"SF Mono", Monaco, "Cascadia Code", "Courier New", monospace';
$measure.style.fontSize = `${state.settings.fontSize}px`;
$measure.style.lineHeight = '1.5';
$measure.style.visibility = 'hidden';
$measure.style.position = 'absolute';
$measure.textContent = 'X'.repeat(100); // Measure 100 chars
document.body.appendChild($measure);
const charWidth = $measure.offsetWidth / 100;
document.body.removeChild($measure);
// Get terminal container width (accounting for padding)
const $container = document.querySelector('.session-output');
if (!$container) {
return 80; // Default fallback
window.scrollToBottom = (sessionId) => {
const $outputContainer = document.getElementById(`session-output-${sessionId}`);
if ($outputContainer) {
$outputContainer.scrollTop = $outputContainer.scrollHeight;
}
const containerWidth = $container.clientWidth - 32; // 16px padding on each side
const cols = Math.floor(containerWidth / charWidth);
// Clamp to reasonable bounds
return Math.max(40, Math.min(cols, 300));
}
async function resizeSessionPTY(sessionId, cols, rows) {
try {
const res = await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ cols, rows }),
});
if (!res.ok) {
console.error('Failed to resize session PTY:', await res.text());
}
} catch (error) {
console.error('Error resizing session PTY:', error);
}
}
function handleSessionResize(sessionId) {
const cols = measureTerminalCols();
const rows = 24; // Reasonable default row count
resizeSessionPTY(sessionId, cols, rows);
}
function handleWindowResize() {
if (resizeDebounceTimer) {
clearTimeout(resizeDebounceTimer);
}
resizeDebounceTimer = setTimeout(() => {
// Resize all expanded sessions
state.sessions.forEach((session) => {
if (session.expanded) {
handleSessionResize(session.id);
}
});
}, 300); // Debounce for 300ms
}
function attachScrollListener(sessionId, $outputContainer) {
// Remove existing listener if present
const existing = scrollListeners.get(sessionId);
if (existing) {
$outputContainer.removeEventListener('scroll', existing);
}
const listener = () => {
const session = state.sessions.get(sessionId);
if (!session) return;
const { scrollTop, clientHeight, scrollHeight } = $outputContainer;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;
if (session.autoScroll !== isAtBottom) {
session.autoScroll = isAtBottom;
updatePinButton(sessionId);
}
};
$outputContainer.addEventListener('scroll', listener);
scrollListeners.set(sessionId, listener);
}
window.toggleAutoScroll = (sessionId) => {
const session = state.sessions.get(sessionId);
if (!session) return;
session.autoScroll = !session.autoScroll;
// If enabling auto-scroll, scroll to bottom immediately
if (session.autoScroll) {
const $outputContainer = document.getElementById(`session-output-${sessionId}`);
if ($outputContainer) {
$outputContainer.scrollTop = $outputContainer.scrollHeight;
}
}
updatePinButton(sessionId);
};
function updatePinButton(sessionId) {
const session = state.sessions.get(sessionId);
if (!session) return;
const $btn = document.querySelector(`#session-output-${sessionId} .pin-scroll-btn`);
if ($btn) {
if (session.autoScroll) {
$btn.classList.add('pinned');
$btn.textContent = '📌';
$btn.title = 'Auto-scroll enabled';
} else {
$btn.classList.remove('pinned');
$btn.textContent = '📍';
$btn.title = 'Auto-scroll disabled';
}
}
}
// Initialize
loadSettings();
applySettings();
connectSSE();
// Set up window resize listener for PTY viewport resize
window.addEventListener('resize', handleWindowResize);
</script>
</body>
</html>

View file

@ -1,505 +0,0 @@
import { expect, test } from "bun:test";
import { ansiToHtml, trimLineEndPreserveAnsi } from "./ansi";
// 1. XSS/HTML escaping tests
test("escapes < to &lt;", () => {
const result = ansiToHtml("<div>");
expect(result).toBe("&lt;div&gt;");
});
test("escapes > to &gt;", () => {
const result = ansiToHtml("a > b");
expect(result).toBe("a &gt; b");
});
test("escapes & to &amp;", () => {
const result = ansiToHtml("foo & bar");
expect(result).toBe("foo &amp; bar");
});
test("escapes <script> tags", () => {
const result = ansiToHtml("<script>alert('xss')</script>");
expect(result).toBe("&lt;script&gt;alert('xss')&lt;/script&gt;");
});
test("escapes HTML in styled text", () => {
const result = ansiToHtml("\x1b[31m<script>alert(1)</script>\x1b[0m");
expect(result).toContain("&lt;script&gt;");
expect(result).toContain("&lt;/script&gt;");
expect(result).not.toContain("<script>");
});
// 2. Basic 16 colors tests
test("red foreground (31)", () => {
const result = ansiToHtml("\x1b[31mred\x1b[0m");
expect(result).toBe('<span style="color:#f85149">red</span>');
});
test("green foreground (32)", () => {
const result = ansiToHtml("\x1b[32mgreen\x1b[0m");
expect(result).toBe('<span style="color:#3fb950">green</span>');
});
test("blue foreground (34)", () => {
const result = ansiToHtml("\x1b[34mblue\x1b[0m");
expect(result).toBe('<span style="color:#58a6ff">blue</span>');
});
test("yellow foreground (33)", () => {
const result = ansiToHtml("\x1b[33myellow\x1b[0m");
expect(result).toBe('<span style="color:#d29922">yellow</span>');
});
test("bright red foreground (91)", () => {
const result = ansiToHtml("\x1b[91mbright red\x1b[0m");
expect(result).toBe('<span style="color:#ff7b72">bright red</span>');
});
test("bright green foreground (92)", () => {
const result = ansiToHtml("\x1b[92mbright green\x1b[0m");
expect(result).toBe('<span style="color:#7ee787">bright green</span>');
});
test("red background (41)", () => {
const result = ansiToHtml("\x1b[41mred bg\x1b[0m");
expect(result).toBe('<span style="background-color:#f85149">red bg</span>');
});
test("green background (42)", () => {
const result = ansiToHtml("\x1b[42mgreen bg\x1b[0m");
expect(result).toBe('<span style="background-color:#3fb950">green bg</span>');
});
test("bright blue background (104)", () => {
const result = ansiToHtml("\x1b[104mbright blue bg\x1b[0m");
expect(result).toBe(
'<span style="background-color:#79c0ff">bright blue bg</span>',
);
});
test("foreground and background together", () => {
const result = ansiToHtml("\x1b[31;42mred on green\x1b[0m");
expect(result).toContain("color:#f85149");
expect(result).toContain("background-color:#3fb950");
});
// 3. 256-color palette tests
test("256-color basic colors (0-15): red", () => {
const result = ansiToHtml("\x1b[38;5;1mred\x1b[0m");
expect(result).toContain("color:#cd3131");
});
test("256-color basic colors (0-15): bright white", () => {
const result = ansiToHtml("\x1b[38;5;15mwhite\x1b[0m");
expect(result).toContain("color:#ffffff");
});
test("256-color 6x6x6 cube: first color (16)", () => {
const result = ansiToHtml("\x1b[38;5;16mcolor\x1b[0m");
expect(result).toContain("rgb(0,0,0)");
});
test("256-color 6x6x6 cube: mid-range color", () => {
// Color 196 = index 180 (196-16) in cube
// ri=5, gi=0, bi=0 -> r=255, g=0, b=0
const result = ansiToHtml("\x1b[38;5;196mred\x1b[0m");
expect(result).toContain("rgb(255,0,0)");
});
test("256-color grayscale ramp (232-255): dark gray", () => {
// Color 232: gray = (232-232)*10+8 = 8
const result = ansiToHtml("\x1b[38;5;232mgray\x1b[0m");
expect(result).toContain("rgb(8,8,8)");
});
test("256-color grayscale ramp (232-255): light gray", () => {
// Color 255: gray = (255-232)*10+8 = 238
const result = ansiToHtml("\x1b[38;5;255mgray\x1b[0m");
expect(result).toContain("rgb(238,238,238)");
});
test("256-color background", () => {
const result = ansiToHtml("\x1b[48;5;21mbg\x1b[0m");
expect(result).toContain("background-color");
});
// 4. 24-bit RGB tests
test("24-bit RGB foreground", () => {
const result = ansiToHtml("\x1b[38;2;255;128;64mrgb\x1b[0m");
expect(result).toBe('<span style="color:rgb(255,128,64)">rgb</span>');
});
test("24-bit RGB background", () => {
const result = ansiToHtml("\x1b[48;2;100;150;200mbg\x1b[0m");
expect(result).toBe(
'<span style="background-color:rgb(100,150,200)">bg</span>',
);
});
test("24-bit RGB foreground and background", () => {
const result = ansiToHtml("\x1b[38;2;255;0;0;48;2;0;255;0mtext\x1b[0m");
expect(result).toContain("color:rgb(255,0,0)");
expect(result).toContain("background-color:rgb(0,255,0)");
});
// 5. Text attributes tests
test("bold (1)", () => {
const result = ansiToHtml("\x1b[1mbold\x1b[0m");
expect(result).toBe('<span style="font-weight:bold">bold</span>');
});
test("dim (2)", () => {
const result = ansiToHtml("\x1b[2mdim\x1b[0m");
expect(result).toBe('<span style="opacity:0.5">dim</span>');
});
test("italic (3)", () => {
const result = ansiToHtml("\x1b[3mitalic\x1b[0m");
expect(result).toBe('<span style="font-style:italic">italic</span>');
});
test("underline (4)", () => {
const result = ansiToHtml("\x1b[4munderline\x1b[0m");
expect(result).toBe(
'<span style="text-decoration:underline">underline</span>',
);
});
test("inverse (7) swaps foreground and background", () => {
const result = ansiToHtml("\x1b[7minverse\x1b[0m");
expect(result).toContain("color:#ffffff");
expect(result).toContain("background-color:#0d1117");
});
test("inverse with colors swaps them", () => {
const result = ansiToHtml("\x1b[31;42;7minverse\x1b[0m");
// Original: fg=#f85149, bg=#3fb950
// Swapped: fg=#3fb950, bg=#f85149
expect(result).toContain("color:#3fb950");
expect(result).toContain("background-color:#f85149");
});
test("multiple attributes combined", () => {
const result = ansiToHtml("\x1b[1;3;4mtext\x1b[0m");
expect(result).toContain("font-weight:bold");
expect(result).toContain("font-style:italic");
expect(result).toContain("text-decoration:underline");
});
test("bold and color", () => {
const result = ansiToHtml("\x1b[1;31mbold red\x1b[0m");
expect(result).toContain("font-weight:bold");
expect(result).toContain("color:#f85149");
});
// 6. Reset codes tests
test("reset (0) clears all styles", () => {
const result = ansiToHtml("\x1b[1;31;42mbold red on green\x1b[0mplain");
expect(result).toBe(
'<span style="color:#f85149;background-color:#3fb950;font-weight:bold">bold red on green</span>plain',
);
});
test("reset bold/dim (22)", () => {
const result = ansiToHtml("\x1b[1mbold\x1b[22mnormal\x1b[0m");
expect(result).toBe('<span style="font-weight:bold">bold</span>normal');
});
test("reset italic (23)", () => {
const result = ansiToHtml("\x1b[3mitalic\x1b[23mnormal\x1b[0m");
expect(result).toBe('<span style="font-style:italic">italic</span>normal');
});
test("reset underline (24)", () => {
const result = ansiToHtml("\x1b[4munderline\x1b[24mnormal\x1b[0m");
expect(result).toBe(
'<span style="text-decoration:underline">underline</span>normal',
);
});
test("reset inverse (27)", () => {
const result = ansiToHtml("\x1b[7minverse\x1b[27mnormal\x1b[0m");
expect(result).toBe(
'<span style="color:#ffffff;background-color:#0d1117">inverse</span>normal',
);
});
test("reset foreground (39)", () => {
const result = ansiToHtml("\x1b[31mred\x1b[39mdefault\x1b[0m");
expect(result).toBe('<span style="color:#f85149">red</span>default');
});
test("reset background (49)", () => {
const result = ansiToHtml("\x1b[41mred bg\x1b[49mdefault\x1b[0m");
expect(result).toBe(
'<span style="background-color:#f85149">red bg</span>default',
);
});
// 7. Multiple codes in one sequence tests
test("bold and red in one sequence", () => {
const result = ansiToHtml("\x1b[1;31mbold red\x1b[0m");
expect(result).toContain("font-weight:bold");
expect(result).toContain("color:#f85149");
});
test("multiple attributes in one sequence", () => {
const result = ansiToHtml("\x1b[1;3;4;31;42mtext\x1b[0m");
expect(result).toContain("font-weight:bold");
expect(result).toContain("font-style:italic");
expect(result).toContain("text-decoration:underline");
expect(result).toContain("color:#f85149");
expect(result).toContain("background-color:#3fb950");
});
test("empty sequence defaults to reset", () => {
const result = ansiToHtml("\x1b[31mred\x1b[mdefault\x1b[0m");
expect(result).toBe('<span style="color:#f85149">red</span>default');
});
// 8. Newline handling tests
test("newline closes and reopens spans", () => {
const result = ansiToHtml("\x1b[31mred\nstill red\x1b[0m");
expect(result).toBe(
'<span style="color:#f85149">red</span>\n<span style="color:#f85149">still red</span>',
);
});
test("newline without styling", () => {
const result = ansiToHtml("line1\nline2");
expect(result).toBe("line1\nline2");
});
test("multiple newlines with styling", () => {
const result = ansiToHtml("\x1b[31mred\n\nstill red\x1b[0m");
expect(result).toBe(
'<span style="color:#f85149">red</span>\n<span style="color:#f85149"></span>\n<span style="color:#f85149">still red</span>',
);
});
test("carriage return in \\r\\n is treated as line ending", () => {
const result = ansiToHtml("line1\r\nline2");
expect(result).toBe("line1\nline2");
});
test("standalone carriage return overwrites from line start", () => {
const result = ansiToHtml("text\rmore");
expect(result).toBe("more");
});
test("multiple carriage returns overwrite progressively", () => {
const result = ansiToHtml("loading...\rloading...\rdone ");
// Note: trailing spaces are trimmed by trimLineEndPreserveAnsi
expect(result).toBe("done");
});
test("carriage return with newlines preserves lines", () => {
const result = ansiToHtml("line1\nloading...\rdone\nline3");
expect(result).toBe("line1\ndone\nline3");
});
test("carriage return with styling preserves style", () => {
const result = ansiToHtml("\x1b[31mloading...\rdone\x1b[0m");
expect(result).toBe('<span style="color:#f85149">done</span>');
});
test("carriage return at start of line", () => {
const result = ansiToHtml("\rtext");
expect(result).toBe("text");
});
// 9. trimLineEndPreserveAnsi tests
test("trimLineEndPreserveAnsi trims trailing spaces", () => {
const result = trimLineEndPreserveAnsi("hello world ");
expect(result).toBe("hello world");
});
test("trimLineEndPreserveAnsi preserves content", () => {
const result = trimLineEndPreserveAnsi("no trailing spaces");
expect(result).toBe("no trailing spaces");
});
test("trimLineEndPreserveAnsi preserves trailing ANSI reset", () => {
const result = trimLineEndPreserveAnsi("text \x1b[0m");
expect(result).toBe("text\x1b[0m");
});
test("trimLineEndPreserveAnsi preserves trailing ANSI color", () => {
const result = trimLineEndPreserveAnsi("text \x1b[31m");
expect(result).toBe("text\x1b[31m");
});
test("trimLineEndPreserveAnsi handles lines without ANSI codes", () => {
const result = trimLineEndPreserveAnsi("plain text ");
expect(result).toBe("plain text");
});
test("trimLineEndPreserveAnsi handles empty string", () => {
const result = trimLineEndPreserveAnsi("");
expect(result).toBe("");
});
test("trimLineEndPreserveAnsi handles only spaces", () => {
const result = trimLineEndPreserveAnsi(" ");
expect(result).toBe("");
});
test("trimLineEndPreserveAnsi preserves multiple trailing ANSI sequences", () => {
const result = trimLineEndPreserveAnsi("text \x1b[0m\x1b[31m");
expect(result).toBe("text\x1b[0m\x1b[31m");
});
test("ansiToHtml uses trimLineEndPreserveAnsi on each line", () => {
const result = ansiToHtml("\x1b[41mtext \x1b[0m\nmore ");
// Trailing spaces should be trimmed
expect(result).not.toContain("text </span>");
expect(result).not.toContain("more ");
});
// 10. Edge cases tests
test("empty string returns empty", () => {
const result = ansiToHtml("");
expect(result).toBe("");
});
test("no ANSI codes (plain text)", () => {
const result = ansiToHtml("plain text");
expect(result).toBe("plain text");
});
test("malformed ANSI sequence (incomplete)", () => {
const result = ansiToHtml("\x1b[31incomplete");
// Incomplete sequence consumes the 3, but outputs the rest
expect(result).toContain("ncomplete");
});
test("ANSI sequence without parameters", () => {
const result = ansiToHtml("\x1b[mtext");
expect(result).toBe("text");
});
test("unknown ANSI codes are ignored", () => {
const result = ansiToHtml("\x1b[999mtext\x1b[0m");
expect(result).toBe("text");
});
test("non-SGR escape sequences are skipped", () => {
// Cursor positioning sequences like ESC[H should be ignored
const result = ansiToHtml("\x1b[Htext");
expect(result).toBe("text");
});
test("consecutive ANSI codes", () => {
const result = ansiToHtml("\x1b[31m\x1b[1mred bold\x1b[0m");
expect(result).toContain("color:#f85149");
expect(result).toContain("font-weight:bold");
});
test("ANSI code at end without text", () => {
const result = ansiToHtml("text\x1b[31m");
// Opens a span even though there's no text after
expect(result).toBe('text<span style="color:#f85149"></span>');
});
test("ANSI code at start", () => {
const result = ansiToHtml("\x1b[31mtext");
expect(result).toBe('<span style="color:#f85149">text</span>');
});
test("multiple resets", () => {
const result = ansiToHtml("\x1b[31mred\x1b[0m\x1b[0m\x1b[0m");
expect(result).toBe('<span style="color:#f85149">red</span>');
});
test("style change without reset", () => {
const result = ansiToHtml("\x1b[31mred\x1b[32mgreen");
expect(result).toBe(
'<span style="color:#f85149">red</span><span style="color:#3fb950">green</span>',
);
});
test("mixed escaped characters and ANSI codes", () => {
const result = ansiToHtml("\x1b[31m<script>&alert</script>\x1b[0m");
expect(result).toContain("&lt;script&gt;");
expect(result).toContain("&amp;alert");
expect(result).toContain("color:#f85149");
});
test("preserves internal spaces", () => {
const result = ansiToHtml("hello world");
expect(result).toBe("hello world");
});
test("handles only ANSI codes with no text", () => {
const result = ansiToHtml("\x1b[31m\x1b[0m");
// Opens and closes span even with no text
expect(result).toBe('<span style="color:#f85149"></span>');
});
// OSC sequences (Operating System Command)
test("strips OSC terminal title with BEL", () => {
// ESC ] 0 ; title BEL
const result = ansiToHtml("\x1b]0;My Terminal Title\x07text after");
expect(result).toBe("text after");
});
test("strips OSC terminal title with ST", () => {
// ESC ] 0 ; title ESC \
const result = ansiToHtml("\x1b]0;My Terminal Title\x1b\\text after");
expect(result).toBe("text after");
});
test("strips OSC with emoji in title", () => {
const result = ansiToHtml("\x1b]0;★ Claude Code\x07hello");
expect(result).toBe("hello");
});
test("strips multiple OSC sequences", () => {
const result = ansiToHtml("\x1b]0;title1\x07text\x1b]0;title2\x07more");
expect(result).toBe("textmore");
});
// Private mode sequences (DEC)
test("strips bracketed paste mode enable", () => {
// ESC [ ? 2004 h
const result = ansiToHtml("\x1b[?2004htext");
expect(result).toBe("text");
});
test("strips bracketed paste mode disable", () => {
// ESC [ ? 2004 l
const result = ansiToHtml("\x1b[?2004ltext");
expect(result).toBe("text");
});
test("strips mixed private mode and SGR", () => {
const result = ansiToHtml("\x1b[?2004h\x1b[31mred\x1b[0m\x1b[?2004l");
expect(result).toBe('<span style="color:#f85149">red</span>');
});
test("strips cursor visibility sequences", () => {
// ESC [ ? 25 h (show cursor) and ESC [ ? 25 l (hide cursor)
const result = ansiToHtml("\x1b[?25lhidden cursor\x1b[?25h");
expect(result).toBe("hidden cursor");
});
test("handles Claude Code typical output", () => {
// Simulated Claude Code startup with title, bracketed paste, and colors
const input =
"\x1b]0;★ Claude Code\x07\x1b[?2004h\x1b[1;33mClaude Code\x1b[0m v2.1.15\x1b[?2004l";
const result = ansiToHtml(input);
expect(result).toContain("Claude Code");
expect(result).toContain("v2.1.15");
expect(result).not.toContain("2004");
expect(result).not.toContain("]0;");
});

View file

@ -1,332 +0,0 @@
// ANSI to HTML converter - processes escape sequences and renders as inline styled spans
const colors: Record<number, string> = {
30: "#0d1117",
31: "#f85149",
32: "#3fb950",
33: "#d29922",
34: "#58a6ff",
35: "#bc8cff",
36: "#39c5cf",
37: "#c9d1d9",
90: "#6e7681",
91: "#ff7b72",
92: "#7ee787",
93: "#e3b341",
94: "#79c0ff",
95: "#d2a8ff",
96: "#56d4dd",
97: "#ffffff",
};
const bgColors: Record<number, string> = {
40: "#0d1117",
41: "#f85149",
42: "#3fb950",
43: "#d29922",
44: "#58a6ff",
45: "#bc8cff",
46: "#39c5cf",
47: "#c9d1d9",
100: "#6e7681",
101: "#ff7b72",
102: "#7ee787",
103: "#e3b341",
104: "#79c0ff",
105: "#d2a8ff",
106: "#56d4dd",
107: "#ffffff",
};
interface AnsiState {
fg: string | null;
bg: string | null;
bold: boolean;
dim: boolean;
italic: boolean;
underline: boolean;
inverse: boolean;
}
interface ExtendedColorResult {
color: string;
skip: number;
}
// Trim trailing whitespace from each line to prevent background color bleeding.
// Terminal buffers often pad lines with spaces. Preserve trailing ANSI resets.
export function trimLineEndPreserveAnsi(line: string): string {
let end = line.length;
let ansiSuffix = "";
while (end > 0) {
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC character is intentional for ANSI sequences
const match = line.slice(0, end).match(/\u001b\[[0-9;?]*[A-Za-z]$/);
if (!match) break;
ansiSuffix = match[0] + ansiSuffix;
end -= match[0].length;
}
return line.slice(0, end).trimEnd() + ansiSuffix;
}
// Parse extended color: 38;2;R;G;B (24-bit) or 38;5;N (256-color)
// Returns { color, skip } where skip is additional codes to skip
function parseExtendedColor(
codes: number[],
idx: number,
): ExtendedColorResult | null {
const mode = codes[idx + 1];
// 24-bit RGB: 38;2;R;G;B
if (mode === 2 && codes[idx + 4] !== undefined) {
const r = codes[idx + 2];
const g = codes[idx + 3];
const b = codes[idx + 4];
return { color: `rgb(${r},${g},${b})`, skip: 4 };
}
// 256-color palette: 38;5;N
if (mode === 5) {
const colorNum = codes[idx + 2];
if (colorNum === undefined) return null;
let color: string;
if (colorNum < 16) {
const basic = [
"#0d1117",
"#cd3131",
"#0dbc79",
"#e5e510",
"#2472c8",
"#bc3fbc",
"#11a8cd",
"#e5e5e5",
"#666666",
"#f14c4c",
"#23d18b",
"#f5f543",
"#3b8eea",
"#d670d6",
"#29b8db",
"#ffffff",
];
color = basic[colorNum] || "#ffffff";
} else if (colorNum < 232) {
const n = colorNum - 16;
const ri = Math.floor(n / 36);
const gi = Math.floor((n % 36) / 6);
const bi = n % 6;
const r = ri === 0 ? 0 : ri * 40 + 55;
const g = gi === 0 ? 0 : gi * 40 + 55;
const b = bi === 0 ? 0 : bi * 40 + 55;
color = `rgb(${r},${g},${b})`;
} else {
const gray = (colorNum - 232) * 10 + 8;
color = `rgb(${gray},${gray},${gray})`;
}
return { color, skip: 2 };
}
return null;
}
function resetState(): AnsiState {
return {
fg: null,
bg: null,
bold: false,
dim: false,
italic: false,
underline: false,
inverse: false,
};
}
function buildStyle(state: AnsiState): string {
let fg = state.fg;
let bg = state.bg;
// For inverse, swap fg and bg
if (state.inverse) {
const tmp = fg;
fg = bg || "#ffffff";
bg = tmp || "#0d1117";
}
const styles: string[] = [];
if (fg) styles.push(`color:${fg}`);
if (bg) styles.push(`background-color:${bg}`);
if (state.bold) styles.push("font-weight:bold");
if (state.dim) styles.push("opacity:0.5");
if (state.italic) styles.push("font-style:italic");
if (state.underline) styles.push("text-decoration:underline");
return styles.join(";");
}
export function ansiToHtml(text: string): string {
if (!text) return "";
// Trim trailing whitespace from each line to prevent background color bleeding
text = text.split("\n").map(trimLineEndPreserveAnsi).join("\n");
let result = "";
let inSpan = false;
let i = 0;
let currentStyle = "";
let state = resetState();
function applyStyle(): void {
const nextStyle = buildStyle(state);
if (nextStyle === currentStyle) return;
if (inSpan) {
result += "</span>";
inSpan = false;
}
if (nextStyle) {
result += `<span style="${nextStyle}">`;
inSpan = true;
}
currentStyle = nextStyle;
}
while (i < text.length) {
// Check for ESC character (char code 27)
if (text.charCodeAt(i) === 27) {
// OSC sequence: ESC ] ... BEL or ESC ] ... ESC \
// Used for terminal title, hyperlinks, etc.
if (text[i + 1] === "]") {
let j = i + 2;
// Skip until BEL (0x07) or ST (ESC \)
while (j < text.length) {
if (text.charCodeAt(j) === 0x07) {
j++;
break;
}
if (text.charCodeAt(j) === 27 && text[j + 1] === "\\") {
j += 2;
break;
}
j++;
}
i = j;
continue;
}
// CSI sequence: ESC [ params command
if (text[i + 1] === "[") {
let j = i + 2;
let params = "";
// Include ? for private mode sequences like ESC[?2004h (bracketed paste)
while (j < text.length && /[0-9;?]/.test(text[j] || "")) {
params += text[j];
j++;
}
const command = text[j] || "";
j++;
// Only process SGR (m command) for styling, skip others (cursor, clear, etc.)
if (command === "m" && !params.includes("?")) {
// SGR - Select Graphic Rendition
const codes = params ? params.split(";").map(Number) : [0];
for (let k = 0; k < codes.length; k++) {
const code = codes[k];
if (code === 0) {
state = resetState();
} else if (code === 1) state.bold = true;
else if (code === 2) state.dim = true;
else if (code === 3) state.italic = true;
else if (code === 4) state.underline = true;
else if (code === 7) state.inverse = true;
else if (code === 22) {
state.bold = false;
state.dim = false;
} else if (code === 23) state.italic = false;
else if (code === 24) state.underline = false;
else if (code === 27) state.inverse = false;
else if (code === 39) state.fg = null;
else if (code === 49) state.bg = null;
else if (code === 38) {
const extColor = parseExtendedColor(codes, k);
if (extColor) {
state.fg = extColor.color;
k += extColor.skip;
}
} else if (code === 48) {
const extColor = parseExtendedColor(codes, k);
if (extColor) {
state.bg = extColor.color;
k += extColor.skip;
}
} else if (code !== undefined && colors[code]) {
state.fg = colors[code];
} else if (code !== undefined && bgColors[code]) {
state.bg = bgColors[code];
}
}
applyStyle();
}
// Skip all CSI sequences (SGR handled above, others like cursor/clear ignored)
i = j;
continue;
}
// Unknown ESC sequence - skip the ESC character
i++;
continue;
}
// Track newlines and carriage returns
if (text[i] === "\n") {
// Close span before newline to prevent background color bleeding
if (inSpan) {
result += "</span>";
}
result += "\n";
// Reopen span after newline if we had styling
if (inSpan) {
result += `<span style="${currentStyle}">`;
}
i++;
continue;
}
// Carriage return: overwrite from start of current line
if (text[i] === "\r") {
// Check if this is \r\n - if so, just skip the \r
if (text[i + 1] === "\n") {
i++;
continue;
}
// Standalone \r: truncate back to last newline (or start)
// Need to handle open spans carefully
const lastNewline = result.lastIndexOf("\n");
if (lastNewline >= 0) {
// Truncate after the last newline
result = result.substring(0, lastNewline + 1);
} else {
// No newline found, truncate everything
result = "";
}
// If we had a span open, we need to reopen it after truncation
if (inSpan) {
result += `<span style="${currentStyle}">`;
}
i++;
continue;
}
// Regular character - escape HTML
const ch = text[i];
if (ch === "<") result += "&lt;";
else if (ch === ">") result += "&gt;";
else if (ch === "&") result += "&amp;";
else result += ch;
i++;
}
if (inSpan) result += "</span>";
return result;
}

View file

@ -3,6 +3,17 @@
import { Database } from "bun:sqlite";
import type { Device, OutputLog, PromptData, Session } from "./types";
// Raw row returned from SQLite before JSON parsing
interface RawPromptRow {
id: number;
session_id: number;
created_at: number;
prompt_text: string;
response: string | null;
responded_at: number | null;
prompt_json: string | null;
}
// Extend Prompt interface to include prompt_json field
export interface Prompt {
id: number;
@ -58,9 +69,12 @@ function runMigrations(): void {
for (const migration of migrations) {
try {
db.exec(migration);
} catch (error: any) {
} catch (error: unknown) {
// Ignore "duplicate column" errors - column already exists
if (!error.message?.includes("duplicate column")) {
if (
!(error instanceof Error) ||
!error.message?.includes("duplicate column")
) {
throw error;
}
}
@ -221,7 +235,7 @@ export function createPrompt(
promptText,
promptJson ?? null,
);
const prompt = row as any;
const prompt = row as RawPromptRow;
// Parse prompt_json if present
if (prompt.prompt_json) {
@ -239,7 +253,7 @@ export function getPrompt(promptId: number): Prompt | null {
const row = getPromptStmt.get(promptId);
if (!row) return null;
const prompt = row as any;
const prompt = row as RawPromptRow;
// Parse prompt_json if present
if (prompt.prompt_json) {
@ -258,9 +272,9 @@ export function respondToPrompt(promptId: number, response: string): void {
}
export function getPendingPrompts(): Prompt[] {
const rows = getPendingPromptsStmt.all();
const rows = getPendingPromptsStmt.all() as RawPromptRow[];
return rows.map((row: any) => {
return rows.map((row) => {
// Parse prompt_json if present
if (row.prompt_json) {
try {

View file

@ -1,5 +0,0 @@
import { expect, test } from "bun:test";
test("placeholder", () => {
expect(true).toBe(true);
});

View file

@ -1,7 +1,6 @@
// Core server: HTTP + WebSocket + SSE
import type { ServerWebSocket } from "bun";
import { ansiToHtml } from "./ansi";
import {
appendOutput,
createDevice,
@ -18,6 +17,12 @@ import {
updateLastSeen,
updateSessionStats,
} from "./db";
import {
createTerminal,
disposeTerminal,
serializeAsHTML,
type TerminalSession,
} from "./terminal";
import type {
AnswerResponse,
ClientMessage,
@ -30,6 +35,7 @@ import type {
const sseClients = new Set<ReadableStreamDefaultController<string>>();
const sessionWebSockets = new Map<number, ServerWebSocket<SessionData>>();
const sessionStates = new Map<number, SessionState>();
const sessionTerminals = new Map<number, TerminalSession>();
interface SessionData {
deviceId: number;
@ -127,8 +133,26 @@ const server = Bun.serve<SessionData>({
start(controller) {
ctrl = controller;
sseClients.add(controller);
// Send initial headers
// Send initial connection acknowledgment
controller.enqueue(": connected\n\n");
// Send current terminal state for all active sessions
for (const [sessionId, termSession] of sessionTerminals.entries()) {
const session = getSession(sessionId);
if (!session || session.ended_at) continue;
// Serialize full terminal state as HTML
const html = serializeAsHTML(termSession);
// Send as initial_state event
const event: SSEEvent = {
type: "initial_state",
session_id: sessionId,
html,
};
const eventStr = `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
controller.enqueue(eventStr);
}
},
cancel() {
sseClients.delete(ctrl);
@ -434,6 +458,19 @@ const server = Bun.serve<SessionData>({
// Initialize in-memory session state
sessionStates.set(session.id, createDefaultSessionState());
// Create terminal emulator wide enough to avoid premature wrapping
// Browser CSS handles responsive display; we just need ANSI processing
try {
const termSession = createTerminal(300, 50);
sessionTerminals.set(session.id, termSession);
} catch (error) {
console.error(
`Failed to create terminal for session ${session.id}:`,
error,
);
// Session will work but output won't be processed through terminal emulator
}
console.debug(
`Session ${session.id} started for device ${device.id}`,
);
@ -484,12 +521,31 @@ const server = Bun.serve<SessionData>({
// Handle output message
if (msg.type === "output") {
appendOutput(ws.data.sessionId, msg.data); // Store raw ANSI
broadcastSSE({
type: "output",
session_id: ws.data.sessionId,
data: ansiToHtml(msg.data), // Parse for display
const sessionId = ws.data.sessionId;
const termSession = sessionTerminals.get(sessionId);
if (!termSession) {
console.error(`No terminal for session ${sessionId}`);
return;
}
// Store raw ANSI in database
appendOutput(sessionId, msg.data);
// Write to terminal emulator (handles all ANSI sequences)
// Use callback to wait for write completion before serializing
termSession.terminal.write(msg.data, () => {
// Serialize current terminal state as HTML
const html = serializeAsHTML(termSession);
// Broadcast to dashboards
broadcastSSE({
type: "output",
session_id: sessionId,
data: html,
});
});
return;
}
@ -594,6 +650,13 @@ const server = Bun.serve<SessionData>({
console.debug(
`Session ${ws.data.sessionId} resized to ${msg.cols}x${msg.rows}`,
);
// Resize terminal emulator
const termSession = sessionTerminals.get(ws.data.sessionId);
if (termSession) {
termSession.terminal.resize(msg.cols, msg.rows);
}
return;
}
@ -632,6 +695,13 @@ const server = Bun.serve<SessionData>({
if (ws.data.sessionId) {
sessionWebSockets.delete(ws.data.sessionId);
// Dispose terminal emulator
const termSession = sessionTerminals.get(ws.data.sessionId);
if (termSession) {
disposeTerminal(termSession);
sessionTerminals.delete(ws.data.sessionId);
}
// Persist final state before cleanup
const state = sessionStates.get(ws.data.sessionId);
if (state?.dirty) {
@ -665,4 +735,20 @@ setInterval(() => {
// Periodic persistence of dirty session states (every 5s)
setInterval(persistDirtySessions, 5000);
// Periodic cleanup for orphaned terminals (every 5 minutes)
// Handles cases where session ended but WebSocket didn't close properly
setInterval(
() => {
for (const [sessionId, termSession] of sessionTerminals.entries()) {
const session = getSession(sessionId);
if (session?.ended_at) {
console.debug(`Cleaning up orphaned terminal for session ${sessionId}`);
disposeTerminal(termSession);
sessionTerminals.delete(sessionId);
}
}
},
5 * 60 * 1000,
);
export { server };

268
src/terminal.test.ts Normal file
View file

@ -0,0 +1,268 @@
import { describe, expect, test } from "bun:test";
import { createTerminal, disposeTerminal, serializeAsHTML } from "./terminal";
// Helper to write to terminal and wait for completion
function writeSync(
term: ReturnType<typeof createTerminal>,
data: string,
): Promise<void> {
return new Promise((resolve) => {
term.terminal.write(data, resolve);
});
}
describe("terminal", () => {
test("creates terminal with correct dimensions", () => {
const term = createTerminal(80, 24);
expect(term.terminal.cols).toBe(80);
expect(term.terminal.rows).toBe(24);
disposeTerminal(term);
});
test("writes data to terminal buffer", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "Hello, world!");
const html = serializeAsHTML(term);
expect(html).toContain("Hello, world!");
disposeTerminal(term);
});
test("handles ANSI cursor movement", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "AAA\x1b[3D"); // Write AAA, move cursor back 3
await writeSync(term, "BBB"); // Overwrite with BBB
const html = serializeAsHTML(term);
expect(html).toContain("BBB");
expect(html).not.toContain("AAA");
disposeTerminal(term);
});
test("handles carriage return correctly", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "Old text\rNew"); // CR should move to col 0
const html = serializeAsHTML(term);
expect(html).toContain("New");
expect(html).not.toContain("Old");
disposeTerminal(term);
});
test("handles incomplete ANSI sequences across writes", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "Hello\x1b["); // Incomplete CSI
await writeSync(term, "31mRed\x1b[0m"); // Complete it
const html = serializeAsHTML(term);
expect(html).toContain("Red");
// Check for red styling (color may vary in format)
expect(html).toMatch(/color|red|#[a-f0-9]{6}/i);
disposeTerminal(term);
});
test("handles screen clearing", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "First line\n");
await writeSync(term, "Second line");
await writeSync(term, "\x1b[2J"); // Clear screen
await writeSync(term, "\x1b[H"); // Cursor home
await writeSync(term, "After clear");
const html = serializeAsHTML(term);
expect(html).toContain("After clear");
// "First line" should be in scrollback but not current screen
disposeTerminal(term);
});
test("handles cursor position command", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "\x1b[5;10H"); // Move cursor to row 5, col 10
await writeSync(term, "Positioned");
const html = serializeAsHTML(term);
expect(html).toContain("Positioned");
// CSI H should not leak through as literal characters
expect(html).not.toMatch(/^[HJ]$/m);
disposeTerminal(term);
});
test("handles resize", () => {
const term = createTerminal(80, 24);
term.terminal.resize(120, 40);
expect(term.terminal.cols).toBe(120);
expect(term.terminal.rows).toBe(40);
disposeTerminal(term);
});
test("serialize returns valid HTML structure", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "Test content");
const html = serializeAsHTML(term);
// Should be valid HTML (has span structure from xterm serialize addon)
expect(html).toMatch(/<span|<div|Test content/);
disposeTerminal(term);
});
test("trims empty trailing rows from HTML output", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "Line 1\r\n");
await writeSync(term, "Line 2\r\n");
await writeSync(term, "Line 3\r\n");
const html = serializeAsHTML(term);
// Count rows in output - should be 3, not 24
const rowCount = (html.match(/<div><span/g) || []).length;
expect(rowCount).toBe(3);
// Content should be present
expect(html).toContain("Line 1");
expect(html).toContain("Line 2");
expect(html).toContain("Line 3");
disposeTerminal(term);
});
test("trims trailing whitespace within rows", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "Hello\r\n");
const html = serializeAsHTML(term);
// Should not have 80 spaces after "Hello"
expect(html).not.toMatch(/Hello\s{10,}/);
// Content should end cleanly
expect(html).toContain("Hello</span>");
disposeTerminal(term);
});
test("handles empty terminal gracefully", async () => {
const term = createTerminal(80, 24);
// No writes - terminal is empty
const html = serializeAsHTML(term);
// Should produce valid minimal HTML, not 24 empty rows
expect(html).toContain("<div style=");
expect(html).toContain("</pre>");
// Should have minimal row divs (ideally 0)
const rowCount = (html.match(/<div><span/g) || []).length;
expect(rowCount).toBe(0);
disposeTerminal(term);
});
test("preserves content with ANSI colors after trimming", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "\x1b[32mGreen\x1b[0m text\r\n");
await writeSync(term, "Normal line\r\n");
const html = serializeAsHTML(term);
// Should have 2 rows
const rowCount = (html.match(/<div><span/g) || []).length;
expect(rowCount).toBe(2);
// Color styling should be preserved
expect(html).toContain("Green");
expect(html).toContain("Normal line");
// Check for color styling (green is typically #4e9a06 or similar)
expect(html).toMatch(/style='[^']*color[^']*'/);
disposeTerminal(term);
});
describe("reconnect scenarios", () => {
test("initial_state provides full terminal state for reconnecting clients", async () => {
const term = createTerminal(80, 24);
// Simulate some output that a client missed
await writeSync(term, "Line 1\r\n");
await writeSync(term, "Line 2\r\n");
await writeSync(term, "\x1b[31mRed line\x1b[0m\r\n");
// Serialize the full state (as sent in initial_state event)
const html = serializeAsHTML(term);
// Verify all content is present
expect(html).toContain("Line 1");
expect(html).toContain("Line 2");
expect(html).toContain("Red line");
// Verify ANSI styling is preserved
expect(html).toMatch(/color|red|#[a-f0-9]{6}/i);
disposeTerminal(term);
});
test("multiple sessions have independent terminal emulators", async () => {
const term1 = createTerminal(80, 24);
const term2 = createTerminal(80, 24);
// Write different content to each
await writeSync(term1, "Session 1 output");
await writeSync(term2, "Session 2 output");
const html1 = serializeAsHTML(term1);
const html2 = serializeAsHTML(term2);
// Each should have only its own content
expect(html1).toContain("Session 1 output");
expect(html1).not.toContain("Session 2 output");
expect(html2).toContain("Session 2 output");
expect(html2).not.toContain("Session 1 output");
disposeTerminal(term1);
disposeTerminal(term2);
});
test("T artifact bug is fixed (CSI sequences with T final byte)", async () => {
const term = createTerminal(80, 24);
// CSI sequences ending in 'T' (scroll down) were leaking through as literal 'T'
// This was a bug in our old ANSI parser
await writeSync(term, "Before\x1b[1T"); // Scroll down 1 line
await writeSync(term, "After");
const html = serializeAsHTML(term);
// Should not contain literal 'T' from the escape sequence
// Only 'Before' and 'After' should be visible
expect(html).toContain("Before");
expect(html).toContain("After");
// Extract text content without HTML tags
const textContent = html
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
// Should not have standalone 'T' characters
expect(textContent).not.toMatch(/\bT\b/);
disposeTerminal(term);
});
test("complex ANSI sequences don't leak artifacts", async () => {
const term = createTerminal(80, 24);
// Test various CSI final bytes that could leak as literals
await writeSync(term, "Start\r\n");
await writeSync(term, "\x1b[2J"); // Clear screen (J)
await writeSync(term, "\x1b[H"); // Cursor home (H)
await writeSync(term, "\x1b[K"); // Clear to end of line (K)
await writeSync(term, "\x1b[1T"); // Scroll down (T)
await writeSync(term, "\x1b[1S"); // Scroll up (S)
await writeSync(term, "Clean output");
const html = serializeAsHTML(term);
// Should only contain the actual text
expect(html).toContain("Clean output");
// Extract text without HTML
const textContent = html
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
// Should not contain literal CSI final bytes
expect(textContent).not.toMatch(/[HJKTS](?!.*Clean)/);
disposeTerminal(term);
});
});
});

128
src/terminal.ts Normal file
View file

@ -0,0 +1,128 @@
import { SerializeAddon } from "@xterm/addon-serialize";
import { Terminal } from "@xterm/headless";
export interface TerminalSession {
terminal: Terminal;
serialize: SerializeAddon;
}
/**
* Create a new headless terminal emulator instance
* @param cols - Terminal width in columns
* @param rows - Terminal height in rows
* @returns Terminal session with emulator and serialization addon
*/
export function createTerminal(cols: number, rows: number): TerminalSession {
const terminal = new Terminal({
cols,
rows,
scrollback: 1000, // Keep 1000 lines of scrollback
allowProposedApi: true,
});
const serialize = new SerializeAddon();
terminal.loadAddon(serialize);
return { terminal, serialize };
}
/**
* Serialize terminal screen buffer as HTML, trimming empty trailing rows
* and trailing whitespace within each row.
*
* xterm's serializeAsHTML outputs ALL rows (e.g., 50 for a 50-row terminal),
* padding empty lines with spaces. This creates a large black box below actual
* content. We trim these to show only content-bearing rows.
*/
export function serializeAsHTML(session: TerminalSession): string {
const html = session.serialize.serializeAsHTML({
onlySelection: false,
includeGlobalBackground: true,
});
return trimHtmlOutput(html);
}
/**
* Trim empty trailing rows and trailing whitespace from xterm HTML output.
*
* WARNING: This function depends on xterm's specific HTML structure from serializeAsHTML().
* If xterm changes their HTML format, this will need updating. Consider filing an issue
* with xterm to add a native trimEmpty option.
*
* Current structure expected:
* - Outer: <pre class="xterm-screen">
* - Rows: <div style="..."><div><span>content</span></div></div>
* - Empty cells: &nbsp; or plain spaces
*
* Full structure: <html><body><!--StartFragment--><pre><div style='...'><div><span>...</span></div>...</div></pre><!--EndFragment--></body></html>
* Each row is: <div><span>content</span></div>
*/
function trimHtmlOutput(html: string): string {
// Find the row container div
const wrapperStart = html.indexOf("<div style=");
const wrapperEnd = html.lastIndexOf("</div></pre>");
if (wrapperStart === -1 || wrapperEnd === -1) return html;
const firstDivEnd = html.indexOf(">", wrapperStart) + 1;
const rowsHtml = html.substring(firstDivEnd, wrapperEnd);
// Parse rows: each is <div><span>...</span></div>
const rows: string[] = [];
let pos = 0;
while (pos < rowsHtml.length) {
const divStart = rowsHtml.indexOf("<div>", pos);
if (divStart === -1) break;
const divEnd = rowsHtml.indexOf("</div>", divStart);
if (divEnd === -1) break;
rows.push(rowsHtml.substring(divStart, divEnd + 6));
pos = divEnd + 6;
}
// Find last non-empty row (working backwards)
let lastNonEmpty = -1;
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (!row) continue;
// Strip HTML tags and check for non-whitespace content
const text = row
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
if (text) {
lastNonEmpty = i;
break;
}
}
if (lastNonEmpty === -1) {
// All rows empty - return minimal HTML
const prefix = html.substring(0, firstDivEnd);
const suffix = html.substring(wrapperEnd);
return prefix + suffix;
}
// Trim trailing whitespace within each span of retained rows
const trimmedRows = rows.slice(0, lastNonEmpty + 1).map((row) => {
return row.replace(
/(<span[^>]*>)([^<]*)(<\/span>)/g,
(_, open, content, close) => {
const trimmed = content.replace(/[\s\u00a0]+$/, "");
return open + trimmed + close;
},
);
});
const prefix = html.substring(0, firstDivEnd);
const suffix = html.substring(wrapperEnd);
return prefix + trimmedRows.join("") + suffix;
}
/**
* Clean up terminal resources
*/
export function disposeTerminal(session: TerminalSession): void {
session.serialize.dispose();
session.terminal.dispose();
}

View file

@ -157,6 +157,7 @@ export type ServerMessage =
// SSE events (Server -> Dashboard)
export type SSEEvent =
| { type: "initial_state"; session_id: number; html: string }
| {
type: "session_start";
session_id: number;

View file

@ -1,7 +1,7 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",