Skip to content
Open
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 plugins/pair-programmer/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package-lock.json

# Config with secrets
.claude/skills/pair-programmer/config.json
.env

# Shared context file (runtime)
.context.json
Expand Down
219 changes: 43 additions & 176 deletions plugins/pair-programmer/skills/pair-programmer/lib/overlay-manager.js
Original file line number Diff line number Diff line change
@@ -1,204 +1,71 @@
const path = require("path");
const { BrowserWindow, screen, ipcMain } = require("electron");

const READY_MESSAGE = `## Hi there!

I'm your AI pair programmer. I can see your screen and hear your audio when you're recording.

To get started, use \`/pair-programmer:record\` to begin recording.

Once recording, I'll be able to:
- Answer questions about your code
- Debug errors you encounter
- Suggest improvements
- Help you build features

Speak your question, then hit **{{shortcut}}** — I'll take it from there.`;

function formatShortcut(accelerator) {
return accelerator
.replace("CommandOrControl", process.platform === "darwin" ? "Cmd" : "Ctrl")
.replace(/\+/g, " + ");
}
const { dialog } = require("electron");

/**
* No-op facade — all callers (HTTP API, MCP tools, hook socket, assistant shortcut)
* continue working without changes. No BrowserWindow is created.
*/
class OverlayManager {
constructor(recState, { assistantShortcut, uiDir } = {}) {
this._window = null;
this._recordingState = recState;
this._shortcut = assistantShortcut;
this._uiDir = uiDir;
this._loading = false;

this._permissionResolve = null;

recState.on("stateChanged", () => this._pushStatus());
ipcMain.on("overlay-close", () => this.setVisible(false));
ipcMain.on("overlay-resize", (_, { width, height }) => {
if (this._window && !this._window.isDestroyed()) {
this._window.setSize(Math.round(width), Math.round(height));
}
});
ipcMain.on("permission-response", (_, decision) => {
if (this._permissionResolve) {
this._permissionResolve(decision);
this._permissionResolve = null;
}
});
}

show(text, options = {}) {
const loading = options.loading === true;
this._loading = loading;
const payload = { text: text != null ? String(text) : "", loading };
console.log("[Overlay]", payload.text || "(loading)");
const win = this._ensureWindow();
win.show();

const send = () => {
win.webContents.send("overlay-content", payload);
if (!loading) this._pushStatus();
};
win.webContents.once("did-finish-load", send);
if (!win.webContents.isLoading()) send();
constructor() {}

show(text) {
console.log("[Overlay] show (no-op):", text || "(empty)");
return { status: "ok" };
}

hide() {
if (this._window) {
this._window.close();
this._window = null;
}
console.log("[Overlay] hide (no-op)");
return { status: "ok" };
}

setVisible(visible) {
if (visible) {
const win = this._ensureWindow();
win.show();
} else if (this._window) {
this._window.hide();
}
console.log(`[Overlay] setVisible(${visible}) (no-op)`);
}

showReady() {
if (!this._shortcut) {
this.show("Set `assistant_shortcut` in config to use the assistant.");
return;
}
this.show(READY_MESSAGE.replace("{{shortcut}}", formatShortcut(this._shortcut)));
console.log("[Overlay] showReady (no-op)");
}

pushHookEvent(data) {
if (!this._window || this._window.isDestroyed()) return;
this._window.webContents.send("hook-event", data);
}
pushHookEvent() {}

pushModelConfig(current) {
if (!this._window || this._window.isDestroyed()) return;
this._window.webContents.send("model-config", {
current,
available: ["haiku", "sonnet", "opus"],
});
}
pushModelConfig() {}

showClaudeError(errorText) {
console.log(`[Overlay] Claude session error`);
const win = this._ensureWindow();
win.show();

const payload = { error: errorText };
const send = () => win.webContents.send("claude-error", payload);
win.webContents.once("did-finish-load", send);
if (!win.webContents.isLoading()) send();

return new Promise((resolve) => {
const handler = () => {
ipcMain.removeListener("claude-error-retry", handler);
resolve();
};
ipcMain.on("claude-error-retry", handler);
});
}

showPermissionPrompt({ toolName, toolInput }) {
console.log(`[Overlay] Permission prompt: ${toolName}`);
const win = this._ensureWindow();
win.show();

const payload = { toolName, toolInput };
const send = () => win.webContents.send("permission-prompt", payload);
win.webContents.once("did-finish-load", send);
if (!win.webContents.isLoading()) send();

return new Promise((resolve) => {
// Auto-deny after 30s if no response
const timeout = setTimeout(() => {
if (this._permissionResolve === resolve) {
this._permissionResolve = null;
resolve("deny");
}
}, 30000);

this._permissionResolve = (decision) => {
clearTimeout(timeout);
resolve(decision);
};
async showClaudeError(errorText) {
console.log("[Overlay] Claude session error — showing native dialog");
const { response } = await dialog.showMessageBox({
type: "error",
title: "Claude Session Error",
message: "Failed to create Claude session",
detail: errorText,
buttons: ["Retry", "Quit"],
defaultId: 0,
cancelId: 1,
});
}

destroy() {
ipcMain.removeAllListeners("overlay-close");
ipcMain.removeAllListeners("overlay-resize");
ipcMain.removeAllListeners("permission-response");
ipcMain.removeAllListeners("claude-error-retry");
ipcMain.removeAllListeners("model-change");
if (this._permissionResolve) {
this._permissionResolve("deny");
this._permissionResolve = null;
if (response === 1) {
const { app } = require("electron");
app.quit();
}
this.hide();
// response === 0 means Retry — resolve and let the retry loop continue
}

_ensureWindow() {
if (this._window) {
this._window.focus();
return this._window;
}

const primaryDisplay = screen.getPrimaryDisplay();
const { width: screenWidth } = primaryDisplay.workAreaSize;

this._window = new BrowserWindow({
width: 340,
height: 400,
x: screenWidth - 360,
y: 20,
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
resizable: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
async showPermissionPrompt({ toolName, toolInput }) {
console.log(`[Overlay] Permission prompt (native dialog): ${toolName}`);
const detail = typeof toolInput === "string"
? toolInput
: JSON.stringify(toolInput, null, 2);
const { response } = await dialog.showMessageBox({
type: "question",
title: "Permission Request",
message: `Allow tool: ${toolName}?`,
detail: detail.substring(0, 500),
buttons: ["Allow", "Deny"],
defaultId: 1,
cancelId: 1,
});

this._window.loadFile(path.join(this._uiDir, "overlay.html"));
this._window.setIgnoreMouseEvents(false);

this._window.on("closed", () => {
this._window = null;
});

return this._window;
return response === 0 ? "allow" : "deny";
}

_pushStatus() {
if (this._loading) return;
if (!this._window || this._window.isDestroyed()) return;
this._window.webContents.send("overlay-status", this._recordingState.toOverlayPayload());
destroy() {
console.log("[Overlay] destroy (no-op)");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class PickerManager {
alwaysOnTop: true,
frame: false,
transparent: false,
backgroundColor: "#1a1a1a",
backgroundColor: "#1c1c1e",
show: false,
skipTaskbar: false,
focusable: true,
Expand Down
36 changes: 6 additions & 30 deletions plugins/pair-programmer/skills/pair-programmer/lib/tray-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ const { Tray, Menu, app, nativeImage } = require("electron");
const EMPTY_ICON = nativeImage.createEmpty();

class TrayManager {
constructor(recState, { overlay, ctxBuffer, onStartRecording, onStopRecording }) {
constructor(recState, { widget, ctxBuffer, onStartRecording, onStopRecording }) {
this._tray = null;
this._recordingState = recState;
this._overlayManager = overlay;
this._widgetManager = widget;
this._contextBuffer = ctxBuffer;
this._onStartRecording = onStartRecording;
this._onStopRecording = onStopRecording;
Expand Down Expand Up @@ -88,8 +88,8 @@ class TrayManager {
menu.push({ type: "separator" });
menu.push({ label: "Start Recording", enabled: false });
menu.push({ type: "separator" });
menu.push({ label: "Show Overlay", click: () => this._overlayManager.setVisible(true) });
menu.push({ label: "Hide Overlay", click: () => this._overlayManager.setVisible(false) });
menu.push({ label: "Show Widget", click: () => this._widgetManager.show() });
menu.push({ label: "Hide Widget", click: () => this._widgetManager.hide() });
menu.push({ type: "separator" });
menu.push({ label: "Quit", click: () => app.quit() });
return Menu.buildFromTemplate(menu);
Expand All @@ -104,24 +104,6 @@ class TrayManager {
await this._onStopRecording();
},
});
menu.push({ type: "separator" });
menu.push({
label: "Show Context",
click: () => {
const ctx = this._contextBuffer.getAll();
const text = [
`Screen: ${ctx.screen.length} records`,
`Mic: ${ctx.mic.length} records`,
`System Audio: ${ctx.system_audio.length} records`,
"",
"Recent screen:",
...ctx.screen
.slice(-3)
.map((r) => ` • ${(r.text || "").substring(0, 50)}...`),
].join("\n");
this._overlayManager.show(text);
},
});
} else {
const hintLabel = rs.failed
? "Recording failed — run /record in Claude to try again"
Expand All @@ -137,14 +119,8 @@ class TrayManager {
}

menu.push({ type: "separator" });
menu.push({
label: "Show Overlay",
click: () => this._overlayManager.setVisible(true),
});
menu.push({
label: "Hide Overlay",
click: () => this._overlayManager.setVisible(false),
});
menu.push({ label: "Show Widget", click: () => this._widgetManager.show() });
menu.push({ label: "Hide Widget", click: () => this._widgetManager.hide() });
menu.push({ type: "separator" });
menu.push({ label: "Quit", click: () => app.quit() });

Expand Down
Loading