diff --git a/collab-electron/package.json b/collab-electron/package.json index 4de867ba..2e1604fb 100644 --- a/collab-electron/package.json +++ b/collab-electron/package.json @@ -83,6 +83,7 @@ } }, "dependencies": { + "@anthropic-ai/sdk": "^0.80.0", "@blocknote/core": "0.47.0", "@blocknote/mantine": "0.47.0", "@blocknote/react": "0.47.0", diff --git a/collab-electron/src/main/index.ts b/collab-electron/src/main/index.ts index 0189700b..9954cd37 100644 --- a/collab-electron/src/main/index.ts +++ b/collab-electron/src/main/index.ts @@ -43,6 +43,11 @@ import { } from "./analytics"; import { stopImageWorker } from "./image-service"; import { installCli } from "./cli-installer"; +import { + initLlmSummary, + isLlmAvailable, + summarizeTerminal, +} from "./llm-summary"; // macOS apps launched from Finder don't inherit the user's shell // LANG, so child processes (tmux, shells) default to ASCII. @@ -579,6 +584,26 @@ ipcMain.handle( (_event, sessionId: string) => pty.getForegroundProcess(sessionId), ); +ipcMain.handle( + "pty:capture-output", + (_event, sessionId: string, lineCount?: number) => + pty.captureRecentOutput(sessionId, lineCount), +); + +ipcMain.handle( + "pty:capture-snapshot", + (_event, sessionId: string) => + pty.captureTerminalSnapshot(sessionId), +); + +ipcMain.handle("pty:llm-available", () => isLlmAvailable()); + +ipcMain.handle( + "pty:summarize-terminal", + (_event, sessionId: string, output: string, context: Record) => + summarizeTerminal(sessionId, output, context), +); + let settingsOpen = false; function setSettingsOpen(open: boolean): void { @@ -727,6 +752,7 @@ app.whenReady().then(async () => { registerToggleShortcuts(mainWindow!); initMainAnalytics(); + initLlmSummary(); trackEvent("app_launched"); mainWindow!.webContents.on("did-finish-load", () => { diff --git a/collab-electron/src/main/llm-summary.ts b/collab-electron/src/main/llm-summary.ts new file mode 100644 index 00000000..601b3d57 --- /dev/null +++ b/collab-electron/src/main/llm-summary.ts @@ -0,0 +1,178 @@ +import Anthropic from "@anthropic-ai/sdk"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import { app } from "electron"; + +interface SummaryContext { + foreground?: string; + shell?: string; + cwd?: string; + title?: string; + otherTerminals?: Array<{ + foreground?: string; + cwd?: string; + summaryCompact?: string; + }>; +} + +interface SummaryResult { + type: string; + status: string; + detailed: string[]; + reduced: string[]; + compact: string; +} + +let client: Anthropic | null = null; +let available = false; + +const cache = new Map(); +const inflight = new Set(); +const MAX_CACHE = 200; +let consecutiveErrors = 0; +const MAX_CONSECUTIVE_ERRORS = 3; + +const SYSTEM_PROMPT = `You summarize terminal activity for a zoomed-out canvas view. + +You receive: +- Terminal output (last ~100 lines, may contain ANSI remnants) +- Foreground process, working directory, pane title +- Brief context about other open terminals + +Produce a JSON object with: +- "status": one of "error", "warning", "success", "running", "info", "idle" +- "detailed": array of 4-6 short strings (shown at 50% zoom). First line is the headline. Include: what's happening, the goal/intent, key output, action items (prefixed with →). +- "reduced": array of 2-3 short strings (shown at 25% zoom). First is headline. +- "compact": single short string (badge, max 16 chars). + +Guidelines: +- Focus on WHAT the user is trying to achieve, not raw process names. +- If an AI agent (Claude, Codex, Gemini, Copilot, Cursor, aider) is running, summarize the TASK it's working on and its current step. +- If a dev server is running, name the framework and port. +- If tests ran, summarize pass/fail counts and failing test names. +- If git operations happened, summarize the branch and changes. +- If idle, mention last command and working directory context. +- For action items, prefix with →. +- Be concise. No prose. Telegraph style. +- Headlines should be bold and descriptive (e.g. "Claude: fixing email dedup" not "claude"). +- The detailed array should tell a story: headline → context → current activity → action items. + +Return ONLY valid JSON, no markdown fences.`; + +function contentHash(output: string): string { + const lines = output + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 2) + .slice(-25); + return crypto.createHash("sha256").update(lines.join("\n")).digest("hex").slice(0, 16); +} + +function loadEnvKey(): string | undefined { + if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY; + try { + const appRoot = app.isPackaged + ? path.dirname(app.getAppPath()) + : path.resolve(app.getAppPath()); + const envPath = path.join(appRoot, ".env"); + const content = fs.readFileSync(envPath, "utf-8"); + for (const line of content.split("\n")) { + const m = line.match(/^\s*ANTHROPIC_API_KEY\s*=\s*(.+)\s*$/); + if (m) return m[1].trim(); + } + } catch { /* .env not found */ } + return undefined; +} + +export function initLlmSummary(): void { + const key = loadEnvKey(); + if (!key) { + console.warn("[llm-summary] ANTHROPIC_API_KEY not set, LLM summaries disabled"); + return; + } + try { + client = new Anthropic({ apiKey: key }); + available = true; + console.log("[llm-summary] initialized"); + } catch (err) { + console.error("[llm-summary] failed to init:", err); + } +} + +export function isLlmAvailable(): boolean { + return available; +} + +export async function summarizeTerminal( + sessionId: string, + output: string, + context: SummaryContext, +): Promise { + if (!client || consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) return null; + + const hash = contentHash(output); + const cacheKey = `${sessionId}:${hash}`; + + const cached = cache.get(cacheKey); + if (cached) return cached; + + if (inflight.has(sessionId)) return null; + inflight.add(sessionId); + + try { + const otherCtx = (context.otherTerminals || []) + .filter((t) => t.summaryCompact) + .map((t) => ` - ${t.summaryCompact} (${t.cwd || "?"})`) + .join("\n"); + + const userMsg = [ + `Terminal output (last lines):`, + "```", + output.slice(-4000), + "```", + "", + `Foreground process: ${context.foreground || "shell"}`, + `Working directory: ${context.cwd || "unknown"}`, + `Pane title: ${context.title || "none"}`, + ...(otherCtx ? [`\nOther terminals:\n${otherCtx}`] : []), + ].join("\n"); + + const response = await client.messages.create({ + model: "claude-haiku-4-20250414", + max_tokens: 300, + system: SYSTEM_PROMPT, + messages: [{ role: "user", content: userMsg }], + }); + + const text = + response.content[0]?.type === "text" ? response.content[0].text : ""; + + const parsed = JSON.parse(text) as SummaryResult; + + if (!parsed.detailed || !parsed.reduced || !parsed.compact) { + return null; + } + + consecutiveErrors = 0; + + if (cache.size > MAX_CACHE) { + const first = cache.keys().next().value; + if (first) cache.delete(first); + } + cache.set(cacheKey, parsed); + + return parsed; + } catch (err) { + consecutiveErrors++; + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + console.error("[llm-summary] too many errors, disabling LLM summaries"); + available = false; + } else { + console.error("[llm-summary] API error:", err); + } + return null; + } finally { + inflight.delete(sessionId); + } +} diff --git a/collab-electron/src/main/pty.ts b/collab-electron/src/main/pty.ts index 205b55a3..8c3a9820 100644 --- a/collab-electron/src/main/pty.ts +++ b/collab-electron/src/main/pty.ts @@ -387,6 +387,48 @@ export function discoverSessions(): DiscoveredSession[] { return result; } +export function captureRecentOutput( + sessionId: string, + lineCount = 50, +): string { + const name = tmuxSessionName(sessionId); + try { + const raw = tmuxExec( + "capture-pane", "-t", name, + "-p", "-S", `-${lineCount}`, + ); + return stripTrailingBlanks(raw); + } catch { + return ""; + } +} + +export function captureTerminalSnapshot( + sessionId: string, +): { output: string; cwd: string; foreground: string; title: string } { + const name = tmuxSessionName(sessionId); + let output = ""; + let cwd = ""; + let foreground = ""; + let title = ""; + try { + output = stripTrailingBlanks( + tmuxExec("capture-pane", "-t", name, "-p", "-S", "-100"), + ); + } catch { /* */ } + try { + const info = tmuxExec( + "display-message", "-t", name, + "-p", "#{pane_current_path}\t#{pane_current_command}\t#{pane_title}", + ); + const parts = info.split("\t"); + cwd = parts[0] || ""; + foreground = parts[1] || ""; + title = parts[2] || ""; + } catch { /* */ } + return { output, cwd, foreground, title }; +} + export function getForegroundProcess( sessionId: string, ): string | null { diff --git a/collab-electron/src/preload/shell.ts b/collab-electron/src/preload/shell.ts index 2d34b086..45bc1713 100644 --- a/collab-electron/src/preload/shell.ts +++ b/collab-electron/src/preload/shell.ts @@ -221,6 +221,22 @@ contextBridge.exposeInMainWorld("shellApi", { ptyKillSession: (sessionId: string): Promise => ipcRenderer.invoke("pty:kill", { sessionId }), + ptyCaptureOutput: (sessionId: string, lineCount?: number): Promise => + ipcRenderer.invoke("pty:capture-output", sessionId, lineCount), + + ptyCaptureSnapshot: (sessionId: string): Promise<{ output: string; cwd: string; foreground: string; title: string }> => + ipcRenderer.invoke("pty:capture-snapshot", sessionId), + + ptyLlmAvailable: (): Promise => + ipcRenderer.invoke("pty:llm-available"), + + ptySummarizeTerminal: ( + sessionId: string, + output: string, + context: Record, + ): Promise<{ type: string; status: string; detailed: string[]; reduced: string[]; compact: string } | null> => + ipcRenderer.invoke("pty:summarize-terminal", sessionId, output, context), + onPtyStatusChanged: ( cb: (payload: { sessionId: string; foreground: string }) => void, ) => { diff --git a/collab-electron/src/windows/shell/src/canvas-rpc.js b/collab-electron/src/windows/shell/src/canvas-rpc.js index 159a8086..79c6e8cf 100644 --- a/collab-electron/src/windows/shell/src/canvas-rpc.js +++ b/collab-electron/src/windows/shell/src/canvas-rpc.js @@ -1,6 +1,7 @@ import { tiles, getTile, defaultSize, snapToGrid, } from "./canvas-state.js"; +import { ZOOM_LEVELS } from "./canvas-viewport.js"; /** * Find a non-overlapping position on the canvas for a tile of the @@ -136,14 +137,15 @@ export function createCanvasRpc({ }; break; } - case "viewportSet": { - if (params.pan) { - viewportState.panX = params.pan.x; - viewportState.panY = params.pan.y; - } - if (params.zoom !== undefined) { - viewportState.zoom = params.zoom; - } + case "viewportSet": { + if (params.pan) { + viewportState.panX = params.pan.x; + viewportState.panY = params.pan.y; + } + if (params.zoom !== undefined) { + viewportState.zoom = ZOOM_LEVELS.reduce((a, b) => + Math.abs(b - params.zoom) < Math.abs(a - params.zoom) ? b : a); + } viewport.updateCanvas(); tileManager.saveCanvasDebounced(); result = {}; diff --git a/collab-electron/src/windows/shell/src/canvas-viewport.js b/collab-electron/src/windows/shell/src/canvas-viewport.js index 35bfae4a..3c2e34e3 100644 --- a/collab-electron/src/windows/shell/src/canvas-viewport.js +++ b/collab-electron/src/windows/shell/src/canvas-viewport.js @@ -1,11 +1,23 @@ -const ZOOM_MIN = 0.33; -const ZOOM_MAX = 1; -const ZOOM_RUBBER_BAND_K = 400; +const ZOOM_LEVELS = [0.25, 0.5, 1, 1.5]; +const ZOOM_MIN = ZOOM_LEVELS[0]; +const ZOOM_MAX = ZOOM_LEVELS[ZOOM_LEVELS.length - 1]; const CELL = 20; const MAJOR = 80; const isMac = typeof navigator !== "undefined" && navigator.platform.startsWith("Mac"); +function nearestZoomIndex(zoom) { + let best = 0; + let bestDist = Math.abs(zoom - ZOOM_LEVELS[0]); + for (let i = 1; i < ZOOM_LEVELS.length; i++) { + const d = Math.abs(zoom - ZOOM_LEVELS[i]); + if (d < bestDist) { bestDist = d; best = i; } + } + return best; +} + +export { ZOOM_LEVELS }; + export function shouldZoom(e, mac = isMac) { return e.ctrlKey || (mac && e.metaKey); } @@ -18,7 +30,6 @@ export function createViewport(canvasEl, gridCanvas) { const gridCtx = gridCanvas.getContext("2d"); let state = null; let onUpdate = null; - let zoomSnapTimer = null; let zoomSnapRaf = null; let lastZoomFocalX = 0; let lastZoomFocalY = 0; @@ -92,18 +103,32 @@ export function createViewport(canvasEl, gridCanvas) { if (onUpdate) onUpdate(); } - function snapBackZoom() { - const fx = lastZoomFocalX; - const fy = lastZoomFocalY; - const target = state.zoom > ZOOM_MAX ? ZOOM_MAX : ZOOM_MIN; + let zoomDeltaAccum = 0; + const SNAP_THRESHOLD = 30; - function animate() { - const prevScale = state.zoom; - state.zoom += (target - state.zoom) * 0.15; + const ZOOM_DURATION = 180; - if (Math.abs(state.zoom - target) < 0.001) { - state.zoom = target; - } + function easeOutCubic(t) { + return 1 - (1 - t) ** 3; + } + + function animateToZoom(target, fx, fy) { + if (zoomSnapRaf) { + cancelAnimationFrame(zoomSnapRaf); + zoomSnapRaf = null; + } + + const startZoom = state.zoom; + const startPanX = state.panX; + const startPanY = state.panY; + const startTime = performance.now(); + + function animate(now) { + const t = Math.min((now - startTime) / ZOOM_DURATION, 1); + const eased = easeOutCubic(t); + + const prevScale = state.zoom; + state.zoom = startZoom + (target - startZoom) * eased; const ratio = state.zoom / prevScale - 1; state.panX -= (fx - state.panX) * ratio; @@ -111,52 +136,34 @@ export function createViewport(canvasEl, gridCanvas) { showZoomIndicator(); updateCanvas(); - if (state.zoom === target) { + if (t < 1) { + zoomSnapRaf = requestAnimationFrame(animate); + } else { zoomSnapRaf = null; - return; } - zoomSnapRaf = requestAnimationFrame(animate); } zoomSnapRaf = requestAnimationFrame(animate); } function applyZoom(deltaY, focalX, focalY) { - if (zoomSnapRaf) { - cancelAnimationFrame(zoomSnapRaf); - zoomSnapRaf = null; - } - clearTimeout(zoomSnapTimer); - - const prevScale = state.zoom; - let factor = Math.exp((-deltaY * 0.6) / 100); - - if (state.zoom >= ZOOM_MAX && factor > 1) { - const overshoot = state.zoom / ZOOM_MAX - 1; - const damping = 1 / (1 + overshoot * ZOOM_RUBBER_BAND_K); - factor = 1 + (factor - 1) * damping; - state.zoom *= factor; - } else if (state.zoom <= ZOOM_MIN && factor < 1) { - const overshoot = ZOOM_MIN / state.zoom - 1; - const damping = 1 / (1 + overshoot * ZOOM_RUBBER_BAND_K); - factor = 1 - (1 - factor) * damping; - state.zoom *= factor; - } else { - state.zoom *= factor; - } - - const ratio = state.zoom / prevScale - 1; - state.panX -= (focalX - state.panX) * ratio; - state.panY -= (focalY - state.panY) * ratio; lastZoomFocalX = focalX; lastZoomFocalY = focalY; - if (state.zoom > ZOOM_MAX || state.zoom < ZOOM_MIN) { - zoomSnapTimer = setTimeout(snapBackZoom, 150); - } + zoomDeltaAccum += deltaY; - showZoomIndicator(); - updateCanvas(); + if (Math.abs(zoomDeltaAccum) < SNAP_THRESHOLD) return; + + const direction = zoomDeltaAccum > 0 ? -1 : 1; + zoomDeltaAccum = 0; + + const curIdx = nearestZoomIndex(state.zoom); + const nextIdx = Math.max(0, Math.min(ZOOM_LEVELS.length - 1, curIdx + direction)); + const target = ZOOM_LEVELS[nextIdx]; + + if (target === state.zoom) return; + + animateToZoom(target, focalX, focalY); } canvasEl.addEventListener("wheel", (e) => { diff --git a/collab-electron/src/windows/shell/src/canvas-viewport.test.ts b/collab-electron/src/windows/shell/src/canvas-viewport.test.ts index 7b15b02a..bf40cca2 100644 --- a/collab-electron/src/windows/shell/src/canvas-viewport.test.ts +++ b/collab-electron/src/windows/shell/src/canvas-viewport.test.ts @@ -1,11 +1,8 @@ /** - * Tests for pure zoom/viewport logic that will live in canvas-viewport.js. - * These functions are currently inlined in renderer.js. - * - * After modularization, update imports to use ./canvas-viewport.js. + * Tests for zoom/viewport logic in canvas-viewport.js. */ import { describe, test, expect } from "bun:test"; -import { shouldZoom } from "./canvas-viewport.js"; +import { shouldZoom, ZOOM_LEVELS } from "./canvas-viewport.js"; // -- shouldZoom modifier key routing -- @@ -31,152 +28,30 @@ describe("shouldZoom", () => { }); }); -// -- Extracted constants and logic (from renderer.js lines 53-230) -- +// -- Snap zoom levels -- -const ZOOM_MIN = 0.33; -const ZOOM_MAX = 1; -const ZOOM_RUBBER_BAND_K = 400; - -interface ViewportState { - panX: number; - panY: number; - zoom: number; -} - -/** - * Core zoom math extracted from applyZoom in renderer.js. - * Applies a single zoom step to the viewport state, returning the new state. - * Does not include rubber-band snap-back (that's animation logic). - */ -function computeZoomStep( - state: ViewportState, - deltaY: number, - focalX: number, - focalY: number, -): ViewportState { - const prevScale = state.zoom; - let factor = Math.exp((-deltaY * 0.6) / 100); - - if (state.zoom >= ZOOM_MAX && factor > 1) { - const overshoot = state.zoom / ZOOM_MAX - 1; - const damping = 1 / (1 + overshoot * ZOOM_RUBBER_BAND_K); - factor = 1 + (factor - 1) * damping; - } else if (state.zoom <= ZOOM_MIN && factor < 1) { - const overshoot = ZOOM_MIN / state.zoom - 1; - const damping = 1 / (1 + overshoot * ZOOM_RUBBER_BAND_K); - factor = 1 - (1 - factor) * damping; - } - - const newZoom = state.zoom * factor; - const ratio = newZoom / prevScale - 1; - const newPanX = state.panX - (focalX - state.panX) * ratio; - const newPanY = state.panY - (focalY - state.panY) * ratio; - - return { panX: newPanX, panY: newPanY, zoom: newZoom }; -} - -// -- Zoom math -- - -describe("computeZoomStep", () => { - test("negative deltaY zooms in (increases zoom)", () => { - const state: ViewportState = { panX: 0, panY: 0, zoom: 0.5 }; - const result = computeZoomStep(state, -100, 500, 400); - expect(result.zoom).toBeGreaterThan(0.5); - }); - - test("positive deltaY zooms out (decreases zoom)", () => { - const state: ViewportState = { panX: 0, panY: 0, zoom: 0.5 }; - const result = computeZoomStep(state, 100, 500, 400); - expect(result.zoom).toBeLessThan(0.5); - }); - - test("zero deltaY does not change zoom", () => { - const state: ViewportState = { panX: 0, panY: 0, zoom: 0.5 }; - const result = computeZoomStep(state, 0, 500, 400); - expect(result.zoom).toBeCloseTo(0.5, 10); +describe("ZOOM_LEVELS", () => { + test("has exactly 4 levels", () => { + expect(ZOOM_LEVELS).toHaveLength(4); }); - test("zoom is focal-point centered (pan adjusts)", () => { - const state: ViewportState = { panX: 100, panY: 100, zoom: 0.5 }; - const result = computeZoomStep(state, -50, 500, 400); - // After zooming in, the focal point should remain stationary - // in screen coordinates. The pan shifts to compensate. - // Verify pan changed (it should shift toward the focal point) - expect(result.panX).not.toBe(100); - expect(result.panY).not.toBe(100); - }); - - test("zooming at viewport origin (0,0) does not shift pan", () => { - const state: ViewportState = { panX: 0, panY: 0, zoom: 0.5 }; - const result = computeZoomStep(state, -50, 0, 0); - // With focal at (0,0) which equals panX/panY, ratio * 0 = 0 - expect(result.panX).toBeCloseTo(0, 10); - expect(result.panY).toBeCloseTo(0, 10); - }); - - test("rubber-band damping limits zoom beyond ZOOM_MAX", () => { - // Start slightly past max so damping is active (overshoot > 0) - const state: ViewportState = { panX: 0, panY: 0, zoom: 1.05 }; - const result = computeZoomStep(state, -100, 500, 400); - // Zooms past max but damping reduces the step significantly - expect(result.zoom).toBeGreaterThan(1.05); - // Damping means it grows much less than undamped would - const undamped = computeZoomStep( - { panX: 0, panY: 0, zoom: 0.5 }, -100, 500, 400, - ); - const undampedRatio = undamped.zoom / 0.5; - const dampedRatio = result.zoom / 1.05; - expect(dampedRatio).toBeLessThan(undampedRatio); - }); - - test("rubber-band damping limits zoom below ZOOM_MIN", () => { - // Start slightly below min so damping is active - const state: ViewportState = { panX: 0, panY: 0, zoom: 0.30 }; - const result = computeZoomStep(state, 100, 500, 400); - expect(result.zoom).toBeLessThan(0.30); - // Damping means it shrinks less than undamped would - const undamped = computeZoomStep( - { panX: 0, panY: 0, zoom: 0.5 }, 100, 500, 400, - ); - const undampedRatio = undamped.zoom / 0.5; - const dampedRatio = result.zoom / 0.30; - expect(dampedRatio).toBeGreaterThan(undampedRatio); - }); - - test("multiple small steps produce consistent zoom direction", () => { - let state: ViewportState = { panX: 100, panY: 100, zoom: 0.5 }; - for (let i = 0; i < 10; i++) { - const prev = state.zoom; - state = computeZoomStep(state, -10, 500, 400); - expect(state.zoom).toBeGreaterThan(prev); + test("levels are sorted ascending", () => { + for (let i = 1; i < ZOOM_LEVELS.length; i++) { + expect(ZOOM_LEVELS[i]).toBeGreaterThan(ZOOM_LEVELS[i - 1]); } }); - test("zoom in then zoom out returns approximately to original", () => { - const original: ViewportState = { panX: 200, panY: 150, zoom: 0.7 }; - // Zoom in - let state = computeZoomStep(original, -50, 500, 400); - // Zoom out by same delta - state = computeZoomStep(state, 50, 500, 400); - // Should be close to original (not exact due to floating point) - expect(state.zoom).toBeCloseTo(original.zoom, 2); - expect(state.panX).toBeCloseTo(original.panX, 0); - expect(state.panY).toBeCloseTo(original.panY, 0); - }); -}); - -// -- Viewport state constraints -- - -describe("viewport zoom constants", () => { - test("ZOOM_MIN is less than ZOOM_MAX", () => { - expect(ZOOM_MIN).toBeLessThan(ZOOM_MAX); + test("first level is 33%", () => { + expect(ZOOM_LEVELS[0]).toBeCloseTo(0.33, 2); }); - test("ZOOM_MIN is positive", () => { - expect(ZOOM_MIN).toBeGreaterThan(0); + test("last level is 100%", () => { + expect(ZOOM_LEVELS[ZOOM_LEVELS.length - 1]).toBe(1); }); - test("ZOOM_MAX is 1 (100%)", () => { - expect(ZOOM_MAX).toBe(1); + test("all levels are positive", () => { + for (const level of ZOOM_LEVELS) { + expect(level).toBeGreaterThan(0); + } }); }); diff --git a/collab-electron/src/windows/shell/src/renderer.js b/collab-electron/src/windows/shell/src/renderer.js index 86612c58..2c8ca090 100644 --- a/collab-electron/src/windows/shell/src/renderer.js +++ b/collab-electron/src/windows/shell/src/renderer.js @@ -248,11 +248,13 @@ async function init() { foreground: null, tileId: tile.id, }); + tileManager.registerTerminalSession(tile); }, onTerminalTileClosed(sessionId) { terminalListWebview.send( "terminal-list:remove", sessionId, ); + tileManager.unregisterTerminalSession(sessionId); }, onTileFocused(tile) { terminalListWebview.send( @@ -287,6 +289,7 @@ async function init() { tileManager.repositionAllTiles(); edgeIndicators.update(); tileManager.saveCanvasDebounced(); + tileManager.applyZoomSummaries(viewportState.zoom); }); edgeIndicators.update(); @@ -901,6 +904,9 @@ async function init() { window.shellApi.onPtyStatusChanged((payload) => { terminalListWebview.send("pty-status-changed", payload); + tileManager.updateTerminalStatus( + payload.sessionId, payload.foreground, + ); }); // -- Terminal list init + click-to-focus -- @@ -1201,10 +1207,13 @@ async function init() { const tile = getTile(id); if (tile?.type === "term" && tile.ptySessionId) { activeSessionIds.push(tile.ptySessionId); + tileManager.registerTerminalSession(tile); } } window.shellApi.ptyCleanDetached?.(activeSessionIds); + tileManager.applyZoomSummaries(viewportState.zoom, true); + // -- Initialize workspaces -- const { workspaces: wsPaths, active } = workspaceData; diff --git a/collab-electron/src/windows/shell/src/shell.css b/collab-electron/src/windows/shell/src/shell.css index 7d8dea67..0f3ae8fb 100644 --- a/collab-electron/src/windows/shell/src/shell.css +++ b/collab-electron/src/windows/shell/src/shell.css @@ -1080,6 +1080,150 @@ body { pointer-events: auto; } +/* Hide webview content when zoom summary is active */ +.canvas-tile.zoom-75 webview, +.canvas-tile.zoom-50 webview, +.canvas-tile.zoom-25 webview { + visibility: hidden; +} + +/* ── Terminal summary overlay (zoom-out) ── */ + +.tile-summary-overlay { + --summary-scale: 1; + display: none; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 200; + flex-direction: column; + align-items: flex-start; + justify-content: center; + padding: calc(16px * var(--summary-scale)) calc(20px * var(--summary-scale)); + font-family: var(--font-mono); + text-align: left; + pointer-events: none; + overflow: hidden; + border-radius: 8px; + gap: calc(2px * var(--summary-scale)); +} + +.tile-summary-overlay > div { + width: 100%; +} + +.summary-detailed, +.summary-reduced, +.summary-compact { + display: none; +} + +.summary-detailed > div, +.summary-reduced > div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.6; +} + +/* Status-colored left border accent */ +.tile-summary-overlay[data-status="error"] { border-left: calc(3px * var(--summary-scale)) solid #e53935; } +.tile-summary-overlay[data-status="warning"] { border-left: calc(3px * var(--summary-scale)) solid #fb8c00; } +.tile-summary-overlay[data-status="success"] { border-left: calc(3px * var(--summary-scale)) solid #43a047; } +.tile-summary-overlay[data-status="running"] { border-left: calc(3px * var(--summary-scale)) solid #4a9eff; } +.tile-summary-overlay[data-status="info"] { border-left: calc(3px * var(--summary-scale)) solid #78909c; } +.tile-summary-overlay[data-status="idle"] { border-left: calc(3px * var(--summary-scale)) solid rgba(128,128,128,0.3); } + +/* Headline (first line) */ +.summary-headline { + font-weight: 700 !important; + opacity: 1 !important; +} + +/* Action items (→ prefixed) */ +.summary-action { + color: #fb8c00 !important; + opacity: 1 !important; + font-weight: 500 !important; +} + +.summary-error-line { + color: #e53935 !important; + opacity: 1 !important; +} + +.summary-success-line { + color: #43a047 !important; + opacity: 1 !important; +} + +/* ── 50% zoom: detailed multi-line summary ── */ +.canvas-tile.zoom-50 .tile-summary-overlay { + display: flex; +} + +.canvas-tile.zoom-50 .summary-detailed { + display: block; +} + +.canvas-tile.zoom-50 .summary-detailed > div { + font-size: calc(13px * var(--summary-scale)); + font-weight: 400; + color: var(--fg); + opacity: 0.7; +} + +.canvas-tile.zoom-50 .summary-detailed > .summary-headline { + font-size: calc(16px * var(--summary-scale)); + font-weight: 700; + opacity: 1; + margin-bottom: calc(2px * var(--summary-scale)); +} + +.canvas-tile[data-tile-type="term"].zoom-50 .tile-summary-overlay { + background: rgb(248, 248, 248); +} + +.dark .canvas-tile[data-tile-type="term"].zoom-50 .tile-summary-overlay { + background: rgb(8, 8, 8); +} + +/* ── 25% zoom: compact 1-2 line badge ── */ +.canvas-tile.zoom-25 .tile-summary-overlay { + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +.canvas-tile.zoom-25 .summary-reduced { + display: block; +} + +.canvas-tile.zoom-25 .summary-reduced > div { + font-size: calc(13px * var(--summary-scale)); + font-weight: 500; + color: var(--fg); + opacity: 0.8; + line-height: 1.5; +} + +.canvas-tile.zoom-25 .summary-reduced > .summary-headline { + font-size: calc(16px * var(--summary-scale)); + font-weight: 700; + opacity: 1; +} + +.canvas-tile[data-tile-type="term"].zoom-25 .tile-summary-overlay { + background: rgb(248, 248, 248); +} + +.dark .canvas-tile[data-tile-type="term"].zoom-25 .tile-summary-overlay { + background: rgb(8, 8, 8); +} + /* ── Selection, dragging, marquee ── */ .canvas-tile.tile-selected { diff --git a/collab-electron/src/windows/shell/src/terminal-summary.js b/collab-electron/src/windows/shell/src/terminal-summary.js new file mode 100644 index 00000000..92930656 --- /dev/null +++ b/collab-electron/src/windows/shell/src/terminal-summary.js @@ -0,0 +1,620 @@ +/** + * Heuristic summary engine for terminal tile output. + * + * Takes ~100 lines of terminal output + metadata (cwd, foreground process, + * pane title, other terminals) and produces three summary tiers: + * - detailed (50% zoom): 4-7 lines — full context + * - reduced (25% zoom): 2-3 lines — key facts + * - compact (badge): 1 line — status word + */ + +const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07|\x1b\(B/g; +const PROMPT_RE = /^[\$❯›%#>»]\s+|^\S+[\$#%]\s+|^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+[:\$#]\s*/; + +function strip(text) { + return (text || "").replace(ANSI_RE, ""); +} + +function shortPath(p) { + if (!p) return ""; + const home = p.replace(/^\/Users\/\w+/, "~"); + const parts = home.split("/"); + if (parts.length <= 3) return home; + return parts.slice(-2).join("/"); +} + +function projectName(cwd) { + if (!cwd) return ""; + const parts = cwd.replace(/\/$/, "").split("/"); + return parts[parts.length - 1] || ""; +} + +function lastCommand(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (PROMPT_RE.test(lines[i])) { + return lines[i].replace(PROMPT_RE, "").trim(); + } + } + return null; +} + +function recentMeaningful(lines, n = 4) { + return lines + .filter((l) => l.trim().length > 2 && !PROMPT_RE.test(l)) + .slice(-n) + .map((l) => l.trim().slice(0, 80)); +} + +function findElapsed(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + const m = lines[i].match(/\b(\d+(?:\.\d+)?)\s*(ms|s|sec|min|minutes?)\b/i); + if (m) return m[0]; + } + return null; +} + +function countPattern(lines, re) { + return lines.filter((l) => re.test(l)).length; +} + +// ── Matchers ── + +function matchTests(lines) { + // Jest / Vitest style + for (let i = lines.length - 1; i >= 0; i--) { + const m = lines[i].match(/Tests?:\s*(\d+)\s*failed?,?\s*(\d+)\s*passed?,?\s*(\d+)\s*total/i) + || lines[i].match(/(\d+)\s*failing[,\s]*(\d+)\s*passing/i); + if (!m) continue; + + const failed = parseInt(m[1], 10); + const passed = parseInt(m[2], 10); + const total = m[3] ? parseInt(m[3], 10) : failed + passed; + const time = findElapsed(lines); + + const failFiles = []; + const failNames = []; + for (const l of lines) { + const ff = l.match(/FAIL\s+(\S+)/); + if (ff) failFiles.push(ff[1].split("/").slice(-2).join("/")); + const fn = l.match(/[✗✕×●]\s+(.+)/); + if (fn && failNames.length < 3) failNames.push(fn[1].trim().slice(0, 55)); + } + + const suites = lines.find((l) => /Test Suites?:/i.test(l)); + const suitesInfo = suites?.match(/(\d+)\s*failed.*?(\d+)\s*passed/i); + + if (failed > 0) { + return { + match: true, type: "test-fail", status: "error", + detailed: [ + `${failed}/${total} tests failed`, + ...(suitesInfo ? [`${suitesInfo[1]} suite${+suitesInfo[1] > 1 ? "s" : ""} failing`] : []), + ...failFiles.slice(0, 2), + ...failNames.slice(0, 2).map((n) => `✗ ${n}`), + ...(time ? [`ran in ${time}`] : []), + ], + reduced: [`✗ ${failed}/${total} failed`, failNames[0] ? `✗ ${failNames[0].slice(0, 35)}` : (failFiles[0] || "")], + compact: `✗ ${failed} failed`, + }; + } + return { + match: true, type: "test-pass", status: "success", + detailed: [`All ${passed} tests passed`, ...(time ? [`in ${time}`] : [])], + reduced: [`✓ ${passed}/${total} passed`], + compact: `✓ All passed`, + }; + } + + const passing = lines.find((l) => /(\d+)\s+passing/i.test(l)); + if (passing) { + const n = passing.match(/(\d+)/)[1]; + return { + match: true, type: "test-pass", status: "success", + detailed: [`${n} tests passing`], + reduced: [`✓ ${n} passing`], + compact: `✓ ${n} passed`, + }; + } + return { match: false }; +} + +function matchBuildError(lines) { + let count = 0; + const files = []; + const msgs = []; + + for (const l of lines) { + const fc = l.match(/Found\s+(\d+)\s+error/i) || l.match(/(\d+)\s+error(?:s)?\s+generated/i); + if (fc) count = Math.max(count, parseInt(fc[1], 10)); + + const ef = l.match(/ERROR\s+in\s+(\S+)/i); + if (ef) files.push(ef[1].split("/").pop()); + + const ts = l.match(/error\s+TS(\d+):\s*(.+)/i); + if (ts) msgs.push(`TS${ts[1]}: ${ts[2].slice(0, 50)}`); + + const syn = l.match(/SyntaxError:\s*(.+)/i); + if (syn) msgs.push(syn[1].slice(0, 55)); + + const mod = l.match(/Module not found:\s*(.+)/i); + if (mod) msgs.push(`Missing: ${mod[1].slice(0, 50)}`); + + const ref = l.match(/ReferenceError:\s*(.+)/i); + if (ref) msgs.push(ref[1].slice(0, 55)); + + const typ = l.match(/TypeError:\s*(.+)/i); + if (typ) msgs.push(typ[1].slice(0, 55)); + } + + if (!count && files.length) count = files.length; + if (!count && msgs.length) count = msgs.length; + if (!count) return { match: false }; + + return { + match: true, type: "build-error", status: "error", + detailed: [ + `Build failed — ${count} error${count > 1 ? "s" : ""}`, + ...files.slice(0, 2).map((f) => `in ${f}`), + ...msgs.slice(0, 3), + ], + reduced: [`⚠ ${count} error${count > 1 ? "s" : ""}`, msgs[0] || files[0] || ""], + compact: `⚠ ${count} error${count > 1 ? "s" : ""}`, + }; +} + +function matchBuildOk(lines) { + const ok = lines.find((l) => /built\s+in|compiled\s+successfully|build\s+completed|✓\s+built/i.test(l)); + if (!ok) return { match: false }; + if (lines.some((l) => /error/i.test(l) && !/0\s+error/i.test(l) && !/no\s+error/i.test(l))) return { match: false }; + + const time = findElapsed(lines); + const sizes = lines + .filter((l) => /\d+(\.\d+)?\s*(kB|KB|MB|bytes|B)\b/.test(l)) + .slice(0, 2) + .map((l) => l.trim().slice(0, 60)); + const warns = countPattern(lines, /warning/i); + + return { + match: true, type: "build-ok", status: "success", + detailed: [ + `Build succeeded${time ? ` in ${time}` : ""}`, + ...(warns ? [`${warns} warning${warns > 1 ? "s" : ""}`] : []), + ...sizes, + ], + reduced: [`✓ Built${time ? ` (${time})` : ""}`, ...(warns ? [`${warns} warning${warns > 1 ? "s" : ""}`] : [])], + compact: "✓ Built", + }; +} + +function matchServer(lines, ctx) { + let port = null; + let ready = false; + let framework = null; + let url = null; + + for (const l of lines) { + const pm = l.match(/https?:\/\/localhost:(\d+)/) || l.match(/localhost:(\d+)/) + || l.match(/port\s+(\d+)/i) || l.match(/:\s*(\d{4,5})\/?[\s]*$/); + if (pm) port = pm[1]; + + const um = l.match(/(https?:\/\/localhost:\d+\S*)/); + if (um) url = um[1]; + + if (/ready\s+in|compiled|successfully|started|listening/i.test(l)) ready = true; + if (/\bnext\b/i.test(l) && !framework) framework = "Next.js"; + if (/\bvite\b/i.test(l) && !framework) framework = "Vite"; + if (/\bwebpack\b/i.test(l) && !framework) framework = "Webpack"; + if (/\bnuxt\b/i.test(l) && !framework) framework = "Nuxt"; + if (/\bremix\b/i.test(l) && !framework) framework = "Remix"; + if (/\bexpress\b/i.test(l) && !framework) framework = "Express"; + if (/\bfastify\b/i.test(l) && !framework) framework = "Fastify"; + if (/\bhono\b/i.test(l) && !framework) framework = "Hono"; + if (/\bflask\b/i.test(l) && !framework) framework = "Flask"; + if (/\bdjango\b/i.test(l) && !framework) framework = "Django"; + if (/\brails\b/i.test(l) && !framework) framework = "Rails"; + if (/\belectron-vite\b/i.test(l)) framework = "Electron"; + } + + if (!port) return { match: false }; + + const label = framework || "Server"; + const proj = projectName(ctx.cwd); + const time = findElapsed(lines); + const hmr = lines.some((l) => /hmr|hot\s+module|watching/i.test(l)); + + return { + match: true, type: "dev-server", status: "running", + detailed: [ + `${label} — :${port}`, + ready ? "Running" : "Starting…", + ...(hmr ? ["HMR active"] : []), + ...(proj ? [proj] : []), + ...(time ? [`ready in ${time}`] : []), + ...(url ? [url] : []), + ], + reduced: [`● ${label} :${port}`, proj || (ready ? "Running" : "Starting…")], + compact: `● :${port}`, + }; +} + +function matchGit(lines) { + const branchLine = lines.find((l) => /On branch\s+/.test(l)); + const branch = branchLine?.match(/On branch\s+(\S+)/)?.[1]; + + const modified = lines.filter((l) => /^\s+modified:/.test(l)); + const added = lines.filter((l) => /^\s+new file:/.test(l)); + const deleted = lines.filter((l) => /^\s+deleted:/.test(l)); + const untracked = lines.some((l) => /Untracked files/i.test(l)); + const conflict = lines.some((l) => /CONFLICT|both modified|Unmerged/i.test(l)); + const aheadM = lines.find((l) => /ahead.*?(\d+)/i.test(l))?.match(/ahead.*?(\d+)/i); + const behindM = lines.find((l) => /behind.*?(\d+)/i.test(l))?.match(/behind.*?(\d+)/i); + const clean = lines.some((l) => /nothing to commit|working tree clean/i.test(l)); + + const total = modified.length + added.length + deleted.length; + if (!branch && total === 0 && !conflict && !clean) return { match: false }; + + if (conflict) { + const conflictFiles = lines + .filter((l) => /both modified/i.test(l)) + .map((l) => l.split(":").pop()?.trim()?.split("/").pop()) + .filter(Boolean); + return { + match: true, type: "git-conflict", status: "error", + detailed: ["Merge conflict", ...(branch ? [`on ${branch}`] : []), ...conflictFiles.slice(0, 3)], + reduced: ["⚠ Merge conflict", branch || ""], + compact: "⚠ Conflict", + }; + } + + const changedNames = [...modified, ...added, ...deleted] + .map((l) => l.split(":").pop()?.trim()?.split("/").pop()) + .filter(Boolean) + .slice(0, 4); + + if (total > 0 || untracked) { + const parts = []; + if (modified.length) parts.push(`${modified.length} modified`); + if (added.length) parts.push(`${added.length} added`); + if (deleted.length) parts.push(`${deleted.length} deleted`); + if (untracked) parts.push("+ untracked"); + + return { + match: true, type: "git-dirty", status: "info", + detailed: [ + branch || "git", + parts.join(", "), + ...changedNames, + ...(aheadM ? [`↑ ${aheadM[1]} to push`] : []), + ...(behindM ? [`↓ ${behindM[1]} behind`] : []), + ], + reduced: [branch || "git", `${total} file${total > 1 ? "s" : ""} changed`], + compact: `${total} changed`, + }; + } + + if (clean && branch) { + return { + match: true, type: "git-clean", status: "success", + detailed: [ + branch, + "Working tree clean", + ...(aheadM ? [`↑ ${aheadM[1]} commit${+aheadM[1] > 1 ? "s" : ""} to push`] : []), + ], + reduced: [branch, "✓ Clean"], + compact: branch.length > 14 ? branch.slice(0, 14) + "…" : branch, + }; + } + + if (branch) { + return { + match: true, type: "git-branch", status: "info", + detailed: [branch, ...(aheadM ? [`↑ ${aheadM[1]} ahead`] : [])], + reduced: [branch], + compact: branch.length > 14 ? branch.slice(0, 14) + "…" : branch, + }; + } + return { match: false }; +} + +function matchGitDiff(lines) { + const diffs = lines.filter((l) => /^diff --git/.test(l)); + if (!diffs.length) return { match: false }; + + const insertions = countPattern(lines, /^\+[^+]/); + const deletions = countPattern(lines, /^-[^-]/); + const fileNames = diffs.map((l) => l.match(/b\/(\S+)/)?.[1]?.split("/").pop()).filter(Boolean); + + return { + match: true, type: "git-diff", status: "info", + detailed: [ + `Diff: ${diffs.length} file${diffs.length > 1 ? "s" : ""}`, + `+${insertions} -${deletions}`, + ...fileNames.slice(0, 3), + ], + reduced: [`${diffs.length} files diffed`, `+${insertions} -${deletions}`], + compact: `+${insertions} -${deletions}`, + }; +} + +function matchGitLog(lines) { + const commits = lines.filter((l) => /^[a-f0-9]{7,40}\s/.test(l)); + if (commits.length < 2) return { match: false }; + + const msgs = commits.slice(0, 4).map((c) => { + const m = c.match(/^[a-f0-9]+\s+(.+)/); + return m ? m[1].trim().slice(0, 50) : c.slice(0, 50); + }); + + return { + match: true, type: "git-log", status: "info", + detailed: [`${commits.length} commits`, ...msgs], + reduced: [msgs[0] || `${commits.length} commits`], + compact: `${commits.length} commits`, + }; +} + +function matchInstall(lines) { + const done = lines.find((l) => /added\s+\d+\s+package/i.test(l)); + if (done) { + const n = done.match(/(\d+)/)[1]; + const time = findElapsed(lines); + const audits = lines.find((l) => /vulnerabilit/i.test(l)); + const auditInfo = audits?.match(/(\d+)\s+vulnerabilit/i)?.[1]; + return { + match: true, type: "install-done", status: "success", + detailed: [ + `Installed ${n} packages`, + ...(time ? [`in ${time}`] : []), + ...(auditInfo ? [`${auditInfo} vulnerabilities found`] : []), + ], + reduced: [`✓ ${n} packages installed`], + compact: "✓ Installed", + }; + } + if (lines.some((l) => /npm\s+(warn|info)|resolving|bun\s+install|installing/i.test(l))) { + return { + match: true, type: "install-wip", status: "running", + detailed: ["Installing dependencies…"], + reduced: ["Installing…"], + compact: "Installing…", + }; + } + return { match: false }; +} + +function matchPrompt(lines) { + const tail = lines.filter((l) => l.trim()).slice(-6); + for (const l of tail) { + if (/\(y\/n\)/i.test(l) || /\(yes\/no\)/i.test(l)) { + return { + match: true, type: "prompt-yn", status: "warning", + detailed: ["Waiting for confirmation", l.trim().slice(0, 60)], + reduced: ["Needs input", l.trim().slice(0, 40)], + compact: "? Confirm", + }; + } + if (/\?\s+\S/.test(l) && /[\(\[]/.test(l)) { + return { + match: true, type: "prompt-choice", status: "warning", + detailed: ["Waiting for selection", l.trim().slice(0, 60)], + reduced: ["Needs input", l.trim().slice(0, 40)], + compact: "? Choose", + }; + } + if (/Enter.*to\s+continue|Press\s+.*to/i.test(l)) { + return { + match: true, type: "prompt-key", status: "warning", + detailed: ["Waiting for keypress", l.trim().slice(0, 60)], + reduced: ["Needs input"], + compact: "? Input", + }; + } + if (/password|passphrase/i.test(l)) { + return { + match: true, type: "prompt-password", status: "warning", + detailed: ["Waiting for password"], + reduced: ["Password required"], + compact: "🔒 Password", + }; + } + } + return { match: false }; +} + +function matchDocker(lines) { + if (lines.some((l) => /Successfully built|Successfully tagged/i.test(l))) { + const tag = lines.find((l) => /tagged/i.test(l))?.match(/tagged\s+(\S+)/)?.[1]; + return { + match: true, type: "docker-built", status: "success", + detailed: ["Docker image built", ...(tag ? [tag] : [])], + reduced: ["✓ Image built", ...(tag ? [tag] : [])], + compact: "✓ Built", + }; + } + if (lines.some((l) => /container.*started|up\s+\d+/i.test(l))) { + const containers = lines.filter((l) => /\s+up\s+/i.test(l)).length; + return { + match: true, type: "docker-up", status: "running", + detailed: [`${containers || "?"} container${containers !== 1 ? "s" : ""} running`], + reduced: ["Containers up"], + compact: "● Docker", + }; + } + return { match: false }; +} + +function matchSSH(lines, ctx) { + const fg = ctx.foreground || ""; + if (!/ssh/i.test(fg)) return { match: false }; + const host = fg.match(/ssh\s+(?:\S+@)?(\S+)/i)?.[1] || "remote"; + const welcome = lines.find((l) => /welcome|last login|ubuntu|debian|centos/i.test(l)); + return { + match: true, type: "ssh", status: "running", + detailed: [`SSH → ${host}`, ...(welcome ? [welcome.trim().slice(0, 55)] : [])], + reduced: [`SSH → ${host}`], + compact: `→ ${host.split(".")[0].slice(0, 12)}`, + }; +} + +function matchProcess(lines, ctx) { + const fg = ctx.foreground || ""; + if (!fg || /^(zsh|bash|fish|sh|login)$/.test(fg)) return { match: false }; + + const cmd = lastCommand(lines); + const recent = recentMeaningful(lines, 3); + const time = findElapsed(lines); + const proj = projectName(ctx.cwd); + + const exitLine = lines.find((l) => /exit\s*(code|status)?\s*(\d+)/i.test(l)); + const exitCode = exitLine?.match(/(\d+)/)?.[1]; + + const isEditor = /vim|nvim|nano|emacs|micro|helix/i.test(fg); + if (isEditor) { + const file = cmd?.match(/\S+$/)?.[0]?.split("/").pop() || ""; + return { + match: true, type: "editor", status: "running", + detailed: [`Editing${file ? `: ${file}` : ""}`, proj || shortPath(ctx.cwd)], + reduced: [fg, file || ""], + compact: file ? file.slice(0, 14) : fg, + }; + } + + let label = cmd || fg; + if (label.length > 60) label = label.slice(0, 57) + "…"; + + return { + match: true, type: "process", status: exitCode ? (exitCode === "0" ? "success" : "error") : "running", + detailed: [ + label, + ...(proj ? [proj] : []), + ...recent, + ...(time ? [`elapsed: ${time}`] : []), + ...(exitCode ? [`exit ${exitCode}`] : []), + ], + reduced: [ + label.slice(0, 40), + recent.length > 0 ? recent[recent.length - 1] : "", + ], + compact: exitCode + ? (exitCode === "0" ? "✓ Done" : `✗ exit ${exitCode}`) + : (cmd || fg).split(/\s/)[0].slice(0, 14), + }; +} + +// ── Cross-terminal enrichment ── + +function enrich(result, ctx) { + if (!ctx.otherTerminals?.length) return result; + const detailed = [...result.detailed]; + + for (const other of ctx.otherTerminals) { + if (!other.summary) continue; + if (result.type === "test-fail" && other.summary.type === "build-error") + detailed.push("→ Likely blocked by build errors"); + if (result.type === "dev-server" && other.summary.type === "dev-server") { + const p = other.summary.compact?.match(/:(\d+)/)?.[1]; + if (p) detailed.push(`Also: :${p}`); + } + if (result.type === "idle") { + if (other.summary.status === "error") { + const act = other.summary.type.includes("build") ? "Fix build errors" + : other.summary.type.includes("test") ? "Fix failing tests" + : other.summary.type.includes("conflict") ? "Resolve merge conflict" + : null; + if (act && !detailed.includes(`→ ${act}`)) detailed.push(`→ ${act}`); + } + } + } + return { ...result, detailed }; +} + +// ── Idle / fallback ── + +function idle(lines, ctx) { + const dir = shortPath(ctx.cwd); + const proj = projectName(ctx.cwd); + const cmd = lastCommand(lines); + const recent = recentMeaningful(lines, 3); + + const actions = []; + for (const o of ctx.otherTerminals || []) { + if (!o.summary) continue; + if (o.summary.type === "build-error") actions.push("→ Fix build errors"); + else if (o.summary.type === "test-fail") actions.push("→ Fix failing tests"); + else if (o.summary.type === "git-conflict") actions.push("→ Resolve conflict"); + } + + const detailed = [proj || "Idle"]; + if (dir && dir !== proj) detailed.push(dir); + if (cmd) detailed.push(`$ ${cmd.slice(0, 55)}`); + for (const r of recent.slice(-2)) detailed.push(r); + for (const a of actions.slice(0, 2)) detailed.push(a); + + const reduced = [proj || "Idle"]; + if (cmd) reduced.push(`$ ${cmd.slice(0, 35)}`); + else if (dir) reduced.push(dir); + if (actions[0]) reduced.push(actions[0]); + + return { + match: true, type: "idle", status: actions.length ? "warning" : "idle", + detailed, + reduced, + compact: actions.length + ? actions[0].replace("→ ", "").slice(0, 16) + : (cmd ? `$ ${cmd.split(/\s/)[0]}` : (proj || "Idle")), + }; +} + +// ── Main ── + +const matchers = [ + matchPrompt, + matchTests, + matchBuildError, + matchBuildOk, + matchServer, + matchGit, + matchGitDiff, + matchGitLog, + matchInstall, + matchDocker, + matchSSH, + matchProcess, +]; + +/** + * @param {string} terminalOutput + * @param {{ foreground?: string, shell?: string, cwd?: string, title?: string, + * otherTerminals?: Array<{ foreground?: string, cwd?: string, summary?: object }> }} ctx + * @returns {{ type: string, status: string, detailed: string[], reduced: string[], compact: string }} + */ +export function generateSummary(terminalOutput, ctx = {}) { + const clean = strip(terminalOutput); + const lines = clean.split("\n").filter((l) => l.trim()); + const fg = ctx.foreground || ""; + const shell = ctx.shell ? ctx.shell.split("/").pop() : "zsh"; + const isIdle = !fg || fg === shell; + + for (const m of matchers) { + const r = m(lines, ctx); + if (r.match) return enrich(r, ctx); + } + + if (isIdle) return enrich(idle(lines, ctx), ctx); + + const cmd = lastCommand(lines); + const recent = recentMeaningful(lines, 3); + const proj = projectName(ctx.cwd); + + return { + type: "unknown", status: "running", + detailed: [ + fg, + ...(proj ? [proj] : []), + ...(cmd ? [`$ ${cmd.slice(0, 55)}`] : []), + ...recent, + ], + reduced: [fg, recent.length ? recent[recent.length - 1] : ""], + compact: fg.split(/\s/)[0].slice(0, 14), + }; +} diff --git a/collab-electron/src/windows/shell/src/tile-manager.js b/collab-electron/src/windows/shell/src/tile-manager.js index a902fb3b..e37e7b04 100644 --- a/collab-electron/src/windows/shell/src/tile-manager.js +++ b/collab-electron/src/windows/shell/src/tile-manager.js @@ -6,8 +6,10 @@ import { } from "./canvas-state.js"; import { createTileDOM, positionTile, updateTileTitle, getTileLabel, + updateTileSummary, } from "./tile-renderer.js"; import { attachDrag, attachResize } from "./tile-interactions.js"; +import { generateSummary } from "./terminal-summary.js"; /** * Tile lifecycle manager: creation, deletion, persistence, webview @@ -682,6 +684,233 @@ export function createTileManager({ } } + // -- Terminal summary overlays -- + + const SUMMARY_CAPTURE_DEBOUNCE = 2000; + const SUMMARY_REFRESH_INTERVAL = 5000; + + /** @type {Map} */ + const terminalStates = new Map(); + /** @type {Map} */ + const captureTimers = new Map(); + let summaryRefreshInterval = null; + let currentZoomTier = null; + let currentSummaryScale = null; + let llmAvailable = false; + let llmChecked = false; + + function zoomTier(zoom) { + if (zoom >= 1) return null; + if (zoom > 0.25) return "zoom-50"; + return "zoom-25"; + } + + function collectOtherTerminals(excludeSessionId) { + const others = []; + for (const [sessionId, state] of terminalStates) { + if (sessionId === excludeSessionId) continue; + others.push({ ...state, sessionId }); + } + return others; + } + + function ensureSummaryOverlay(dom) { + if (dom.summaryOverlay) return; + const overlay = document.createElement("div"); + overlay.className = "tile-summary-overlay"; + const detailed = document.createElement("div"); + detailed.className = "summary-detailed"; + const reduced = document.createElement("div"); + reduced.className = "summary-reduced"; + const compact = document.createElement("div"); + compact.className = "summary-compact"; + overlay.appendChild(detailed); + overlay.appendChild(reduced); + overlay.appendChild(compact); + if (currentSummaryScale) { + overlay.style.setProperty("--summary-scale", currentSummaryScale); + } + dom.container.appendChild(overlay); + dom.summaryOverlay = overlay; + } + + function showExistingOrPlaceholder(tile) { + const state = terminalStates.get(tile.ptySessionId); + const dom = tileDOMs.get(tile.id); + if (!dom) return; + ensureSummaryOverlay(dom); + if (state?.summary) { + updateTileSummary(dom, state.summary); + } else { + const fg = state?.foreground || "zsh"; + const shellName = (state?.shell || "zsh").split("/").pop(); + const isIdle = !fg || fg === shellName; + updateTileSummary(dom, { + status: isIdle ? "idle" : "running", + detailed: isIdle ? ["Idle", tile.cwd || ""] : [`Running: ${fg}`], + reduced: isIdle ? ["Idle"] : [fg], + compact: isIdle ? "Idle" : fg, + }); + } + } + + async function checkLlmAvailability() { + if (llmChecked) return; + llmChecked = true; + try { + llmAvailable = await window.shellApi.ptyLlmAvailable(); + } catch { llmAvailable = false; } + } + + function refreshSummaryForTile(tile) { + if (!tile.ptySessionId) return; + const state = terminalStates.get(tile.ptySessionId); + if (!state) return; + + const existing = captureTimers.get(tile.ptySessionId); + if (existing) clearTimeout(existing); + + captureTimers.set(tile.ptySessionId, setTimeout(async () => { + captureTimers.delete(tile.ptySessionId); + try { + const snap = await window.shellApi.ptyCaptureSnapshot( + tile.ptySessionId, + ); + if (snap.foreground) state.foreground = snap.foreground; + if (snap.cwd) tile.cwd = snap.cwd; + + const context = { + foreground: snap.foreground || state.foreground, + shell: state.shell || "zsh", + cwd: snap.cwd || tile.cwd || "", + title: snap.title || "", + otherTerminals: collectOtherTerminals(tile.ptySessionId), + }; + + const heuristic = generateSummary(snap.output, context); + state.summary = heuristic; + + const dom = tileDOMs.get(tile.id); + if (dom) { + ensureSummaryOverlay(dom); + updateTileSummary(dom, heuristic); + } + + if (llmAvailable) { + const llmCtx = { + foreground: context.foreground, + shell: context.shell, + cwd: context.cwd, + title: context.title, + otherTerminals: (context.otherTerminals || []).map((t) => ({ + foreground: t.foreground, + cwd: t.cwd, + summaryCompact: t.summary?.compact || "", + })), + }; + const llmResult = await window.shellApi.ptySummarizeTerminal( + tile.ptySessionId, snap.output, llmCtx, + ); + if (llmResult) { + state.summary = llmResult; + const d = tileDOMs.get(tile.id); + if (d) { + ensureSummaryOverlay(d); + updateTileSummary(d, llmResult); + } + } + } + } catch { /* capture may fail for dead sessions */ } + }, SUMMARY_CAPTURE_DEBOUNCE)); + } + + function refreshAllSummaries() { + for (const t of tiles) { + if (t.type === "term" && t.ptySessionId) { + refreshSummaryForTile(t); + } + } + } + + function applyZoomSummaries(zoom, force = false) { + const tier = zoomTier(zoom); + if (!force && tier === currentZoomTier) return; + + if (tier && !llmChecked) checkLlmAvailability(); + + const prevTier = currentZoomTier; + currentZoomTier = tier; + + currentSummaryScale = tier ? (1 / Math.pow(zoom, 0.65)).toFixed(3) : null; + + for (const t of tiles) { + if (t.type !== "term") continue; + const dom = tileDOMs.get(t.id); + if (!dom) continue; + dom.container.classList.remove("zoom-75", "zoom-50", "zoom-25"); + if (tier) { + dom.container.classList.add(tier); + if (t.ptySessionId) showExistingOrPlaceholder(t); + if (dom.summaryOverlay) { + dom.summaryOverlay.style.setProperty("--summary-scale", currentSummaryScale); + } + } + } + + if (tier && !prevTier) { + refreshAllSummaries(); + summaryRefreshInterval = setInterval( + refreshAllSummaries, SUMMARY_REFRESH_INTERVAL, + ); + } else if (!tier && prevTier) { + if (summaryRefreshInterval) { + clearInterval(summaryRefreshInterval); + summaryRefreshInterval = null; + } + } else if (tier && prevTier) { + refreshAllSummaries(); + } + } + + function updateTerminalStatus(sessionId, foreground) { + let state = terminalStates.get(sessionId); + if (!state) { + state = {}; + terminalStates.set(sessionId, state); + } + state.foreground = foreground; + + if (currentZoomTier) { + const tile = tiles.find( + (t) => t.type === "term" && t.ptySessionId === sessionId, + ); + if (tile) refreshSummaryForTile(tile); + } + } + + function registerTerminalSession(tile) { + if (!tile.ptySessionId) return; + if (!terminalStates.has(tile.ptySessionId)) { + terminalStates.set(tile.ptySessionId, { shell: "zsh" }); + } + if (currentZoomTier) { + const dom = tileDOMs.get(tile.id); + if (dom) { + dom.container.classList.add(currentZoomTier); + } + refreshSummaryForTile(tile); + } + } + + function unregisterTerminalSession(sessionId) { + terminalStates.delete(sessionId); + const timer = captureTimers.get(sessionId); + if (timer) { + clearTimeout(timer); + captureTimers.delete(sessionId); + } + } + return { createCanvasTile, closeCanvasTile, @@ -706,5 +935,9 @@ export function createTileManager({ broadcastToTileWebviews, saveCanvasDebounced, saveCanvasImmediate, + applyZoomSummaries, + updateTerminalStatus, + registerTerminalSession, + unregisterTerminalSession, }; } diff --git a/collab-electron/src/windows/shell/src/tile-renderer.js b/collab-electron/src/windows/shell/src/tile-renderer.js index 8955b97a..7f7f0cb1 100644 --- a/collab-electron/src/windows/shell/src/tile-renderer.js +++ b/collab-electron/src/windows/shell/src/tile-renderer.js @@ -163,13 +163,33 @@ export function createTileDOM(tile, callbacks) { const contentOverlay = document.createElement("div"); contentOverlay.className = "tile-content-overlay"; + let summaryOverlay = null; + if (tile.type === "term") { + summaryOverlay = document.createElement("div"); + summaryOverlay.className = "tile-summary-overlay"; + + const detailed = document.createElement("div"); + detailed.className = "summary-detailed"; + + const reduced = document.createElement("div"); + reduced.className = "summary-reduced"; + + const compact = document.createElement("div"); + compact.className = "summary-compact"; + + summaryOverlay.appendChild(detailed); + summaryOverlay.appendChild(reduced); + summaryOverlay.appendChild(compact); + } + if (urlInput) titleBar.insertBefore(urlInput, btnGroup); container.appendChild(titleBar); container.appendChild(contentArea); contentArea.appendChild(contentOverlay); + if (summaryOverlay) container.appendChild(summaryOverlay); - return { container, titleBar, titleText, contentArea, contentOverlay, closeBtn, urlInput, navBack, navForward, navReload }; + return { container, titleBar, titleText, contentArea, contentOverlay, summaryOverlay, closeBtn, urlInput, navBack, navForward, navReload }; } export function getTileLabel(tile) { @@ -211,6 +231,52 @@ export function updateTileTitle(dom, tile) { titleText.title = tile.filePath || tile.folderPath || ""; } +/** + * Updates the summary overlay content for a terminal tile. + * @param {{ summaryOverlay: HTMLElement | null }} dom + * @param {{ detailed: string[], reduced: string[], compact: string, status?: string }} summary + */ +export function updateTileSummary(dom, summary) { + if (!dom.summaryOverlay) return; + + const statusClass = summary.status || "idle"; + dom.summaryOverlay.dataset.status = statusClass; + + const detailed = dom.summaryOverlay.querySelector(".summary-detailed"); + const reduced = dom.summaryOverlay.querySelector(".summary-reduced"); + const compact = dom.summaryOverlay.querySelector(".summary-compact"); + + if (detailed) { + detailed.textContent = ""; + for (let i = 0; i < summary.detailed.length; i++) { + const line = summary.detailed[i]; + if (!line) continue; + const div = document.createElement("div"); + div.textContent = line; + if (i === 0) div.className = "summary-headline"; + else if (line.startsWith("→")) div.className = "summary-action"; + else if (line.startsWith("✗") || line.startsWith("⚠")) div.className = "summary-error-line"; + else if (line.startsWith("✓") || line.startsWith("●")) div.className = "summary-success-line"; + detailed.appendChild(div); + } + } + if (reduced) { + reduced.textContent = ""; + for (let i = 0; i < summary.reduced.length; i++) { + const line = summary.reduced[i]; + if (!line) continue; + const div = document.createElement("div"); + div.textContent = line; + if (i === 0) div.className = "summary-headline"; + else if (line.startsWith("→")) div.className = "summary-action"; + reduced.appendChild(div); + } + } + if (compact) { + compact.textContent = summary.compact; + } +} + /** * Positions a tile container in screen coordinates. * @param {HTMLElement} container