diff --git a/public/manage.html b/public/manage.html index a1edba6..bee033c 100644 --- a/public/manage.html +++ b/public/manage.html @@ -5,6 +5,7 @@ Session Manager — Web Terminal + diff --git a/public/manage.ts b/public/manage.ts index 50d3f3b..cf29de9 100644 --- a/public/manage.ts +++ b/public/manage.ts @@ -1,12 +1,19 @@ /** * public/manage.ts — standalone Session Manager page (/manage.html). * - * Lists the host's running server sessions (GET /live-sessions) and lets you - * open one (?join=), kill one (DELETE /live-sessions/:id), or bulk-kill all - * / only detached ones (DELETE /live-sessions[?detached=1]). Auto-refreshes. - * Independent of the main app, so it works even when many sessions are open. + * A full-page grid of the host's running sessions, each with a LIVE preview + * thumbnail (a read-only xterm rendering the session's current screen, scaled + * down like a screenshot) so you can see what each one is doing at a glance. + * Open one (?join=), kill one, or bulk-kill. Auto-refreshes. + * + * Previews use GET /live-sessions/:id/preview (the scrollback tail) — they do + * NOT open a WS / attach, so they don't inflate watcher counts or keep sessions + * alive. */ +import { Terminal } from '@xterm/xterm' +import '@xterm/xterm/css/xterm.css' + interface LiveSession { id: string createdAt: number @@ -18,33 +25,30 @@ interface LiveSession { rows: number } -const REFRESH_MS = 3000 - -function relTime(ms: number): string { - const s = Math.max(0, (Date.now() - ms) / 1000) - if (s < 60) return `${Math.floor(s)}s` - if (s < 3600) return `${Math.floor(s / 60)}m` - if (s < 86400) return `${Math.floor(s / 3600)}h` - return `${Math.floor(s / 86400)}d` +interface Preview { + id: string + cols: number + rows: number + data: string } -function statusGlyph(s: LiveSession['status']): string { - return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·' +const REFRESH_MS = 4000 +const THUMB_W = 360 // card thumbnail width in px +const THUMB_MAX_H = 220 +const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m' // reset screen+scrollback+cursor before writing the tail +const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' } + +interface Card { + el: HTMLElement + term: Terminal + inner: HTMLElement + thumb: HTMLElement + meta: HTMLElement + watch: HTMLElement + status: HTMLElement } -async function fetchSessions(): Promise { - try { - const res = await fetch('/live-sessions') - const data: unknown = await res.json() - return Array.isArray(data) ? (data as LiveSession[]) : [] - } catch { - return [] - } -} - -const root = document.getElementById('manage-root') -if (!root) throw new Error('#manage-root not found') - +const cards = new Map() let busy = false function el( @@ -58,43 +62,84 @@ function el( return node } -async function killOne(id: string): Promise { - if (busy) return - busy = true +function relTime(ms: number): string { + const s = Math.max(0, (Date.now() - ms) / 1000) + if (s < 60) return `${Math.floor(s)}s` + if (s < 3600) return `${Math.floor(s / 60)}m` + if (s < 86400) return `${Math.floor(s / 3600)}h` + return `${Math.floor(s / 86400)}d` +} + +function statusText(s: LiveSession['status']): string { + return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·' +} + +function name(s: LiveSession): string { + return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8) +} + +async function getJSON(url: string): Promise { try { - await fetch(`/live-sessions/${id}`, { method: 'DELETE' }) + const res = await fetch(url) + if (!res.ok) return null + return (await res.json()) as T } catch { - /* best-effort */ + return null } - busy = false +} + +async function killOne(id: string): Promise { + await fetch(`/live-sessions/${id}`, { method: 'DELETE' }).catch(() => {}) void render() } async function killBulk(detachedOnly: boolean): Promise { - if (busy) return const label = detachedOnly ? 'all DETACHED sessions (no device watching)' : 'ALL sessions' if (!confirm(`Kill ${label}? Running shells/Claude will be terminated.`)) return - busy = true - try { - await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' }) - } catch { - /* best-effort */ - } - busy = false + await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' }).catch(() => {}) void render() } -function row(s: LiveSession): HTMLElement { - const r = el('div', 'mg-row') +/** Scale the rendered xterm down so the full screen fits the thumbnail width. */ +function fitThumb(card: Card): void { + const w = card.inner.offsetWidth + const h = card.inner.offsetHeight + if (w === 0 || h === 0) return + const scale = Math.min(1, THUMB_W / w) + card.inner.style.transform = `scale(${scale})` + card.thumb.style.height = `${Math.min(h * scale, THUMB_MAX_H)}px` +} - const main = el('div', 'mg-main') - const top = el('div', 'mg-top') - const name = el('span', 'mg-name', s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)) - const watchers = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`) - const status = el('span', `mg-status mg-${s.status}`, statusGlyph(s.status)) - top.append(name, watchers, status) - const meta = el('div', 'mg-meta', `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}`) - main.append(top, meta) +function makeCard(s: LiveSession): Card { + const el0 = el('div', 'mg-card') + + const head = el('div', 'mg-card-head') + const title = el('span', 'mg-name', name(s)) + const status = el('span', `mg-status mg-${s.status}`, statusText(s.status)) + const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`) + head.append(title, status, watch) + + const thumb = el('div', 'mg-thumb') + const inner = el('div', 'mg-thumb-inner') + thumb.append(inner) + // Click the preview to open the session full. + thumb.addEventListener('click', () => { + location.href = `/?join=${s.id}` + }) + + const term = new Terminal({ + cols: Math.max(2, s.cols), + rows: Math.max(2, s.rows), + disableStdin: true, + cursorBlink: false, + fontFamily: 'Menlo, Consolas, monospace', + fontSize: 12, + scrollback: 0, + theme: PREVIEW_THEME, + }) + term.open(inner) + + const meta = el('div', 'mg-meta') const actions = el('div', 'mg-actions') const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement @@ -103,18 +148,40 @@ function row(s: LiveSession): HTMLElement { kill.addEventListener('click', () => void killOne(s.id)) actions.append(open, kill) - r.append(main, actions) - return r + el0.append(head, thumb, meta, actions) + return { el: el0, term, inner, thumb, meta, watch, status } } -async function render(): Promise { - const sessions = await fetchSessions() - root!.replaceChildren() +async function refreshPreview(id: string, card: Card): Promise { + const p = await getJSON(`/live-sessions/${id}/preview`) + if (!p) return + if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) { + card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows)) + } + card.term.reset() + card.term.write(CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card))) +} +function updateCard(card: Card, s: LiveSession): void { + card.status.className = `mg-status mg-${s.status}` + card.status.textContent = statusText(s.status) + card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch' + card.watch.textContent = `👁 ${s.clientCount}` + card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}` +} + +const root = document.getElementById('manage-root') +if (!root) throw new Error('#manage-root not found') + +let grid: HTMLElement | null = null +let countEl: HTMLElement | null = null + +function ensureChrome(): void { + if (grid) return const header = el('div', 'mg-header') header.append(el('div', 'mg-title', 'Session Manager')) - const sub = el('div', 'mg-sub', `${sessions.length} session(s) running on the host`) - header.append(sub) + countEl = el('div', 'mg-sub', '') + header.append(countEl) const bar = el('div', 'mg-bar') const back = el('a', 'mg-btn', '← Back to terminal') as HTMLAnchorElement @@ -127,18 +194,51 @@ async function render(): Promise { killAll.addEventListener('click', () => void killBulk(false)) bar.append(back, refresh, killDetached, killAll) header.append(bar) - root!.append(header) - if (sessions.length === 0) { - root!.append(el('div', 'mg-empty', 'No sessions running. Open the terminal to start one.')) - return + grid = el('div', 'mg-grid') + root!.append(header, grid) +} + +async function render(): Promise { + if (busy) return + busy = true + ensureChrome() + + const sessions = (await getJSON('/live-sessions')) ?? [] + if (countEl) countEl.textContent = `${sessions.length} session(s) running on the host` + + const seen = new Set() + for (const s of sessions) { + seen.add(s.id) + let card = cards.get(s.id) + if (!card) { + card = makeCard(s) + cards.set(s.id, card) + grid!.append(card.el) + } + updateCard(card, s) + void refreshPreview(s.id, card) } - const list = el('div', 'mg-list') - for (const s of sessions) list.append(row(s)) - root!.append(list) + // Drop cards for sessions that are gone. + for (const [id, card] of cards) { + if (!seen.has(id)) { + card.term.dispose() + card.el.remove() + cards.delete(id) + } + } + + if (sessions.length === 0 && grid && grid.querySelector('.mg-empty') === null) { + grid.append(el('div', 'mg-empty', 'No sessions running. Open the terminal to start one.')) + } else { + grid?.querySelector('.mg-empty')?.remove() + } + busy = false } void render() -setInterval(() => { - if (!busy) void render() -}, REFRESH_MS) +setInterval(() => void render(), REFRESH_MS) +// Re-scale thumbnails if the window resizes. +window.addEventListener('resize', () => { + for (const card of cards.values()) fitThumb(card) +}) diff --git a/public/style.css b/public/style.css index b953ddb..6861336 100644 --- a/public/style.css +++ b/public/style.css @@ -662,9 +662,12 @@ body { /* ── Session Manager page (manage.html) ──────────────────────────── */ #manage-root { - max-width: 760px; + max-width: 1200px; margin: 0 auto; padding: 24px 16px 60px; + height: 100%; + overflow-y: auto; + box-sizing: border-box; } .mg-header { margin-bottom: 18px; @@ -708,32 +711,54 @@ body { border-color: rgba(255, 107, 107, 0.5); color: var(--red); } -.mg-list { +.mg-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); + gap: 14px; +} +.mg-card { display: flex; flex-direction: column; - gap: 8px; -} -.mg-row { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 14px; + gap: 10px; + padding: 12px; background: var(--surface-2); border: 1px solid var(--border); - border-radius: 10px; + border-radius: 12px; } -.mg-main { - flex: 1 1 auto; - min-width: 0; -} -.mg-top { +.mg-card-head { display: flex; align-items: center; gap: 8px; } .mg-name { + flex: 1 1 auto; + min-width: 0; color: #fff; font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* Live preview thumbnail (scaled read-only xterm) */ +.mg-thumb { + position: relative; + width: 100%; + min-height: 90px; + background: #0e0f13; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + cursor: pointer; +} +.mg-thumb:hover { + border-color: var(--accent); +} +.mg-thumb-inner { + position: absolute; + top: 0; + left: 0; + transform-origin: top left; + padding: 4px; } .mg-watch { font-size: 11px; @@ -759,18 +784,20 @@ body { } .mg-meta { color: var(--text-faint); - font-size: 12px; - margin-top: 3px; + font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: Menlo, Consolas, monospace; } .mg-actions { - flex: none; display: flex; gap: 6px; } +.mg-actions .mg-open { + flex: 1 1 auto; + text-align: center; +} .mg-open { background: var(--accent); color: #fff; diff --git a/src/server.ts b/src/server.ts index fab3ace..d73aced 100644 --- a/src/server.ts +++ b/src/server.ts @@ -48,6 +48,10 @@ import { WS_OPEN } from './types.js' const DEFAULT_COLS = 80 const DEFAULT_ROWS = 24 +// How many bytes of recent scrollback the manage-page preview renders (enough to +// contain a full-screen TUI repaint, so the thumbnail shows the current screen). +const PREVIEW_BYTES = 24 * 1024 + // H3: how long the server holds a PermissionRequest before falling back to // Claude's own interactive prompt (so it never hangs if nobody responds). const PERM_TIMEOUT_MS = 5 * 60_000 @@ -117,6 +121,22 @@ export function startServer(cfg: Config): { close(): Promise } { res.json(manager.list()) }) + // Preview (v0.4 manage page) — the tail of a session's scrollback so the grid + // can render a live read-only thumbnail of its current screen. No client/attach. + app.get('/live-sessions/:id/preview', (req, res) => { + const session = manager.get(req.params.id) + if (session === undefined) { + res.status(404).end() + return + } + res.json({ + id: session.meta.id, + cols: session.pty.cols, + rows: session.pty.rows, + data: session.buffer.tail(PREVIEW_BYTES), + }) + }) + // Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action. app.delete('/live-sessions', (req, res) => { const onlyDetached = req.query['detached'] === '1' diff --git a/src/session/ring-buffer.ts b/src/session/ring-buffer.ts index c937353..5ddef5d 100644 --- a/src/session/ring-buffer.ts +++ b/src/session/ring-buffer.ts @@ -61,5 +61,20 @@ export function createRingBuffer(maxBytes: number): RingBuffer { return SOFT_RESET + chunks.map((c) => c.text).join(''); } - return { append, snapshot }; + function tail(maxBytes: number): string { + if (maxBytes <= 0) return ''; + // Collect whole chunks from the newest end until we have ~maxBytes — never + // split a chunk (so no ANSI/UTF-8 sequence is cut, same rationale as M2). + const picked: string[] = []; + let acc = 0; + for (let i = chunks.length - 1; i >= 0; i -= 1) { + const c = chunks[i]!; + picked.push(c.text); + acc += c.bytes; + if (acc >= maxBytes) break; + } + return picked.reverse().join(''); + } + + return { append, snapshot, tail }; } diff --git a/src/types.ts b/src/types.ts index 2046d88..c700843 100644 --- a/src/types.ts +++ b/src/types.ts @@ -113,6 +113,9 @@ export interface RingBuffer { append(chunk: string): void; /** full buffer for replay; prepend `\x1b[0m` soft-reset as a safety net (M2). */ snapshot(): string; + /** last ~maxBytes of recent output, on whole-chunk boundaries (never splits an + * ANSI/UTF-8 sequence), for a read-only session preview. No soft-reset prefix. */ + tail(maxBytes: number): string; } // impl anchor [src/session/ring-buffer.ts]: diff --git a/test/ring-buffer.test.ts b/test/ring-buffer.test.ts index 7c23b4b..39ba0bf 100644 --- a/test/ring-buffer.test.ts +++ b/test/ring-buffer.test.ts @@ -35,6 +35,31 @@ describe('createRingBuffer', () => { }); }); + describe('tail()', () => { + it('returns empty for an empty buffer or non-positive maxBytes', () => { + const rb = createRingBuffer(1024); + expect(rb.tail(100)).toBe(''); + rb.append('hello'); + expect(rb.tail(0)).toBe(''); + }); + + it('returns the most recent chunks up to ~maxBytes, on chunk boundaries', () => { + const rb = createRingBuffer(1024); + rb.append('aaaa'); // 4 bytes + rb.append('bbbb'); // 4 bytes + rb.append('cccc'); // 4 bytes + // maxBytes=5 → newest 'cccc' (4) then 'bbbb' (8 ≥ 5) → stop; never splits. + expect(rb.tail(5)).toBe('bbbbcccc'); + }); + + it('returns the whole buffer when maxBytes exceeds its size (no soft-reset)', () => { + const rb = createRingBuffer(1024); + rb.append('one'); + rb.append('two'); + expect(rb.tail(1000)).toBe('onetwo'); + }); + }); + describe('within capacity', () => { it('replays everything appended, in order', () => { const rb = createRingBuffer(1024);