Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions collab-electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions collab-electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>) =>
summarizeTerminal(sessionId, output, context),
);

let settingsOpen = false;

function setSettingsOpen(open: boolean): void {
Expand Down Expand Up @@ -727,6 +752,7 @@ app.whenReady().then(async () => {
registerToggleShortcuts(mainWindow!);

initMainAnalytics();
initLlmSummary();
trackEvent("app_launched");

mainWindow!.webContents.on("did-finish-load", () => {
Expand Down
178 changes: 178 additions & 0 deletions collab-electron/src/main/llm-summary.ts
Original file line number Diff line number Diff line change
@@ -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<string, SummaryResult>();
const inflight = new Set<string>();
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<SummaryResult | null> {
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);
}
}
42 changes: 42 additions & 0 deletions collab-electron/src/main/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions collab-electron/src/preload/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,22 @@ contextBridge.exposeInMainWorld("shellApi", {
ptyKillSession: (sessionId: string): Promise<void> =>
ipcRenderer.invoke("pty:kill", { sessionId }),

ptyCaptureOutput: (sessionId: string, lineCount?: number): Promise<string> =>
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<boolean> =>
ipcRenderer.invoke("pty:llm-available"),

ptySummarizeTerminal: (
sessionId: string,
output: string,
context: Record<string, unknown>,
): 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,
) => {
Expand Down
18 changes: 10 additions & 8 deletions collab-electron/src/windows/shell/src/canvas-rpc.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = {};
Expand Down
Loading