Compare commits
38 commits
423fa8375d
...
b28283bf34
| Author | SHA1 | Date | |
|---|---|---|---|
| b28283bf34 | |||
| 0a17b61bf3 | |||
| f2a60658cf | |||
| 4502be8d06 | |||
| baa6ef9d70 | |||
| 6342c7a699 | |||
| 58996a51f8 | |||
| 22c5c3d102 | |||
| 1b72d1e4e4 | |||
| ab61445bcc | |||
| 31340fe0a8 | |||
| 9bc77292cd | |||
| 116847ab58 | |||
| 0ac5eec30d | |||
| 6bd599d47b | |||
| 130f01a19f | |||
| 43648f7d60 | |||
| 88afb7249d | |||
| 6c7de2332b | |||
| 0366e459f5 | |||
| c9908c87c3 | |||
| 5016cd9960 | |||
| a14decf2bc | |||
| 3e5afbd5a8 | |||
| 42ba893ea5 | |||
| 0a3bfa6092 | |||
| c09654c6c7 | |||
| a8eea4e694 | |||
| 721bff81d0 | |||
| f2c3f6f067 | |||
| a8a73aad4e | |||
| 0df63961e0 | |||
| aeebe863e9 | |||
| 33cf28d643 | |||
| 5597502a8c | |||
| 9e8a275831 | |||
| b67247e340 | |||
| 386c1e74cc |
18 changed files with 1643 additions and 1059 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.test.ts
|
||||
*.md
|
||||
.env*
|
||||
data
|
||||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
25
TODO.txt
25
TODO.txt
|
|
@ -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
|
||||
|
|
|
|||
6
bun.lock
6
bun.lock
|
|
@ -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
836
docs/terminal-emulation.md
Normal 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.
|
||||
|
|
@ -19,6 +19,8 @@
|
|||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"bun-pty": "^0.4.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
505
src/ansi.test.ts
505
src/ansi.test.ts
|
|
@ -1,505 +0,0 @@
|
|||
import { expect, test } from "bun:test";
|
||||
import { ansiToHtml, trimLineEndPreserveAnsi } from "./ansi";
|
||||
|
||||
// 1. XSS/HTML escaping tests
|
||||
|
||||
test("escapes < to <", () => {
|
||||
const result = ansiToHtml("<div>");
|
||||
expect(result).toBe("<div>");
|
||||
});
|
||||
|
||||
test("escapes > to >", () => {
|
||||
const result = ansiToHtml("a > b");
|
||||
expect(result).toBe("a > b");
|
||||
});
|
||||
|
||||
test("escapes & to &", () => {
|
||||
const result = ansiToHtml("foo & bar");
|
||||
expect(result).toBe("foo & bar");
|
||||
});
|
||||
|
||||
test("escapes <script> tags", () => {
|
||||
const result = ansiToHtml("<script>alert('xss')</script>");
|
||||
expect(result).toBe("<script>alert('xss')</script>");
|
||||
});
|
||||
|
||||
test("escapes HTML in styled text", () => {
|
||||
const result = ansiToHtml("\x1b[31m<script>alert(1)</script>\x1b[0m");
|
||||
expect(result).toContain("<script>");
|
||||
expect(result).toContain("</script>");
|
||||
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("<script>");
|
||||
expect(result).toContain("&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;");
|
||||
});
|
||||
332
src/ansi.ts
332
src/ansi.ts
|
|
@ -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 += "<";
|
||||
else if (ch === ">") result += ">";
|
||||
else if (ch === "&") result += "&";
|
||||
else result += ch;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (inSpan) result += "</span>";
|
||||
|
||||
return result;
|
||||
}
|
||||
26
src/db.ts
26
src/db.ts
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { expect, test } from "bun:test";
|
||||
|
||||
test("placeholder", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
100
src/server.ts
100
src/server.ts
|
|
@ -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
268
src/terminal.test.ts
Normal 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(/ /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(/ /g, " ")
|
||||
.trim();
|
||||
|
||||
// Should not contain literal CSI final bytes
|
||||
expect(textContent).not.toMatch(/[HJKTS](?!.*Clean)/);
|
||||
|
||||
disposeTerminal(term);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
src/terminal.ts
Normal file
128
src/terminal.ts
Normal 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: 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(/ /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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
|
|
|
|||
Loading…
Reference in a new issue