fix: address review report across security, architecture, quality, tests
Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review). typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage thresholds (80%) enforced in vitest.config.ts. Critical: - multi-device approval race: release held approval only when the last client detaches (closing one mirror no longer cancels another's prompt) - unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS), enforced in manager via the existing M4 exit(-1) path - signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close() - terminal-session initialInput timer tracked + cleared on dispose - tabs.addEntry null-as-cast type hole removed (build session before entry) Should-fix: - security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id] - history.ts converted to fs/promises (async /sessions handler) - removed dead clientDims map + blur protocol message end-to-end - per-connection WS message rate limit (Config.maxMsgsPerSec) - /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7) Tests: - new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites - extended history/config/manager/integration coverage incl. regressions Hygiene: - parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation - log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped - operational constants moved into Config - extracted public/preview-grid.ts (DRY launcher/manage) - doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
This commit is contained in:
@@ -5,28 +5,20 @@
|
||||
* start screen lists the host's running sessions as live preview thumbnails
|
||||
* (read-only xterm, same as the manage page) so the user picks which to open —
|
||||
* or starts a new one. Sessions persist server-side, so opening one replays its
|
||||
* full scrollback.
|
||||
* full scrollback. The thumbnail card + preview plumbing is shared with the
|
||||
* manage page via public/preview-grid.ts (DRY).
|
||||
*/
|
||||
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
|
||||
interface LiveSession {
|
||||
id: string
|
||||
createdAt: number
|
||||
clientCount: number
|
||||
status: 'working' | 'waiting' | 'idle' | 'unknown'
|
||||
exited: boolean
|
||||
cwd: string | null
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
|
||||
interface Preview {
|
||||
id: string
|
||||
cols: number
|
||||
rows: number
|
||||
data: string
|
||||
}
|
||||
import type { LiveSessionInfo } from '../src/types.js'
|
||||
import {
|
||||
el,
|
||||
relTime,
|
||||
makePreviewCard,
|
||||
updatePreviewCard,
|
||||
loadPreviewInto,
|
||||
fetchLiveSessions,
|
||||
type PreviewCard,
|
||||
} from './preview-grid.js'
|
||||
|
||||
export interface LauncherHooks {
|
||||
onOpen: (id: string) => void
|
||||
@@ -36,45 +28,6 @@ export interface LauncherHooks {
|
||||
const THUMB_W = 320
|
||||
const THUMB_MAX_H = 200
|
||||
const REFRESH_MS = 5000
|
||||
const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m'
|
||||
const THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
cls?: string,
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag)
|
||||
if (cls) node.className = cls
|
||||
if (text !== undefined) node.textContent = text
|
||||
return node
|
||||
}
|
||||
|
||||
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 sessionName(s: LiveSession): string {
|
||||
return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
|
||||
}
|
||||
|
||||
interface Card {
|
||||
el: HTMLElement
|
||||
term: Terminal
|
||||
inner: HTMLElement
|
||||
thumb: HTMLElement
|
||||
watch: HTMLElement
|
||||
status: HTMLElement
|
||||
meta: HTMLElement
|
||||
}
|
||||
|
||||
export interface Launcher {
|
||||
setVisible(v: boolean): void
|
||||
@@ -101,77 +54,15 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher
|
||||
const grid = el('div', 'mg-grid')
|
||||
root.append(grid)
|
||||
|
||||
const cards = new Map<string, Card>()
|
||||
const cards = new Map<string, PreviewCard>()
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function fit(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`
|
||||
}
|
||||
|
||||
function makeCard(s: LiveSession): Card {
|
||||
const cardEl = el('div', 'mg-card')
|
||||
const h = el('div', 'mg-card-head')
|
||||
const title = el('span', 'mg-name', sessionName(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}`)
|
||||
h.append(title, status, watch)
|
||||
|
||||
const thumb = el('div', 'mg-thumb')
|
||||
const inner = el('div', 'mg-thumb-inner')
|
||||
thumb.append(inner)
|
||||
thumb.addEventListener('click', () => hooks.onOpen(s.id))
|
||||
|
||||
const meta = el('div', 'mg-meta')
|
||||
const actions2 = el('div', 'mg-actions')
|
||||
const open = el('button', 'mg-open', 'Open ↗')
|
||||
open.addEventListener('click', () => hooks.onOpen(s.id))
|
||||
actions2.append(open)
|
||||
|
||||
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: THEME,
|
||||
})
|
||||
term.open(inner)
|
||||
|
||||
cardEl.append(h, thumb, meta, actions2)
|
||||
return { el: cardEl, term, inner, thumb, watch, status, meta }
|
||||
}
|
||||
|
||||
async function loadPreview(id: string, card: Card): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/live-sessions/${id}/preview`)
|
||||
if (!res.ok) return
|
||||
const p = (await res.json()) as Preview
|
||||
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(() => fit(card)))
|
||||
} catch {
|
||||
/* preview is best-effort */
|
||||
}
|
||||
function makeCard(s: LiveSessionInfo): PreviewCard {
|
||||
return makePreviewCard(s, { onOpen: (id) => hooks.onOpen(id) })
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
let sessions: LiveSession[] = []
|
||||
try {
|
||||
const res = await fetch('/live-sessions')
|
||||
const data: unknown = await res.json()
|
||||
if (Array.isArray(data)) sessions = data as LiveSession[]
|
||||
} catch {
|
||||
sessions = []
|
||||
}
|
||||
const sessions = await fetchLiveSessions()
|
||||
|
||||
sub.textContent = sessions.length
|
||||
? `${sessions.length} running on this host — pick one to open`
|
||||
@@ -186,12 +77,9 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher
|
||||
cards.set(s.id, card)
|
||||
grid.append(card.el)
|
||||
}
|
||||
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}`
|
||||
updatePreviewCard(card, s)
|
||||
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old`
|
||||
void loadPreview(s.id, card)
|
||||
void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H)
|
||||
}
|
||||
for (const [id, card] of cards) {
|
||||
if (!seen.has(id)) {
|
||||
|
||||
163
public/manage.ts
163
public/manage.ts
@@ -8,86 +8,30 @@
|
||||
*
|
||||
* 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.
|
||||
* alive. The card + preview plumbing is shared with the launcher via
|
||||
* public/preview-grid.ts (DRY).
|
||||
*/
|
||||
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
interface LiveSession {
|
||||
id: string
|
||||
createdAt: number
|
||||
clientCount: number
|
||||
status: 'working' | 'waiting' | 'idle' | 'unknown'
|
||||
exited: boolean
|
||||
cwd: string | null
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
|
||||
interface Preview {
|
||||
id: string
|
||||
cols: number
|
||||
rows: number
|
||||
data: string
|
||||
}
|
||||
import type { LiveSessionInfo } from '../src/types.js'
|
||||
import {
|
||||
el,
|
||||
relTime,
|
||||
makePreviewCard,
|
||||
updatePreviewCard,
|
||||
loadPreviewInto,
|
||||
fitThumb,
|
||||
fetchLiveSessions,
|
||||
type PreviewCard,
|
||||
} from './preview-grid.js'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const cards = new Map<string, Card>()
|
||||
const cards = new Map<string, PreviewCard>()
|
||||
let busy = false
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
cls?: string,
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag)
|
||||
if (cls) node.className = cls
|
||||
if (text !== undefined) node.textContent = text
|
||||
return node
|
||||
}
|
||||
|
||||
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<T>(url: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function killOne(id: string): Promise<void> {
|
||||
await fetch(`/live-sessions/${id}`, { method: 'DELETE' }).catch(() => {})
|
||||
void render()
|
||||
@@ -100,73 +44,20 @@ async function killBulk(detachedOnly: boolean): Promise<void> {
|
||||
void render()
|
||||
}
|
||||
|
||||
/** 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`
|
||||
}
|
||||
|
||||
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
|
||||
open.href = `/?join=${s.id}`
|
||||
function makeCard(s: LiveSessionInfo): PreviewCard {
|
||||
const kill = el('button', 'mg-kill', 'Kill ✕')
|
||||
kill.addEventListener('click', () => void killOne(s.id))
|
||||
actions.append(open, kill)
|
||||
|
||||
el0.append(head, thumb, meta, actions)
|
||||
return { el: el0, term, inner, thumb, meta, watch, status }
|
||||
return makePreviewCard(s, {
|
||||
onOpen: (id) => {
|
||||
location.href = `/?join=${id}`
|
||||
},
|
||||
openHref: (id) => `/?join=${id}`,
|
||||
extraActions: () => [kill],
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshPreview(id: string, card: Card): Promise<void> {
|
||||
const p = await getJSON<Preview>(`/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}`
|
||||
function updateCard(card: PreviewCard, s: LiveSessionInfo): void {
|
||||
updatePreviewCard(card, s)
|
||||
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}`
|
||||
}
|
||||
|
||||
@@ -204,7 +95,7 @@ async function render(): Promise<void> {
|
||||
busy = true
|
||||
ensureChrome()
|
||||
|
||||
const sessions = (await getJSON<LiveSession[]>('/live-sessions')) ?? []
|
||||
const sessions = await fetchLiveSessions()
|
||||
if (countEl) countEl.textContent = `${sessions.length} session(s) running on the host`
|
||||
|
||||
const seen = new Set<string>()
|
||||
@@ -217,7 +108,7 @@ async function render(): Promise<void> {
|
||||
grid!.append(card.el)
|
||||
}
|
||||
updateCard(card, s)
|
||||
void refreshPreview(s.id, card)
|
||||
void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H)
|
||||
}
|
||||
// Drop cards for sessions that are gone.
|
||||
for (const [id, card] of cards) {
|
||||
@@ -240,5 +131,5 @@ void render()
|
||||
setInterval(() => void render(), REFRESH_MS)
|
||||
// Re-scale thumbnails if the window resizes.
|
||||
window.addEventListener('resize', () => {
|
||||
for (const card of cards.values()) fitThumb(card)
|
||||
for (const card of cards.values()) fitThumb(card, THUMB_W, THUMB_MAX_H)
|
||||
})
|
||||
|
||||
180
public/preview-grid.ts
Normal file
180
public/preview-grid.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* public/preview-grid.ts — shared live-preview thumbnail helpers.
|
||||
*
|
||||
* The launcher (home session chooser) and the manage page both render a grid of
|
||||
* the host's running sessions as read-only xterm thumbnails fed by
|
||||
* GET /live-sessions/:id/preview. This module holds the DRY core: the small DOM
|
||||
* helper, time/status formatting, the preview card factory, the scaling fit, and
|
||||
* the preview fetch — so both pages import it instead of duplicating ~80%.
|
||||
*/
|
||||
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import type { LiveSessionInfo } from '../src/types.js'
|
||||
|
||||
/** Shape of GET /live-sessions/:id/preview. */
|
||||
export interface SessionPreview {
|
||||
id: string
|
||||
cols: number
|
||||
rows: number
|
||||
data: string
|
||||
}
|
||||
|
||||
/** Reset screen + scrollback + cursor before writing the tail. */
|
||||
export const PREVIEW_CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m'
|
||||
export const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }
|
||||
|
||||
/** Create an element with an optional class and text content. */
|
||||
export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
cls?: string,
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag)
|
||||
if (cls) node.className = cls
|
||||
if (text !== undefined) node.textContent = text
|
||||
return node
|
||||
}
|
||||
|
||||
/** Coarse "Ns / Nm / Nh / Nd ago" formatter. */
|
||||
export 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`
|
||||
}
|
||||
|
||||
/** Human label for a Claude activity status. */
|
||||
export function statusText(s: LiveSessionInfo['status']): string {
|
||||
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
|
||||
}
|
||||
|
||||
/** Display name for a session: last cwd segment, else the short id. */
|
||||
export function sessionName(s: LiveSessionInfo): string {
|
||||
return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
|
||||
}
|
||||
|
||||
/** A rendered preview card: the element tree plus the live xterm and its parts. */
|
||||
export interface PreviewCard {
|
||||
el: HTMLElement
|
||||
term: Terminal
|
||||
inner: HTMLElement
|
||||
thumb: HTMLElement
|
||||
watch: HTMLElement
|
||||
status: HTMLElement
|
||||
meta: HTMLElement
|
||||
}
|
||||
|
||||
export interface MakeCardOpts {
|
||||
/** Called when the card (thumbnail or Open action) is activated. */
|
||||
onOpen: (id: string) => void
|
||||
/** Optional extra action button(s) appended to the card actions row. */
|
||||
extraActions?: (s: LiveSessionInfo) => HTMLElement[]
|
||||
/** Use an <a href> Open link instead of a button (manage page). */
|
||||
openHref?: (id: string) => string
|
||||
}
|
||||
|
||||
/** Build a preview card for one session (read-only xterm thumbnail). */
|
||||
export function makePreviewCard(s: LiveSessionInfo, opts: MakeCardOpts): PreviewCard {
|
||||
const cardEl = el('div', 'mg-card')
|
||||
|
||||
const head = el('div', 'mg-card-head')
|
||||
const title = el('span', 'mg-name', sessionName(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)
|
||||
thumb.addEventListener('click', () => opts.onOpen(s.id))
|
||||
|
||||
const meta = el('div', 'mg-meta')
|
||||
|
||||
const actions = el('div', 'mg-actions')
|
||||
if (opts.openHref) {
|
||||
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
|
||||
open.href = opts.openHref(s.id)
|
||||
actions.append(open)
|
||||
} else {
|
||||
const open = el('button', 'mg-open', 'Open ↗')
|
||||
open.addEventListener('click', () => opts.onOpen(s.id))
|
||||
actions.append(open)
|
||||
}
|
||||
if (opts.extraActions) actions.append(...opts.extraActions(s))
|
||||
|
||||
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)
|
||||
|
||||
cardEl.append(head, thumb, meta, actions)
|
||||
return { el: cardEl, term, inner, thumb, watch, status, meta }
|
||||
}
|
||||
|
||||
/** Re-apply a session's live status/watch/meta fields to its card in place. */
|
||||
export function updatePreviewCard(card: PreviewCard, s: LiveSessionInfo): 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}`
|
||||
}
|
||||
|
||||
/** Scale the rendered xterm down so the full screen fits `thumbW` px wide. */
|
||||
export function fitThumb(card: PreviewCard, thumbW: number, thumbMaxH: number): void {
|
||||
const w = card.inner.offsetWidth
|
||||
const h = card.inner.offsetHeight
|
||||
if (w === 0 || h === 0) return
|
||||
const scale = Math.min(1, thumbW / w)
|
||||
card.inner.style.transform = `scale(${scale})`
|
||||
card.thumb.style.height = `${Math.min(h * scale, thumbMaxH)}px`
|
||||
}
|
||||
|
||||
/** Fetch a session preview, returning null on any failure (best-effort). */
|
||||
export async function fetchPreview(id: string): Promise<SessionPreview | null> {
|
||||
try {
|
||||
const res = await fetch(`/live-sessions/${id}/preview`)
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as SessionPreview
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a preview into a card's xterm, resizing + scaling to fit. */
|
||||
export function renderPreview(card: PreviewCard, p: SessionPreview, thumbW: number, thumbMaxH: number): void {
|
||||
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(PREVIEW_CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card, thumbW, thumbMaxH)))
|
||||
}
|
||||
|
||||
/** Fetch + render a session's preview into its card (best-effort, no throw). */
|
||||
export async function loadPreviewInto(
|
||||
id: string,
|
||||
card: PreviewCard,
|
||||
thumbW: number,
|
||||
thumbMaxH: number,
|
||||
): Promise<void> {
|
||||
const p = await fetchPreview(id)
|
||||
if (p) renderPreview(card, p, thumbW, thumbMaxH)
|
||||
}
|
||||
|
||||
/** Fetch the host's live sessions list, returning [] on failure. */
|
||||
export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
|
||||
try {
|
||||
const res = await fetch('/live-sessions')
|
||||
const data: unknown = await res.json()
|
||||
return Array.isArray(data) ? (data as LiveSessionInfo[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -146,14 +146,11 @@ export class TabApp {
|
||||
cwd?: string,
|
||||
initialInput?: string,
|
||||
): TabEntry {
|
||||
const entry: TabEntry = {
|
||||
session: null as unknown as TerminalSession,
|
||||
customTitle,
|
||||
autoTitle: null,
|
||||
hasActivity: false,
|
||||
el: null,
|
||||
}
|
||||
entry.session = new TerminalSession({
|
||||
// Build the session FIRST so `entry` is never typed with a null session.
|
||||
// The callbacks below capture `entry` by reference; they only fire after
|
||||
// construction (async), by which point `entry` is assigned.
|
||||
let entry: TabEntry
|
||||
const session = new TerminalSession({
|
||||
sessionId,
|
||||
...(cwd !== undefined ? { cwd } : {}),
|
||||
...(initialInput !== undefined ? { initialInput } : {}),
|
||||
@@ -177,10 +174,11 @@ export class TabApp {
|
||||
}
|
||||
},
|
||||
})
|
||||
this.paneHost.appendChild(entry.session.el)
|
||||
entry = { session, customTitle, autoTitle: null, hasActivity: false, el: null }
|
||||
this.paneHost.appendChild(session.el)
|
||||
this.tabs.push(entry)
|
||||
entry.session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
|
||||
entry.session.connect()
|
||||
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
|
||||
session.connect()
|
||||
return entry
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js'
|
||||
import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
|
||||
|
||||
// Delay after the shell is ready before typing a session's initial command
|
||||
// (e.g. `claude --resume …`), giving the shell time to finish its prompt.
|
||||
const INITIAL_INPUT_DELAY_MS = 700
|
||||
|
||||
const RESET = '\x1b[0m'
|
||||
const BOLD = '\x1b[1m'
|
||||
const DIM = '\x1b[2m'
|
||||
@@ -84,6 +88,7 @@ export class TerminalSession {
|
||||
private reconnectDelay = 1000 // ms; doubles each attempt, capped at 30 000
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private resizeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private initialInputTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private exitListener: { dispose(): void } | null = null
|
||||
private isConnecting = false
|
||||
private disposed = false
|
||||
@@ -232,7 +237,8 @@ export class TerminalSession {
|
||||
})
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
this.isConnecting = false
|
||||
// Do NOT reset isConnecting here — the 'close' event always follows 'error'
|
||||
// and owns the cleanup (resetting it twice could race a reconnect).
|
||||
})
|
||||
}
|
||||
|
||||
@@ -247,7 +253,11 @@ export class TerminalSession {
|
||||
if (this.initialInput !== undefined && !this.initialSent) {
|
||||
this.initialSent = true
|
||||
const cmd = this.initialInput
|
||||
setTimeout(() => this.send(cmd), 700)
|
||||
this.initialInputTimer = setTimeout(() => {
|
||||
this.initialInputTimer = null
|
||||
if (this.disposed) return
|
||||
this.send(cmd)
|
||||
}, INITIAL_INPUT_DELAY_MS)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -364,12 +374,9 @@ export class TerminalSession {
|
||||
|
||||
hide(): void {
|
||||
this.el.style.display = 'none'
|
||||
// v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the
|
||||
// shared PTY to this device's size. Output still streams (background mirror).
|
||||
if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(buildMessage({ type: 'blur' }))
|
||||
}
|
||||
// Force show() to re-cast our dims even if unchanged.
|
||||
// v0.4 (latest-writer-wins): hiding does NOT resize the shared PTY — the
|
||||
// device still actively viewing keeps its size. Output still streams
|
||||
// (background mirror). Force show()/refit() to re-cast our dims on return.
|
||||
this.lastCols = -1
|
||||
this.lastRows = -1
|
||||
}
|
||||
@@ -385,6 +392,10 @@ export class TerminalSession {
|
||||
clearTimeout(this.resizeTimer)
|
||||
this.resizeTimer = null
|
||||
}
|
||||
if (this.initialInputTimer !== null) {
|
||||
clearTimeout(this.initialInputTimer)
|
||||
this.initialInputTimer = null
|
||||
}
|
||||
this.resizeObserver.disconnect()
|
||||
if (this.ws !== null) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user