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);