From e20775f83418f55636b1c2e25abde331eb8a9b50 Mon Sep 17 00:00:00 2001 From: "thomas g. lopes" Date: Wed, 11 Mar 2026 00:52:18 +0000 Subject: [PATCH] improve peon commands --- pi/files/agent/extensions/peon.ts | 660 +++++++++++++++++++++--------- 1 file changed, 474 insertions(+), 186 deletions(-) diff --git a/pi/files/agent/extensions/peon.ts b/pi/files/agent/extensions/peon.ts index debba85..99af2f1 100644 --- a/pi/files/agent/extensions/peon.ts +++ b/pi/files/agent/extensions/peon.ts @@ -4,25 +4,23 @@ * Announces Pi events with customizable sound packs following the CESP standard. * Default pack: Warcraft Orc Peon * - * Usage: - * /peon status - Show current pack and settings - * /peon list - List installed packs - * /peon set - Switch to a different pack - * /peon volume <0-100> - Set master volume - * /peon mute - Toggle global mute - * /peon test - Test a sound category - * - * Categories: session.start, task.acknowledge, task.complete, task.error, - * input.required, resource.limit + * Commands: + * /peon:status - Show current pack and settings + * /peon:list - List installed packs + * /peon:set - Interactively select a sound pack + * /peon:settings - Interactive settings editor + * /peon:volume [0-100] - Set or adjust master volume + * /peon:mute - Toggle global mute + * /peon:toggle [category]- Toggle sound categories + * /peon:test [category] - Test a sound category */ -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; import * as fs from "node:fs"; import * as path from "node:path"; import { exec, execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execAsync = promisify(exec); // ============ CONFIGURATION ============ const PACKS_DIR = path.join(process.env.HOME || "~", ".config/openpeon/packs"); @@ -33,26 +31,23 @@ const DEBOUNCE_MS = 500; // Get the remote host to SSH back to (the machine the user is physically on) function getRemoteHost(): string | null { - // Try SSH_CONNECTION to get client's IP (format: client_ip client_port server_ip server_port) const sshConn = process.env.SSH_CONNECTION || ""; const clientIP = sshConn.split(" ")[0]; if (clientIP && clientIP !== "::1" && clientIP !== "127.0.0.1") { return clientIP; } - - // Fall back to SSH_CLIENT (older format) + const sshClient = process.env.SSH_CLIENT || ""; const clientIP2 = sshClient.split(" ")[0]; if (clientIP2 && clientIP2 !== "::1" && clientIP2 !== "127.0.0.1") { return clientIP2; } - + return null; } // Map Mac paths to Linux paths for SSH playback function mapPathToRemote(soundPath: string): string { - // Mac home → Linux home (different username) if (soundPath.startsWith("/Users/thomasglopes/")) { return soundPath.replace("/Users/thomasglopes/", "/home/thomasgl/"); } @@ -61,14 +56,9 @@ function mapPathToRemote(soundPath: string): string { // ============ SSH DETECTION ============ function isSSH(): boolean { - if ( - process.env.SSH_CONNECTION || - process.env.SSH_CLIENT || - process.env.SSH_TTY - ) { + if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) { return true; } - // Check for sshd-session process (works in tmux/zellij) try { const { execSync } = require("child_process"); const result = execSync("pgrep -u $USER -f sshd-session 2>/dev/null", { @@ -80,7 +70,6 @@ function isSSH(): boolean { return false; } } -// ======================================= // CESP Core Categories const CORE_CATEGORIES = [ @@ -115,12 +104,11 @@ interface OpenPeonManifest { interface PeonConfig { activePack: string; - volume: number; // 0.0 to 1.0 + volume: number; muted: boolean; enabledCategories: Record; } -// Default config const DEFAULT_CONFIG: PeonConfig = { activePack: DEFAULT_PACK, volume: 0.7, @@ -138,6 +126,8 @@ const DEFAULT_CONFIG: PeonConfig = { // State let config: PeonConfig = { ...DEFAULT_CONFIG }; let lastPlayed: number = 0; +let lastSoundPerCategory: Map = new Map(); +let manifestCache: Map = new Map(); // ============ CONFIG PERSISTENCE ============ function loadConfig(): PeonConfig { @@ -164,29 +154,22 @@ function saveConfig(): void { console.error("[peon] Failed to save config:", err); } } -let lastSoundPerCategory: Map = new Map(); -let manifestCache: Map = new Map(); // ============ AUDIO PLAYBACK ============ - function playSoundLocal(soundPath: string, volume: number): void { if (volume <= 0) return; const platform = process.platform; if (platform === "darwin") { - // macOS const vol = volume.toFixed(2); exec(`nohup afplay -v ${vol} "${soundPath}" >/dev/null 2>&1 &`); } else if (platform === "linux") { - // Linux - try PipeWire first, then fall back execFile("pw-play", ["--volume=" + volume, soundPath], (err) => { if (err) { - // Fallback to paplay (PulseAudio) const paVol = Math.round(volume * 65536); execFile("paplay", ["--volume=" + paVol, soundPath], (err2) => { if (err2) { - // Final fallback to aplay (ALSA) execFile("aplay", [soundPath]); } }); @@ -197,37 +180,31 @@ function playSoundLocal(soundPath: string, volume: number): void { function playSound(soundPath: string, volume: number): void { const remoteHost = getRemoteHost(); - - // If in SSH session from Mac, play only on the remote Linux machine (where user physically is) + if (remoteHost && isSSH() && process.platform === "darwin") { const remotePath = mapPathToRemote(soundPath); - // SSH back to Linux with correct username and mapped path - // pw-play expects volume as 0.0-1.0 float exec( `ssh -o ConnectTimeout=2 -o BatchMode=yes thomasgl@${remoteHost} "pw-play --volume=${volume} '${remotePath}' 2>/dev/null" 2>/dev/null || true`, { timeout: 5000 } ); - return; // Don't play locally - user is on Linux + return; } - - // If in SSH session from Linux, play only on remote Mac + if (remoteHost && isSSH() && process.platform === "linux") { const vol = Math.round(volume * 100); exec( - `ssh -o ConnectTimeout=2 -o BatchMode=yes ${remoteHost} "afplay -v ${vol/100} '${soundPath}' 2>/dev/null" 2>/dev/null || true`, + `ssh -o ConnectTimeout=2 -o BatchMode=yes ${remoteHost} "afplay -v ${vol / 100} '${soundPath}' 2>/dev/null" 2>/dev/null || true`, { timeout: 5000 } ); - return; // Don't play locally - user is on Mac + return; } - // Play locally when not in SSH session playSoundLocal(soundPath, volume); } function sendNotification(title: string, message: string): void { const remoteHost = getRemoteHost(); - - // If SSH'd from Mac to Linux, send notification to Linux (where user is) + if (remoteHost && isSSH() && process.platform === "darwin") { exec( `ssh -o ConnectTimeout=2 -o BatchMode=yes thomasgl@${remoteHost} "notify-send -i ~/.pi/agent/extensions/assets/pi-logo.svg '${title}' '${message}'" 2>/dev/null || true`, @@ -235,17 +212,15 @@ function sendNotification(title: string, message: string): void { ); return; } - - // If SSH'd from Linux to Mac, send notification to Mac (where user is) + if (remoteHost && isSSH() && process.platform === "linux") { exec( - `ssh -o ConnectTimeout=2 -o BatchMode=yes ${remoteHost} "osascript -e 'display notification \"${message}\" with title \"${title}\"'" 2>/dev/null || true`, + `ssh -o ConnectTimeout=2 -o BatchMode=yes ${remoteHost} "osascript -e 'display notification "${message}" with title "${title}"'" 2>/dev/null || true`, { timeout: 5000 } ); return; } - - // Send locally when not in SSH session + if (process.platform === "linux") { exec( `notify-send -i ~/.pi/agent/extensions/assets/pi-logo.svg '${title}' '${message}' 2>/dev/null || true`, @@ -260,7 +235,6 @@ function sendNotification(title: string, message: string): void { } // ============ PACK MANAGEMENT ============ - function getPackPath(packName: string): string { return path.join(PACKS_DIR, packName); } @@ -297,13 +271,16 @@ function getInstalledPacks(): string[] { }); } +function getPackDisplayName(packName: string): string { + const manifest = loadManifest(packName); + return manifest?.display_name || packName; +} + function resolveCategory(manifest: OpenPeonManifest, category: Category): CategoryConfig | null { - // Direct lookup if (manifest.categories[category]) { return manifest.categories[category]; } - // Check aliases if (manifest.category_aliases) { const aliased = manifest.category_aliases[category]; if (aliased && manifest.categories[aliased]) { @@ -318,53 +295,44 @@ function pickSound(categoryConfig: CategoryConfig, category: Category): Sound | const sounds = categoryConfig.sounds; if (sounds.length === 0) return null; - // No-repeat: exclude last played sound if there are alternatives const lastSound = lastSoundPerCategory.get(category); let candidates = sounds; if (lastSound && sounds.length > 1) { candidates = sounds.filter((s) => s.file !== lastSound); } - // Random selection const sound = candidates[Math.floor(Math.random() * candidates.length)]; lastSoundPerCategory.set(category, sound.file); return sound; } // ============ SOUND PLAYBACK ============ - function play(category: Category): void { if (config.muted) return; if (!config.enabledCategories[category]) return; - // Global debounce check - never play two sounds at once const now = Date.now(); if (now - lastPlayed < DEBOUNCE_MS) { return; } lastPlayed = now; - // Load manifest const manifest = loadManifest(config.activePack); if (!manifest) { console.error(`[peon] Pack not found: ${config.activePack}`); return; } - // Resolve category const categoryConfig = resolveCategory(manifest, category); if (!categoryConfig) { - // Silently skip if pack doesn't have this category return; } - // Pick and play sound const sound = pickSound(categoryConfig, category); if (!sound) return; const soundPath = path.join(getPackPath(config.activePack), sound.file); if (!fs.existsSync(soundPath)) { - // Try with sounds/ prefix if no slash in path if (!sound.file.includes("/")) { const altPath = path.join(getPackPath(config.activePack), "sounds", sound.file); if (fs.existsSync(altPath)) { @@ -378,10 +346,9 @@ function play(category: Category): void { playSound(soundPath, config.volume); - // Send desktop notification for important events const notificationMessages: Record = { - "session.start": null, // Too noisy on startup - "task.acknowledge": null, // Too noisy + "session.start": null, + "task.acknowledge": null, "task.complete": { title: "Pi", message: "Task complete" }, "task.error": { title: "Pi", message: "Task failed" }, "input.required": { title: "Pi", message: "Input required" }, @@ -397,150 +364,475 @@ function play(category: Category): void { // ============ COMMANDS ============ function registerCommands(pi: ExtensionAPI) { - pi.registerCommand("peon", { - description: "Manage sound packs and settings", - handler: async (args: string, ctx: ExtensionContext) => { - const parts = args.trim().split(/\s+/); - const cmd = parts[0] || "status"; + // /peon:status - Show current pack and settings + pi.registerCommand("peon:status", { + description: "Show current sound pack and settings", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + const manifest = loadManifest(config.activePack); + const packDisplay = manifest?.display_name || config.activePack; + const lines = [ + `Active pack: ${packDisplay}`, + `Volume: ${Math.round(config.volume * 100)}%`, + `Muted: ${config.muted ? "yes" : "no"}`, + "", + "Enabled categories:", + ]; + for (const cat of CORE_CATEGORIES) { + const enabled = config.enabledCategories[cat] ? "✓" : "✗"; + lines.push(` ${enabled} ${cat}`); + } + ctx.ui.notify(lines.join("\n"), "info"); + }, + }); - switch (cmd) { - case "status": { - const manifest = loadManifest(config.activePack); - const packDisplay = manifest?.display_name || config.activePack; - const lines = [ - `Active pack: ${packDisplay}`, - `Volume: ${Math.round(config.volume * 100)}%`, - `Muted: ${config.muted}`, - "Enabled categories:", - ]; - for (const cat of CORE_CATEGORIES) { - const enabled = config.enabledCategories[cat] ? "✓" : "✗"; - lines.push(` ${enabled} ${cat}`); - } - ctx.ui.notify(lines.join("\n"), "info"); - break; - } - - case "list": { - const packs = getInstalledPacks(); - if (packs.length === 0) { - ctx.ui.notify("No packs installed. Add packs to ~/.config/openpeon/packs/", "warning"); - } else { - const lines = ["Installed packs:"]; - for (const pack of packs) { - const m = loadManifest(pack); - const marker = pack === config.activePack ? "→ " : " "; - lines.push(`${marker}${m?.display_name || pack} (${pack})`); - } - ctx.ui.notify(lines.join("\n"), "info"); - } - break; - } - - case "set": { - const packName = parts[1]; - if (!packName) { - ctx.ui.notify("Usage: /peon set ", "error"); - return; - } - const packs = getInstalledPacks(); - if (!packs.includes(packName)) { - ctx.ui.notify(`Pack '${packName}' not found. Run /peon list to see installed packs.`, "error"); - return; - } - config.activePack = packName; - manifestCache.delete(packName); // Clear cache - saveConfig(); - ctx.ui.notify(`Switched to pack: ${packName}`, "info"); - break; - } - - case "volume": { - const volStr = parts[1]; - if (!volStr) { - ctx.ui.notify(`Current volume: ${Math.round(config.volume * 100)}%`, "info"); - return; - } - const vol = parseInt(volStr, 10); - if (isNaN(vol) || vol < 0 || vol > 100) { - ctx.ui.notify("Volume must be 0-100", "error"); - return; - } - config.volume = vol / 100; - saveConfig(); - ctx.ui.notify(`Volume set to ${vol}%`, "info"); - break; - } - - case "mute": { - config.muted = !config.muted; - saveConfig(); - ctx.ui.notify(config.muted ? "Muted" : "Unmuted", "info"); - break; - } - - case "toggle": { - const cat = parts[1] as Category; - if (!CORE_CATEGORIES.includes(cat)) { - ctx.ui.notify(`Unknown category: ${cat}\nAvailable: ${CORE_CATEGORIES.join(", ")}`, "error"); - return; - } - config.enabledCategories[cat] = !config.enabledCategories[cat]; - saveConfig(); - ctx.ui.notify(`${cat}: ${config.enabledCategories[cat] ? "enabled" : "disabled"}`, "info"); - break; - } - - case "test": { - const cat = parts[1] as Category; - if (!CORE_CATEGORIES.includes(cat)) { - ctx.ui.notify(`Unknown category: ${cat}\nAvailable: ${CORE_CATEGORIES.join(", ")}`, "error"); - return; - } - ctx.ui.notify(`Testing ${cat}...`, "info"); - play(cat); - break; - } - - default: { - ctx.ui.notify( - "Usage: /peon [status|list|set |volume <0-100>|mute|toggle |test ]", - "info" - ); + // /peon:list - List installed packs + pi.registerCommand("peon:list", { + description: "List installed sound packs", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + const packs = getInstalledPacks(); + if (packs.length === 0) { + ctx.ui.notify("No packs installed. Add packs to ~/.config/openpeon/packs/", "warning"); + } else { + const lines = ["Installed packs:"]; + for (const pack of packs) { + const m = loadManifest(pack); + const marker = pack === config.activePack ? "→ " : " "; + lines.push(`${marker}${m?.display_name || pack} (${pack})`); } + ctx.ui.notify(lines.join("\n"), "info"); } }, }); + + // /peon:set - Interactively select a sound pack + pi.registerCommand("peon:set", { + description: "Select a sound pack interactively", + handler: async (args: string, ctx: ExtensionCommandContext) => { + await handleSetCommand(args.trim(), ctx); + }, + }); + + // /peon:settings - Interactive settings editor + pi.registerCommand("peon:settings", { + description: "Edit peon settings interactively", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + await showSettingsMenu(ctx); + }, + }); + + // /peon:volume - Set or adjust volume + pi.registerCommand("peon:volume", { + description: "Set master volume (0-100) or adjust interactively", + handler: async (args: string, ctx: ExtensionCommandContext) => { + await handleVolumeCommand(args.trim(), ctx); + }, + }); + + // /peon:mute - Toggle mute + pi.registerCommand("peon:mute", { + description: "Toggle global mute", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + config.muted = !config.muted; + saveConfig(); + ctx.ui.notify(config.muted ? "Muted" : "Unmuted", "info"); + }, + }); + + // /peon:toggle - Toggle sound categories + pi.registerCommand("peon:toggle", { + description: "Toggle sound categories on/off", + handler: async (args: string, ctx: ExtensionCommandContext) => { + await handleToggleCommand(args.trim(), ctx); + }, + }); + + // /peon:test - Test sound categories + pi.registerCommand("peon:test", { + description: "Test a sound category", + handler: async (args: string, ctx: ExtensionCommandContext) => { + await handleTestCommand(args.trim(), ctx); + }, + }); +} + +// ============ COMMAND HANDLERS ============ + +async function handleSetCommand(packName: string, ctx: ExtensionCommandContext) { + const packs = getInstalledPacks(); + + if (packs.length === 0) { + ctx.ui.notify("No packs installed. Add packs to ~/.config/openpeon/packs/", "warning"); + return; + } + + // If pack name provided directly, use it + if (packName) { + if (!packs.includes(packName)) { + ctx.ui.notify(`Pack '${packName}' not found.`, "error"); + return; + } + config.activePack = packName; + manifestCache.delete(packName); + saveConfig(); + ctx.ui.notify(`Switched to pack: ${getPackDisplayName(packName)}`, "info"); + return; + } + + // Interactive selection using SelectList + const items: SelectItem[] = packs.map((pack) => ({ + value: pack, + label: getPackDisplayName(pack), + description: pack === config.activePack ? "currently active" : undefined, + })); + + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + const container = new Container(); + + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Text(theme.fg("accent", theme.bold("Select Sound Pack")), 1, 0)); + + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }); + selectList.onSelect = (item) => done(item.value); + selectList.onCancel = () => done(null); + container.addChild(selectList); + + container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0)); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => { + selectList.handleInput(data); + tui.requestRender(); + }, + }; + }); + + if (!result) return; + + config.activePack = result; + manifestCache.delete(result); + saveConfig(); + ctx.ui.notify(`Switched to pack: ${getPackDisplayName(result)}`, "info"); +} + +async function handleVolumeCommand(volStr: string, ctx: ExtensionCommandContext) { + if (!volStr) { + // Interactive volume selection + const volumes = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + const currentVol = Math.round(config.volume * 100); + + const items: SelectItem[] = volumes.map((v) => ({ + value: String(v), + label: `${v}%`, + description: v === currentVol ? "current" : undefined, + })); + + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + const container = new Container(); + + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Text(theme.fg("accent", theme.bold("Select Volume")), 1, 0)); + + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }); + selectList.onSelect = (item) => done(item.value); + selectList.onCancel = () => done(null); + container.addChild(selectList); + + container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0)); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => { + selectList.handleInput(data); + tui.requestRender(); + }, + }; + }); + + if (!result) return; + + const vol = parseInt(result, 10); + config.volume = vol / 100; + saveConfig(); + ctx.ui.notify(`Volume set to ${vol}%`, "info"); + return; + } + + const vol = parseInt(volStr, 10); + if (isNaN(vol) || vol < 0 || vol > 100) { + ctx.ui.notify("Volume must be 0-100", "error"); + return; + } + + config.volume = vol / 100; + saveConfig(); + ctx.ui.notify(`Volume set to ${vol}%`, "info"); +} + +async function showSettingsMenu(ctx: ExtensionCommandContext) { + type SettingsAction = "volume" | "mute" | "pack" | "categories" | "test" | "done"; + + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + const items: SelectItem[] = [ + { value: "volume", label: "Volume", description: `${Math.round(config.volume * 100)}%` }, + { value: "mute", label: "Muted", description: config.muted ? "yes" : "no" }, + { value: "pack", label: "Active Pack", description: getPackDisplayName(config.activePack) }, + { value: "categories", label: "Toggle Categories..." }, + { value: "test", label: "Test Sounds..." }, + { value: "done", label: "Done" }, + ]; + + const container = new Container(); + + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Text(theme.fg("accent", theme.bold("Peon Settings")), 1, 0)); + + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }); + selectList.onSelect = (item) => done(item.value as SettingsAction); + selectList.onCancel = () => done(null); + container.addChild(selectList); + + container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0)); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => { + selectList.handleInput(data); + tui.requestRender(); + }, + }; + }); + + if (!result || result === "done") { + if (result === "done") { + ctx.ui.notify("Settings saved", "info"); + } + return; + } + + // Handle submenu + switch (result) { + case "volume": + await handleVolumeCommand("", ctx); + break; + case "mute": + config.muted = !config.muted; + saveConfig(); + ctx.ui.notify(config.muted ? "Muted" : "Unmuted", "info"); + break; + case "pack": + await handleSetCommand("", ctx); + break; + case "categories": + await showCategoryToggle(ctx); + break; + case "test": + await handleTestCommand("", ctx); + break; + } + + // Return to settings menu + await showSettingsMenu(ctx); +} + +async function showCategoryToggle(ctx: ExtensionCommandContext) { + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + const items: SelectItem[] = [ + ...CORE_CATEGORIES.map((cat) => ({ + value: cat, + label: `${config.enabledCategories[cat] ? "✓" : "✗"} ${cat}`, + })), + { value: "done", label: "← Back" }, + ]; + + const container = new Container(); + + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Text(theme.fg("accent", theme.bold("Toggle Categories (Space to toggle)")), 1, 0)); + + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }); + + // Handle selection to toggle + selectList.onSelect = (item) => { + if (item.value === "done") { + done("done"); + return; + } + // Toggle the category + const cat = item.value as Category; + config.enabledCategories[cat] = !config.enabledCategories[cat]; + saveConfig(); + // Update the label + item.label = `${config.enabledCategories[cat] ? "✓" : "✗"} ${cat}`; + tui.requestRender(); + }; + + selectList.onCancel = () => done(null); + container.addChild(selectList); + + container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter toggle • esc back"), 1, 0)); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => { + selectList.handleInput(data); + tui.requestRender(); + }, + }; + }); + + if (result === null) return; + if (result === "done") return; + + // If a category was selected, stay in the toggle menu + await showCategoryToggle(ctx); +} + +async function handleToggleCommand(cat: string, ctx: ExtensionCommandContext) { + if (!cat) { + await showCategoryToggle(ctx); + return; + } + + if (!CORE_CATEGORIES.includes(cat as Category)) { + ctx.ui.notify(`Unknown category: ${cat}\nAvailable: ${CORE_CATEGORIES.join(", ")}`, "error"); + return; + } + + config.enabledCategories[cat as Category] = !config.enabledCategories[cat as Category]; + saveConfig(); + ctx.ui.notify(`${cat}: ${config.enabledCategories[cat as Category] ? "enabled" : "disabled"}`, "info"); +} + +async function handleTestCommand(cat: string, ctx: ExtensionCommandContext) { + if (!cat) { + // Interactive test selection - stays open after testing + await showTestMenu(ctx); + return; + } + + if (!CORE_CATEGORIES.includes(cat as Category)) { + ctx.ui.notify(`Unknown category: ${cat}\nAvailable: ${CORE_CATEGORIES.join(", ")}`, "error"); + return; + } + + ctx.ui.notify(`Testing ${cat}...`, "info"); + play(cat as Category); +} + +async function showTestMenu(ctx: ExtensionCommandContext) { + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + const items: SelectItem[] = [ + ...CORE_CATEGORIES.map((category) => ({ + value: category, + label: category, + })), + { value: "done", label: "← Back" }, + ]; + + const container = new Container(); + + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Text(theme.fg("accent", theme.bold("Test Sound Category")), 1, 0)); + + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }); + + // Play sound but stay open + selectList.onSelect = (item) => { + if (item.value === "done") { + done("done"); + return; + } + // Play the sound but don't close + const category = item.value as Category; + ctx.ui.notify(`Testing ${category}...`, "info"); + play(category); + }; + + selectList.onCancel = () => done(null); + container.addChild(selectList); + + container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter test • esc back"), 1, 0)); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => { + selectList.handleInput(data); + tui.requestRender(); + }, + }; + }); + + if (result === null) return; + if (result === "done") return; + + // If a category was tested, stay in the test menu + await showTestMenu(ctx); } // ============ EVENT WIRING ============ const INTERACTIVE_TOOLS = new Set(["question", "questionnaire"]); -export default function(pi: ExtensionAPI) { +export default function (pi: ExtensionAPI) { registerCommands(pi); - // Session start pi.on("session_start", async (_event, ctx) => { - // Load persisted config config = loadConfig(); if (!ctx.hasUI) return; play("session.start"); }); - // Task acknowledge - when agent starts working pi.on("agent_start", async (_event, ctx) => { if (!ctx.hasUI) return; play("task.acknowledge"); }); - // Task complete - when agent finishes pi.on("agent_end", async (_event, ctx) => { if (!ctx.hasUI) return; play("task.complete"); }); - // Task error - when a tool errors pi.on("tool_result", async (event, ctx) => { if (!ctx.hasUI) return; if (event.isError) { @@ -548,7 +840,6 @@ export default function(pi: ExtensionAPI) { } }); - // Input required - when question/questionnaire is called pi.on("tool_call", async (event, ctx) => { if (!ctx.hasUI) return; if (INTERACTIVE_TOOLS.has(event.toolName)) { @@ -556,10 +847,8 @@ export default function(pi: ExtensionAPI) { } }); - // Resource limit - detect rate limiting in tool results pi.on("tool_result", async (event, ctx) => { if (!ctx.hasUI) return; - // Check for rate limit indicators in error messages const firstContent = event.content?.[0]; const content = firstContent?.type === "text" ? firstContent.text : ""; if (event.isError && /rate.limit|quota|too.many.requests|429/i.test(content)) { @@ -567,7 +856,6 @@ export default function(pi: ExtensionAPI) { } }); - // Listen for input_required events from other extensions (permission-gate, confirm-destructive, etc.) pi.events.on("peon:input_required", (_data) => { play("input.required"); });