From 9e484e67b7bd02c7485fff72775a66b45a8e8217 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 10 May 2026 22:04:00 +0200 Subject: [PATCH] =?UTF-8?q?refactor(sf):=20fold=20sf-tui=20extension=20int?= =?UTF-8?q?o=20sf/ui/=20=E2=80=94=20remove=20separate=20extension=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sf-tui was a 'bundled' extension with zero features independent of the sf/ extension. Every hook, shortcut, tool, header and footer render depended on sf/ internals (getAutoSession, isAutoActive, projectRoot, getExperimentalFlag). The separation was artificial. Changes: - Moved all sf-tui/*.js into sf/ui/ (header, footer, git, color-band, emoji, prompt-history, marketplace, powerline, shared) - Fixed imports: ../sf/ → ../ (one level up from ui/) - Registered sf/ui/index.js from sf/index.js in a try/catch so a UI failure can't take out the core SF commands - Merged sf-tui manifest entries (9 commands, 3 shortcuts, agent_start hook) into sf/extension-manifest.json - Deleted src/resources/extensions/sf-tui/ entirely - Fixed prompt-history.test.mjs import path Result: one fewer extension to discover, load and validate at startup. sf is now the single extension that owns both planning state and UI chrome. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/sf/extension-manifest.json | 41 +- src/resources/extensions/sf/index.js | 14 + .../sf/tests/prompt-history.test.mjs | 2 +- src/resources/extensions/sf/ui/color-band.js | 319 ++++++++++ src/resources/extensions/sf/ui/emoji.js | 433 +++++++++++++ src/resources/extensions/sf/ui/footer.js | 227 +++++++ src/resources/extensions/sf/ui/git.js | 158 +++++ src/resources/extensions/sf/ui/header.js | 168 +++++ src/resources/extensions/sf/ui/index.js | 581 ++++++++++++++++++ src/resources/extensions/sf/ui/marketplace.js | 346 +++++++++++ src/resources/extensions/sf/ui/powerline.js | 157 +++++ .../extensions/sf/ui/prompt-history.js | 243 ++++++++ src/resources/extensions/sf/ui/shared.js | 7 + 13 files changed, 2682 insertions(+), 14 deletions(-) create mode 100644 src/resources/extensions/sf/ui/color-band.js create mode 100644 src/resources/extensions/sf/ui/emoji.js create mode 100644 src/resources/extensions/sf/ui/footer.js create mode 100644 src/resources/extensions/sf/ui/git.js create mode 100644 src/resources/extensions/sf/ui/header.js create mode 100644 src/resources/extensions/sf/ui/index.js create mode 100644 src/resources/extensions/sf/ui/marketplace.js create mode 100644 src/resources/extensions/sf/ui/powerline.js create mode 100644 src/resources/extensions/sf/ui/prompt-history.js create mode 100644 src/resources/extensions/sf/ui/shared.js diff --git a/src/resources/extensions/sf/extension-manifest.json b/src/resources/extensions/sf/extension-manifest.json index 8f41076aa..6f7614cb7 100644 --- a/src/resources/extensions/sf/extension-manifest.json +++ b/src/resources/extensions/sf/extension-manifest.json @@ -2,7 +2,7 @@ "id": "sf", "name": "SF Workflow", "version": "1.0.0", - "description": "Core SF workflow engine \u2014 milestone planning, execution, and tracking", + "description": "Core SF workflow engine — milestone planning, execution, and tracking", "tier": "core", "requires": { "platform": ">=2.29.0" @@ -56,6 +56,11 @@ "cleanup", "cmux", "codebase", + "color", + "color-char", + "color-config", + "color-next", + "color-set", "config", "configure-agent", "control", @@ -67,6 +72,10 @@ "dispatch", "do", "doctor", + "emoji", + "emoji-config", + "emoji-history", + "emoji-set", "escalate", "eval-review", "experimental", @@ -151,22 +160,28 @@ "wt" ], "hooks": [ + "agent_end", + "agent_start", + "bash_transform", + "before_agent_start", + "before_provider_request", + "model_select", + "session_before_compact", + "session_fork", + "session_shutdown", "session_start", "session_switch", - "bash_transform", - "session_fork", - "before_agent_start", - "agent_end", - "turn_end", - "session_before_compact", - "session_shutdown", "tool_call", - "tool_result", - "tool_execution_start", "tool_execution_end", - "model_select", - "before_provider_request" + "tool_execution_start", + "tool_result", + "turn_end" ], - "shortcuts": ["Ctrl+Alt+G"] + "shortcuts": [ + "Ctrl+Alt+G", + "Ctrl+Alt+H", + "Ctrl+Alt+M", + "Ctrl+Shift+H" + ] } } diff --git a/src/resources/extensions/sf/index.js b/src/resources/extensions/sf/index.js index 090c7fb2f..26f00f689 100644 --- a/src/resources/extensions/sf/index.js +++ b/src/resources/extensions/sf/index.js @@ -40,4 +40,18 @@ export default async function registerExtension(pi) { `Extension setup partially failed — SF commands are available but shortcuts/tools may be missing: ${err instanceof Error ? err.message : String(err)}`, ); } + + // Register SF TUI chrome (header, footer, prompt history, shortcuts, tools). + // This was previously a separate sf-tui bundled extension; folded here because + // every feature has a hard dependency on sf/ internals (auto session, project root). + try { + const { default: registerSFTUI } = await import("./ui/index.js"); + registerSFTUI(pi); + } catch (err) { + const { logWarning } = await import("./workflow-logger.js"); + logWarning( + "ui", + `SF TUI setup failed — running with default header/footer: ${err instanceof Error ? err.message : String(err)}`, + ); + } } diff --git a/src/resources/extensions/sf/tests/prompt-history.test.mjs b/src/resources/extensions/sf/tests/prompt-history.test.mjs index c1545087f..5b7b4271c 100644 --- a/src/resources/extensions/sf/tests/prompt-history.test.mjs +++ b/src/resources/extensions/sf/tests/prompt-history.test.mjs @@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { appendPromptHistory, readPromptHistory, -} from "../../sf-tui/prompt-history.js"; +} from "../ui/prompt-history.js"; describe("prompt history", () => { let oldHome; diff --git a/src/resources/extensions/sf/ui/color-band.js b/src/resources/extensions/sf/ui/color-band.js new file mode 100644 index 000000000..ec23bd9e5 --- /dev/null +++ b/src/resources/extensions/sf/ui/color-band.js @@ -0,0 +1,319 @@ +/** + * Session Color — TUI colored status band + * + * Displays a colored band in the footer to visually distinguish sessions. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const DEFAULT_CONFIG = { + enabledByDefault: true, + blockChar: "▁", + blockCount: "full", +}; +const STATE_FILE = path.join(os.homedir(), ".sf", "session-color-state.json"); +const COLOR_PALETTE = [ + 196, 51, 226, 129, 46, 208, 27, 213, 118, 160, 87, 220, 93, 34, 202, 75, 199, + 154, 124, 45, 214, 135, 40, 166, 69, 205, 190, 88, 80, 228, 97, 28, 172, 63, + 197, 82, 130, 39, 219, 106, +]; +const BLOCK_CHARS = [ + { char: "▁", name: "Lower 1/8 block" }, + { char: "▂", name: "Lower 1/4 block" }, + { char: "▄", name: "Lower half block" }, + { char: "█", name: "Full block" }, + { char: "▔", name: "Upper 1/8 block" }, + { char: "▀", name: "Upper half block" }, + { char: "─", name: "Light horizontal" }, + { char: "━", name: "Heavy horizontal" }, + { char: "═", name: "Double horizontal" }, +]; +const RESET = "\x1b[0m"; +// ───────────────────────────────────────────────────────────────────────────── +// Main +// ───────────────────────────────────────────────────────────────────────────── +export function registerSessionColor(pi) { + const state = { + colorIndex: null, + assigned: false, + enabledOverride: null, + blockCharOverride: null, + blockCharIndex: 0, + }; + let currentCtx = null; + let resizeHandler = null; + function setupResizeListener(ctx, config) { + if (resizeHandler) process.stdout.off("resize", resizeHandler); + if (config.blockCount === "full" && state.colorIndex !== null) { + currentCtx = ctx; + resizeHandler = () => { + if (currentCtx && state.colorIndex !== null) { + const isEnabled = state.enabledOverride ?? config.enabledByDefault; + if (isEnabled) updateStatus(currentCtx, config, state); + } + }; + process.stdout.on("resize", resizeHandler); + } + } + registerCommands(pi, state); + // Gate the session-lifecycle work on having a real TUI. The color band is + // pure footer decoration — nothing to render into in headless mode, so + // skip state-file writes and resize listeners entirely. + pi.on("session_start", async (_, ctx) => { + if (!ctx.hasUI) return; + currentCtx = ctx; + initSession(ctx, state, setupResizeListener); + }); + pi.on("session_switch", async (event, ctx) => { + if (!ctx.hasUI) return; + if (event.reason === "new") { + currentCtx = ctx; + initSession(ctx, state, setupResizeListener); + } + }); +} +// ───────────────────────────────────────────────────────────────────────────── +// Session Lifecycle +// ───────────────────────────────────────────────────────────────────────────── +function initSession(ctx, state, setupResize) { + Object.assign(state, { + colorIndex: null, + assigned: false, + enabledOverride: null, + blockCharOverride: null, + blockCharIndex: 0, + }); + const config = getConfig(ctx); + if (!config.enabledByDefault) { + ctx.ui.setStatus("0-color-band", ""); + return; + } + const sessionId = ctx.sessionManager.getSessionId(); + const persisted = readColorState(); + if (persisted?.sessionId === sessionId) { + state.colorIndex = persisted.lastColorIndex; + state.assigned = true; + updateStatus(ctx, config, state); + setupResize(ctx, config); + return; + } + const lastIndex = persisted?.lastColorIndex ?? -1; + const nextIndex = (lastIndex + 1) % COLOR_PALETTE.length; + state.colorIndex = nextIndex; + state.assigned = true; + writeColorState({ + lastColorIndex: nextIndex, + sessionId, + timestamp: Date.now(), + }); + updateStatus(ctx, config, state); + setupResize(ctx, config); +} +// ───────────────────────────────────────────────────────────────────────────── +// Status Display +// ───────────────────────────────────────────────────────────────────────────── +function updateStatus(ctx, config, state) { + if (state.colorIndex === null) return; + const color = COLOR_PALETTE[state.colorIndex]; + const count = + config.blockCount === "full" + ? process.stdout.columns || 80 + : config.blockCount; + const char = state.blockCharOverride ?? config.blockChar; + const block = char.repeat(count); + ctx.ui.setStatus("0-color-band", `\x1b[38;5;${color}m${block}${RESET}`); +} +// ───────────────────────────────────────────────────────────────────────────── +// Persistence +// ───────────────────────────────────────────────────────────────────────────── +function readColorState() { + try { + if (fs.existsSync(STATE_FILE)) { + return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")); + } + } catch {} // file missing or corrupt → return null (no saved state) + return null; +} +function writeColorState(s) { + try { + const dir = path.dirname(STATE_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2), "utf8"); + } catch {} // write failure → state not persisted, but operation continues +} +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── +function getConfig(ctx) { + const settings = ctx.settingsManager?.getSettings() ?? {}; + return { ...DEFAULT_CONFIG, ...(settings.sessionColor ?? {}) }; +} +// ───────────────────────────────────────────────────────────────────────────── +// Commands +// ───────────────────────────────────────────────────────────────────────────── +function registerCommands(pi, state) { + pi.registerCommand("color", { + description: "Toggle color band on/off", + handler: async (_, ctx) => { + const config = getConfig(ctx); + const current = state.enabledOverride ?? config.enabledByDefault; + state.enabledOverride = !current; + if (state.enabledOverride) { + ctx.ui.notify("🎨 Color band ON", "info"); + if (state.colorIndex !== null) { + updateStatus(ctx, config, state); + } else { + const persisted = readColorState(); + const nextIndex = + ((persisted?.lastColorIndex ?? -1) + 1) % COLOR_PALETTE.length; + state.colorIndex = nextIndex; + state.assigned = true; + writeColorState({ + lastColorIndex: nextIndex, + sessionId: ctx.sessionManager.getSessionId(), + timestamp: Date.now(), + }); + updateStatus(ctx, config, state); + } + } else { + ctx.ui.notify("⬜ Color band OFF", "warning"); + ctx.ui.setStatus("0-color-band", ""); + } + }, + }); + pi.registerCommand("color-set", { + description: "Set color by index (0-39)", + handler: async (args, ctx) => { + const _config = getConfig(ctx); + const input = typeof args === "string" ? args.trim() : ""; + if (input) { + const index = parseInt(input, 10); + if (Number.isNaN(index) || index < 0 || index >= COLOR_PALETTE.length) { + ctx.ui.notify( + `Invalid index. Use 0-${COLOR_PALETTE.length - 1}`, + "error", + ); + return; + } + setColor(ctx, state, index); + ctx.ui.notify(`Color set to index ${index}`, "info"); + return; + } + if (!ctx.hasUI) { + ctx.ui.notify( + `Usage: /color-set <0-${COLOR_PALETTE.length - 1}>`, + "info", + ); + return; + } + ctx.ui.notify("Color palette:", "info"); + for (let i = 0; i < COLOR_PALETTE.length; i += 10) { + const blocks = COLOR_PALETTE.slice(i, i + 10) + .map((c) => `\x1b[38;5;${c}m██${RESET}`) + .join(" "); + ctx.ui.notify( + `${String(i).padStart(2)}-${Math.min(i + 9, 39)}: ${blocks}`, + "info", + ); + } + const indexStr = await ctx.ui.input( + `Enter index (0-${COLOR_PALETTE.length - 1}):`, + ); + if (!indexStr) return; + const index = parseInt(indexStr, 10); + if (Number.isNaN(index) || index < 0 || index >= COLOR_PALETTE.length) { + ctx.ui.notify("Invalid index", "error"); + return; + } + setColor(ctx, state, index); + ctx.ui.notify(`Color set to index ${index}`, "info"); + }, + }); + pi.registerCommand("color-next", { + description: "Skip to next color", + handler: async (_, ctx) => { + const nextIndex = ((state.colorIndex ?? -1) + 1) % COLOR_PALETTE.length; + setColor(ctx, state, nextIndex); + ctx.ui.notify(`Skipped to color ${nextIndex}`, "info"); + }, + }); + pi.registerCommand("color-char", { + description: "Change block character (cycles if no arg)", + handler: async (args, ctx) => { + const config = getConfig(ctx); + const input = typeof args === "string" ? args.trim() : ""; + if (state.colorIndex === null) { + ctx.ui.notify("No color assigned yet", "error"); + return; + } + if (input) { + state.blockCharOverride = input; + updateStatus(ctx, config, state); + ctx.ui.notify(`Block char set to "${input}"`, "info"); + return; + } + state.blockCharIndex = (state.blockCharIndex + 1) % BLOCK_CHARS.length; + const next = BLOCK_CHARS[state.blockCharIndex]; + state.blockCharOverride = next.char; + updateStatus(ctx, config, state); + ctx.ui.notify(`${next.char} ${next.name}`, "info"); + }, + }); + pi.registerCommand("color-config", { + description: "View color settings", + handler: async (_, ctx) => { + const config = getConfig(ctx); + const isEnabled = state.enabledOverride ?? config.enabledByDefault; + const persisted = readColorState(); + ctx.ui.notify("─── Session Color ───", "info"); + ctx.ui.notify( + `Status: ${isEnabled ? "🎨 ON" : "⬜ OFF"} │ Index: ${state.colorIndex ?? "(none)"}`, + "info", + ); + ctx.ui.notify( + `Char: "${state.blockCharOverride ?? config.blockChar}" │ Palette: ${COLOR_PALETTE.length} colors`, + "info", + ); + if (persisted) + ctx.ui.notify(`Last used: index ${persisted.lastColorIndex}`, "info"); + if (!ctx.hasUI) return; + const action = await ctx.ui.select("Options", [ + "🎨 Preview all colors", + "🔄 Reset sequence", + "❌ Cancel", + ]); + const selectedAction = typeof action === "string" ? action : undefined; + if (!selectedAction) return; + if (selectedAction.startsWith("🎨")) { + for (let i = 0; i < COLOR_PALETTE.length; i += 10) { + const blocks = COLOR_PALETTE.slice(i, i + 10) + .map((c) => `\x1b[38;5;${c}m██${RESET}`) + .join(" "); + ctx.ui.notify(blocks, "info"); + } + } else if (selectedAction.startsWith("🔄")) { + writeColorState({ + lastColorIndex: -1, + sessionId: "", + timestamp: Date.now(), + }); + ctx.ui.notify( + "Sequence reset. Next session starts at color 0.", + "info", + ); + } + }, + }); +} +function setColor(ctx, state, index) { + const config = getConfig(ctx); + state.colorIndex = index; + state.assigned = true; + writeColorState({ + lastColorIndex: index, + sessionId: ctx.sessionManager.getSessionId(), + timestamp: Date.now(), + }); + updateStatus(ctx, config, state); +} diff --git a/src/resources/extensions/sf/ui/emoji.js b/src/resources/extensions/sf/ui/emoji.js new file mode 100644 index 000000000..9172ffaac --- /dev/null +++ b/src/resources/extensions/sf/ui/emoji.js @@ -0,0 +1,433 @@ +/** + * Session Emoji — TUI status line emoji + * + * Displays an emoji in the footer status line. Supports manual selection, + * AI-powered selection based on conversation, or random assignment. + */ +import { complete } from "@singularity-forge/ai"; + +const DEFAULT_CONFIG = { + enabledByDefault: true, + autoAssignMode: "ai", + autoAssignThreshold: 3, + contextMessages: 5, + emojiSet: "default", + customEmojis: [], +}; +const EMOJI_SETS = { + default: ["🚀", "✨", "🎯", "💡", "🔥", "⚡", "🎨", "🌟", "💻", "🎭"], + animals: ["🐱", "🐶", "🐼", "🦊", "🐻", "🦁", "🐯", "🐨", "🐰", "🦉"], + tech: ["💻", "🖥️", "⌨️", "🖱️", "💾", "📱", "🔌", "🔋", "🖨️", "📡"], + fun: ["🎉", "🎊", "🎈", "🎁", "🎂", "🍕", "🍩", "🌮", "🎮", "🎲"], +}; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const AI_PROMPTS = { + select: `You are an emoji selector. Given a conversation context and a list of recently used emojis, choose ONE unique emoji that: +1. Represents the main topic/theme of the conversation +2. Is NOT in the recently used list +3. Is relevant and appropriate +4. Stands alone (no skin tone modifiers) + +Output ONLY the single emoji character, nothing else.`, + fromText: `You are an emoji selector. Given a text description, choose ONE emoji that best represents it. +Output ONLY the single emoji character, nothing else.`, +}; +// ───────────────────────────────────────────────────────────────────────────── +// Main +// ───────────────────────────────────────────────────────────────────────────── +export function registerSessionEmoji(pi) { + const state = { + emoji: null, + messageCount: 0, + assigned: false, + selecting: false, + enabledOverride: null, + }; + registerCommands(pi, state); + // Gate the session-lifecycle work on having a real TUI. Headless mode + // (sf headless autonomous, --print, CI) has no footer to render into, and the + // AI auto-assign path would spend tokens choosing an emoji nothing sees. + pi.on("session_start", (_, ctx) => { + if (!ctx.hasUI) return; + return initSession(ctx, pi, state); + }); + pi.on("agent_start", (_, ctx) => { + if (!ctx.hasUI) return; + return handleAgentStart(ctx, pi, state); + }); +} +// ───────────────────────────────────────────────────────────────────────────── +// Session Lifecycle +// ───────────────────────────────────────────────────────────────────────────── +async function initSession(ctx, pi, state) { + Object.assign(state, { + emoji: null, + messageCount: 0, + assigned: false, + selecting: false, + enabledOverride: null, + }); + const config = getConfig(ctx); + if (!config.enabledByDefault) { + ctx.ui.setStatus("0-emoji", ""); + return; + } + const existing = findExistingEmoji(ctx); + if (existing) { + state.emoji = existing; + state.assigned = true; + ctx.ui.setStatus("0-emoji", existing); + return; + } + if (config.autoAssignMode === "immediate") { + await assignEmoji(ctx, pi, state, config); + } else { + ctx.ui.setStatus("0-emoji", `⏳ (${config.autoAssignThreshold})`); + } +} +async function handleAgentStart(ctx, pi, state) { + const config = getConfig(ctx); + const isEnabled = state.enabledOverride ?? config.enabledByDefault; + if (!isEnabled || state.assigned || config.autoAssignMode === "immediate") + return; + state.messageCount++; + if (state.messageCount >= config.autoAssignThreshold) { + await assignEmoji(ctx, pi, state, config); + } else { + ctx.ui.setStatus( + "0-emoji", + `⏳ (${config.autoAssignThreshold - state.messageCount})`, + ); + } +} +// ───────────────────────────────────────────────────────────────────────────── +// Emoji Selection +// ───────────────────────────────────────────────────────────────────────────── +async function assignEmoji(ctx, pi, state, config) { + if (state.assigned || state.selecting) return; + state.selecting = true; + try { + if (config.autoAssignMode === "ai") ctx.ui.setStatus("0-emoji", "🔄"); + const emoji = + config.autoAssignMode === "ai" + ? await selectEmojiWithAI(ctx, config) + : selectRandomEmoji(ctx, config); + state.emoji = emoji; + state.assigned = true; + persistEmoji(ctx, pi, emoji); + ctx.ui.setStatus("0-emoji", emoji); + } finally { + state.selecting = false; + } +} +function selectRandomEmoji(ctx, config) { + const emojis = getEmojiList(config); + const recent = getRecentEmojis(ctx); + const available = emojis.filter((e) => !recent.has(e)); + const pool = available.length > 0 ? available : emojis; + return pool[Math.floor(Math.random() * pool.length)]; +} +async function selectEmojiWithAI(ctx, config) { + if (!ctx.model) return selectRandomEmoji(ctx, config); + try { + const context = getConversationContext(ctx, config.contextMessages); + const recent = getRecentEmojis(ctx); + const prompt = `Conversation context:\n${context || "(No messages yet - choose a welcoming, friendly emoji)"}\n\nRecently used emojis (DO NOT use these):\n${recent.size > 0 ? Array.from(recent).join(", ") : "(none)"}\n\nChoose a unique, topical emoji for this session.`; + const emoji = await callAI(ctx, AI_PROMPTS.select, prompt); + if (emoji) return emoji; + } catch { + // Fall through to random + } + return selectRandomEmoji(ctx, config); +} +async function selectEmojiFromText(ctx, description) { + if (!ctx.model) return null; + try { + return await callAI(ctx, AI_PROMPTS.fromText, description); + } catch { + return null; + } +} +async function callAI(ctx, systemPrompt, userText) { + const apiKey = await ctx.modelRegistry.getApiKey(ctx.model); + const userMessage = { + role: "user", + content: [{ type: "text", text: userText }], + timestamp: Date.now(), + }; + const response = await complete( + ctx.model, + { systemPrompt, messages: [userMessage] }, + { apiKey, maxTokens: 10 }, + ); + const emoji = response.content + .filter((c) => c.type === "text") + .map((c) => c.text.trim()) + .join("") + .slice(0, 10); + return emoji && emoji.length > 0 && emoji.length <= 10 ? emoji : null; +} +// ───────────────────────────────────────────────────────────────────────────── +// Persistence & History +// ───────────────────────────────────────────────────────────────────────────── +function persistEmoji(ctx, pi, emoji) { + const context = + getConversationContext(ctx, 2).slice(0, 100) || "(initial session)"; + pi.appendEntry("session-emoji-history", { + sessionId: ctx.sessionManager.getSessionId(), + emoji, + timestamp: Date.now(), + context, + }); +} +function findExistingEmoji(ctx) { + const sessionId = ctx.sessionManager.getSessionId(); + for (const entry of ctx.sessionManager.getEntries()) { + if ( + entry.type === "custom" && + entry.customType === "session-emoji-history" + ) { + const data = entry.data; + if (data?.sessionId === sessionId) return data.emoji; + } + } + return null; +} +function getRecentEmojis(ctx) { + const cutoff = Date.now() - ONE_DAY_MS; + const recent = new Set(); + for (const entry of ctx.sessionManager.getEntries()) { + if ( + entry.type === "custom" && + entry.customType === "session-emoji-history" + ) { + const data = entry.data; + if (data?.timestamp >= cutoff) recent.add(data.emoji); + } + } + return recent; +} +function getEmojiHistory(ctx) { + const cutoff = Date.now() - ONE_DAY_MS; + const history = []; + for (const entry of ctx.sessionManager.getEntries()) { + if ( + entry.type === "custom" && + entry.customType === "session-emoji-history" + ) { + const data = entry.data; + if (data?.timestamp >= cutoff) history.push(data); + } + } + return history.sort((a, b) => b.timestamp - a.timestamp); +} +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── +function getConfig(ctx) { + const settings = ctx.settingsManager?.getSettings() ?? {}; + return { ...DEFAULT_CONFIG, ...(settings.sessionEmoji ?? {}) }; +} +function getEmojiList(config) { + if (config.emojiSet === "custom" && config.customEmojis?.length > 0) { + return config.customEmojis; + } + return EMOJI_SETS[config.emojiSet] ?? EMOJI_SETS.default; +} +function getConversationContext(ctx, maxMessages) { + const branch = ctx.sessionManager.getBranch(); + const messages = []; + for ( + let i = branch.length - 1; + i >= 0 && messages.length < maxMessages; + i-- + ) { + const entry = branch[i]; + if ( + entry.type === "message" && + "message" in entry && + entry.message.role === "user" + ) { + const content = entry.message.content; + const text = + typeof content === "string" + ? content + : Array.isArray(content) + ? content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n") + : ""; + if (text.trim()) messages.unshift(text); + } + } + return messages.join("\n\n"); +} +function formatTimeAgo(timestamp) { + const mins = Math.round((Date.now() - timestamp) / 60000); + return mins < 60 ? `${mins}m ago` : `${Math.round(mins / 60)}h ago`; +} +// ───────────────────────────────────────────────────────────────────────────── +// Commands +// ───────────────────────────────────────────────────────────────────────────── +function registerCommands(pi, state) { + pi.registerCommand("emoji", { + description: "Toggle session emoji on/off", + handler: async (_, ctx) => { + const config = getConfig(ctx); + const current = state.enabledOverride ?? config.enabledByDefault; + state.enabledOverride = !current; + if (state.enabledOverride) { + ctx.ui.notify("🎨 Session emoji ON", "info"); + ctx.ui.setStatus( + "0-emoji", + state.emoji ?? `⏳ (${config.autoAssignThreshold})`, + ); + } else { + ctx.ui.notify("⬜ Session emoji OFF", "warning"); + ctx.ui.setStatus("0-emoji", ""); + } + }, + }); + pi.registerCommand("emoji-set", { + description: "Set emoji manually (emoji or description)", + handler: async (args, ctx) => { + const input = typeof args === "string" ? args.trim() : ""; + if (!input) { + if (!ctx.hasUI) { + ctx.ui.notify("Usage: /emoji-set ", "info"); + return; + } + const choice = await ctx.ui.select("Set emoji how?", [ + "📝 Enter emoji directly", + "💬 Describe what you want", + "🎲 Pick random from set", + "❌ Cancel", + ]); + const selectedChoice = typeof choice === "string" ? choice : undefined; + if (!selectedChoice || selectedChoice.startsWith("❌")) return; + if (selectedChoice.startsWith("📝")) { + const emoji = await ctx.ui.input("Enter emoji:"); + if (emoji) { + setManualEmoji(ctx, pi, state, emoji.trim()); + ctx.ui.notify(`Emoji set to ${emoji.trim()}`, "info"); + } + } else if (selectedChoice.startsWith("💬")) { + const desc = await ctx.ui.input("Describe the emoji:"); + if (desc) { + ctx.ui.notify("🔄 Selecting...", "info"); + const emoji = await selectEmojiFromText(ctx, desc); + if (emoji) { + setManualEmoji(ctx, pi, state, emoji); + ctx.ui.notify(`Emoji set to ${emoji}`, "info"); + } else { + ctx.ui.notify("Could not select emoji", "error"); + } + } + } else if (selectedChoice.startsWith("🎲")) { + const setChoice = await ctx.ui.select( + "Choose set:", + Object.keys(EMOJI_SETS), + ); + const selectedSet = + typeof setChoice === "string" ? setChoice : undefined; + if (!selectedSet) return; + const emojis = EMOJI_SETS[selectedSet] ?? EMOJI_SETS.default; + const emoji = emojis[Math.floor(Math.random() * emojis.length)]; + setManualEmoji(ctx, pi, state, emoji); + ctx.ui.notify(`Emoji set to ${emoji}`, "info"); + } + return; + } + const emojiRegex = /^[\p{Emoji_Presentation}\p{Emoji}\u200d]+/u; + if (emojiRegex.test(input)) { + const emoji = input.match(emojiRegex)?.[0] ?? input; + setManualEmoji(ctx, pi, state, emoji); + ctx.ui.notify(`Emoji set to ${emoji}`, "info"); + } else { + ctx.ui.notify("🔄 Selecting...", "info"); + const emoji = await selectEmojiFromText(ctx, input); + if (emoji) { + setManualEmoji(ctx, pi, state, emoji); + ctx.ui.notify(`Emoji set to ${emoji}`, "info"); + } else { + ctx.ui.notify("Could not select emoji", "error"); + } + } + }, + }); + pi.registerCommand("emoji-config", { + description: "View emoji settings", + handler: async (_, ctx) => { + const config = getConfig(ctx); + const isEnabled = state.enabledOverride ?? config.enabledByDefault; + ctx.ui.notify("─── Session Emoji ───", "info"); + ctx.ui.notify( + `Status: ${isEnabled ? "🎨 ON" : "⬜ OFF"} │ Current: ${state.emoji ?? "(none)"}`, + "info", + ); + ctx.ui.notify( + `Mode: ${config.autoAssignMode} │ Threshold: ${config.autoAssignThreshold} │ Set: ${config.emojiSet}`, + "info", + ); + if (!ctx.hasUI) return; + const action = await ctx.ui.select("Options", [ + "🎨 Preview sets", + "📋 View history", + "❌ Cancel", + ]); + const selectedAction = typeof action === "string" ? action : undefined; + if (!selectedAction) return; + if (selectedAction.startsWith("🎨")) { + for (const [name, emojis] of Object.entries(EMOJI_SETS)) { + ctx.ui.notify(`${name}: ${emojis.join(" ")}`, "info"); + } + } else if (selectedAction.startsWith("📋")) { + const history = getEmojiHistory(ctx); + if (history.length === 0) { + ctx.ui.notify("No history in past 24h", "info"); + } else { + history.slice(0, 10).forEach((h, i) => { + const current = + h.sessionId === ctx.sessionManager.getSessionId() + ? " (current)" + : ""; + ctx.ui.notify( + `${i + 1}. ${h.emoji} - ${formatTimeAgo(h.timestamp)}${current}`, + "info", + ); + }); + } + } + }, + }); + pi.registerCommand("emoji-history", { + description: "Show emoji history (24h)", + handler: async (_, ctx) => { + const history = getEmojiHistory(ctx); + if (history.length === 0) { + ctx.ui.notify("No history in past 24h", "info"); + return; + } + const unique = new Set(history.map((h) => h.emoji)); + ctx.ui.notify( + `📊 Emoji History - ${history.length} sessions, ${unique.size} unique`, + "info", + ); + history.slice(0, 15).forEach((h, i) => { + const current = + h.sessionId === ctx.sessionManager.getSessionId() ? " (current)" : ""; + ctx.ui.notify( + `${i + 1}. ${h.emoji} - ${formatTimeAgo(h.timestamp)}${current}`, + "info", + ); + }); + }, + }); +} +function setManualEmoji(ctx, pi, state, emoji) { + state.emoji = emoji; + state.assigned = true; + persistEmoji(ctx, pi, emoji); + ctx.ui.setStatus("0-emoji", emoji); +} diff --git a/src/resources/extensions/sf/ui/footer.js b/src/resources/extensions/sf/ui/footer.js new file mode 100644 index 000000000..c80fc357f --- /dev/null +++ b/src/resources/extensions/sf/ui/footer.js @@ -0,0 +1,227 @@ +import { truncateToWidth, visibleWidth } from "@singularity-forge/tui"; +import { getAutoSession } from "../auto/session.js"; +import { refreshGitStatus } from "./git.js"; +import { renderModeBadge } from "./header.js"; + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const SE = { + ember40: "#ff8838", + gray60: "#8d877a", + stone60: "#6b6659", + paper: "#f7f5f1", + warning: "#ff8838", + success: "#24a148", + error: "#da1e28", +}; +function hexToRgb(hex) { + const cleaned = hex.replace("#", ""); + return { + r: parseInt(cleaned.slice(0, 2), 16), + g: parseInt(cleaned.slice(2, 4), 16), + b: parseInt(cleaned.slice(4, 6), 16), + }; +} +function ansiFg(hex, text, bold = false) { + // Use 16-color ANSI codes for Termius compatibility + // Map hex colors to nearest standard ANSI color + const { r, g, b } = hexToRgb(hex); + const brightness = (r + g + b) / 3; + let colorCode; + if (brightness < 50) { + colorCode = 30; // black + } else if (brightness < 100) { + colorCode = 90; // bright black + } else if (r > g + b) { + colorCode = bold ? 91 : 31; // red + } else if (g > r + b) { + colorCode = bold ? 92 : 32; // green + } else if (b > r + g) { + colorCode = bold ? 94 : 34; // blue + } else if (r > 200 && g > 150) { + colorCode = bold ? 93 : 33; // yellow/orange + } else if (r > 200 && g < 100 && b > 150) { + colorCode = bold ? 95 : 35; // magenta + } else if (g > 200 && b > 150) { + colorCode = bold ? 96 : 36; // cyan + } else if (brightness > 200) { + colorCode = bold ? 97 : 37; // white + } else { + colorCode = bold ? 97 : 37; // default white + } + return `\x1b[${bold ? "1;" : ""}${colorCode}m${text}${RESET}`; +} +function toneHex(tone) { + switch (tone) { + case "accent": + case "warning": + return SE.ember40; + case "success": + return SE.success; + case "error": + return SE.error; + case "text": + return SE.paper; + default: + return SE.gray60; + } +} +function chip(label, value, tone = "text") { + return `${ansiFg(SE.gray60, `${label} `)}${ansiFg(toneHex(tone), value)}`; +} +function join(parts) { + return parts.filter(Boolean).join(ansiFg(SE.stone60, " | ")); +} +function shorten(text, max) { + return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text; +} + +/** Minimal theme adapter so renderModeBadge (header.js) can run with footer's ANSI helpers. */ +const FOOTER_THEME = { + fg: (tone, text) => ansiFg(toneHex(tone), text), + bold: (text) => `${BOLD}${text}${RESET}`, +}; +function getSessionStats(ctx) { + let cost = 0; + let tokens = 0; + let cxPct = 0; + try { + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "message") { + const msg = entry.message; + if (msg?.role === "assistant" && msg.usage) { + cost += msg.usage.cost?.total || 0; + tokens += (msg.usage.input || 0) + (msg.usage.output || 0); + } + } + } + const cx = ctx.getContextUsage?.(); + if (cx?.percent != null) cxPct = cx.percent; + } catch { + /* ignore */ + } + return { cost, tokens, cxPct }; +} +export function renderFooter(_theme, footerData, ctx, width) { + const git = refreshGitStatus(process.cwd()); + const { cost, tokens, cxPct } = getSessionStats(ctx); + const session = getAutoSession(); + const mode = session?.getMode?.(); + const leftParts = []; + if (git.repo) { + leftParts.push(ansiFg(SE.ember40, git.repo, true)); + } else { + leftParts.push(`${BOLD}${ansiFg(SE.ember40, "SF")}`); + } + if (git.branch) { + leftParts.push(chip("branch", git.branch, "muted")); + const state = git.dirty ? "dirty" : git.untracked ? "new" : "clean"; + leftParts.push( + chip("state", state, state === "clean" ? "success" : "warning"), + ); + if (git.added || git.deleted) { + leftParts.push(chip("diff", `+${git.added}/-${git.deleted}`, "warning")); + } + if (git.ahead || git.behind) { + const syncParts = []; + if (git.ahead) syncParts.push(`↑${git.ahead}`); + if (git.behind) syncParts.push(`↓${git.behind}`); + leftParts.push(chip("sync", syncParts.join(" "), "warning")); + } + if (git.lastCommit) { + leftParts.push( + chip( + "last", + `${git.lastCommit.timeAgo} ${shorten(git.lastCommit.message, 26)}`, + "muted", + ), + ); + } + } + const statuses = Array.from(footerData.getExtensionStatuses().entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, text]) => String(text ?? "").trim()) + .filter(Boolean); + if (statuses.length) { + leftParts.push(chip("status", statuses.join(" "), "accent")); + } + const rightParts = []; + if (mode) { + rightParts.push(renderModeBadge(FOOTER_THEME, mode, width < 80)); + } + if (ctx.model) { + rightParts.push( + chip("model", `${ctx.model.provider}/${ctx.model.id}`, "text"), + ); + } + if (cost > 0) { + rightParts.push(chip("spent", `$${cost.toFixed(2)}`, "warning")); + } + // Only show ctx% once the session has sent at least one message (avoid "1%" noise from system prompt at startup) + if (tokens > 0) { + const cxTone = cxPct >= 85 ? "error" : cxPct >= 60 ? "warning" : "success"; + rightParts.push(chip("ctx", `${Math.round(cxPct)}%`, cxTone)); + } + let rightLine = join(rightParts); + const maxRightWidth = Math.max(16, Math.floor(width * 0.55)); + if (visibleWidth(rightLine) > maxRightWidth) { + rightLine = truncateToWidth( + rightLine, + maxRightWidth, + ansiFg(SE.gray60, "..."), + ); + } + const rightWidth = visibleWidth(rightLine); + const leftBudget = Math.max(1, width - rightWidth - 2); + const leftLine = truncateToWidth( + join(leftParts), + leftBudget, + ansiFg(SE.gray60, "..."), + ); + const gap = Math.max(1, width - visibleWidth(leftLine) - rightWidth); + const line = leftLine + " ".repeat(gap) + rightLine; + return [truncateToWidth(line, width, ansiFg(SE.gray60, "..."))]; +} + +/** + * Minimal auto-mode footer — shows only mode badge + progress hint. + * Keeps the user aware SF is running autonomously without full footer noise. + */ +export function renderAutoFooter(_theme, footerData, ctx, width) { + const session = getAutoSession(); + const mode = session?.getMode?.() ?? { + workMode: "build", + runControl: "autonomous", + permissionProfile: "normal", + modelMode: "smart", + }; + + const badge = renderModeBadge(FOOTER_THEME, mode, width < 80); + const leftParts = [`${BOLD}${ansiFg(SE.ember40, "SF")}`, badge].filter( + Boolean, + ); + + const statuses = Array.from(footerData.getExtensionStatuses().entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, text]) => String(text ?? "").trim()) + .filter(Boolean); + if (statuses.length) { + leftParts.push(ansiFg(SE.gray60, statuses.join(" "))); + } + + const rightParts = []; + if (ctx.model) { + rightParts.push(ansiFg(SE.gray60, `${ctx.model.provider}/${ctx.model.id}`)); + } + const { cost } = getSessionStats(ctx); + if (cost > 0) { + rightParts.push(ansiFg(SE.warning, `$${cost.toFixed(2)}`)); + } + + const leftLine = leftParts.join(" "); + const rightLine = rightParts.join(ansiFg(SE.gray60, " · ")); + const rightWidth = visibleWidth(rightLine); + const gap = Math.max(1, width - visibleWidth(leftLine) - rightWidth); + const line = leftLine + " ".repeat(gap) + rightLine; + return [truncateToWidth(line, width, ansiFg(SE.gray60, "..."))]; +} diff --git a/src/resources/extensions/sf/ui/git.js b/src/resources/extensions/sf/ui/git.js new file mode 100644 index 000000000..8396c7f32 --- /dev/null +++ b/src/resources/extensions/sf/ui/git.js @@ -0,0 +1,158 @@ +import { execFileSync } from "node:child_process"; +import { basename } from "node:path"; + +let cache = null; +let lastFetch = 0; +function getRepoName(cwd) { + try { + const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + timeout: 1500, + }).trim(); + return root ? basename(root) : basename(cwd) || null; + } catch { + return basename(cwd) || null; + } +} +function getLastCommit(cwd) { + try { + const raw = execFileSync("git", ["log", "-1", "--format=%cr|%s"], { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + timeout: 1500, + }).trim(); + const sep = raw.indexOf("|"); + if (sep > 0) { + return { + timeAgo: raw.slice(0, sep).replace(/ ago$/, ""), + message: raw.slice(sep + 1), + }; + } + } catch { + /* ignore */ + } + return null; +} +function getDiffStats(cwd) { + try { + const raw = execFileSync("git", ["diff", "HEAD", "--stat"], { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + timeout: 1500, + }); + let added = 0; + let deleted = 0; + let modified = 0; + for (const line of raw.split("\n")) { + const addMatch = line.match(/(\d+) insertion/); + const delMatch = line.match(/(\d+) deletion/); + if (addMatch || delMatch) { + const a = addMatch ? parseInt(addMatch[1], 10) : 0; + const d = delMatch ? parseInt(delMatch[1], 10) : 0; + if (a) added += a; + if (d) deleted += d; + if (a || d) modified++; + } + } + return { added, deleted, modified }; + } catch { + return { added: 0, deleted: 0, modified: 0 }; + } +} +export function refreshGitStatus(cwd) { + const now = Date.now(); + if (now - lastFetch < 400 && cache) return cache; + lastFetch = now; + const repo = getRepoName(cwd); + let branch = null; + try { + branch = + execFileSync("git", ["branch", "--show-current"], { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + timeout: 1500, + }).trim() || null; + } catch { + cache = { + repo, + branch: null, + dirty: false, + untracked: false, + ahead: 0, + behind: 0, + added: 0, + deleted: 0, + modified: 0, + lastCommit: null, + }; + return cache; + } + try { + const status = execFileSync("git", ["status", "--porcelain"], { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + timeout: 1500, + }); + const lines = status.split("\n").filter((l) => l.length > 2); + const dirty = lines.some((l) => { + const x = l[0] ?? " "; + const y = l[1] ?? " "; + return (x !== "?" && x !== " " && x !== "!") || (y !== " " && y !== "?"); + }); + const untracked = lines.some((l) => l.startsWith("??")); + let ahead = 0; + let behind = 0; + try { + const ab = execFileSync( + "git", + ["rev-list", "--left-right", "--count", "HEAD...@{u}"], + { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + timeout: 1500, + }, + ).trim(); + const [a, b] = ab.split("\t").map((n) => parseInt(n, 10)); + ahead = Number.isNaN(a) ? 0 : a; + behind = Number.isNaN(b) ? 0 : b; + } catch { + /* no upstream */ + } + const diff = getDiffStats(cwd); + const lastCommit = getLastCommit(cwd); + cache = { + repo, + branch, + dirty, + untracked, + ahead, + behind, + ...diff, + lastCommit, + }; + } catch { + cache = { + repo, + branch, + dirty: false, + untracked: false, + ahead: 0, + behind: 0, + added: 0, + deleted: 0, + modified: 0, + lastCommit: getLastCommit(cwd), + }; + } + return cache; +} +export function invalidateGitStatus() { + lastFetch = 0; +} diff --git a/src/resources/extensions/sf/ui/header.js b/src/resources/extensions/sf/ui/header.js new file mode 100644 index 000000000..d41eec216 --- /dev/null +++ b/src/resources/extensions/sf/ui/header.js @@ -0,0 +1,168 @@ +import { basename } from "node:path"; +import { truncateToWidth, visibleWidth } from "@singularity-forge/tui"; +import { getAutoSession } from "../auto/session.js"; +import { refreshGitStatus } from "./git.js"; + +function align(left, right, width, ellipsis) { + const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right)); + return truncateToWidth(left + " ".repeat(gap) + right, width, ellipsis); +} + +function compactModeBadge(mode) { + const map = { + chat: "C", + plan: "P", + build: "B", + review: "R", + repair: "F", + research: "S", + }; + return map[mode] ?? "?"; +} + +function compactRunControlBadge(rc) { + const map = { + manual: "M", + assisted: "A", + autonomous: "∞", + }; + return map[rc] ?? "?"; +} + +function compactPermissionBadge(pp) { + const map = { + restricted: "R", + normal: "N", + trusted: "T", + unrestricted: "U", + }; + return map[pp] ?? "?"; +} + +function compactModelModeBadge(mm) { + const map = { + fast: "F", + smart: "S", + deep: "D", + }; + return map[mm] ?? "?"; +} + +function renderModeBadge(theme, mode, compact) { + if (!mode) return ""; + const th = theme; + const paused = mode.paused === true; + if (compact) { + const badges = [ + paused ? th.fg("dim", "P!") : "", + th.fg(paused ? "dim" : "accent", compactModeBadge(mode.workMode)), + th.fg("dim", compactRunControlBadge(mode.runControl)), + th.fg( + paused ? "dim" : "warning", + compactPermissionBadge(mode.permissionProfile), + ), + th.fg(paused ? "dim" : "success", compactModelModeBadge(mode.modelMode)), + ].filter(Boolean); + return `[${badges.join("")}]`; + } + const parts = [ + paused ? th.fg("dim", "paused") : "", + paused ? th.fg("dim", "·") : "", + th.fg(paused ? "dim" : "accent", mode.workMode), + th.fg("dim", "·"), + th.fg("dim", mode.runControl), + th.fg("dim", "·"), + th.fg(paused ? "dim" : "warning", mode.permissionProfile), + th.fg("dim", "·"), + th.fg(paused ? "dim" : "success", mode.modelMode), + ].filter(Boolean); + return parts.join(" "); +} + +export { renderModeBadge }; + +/** + * Minimal auto-mode header — shows only mode badge + project name. + * Keeps the user aware SF is running autonomously without full header noise. + */ +export function renderAutoHeader(theme, ctx, width) { + const th = theme; + const projectName = basename(process.cwd()); + const session = getAutoSession(); + const mode = session?.getMode?.() ?? { + workMode: "build", + runControl: "autonomous", + permissionProfile: "normal", + modelMode: "smart", + }; + + const modeBadge = renderModeBadge(th, mode, width < 80); + const left = [ + th.bold(th.fg("accent", "SF")), + th.fg("dim", "▸"), + th.fg("text", projectName), + modeBadge ? th.fg("dim", "·") : "", + modeBadge, + ] + .filter(Boolean) + .join(" "); + + const model = ctx.model + ? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "") + : ""; + const right = model ? th.fg("dim", model) : ""; + + const ellipsis = th.fg("dim", "…"); + return [align(left, right, width, ellipsis)]; +} + +export function renderHeader(theme, ctx, width) { + const th = theme; + const git = refreshGitStatus(process.cwd()); + const projectName = basename(process.cwd()); + const mode = ctx.sessionManager?.getMode?.() ?? getAutoSession().getMode(); + const model = ctx.model + ? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "") + : ""; + const modelLabel = model + ? `${th.fg("dim", "model ")}${th.fg("text", model)}` + : ""; + const modeBadge = renderModeBadge(th, mode, width < 80); + const topLeft = [ + th.fg("accent", "╭─"), + th.bold(th.fg("accent", "SF")), + th.fg("dim", "▸"), + th.fg("text", projectName), + modeBadge ? th.fg("dim", "·") : "", + modeBadge, + ] + .filter(Boolean) + .join(" "); + const branchState = git.branch + ? git.dirty + ? th.fg("warning", "modified") + : git.untracked + ? th.fg("warning", "untracked") + : th.fg("success", "clean") + : th.fg("dim", "no git"); + const branchLabel = git.branch + ? `${th.fg("dim", "branch ")}${th.fg("accent", git.branch)} ${th.fg("dim", "·")} ${branchState}` + : branchState; + const sync = []; + if (git.ahead) sync.push(th.fg("success", `↑${git.ahead}`)); + if (git.behind) sync.push(th.fg("warning", `↓${git.behind}`)); + if (git.added || git.deleted) { + sync.push(th.fg("muted", `Δ +${git.added}/-${git.deleted}`)); + } + const bottomRight = sync.join(th.fg("dim", " ")); + const ellipsis = th.fg("dim", "…"); + const top = align(topLeft, modelLabel, width, ellipsis); + if (width < 64) return [top]; + const bottom = align( + `${th.fg("accent", "╰─")} ${branchLabel}`, + bottomRight, + width, + ellipsis, + ); + return [top, bottom]; +} diff --git a/src/resources/extensions/sf/ui/index.js b/src/resources/extensions/sf/ui/index.js new file mode 100644 index 000000000..6f77c5ba8 --- /dev/null +++ b/src/resources/extensions/sf/ui/index.js @@ -0,0 +1,581 @@ +/** + * SF-TUI — Unified TUI enhancements for Singularity Forge + * + * Features: + * - Powerline footer: git branch, diff stats, last commit, model, cost, context + * - Header: project name + branch + model + * - Prompt history: Ctrl+Alt+H overlay + * - Mode cycling: Ctrl+Shift+M, Ctrl+Shift+R, Ctrl+Shift+A, Ctrl+Shift+S + */ + +import { execSync, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { platform } from "node:os"; +import { Key } from "@singularity-forge/tui"; +import { getAutoSession } from "../auto/session.js"; +import { isAutoActive } from "../auto.js"; +import { projectRoot } from "../commands/context.js"; +import { + getExperimentalFlag, + setExperimentalFlag, +} from "../experimental.js"; +import { registerSessionColor } from "./color-band.js"; +import { registerSessionEmoji } from "./emoji.js"; +import { renderAutoFooter, renderFooter } from "./footer.js"; +import { invalidateGitStatus } from "./git.js"; +import { renderAutoHeader, renderHeader } from "./header.js"; +import { openMarketplaceOverlay } from "./marketplace.js"; +import { + appendPromptHistory, + openPromptHistoryOverlay, + pushPromptHistory, + readPromptHistory, +} from "./prompt-history.js"; + +const WORK_MODE_CYCLE = [ + "chat", + "plan", + "build", + "review", + "repair", + "research", +]; +const PERMISSION_PROFILE_CYCLE = [ + "restricted", + "normal", + "trusted", + "unrestricted", +]; + +function cycleWorkMode(ctx) { + const session = getAutoSession(); + const current = session.getMode().workMode; + const idx = WORK_MODE_CYCLE.indexOf(current); + const next = WORK_MODE_CYCLE[(idx + 1) % WORK_MODE_CYCLE.length]; + const transition = session.setMode({ workMode: next }); + ctx.ui.notify( + `Mode: ${transition.from.workMode} → ${transition.to.workMode}`, + "info", + ); +} + +function setWorkMode(ctx, mode) { + const session = getAutoSession(); + const transition = session.setMode({ workMode: mode }); + ctx.ui.notify( + `Mode: ${transition.from.workMode} → ${transition.to.workMode}`, + "info", + ); +} + +function setRunControl(ctx, rc) { + const session = getAutoSession(); + const transition = session.setMode({ runControl: rc }); + ctx.ui.notify( + `Run control: ${transition.from.runControl} → ${transition.to.runControl}`, + "info", + ); +} + +function cyclePermissionProfile(ctx) { + const session = getAutoSession(); + const current = session.getMode().permissionProfile; + const idx = PERMISSION_PROFILE_CYCLE.indexOf(current); + const next = + PERMISSION_PROFILE_CYCLE[(idx + 1) % PERMISSION_PROFILE_CYCLE.length]; + const transition = session.setMode({ permissionProfile: next }); + ctx.ui.notify( + `Trust: ${transition.from.permissionProfile} → ${transition.to.permissionProfile}`, + "info", + ); +} + +function installHeader(ctx) { + if (!ctx.hasUI) return; + ctx.ui.setHeader((_tui, theme) => { + return { + render: (width) => { + if (isAutoActive()) { + return renderAutoHeader(theme, ctx, width); + } + return renderHeader(theme, ctx, width); + }, + invalidate: () => {}, + dispose: () => {}, + }; + }); +} +function installFooter(ctx) { + if (!ctx.hasUI) return; + ctx.ui.setFooter((_tui, theme, footerData) => { + return { + render: (width) => { + if (isAutoActive()) { + return renderAutoFooter(theme, footerData, ctx, width); + } + return renderFooter(theme, footerData, ctx, width); + }, + invalidate: () => {}, + dispose: () => {}, + }; + }); +} +export default function sfTui(pi) { + registerSessionEmoji(pi); + registerSessionColor(pi); + const promptHistory = readPromptHistory(); + let promptHistorySessionId = randomUUID(); + let projectBasePath = null; + let wasAutoActive = false; + pi.on("session_start", async (_event, ctx) => { + if (!ctx.hasUI) return; + promptHistorySessionId = + ctx.sessionManager?.getSessionId?.() ?? promptHistorySessionId; + try { + projectBasePath = projectRoot(); + const projectPromptHistory = readPromptHistory(projectBasePath); + promptHistory.splice(0, promptHistory.length, ...projectPromptHistory); + } catch { + projectBasePath = null; + } + installHeader(ctx); + installFooter(ctx); + const openProjectPromptHistory = (overlayCtx) => + openPromptHistoryOverlay(overlayCtx, projectBasePath ?? undefined); + pi.registerShortcut(Key.ctrlAlt("h"), { + description: "Open prompt history", + handler: openProjectPromptHistory, + }); + pi.registerShortcut(Key.ctrlShift("h"), { + description: "Open prompt history (fallback)", + handler: openProjectPromptHistory, + }); + pi.registerShortcut(Key.ctrlAlt("m"), { + description: "Open marketplace browser", + handler: openMarketplaceOverlay, + }); + // Mode cycling shortcuts + pi.registerShortcut(Key.ctrlShift("m"), { + description: "Cycle work mode (chat→plan→build→review→repair→research)", + handler: () => cycleWorkMode(ctx), + }); + pi.registerShortcut(Key.ctrlShift("r"), { + description: "Set work mode to repair", + handler: () => setWorkMode(ctx, "repair"), + }); + pi.registerShortcut(Key.ctrlShift("a"), { + description: "Set run control to autonomous", + handler: () => setRunControl(ctx, "autonomous"), + }); + pi.registerShortcut(Key.ctrlShift("s"), { + description: "Set run control to assisted (step)", + handler: () => setRunControl(ctx, "assisted"), + }); + pi.registerShortcut(Key.ctrlShift("p"), { + description: + "Cycle permission profile (restricted→normal→trusted→unrestricted)", + handler: () => cyclePermissionProfile(ctx), + }); + // Ctrl+G — open current project in $EDITOR (or notify if none) + pi.registerShortcut(Key.ctrl("g"), { + description: "Open project root in $EDITOR", + handler: () => { + const editor = process.env.EDITOR || process.env.VISUAL; + if (!editor) { + ctx.ui.notify( + "No $EDITOR set. Set EDITOR=code (or vim, nano, etc.) in your shell.", + "warning", + ); + return; + } + spawn(editor, [projectRoot() ?? "."], { + stdio: "ignore", + detached: true, + }).unref(); + ctx.ui.notify(`Opened ${editor} ${projectRoot() ?? "."}`, "info"); + }, + }); + // Ctrl+T — toggle reasoning display + pi.registerShortcut(Key.ctrl("t"), { + description: "Toggle extended thinking / reasoning display", + handler: () => { + const current = getExperimentalFlag("show_reasoning"); + setExperimentalFlag("show_reasoning", !current); + ctx.ui.notify(`Reasoning display ${current ? "OFF" : "ON"}`, "info"); + }, + }); + // Ctrl+X B — open background session switcher (BACKGROUND_SESSIONS flag) + pi.registerShortcut(Key.ctrlAlt("b"), { + description: "Open background session switcher", + handler: async () => { + if (!getExperimentalFlag("background_sessions")) { + ctx.sendMessage?.("/tasks"); + return; + } + await openBgSessionSwitcher(ctx); + }, + }); + // Ctrl+X O — open URL from last agent message + pi.registerShortcut(Key.ctrlAlt("o"), { + description: "Open last URL from agent output in browser", + handler: () => { + const entries = ctx.sessionManager?.getEntries?.() ?? []; + const lastText = + [...entries].reverse().find((e) => e.type === "assistant")?.content ?? + ""; + const urlMatch = String(lastText).match(/https?:\/\/[^\s"'<>)]+/); + if (!urlMatch) { + ctx.ui.notify("No URL found in last agent output.", "info"); + return; + } + const url = urlMatch[0]; + try { + const openCmd = + platform() === "darwin" + ? "open" + : platform() === "win32" + ? "start" + : "xdg-open"; + execSync(`${openCmd} "${url}"`, { stdio: "ignore" }); + ctx.ui.notify(`Opened: ${url}`, "info"); + } catch { + ctx.ui.notify(`URL: ${url}`, "info"); + } + }, + }); + // STATUS_LINE — spawn user script every 5s, pipe stdout to footer chip + startStatusLineRunner(ctx); + wasAutoActive = isAutoActive(); + }); + pi.on("before_agent_start", async (event) => { + const prompt = event.prompt?.trim(); + if (prompt) { + pushPromptHistory(promptHistory, prompt); + appendPromptHistory( + prompt, + projectBasePath ?? undefined, + promptHistorySessionId, + ); + pi.appendEntry("sf-prompt-history", { + prompt, + projectRoot: projectBasePath, + sessionId: promptHistorySessionId, + timestamp: Date.now(), + }); + } + }); + pi.on("tool_result", async (_event, ctx) => { + invalidateGitStatus(); + const autoNow = isAutoActive(); + if (!autoNow && wasAutoActive) { + installHeader(ctx); + installFooter(ctx); + } + wasAutoActive = autoNow; + }); + pi.on("agent_end", async (_event, ctx) => { + const autoNow = isAutoActive(); + if (!autoNow) { + installHeader(ctx); + installFooter(ctx); + } + wasAutoActive = autoNow; + }); + // SHOW_FILE tool — renders a file path + optional line range as a code block + // in the agent timeline when the experimental flag is enabled. + pi.registerTool({ + name: "show_file", + description: + "Display a file (or a section of it) as a highlighted code block in the conversation timeline. Use when you want to show the user specific code without just dumping text.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Absolute or relative path to the file", + }, + start_line: { + type: "number", + description: "First line to display (1-indexed, optional)", + }, + end_line: { + type: "number", + description: "Last line to display (inclusive, optional)", + }, + }, + required: ["path"], + }, + execute: async ({ path: filePath, start_line, end_line }) => { + if (!getExperimentalFlag("show_file")) { + return { + output: + "SHOW_FILE is not enabled. Run /experimental on show_file to enable.", + }; + } + const { readFileSync, existsSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const abs = resolve(projectRoot() ?? ".", filePath); + if (!existsSync(abs)) { + return { output: `File not found: ${filePath}` }; + } + const raw = readFileSync(abs, "utf-8"); + const lines = raw.split("\n"); + const from = start_line != null ? Math.max(1, start_line) - 1 : 0; + const to = + end_line != null ? Math.min(lines.length, end_line) : lines.length; + const slice = lines.slice(from, to).join("\n"); + const ext = abs.split(".").pop() ?? ""; + return { output: `\`\`\`${ext}\n${slice}\n\`\`\`` }; + }, + renderResult: ({ output }) => output, + }); + + // ASK_USER_ELICITATION — structured form-based ask_user replacement. + // When the flag is on and the agent calls this tool with choices, a TUI + // select overlay is shown instead of a plain text prompt. + pi.registerTool({ + name: "ask_user_elicitation", + description: + "Ask the user a question using a structured form with optional choices. When ASK_USER_ELICITATION is enabled this is preferred over plain ask_user for questions with known choices.", + parameters: { + type: "object", + properties: { + question: { + type: "string", + description: "The question to ask the user", + }, + choices: { + type: "array", + items: { type: "string" }, + description: "Optional list of choices to show as a select menu", + }, + allow_freeform: { + type: "boolean", + description: + "Whether to allow freeform text input in addition to choices", + }, + }, + required: ["question"], + }, + execute: async ({ question, choices, allow_freeform }, ctx) => { + if (!ctx?.hasUI) { + return { output: "No UI available for elicitation." }; + } + if (!getExperimentalFlag("ask_elicitation")) { + return { + output: + "ASK_USER_ELICITATION is not enabled. Run /experimental on ask_elicitation to enable.", + }; + } + if (choices?.length) { + const answer = await ctx.ui.select(question, choices); + if (!answer && allow_freeform) { + const freeform = await ctx.ui.input(question); + return { output: freeform ?? "" }; + } + return { output: answer ?? "" }; + } + const answer = await ctx.ui.input(question); + return { output: answer ?? "" }; + }, + renderResult: ({ output }) => (output ? `**Answer:** ${output}` : ""), + }); + + // MULTI_TURN_AGENTS — persistent named sub-agent sessions via file-backed state. + // Tool that spawns or resumes a named SF child process, relaying messages. + pi.registerTool({ + name: "spawn_agent", + description: + "Spawn or resume a named persistent sub-agent. Sends a message and waits for the response. The agent persists across calls using file-backed state in .sf/agents//.", + parameters: { + type: "object", + properties: { + name: { + type: "string", + description: + "Unique agent name (alphanumeric + hyphens, e.g. 'researcher')", + }, + message: { + type: "string", + description: "Message to send to the agent", + }, + reset: { + type: "boolean", + description: + "If true, clear the agent's state and start fresh (default: false)", + }, + }, + required: ["name", "message"], + }, + execute: async ({ name, message, reset }) => { + if (!getExperimentalFlag("multi_turn_agents")) { + return { + output: + "MULTI_TURN_AGENTS is not enabled. Run /experimental on multi_turn_agents to enable.", + }; + } + if (!/^[a-z0-9-]{1,32}$/i.test(name)) { + return { + output: "Agent name must be 1-32 alphanumeric/hyphen characters.", + }; + } + const { join: pathJoin } = await import("node:path"); + const { mkdirSync, writeFileSync, readFileSync, existsSync } = + await import("node:fs"); + const stateDir = pathJoin( + projectRoot() ?? process.cwd(), + ".sf", + "agents", + name, + ); + mkdirSync(stateDir, { recursive: true }); + const historyPath = pathJoin(stateDir, "history.jsonl"); + if (reset && existsSync(historyPath)) { + writeFileSync(historyPath, "", "utf-8"); + } + // Append user message to history + const entry = JSON.stringify({ + role: "user", + content: message, + ts: Date.now(), + }); + const { appendFileSync } = await import("node:fs"); + appendFileSync(historyPath, `${entry}\n`, "utf-8"); + // Dispatch to SF headless with the conversation history as context + const historyLines = existsSync(historyPath) + ? readFileSync(historyPath, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((l) => { + try { + return JSON.parse(l); + } catch { + return null; + } + }) + .filter(Boolean) + : []; + const contextMsg = historyLines + .slice(-10) // last 10 turns for context + .map((e) => `${e.role === "user" ? "User" : "Agent"}: ${e.content}`) + .join("\n"); + const fullPrompt = `[Agent: ${name}]\n\nConversation history:\n${contextMsg}\n\nRespond to the last user message only.`; + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execFileAsync = promisify(execFile); + try { + const { stdout } = await execFileAsync( + process.execPath, + [ + "-y", + "node@24", + process.env.SF_LOADER ?? "dist/loader.js", + "headless", + "--print", + fullPrompt, + ], + { + timeout: 60000, + encoding: "utf-8", + env: { ...process.env }, + }, + ); + const response = stdout.trim(); + appendFileSync( + historyPath, + `${JSON.stringify({ role: "assistant", content: response, ts: Date.now() })}\n`, + "utf-8", + ); + return { output: response }; + } catch (err) { + return { + output: `Agent dispatch failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, + renderResult: ({ output }) => output, + }); +} + +/** Run the STATUS_LINE user script on a 5s interval, posting stdout to footer. */ +let _statusLineInterval = null; +function startStatusLineRunner(ctx) { + if (_statusLineInterval) { + clearInterval(_statusLineInterval); + _statusLineInterval = null; + } + const tick = async () => { + if (!getExperimentalFlag("status_line")) return; + const scriptPath = getExperimentalFlag("status_line_script"); + if (!scriptPath || typeof scriptPath !== "string") return; + try { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execFileAsync = promisify(execFile); + const { stdout } = await execFileAsync(scriptPath, [], { + timeout: 4000, + encoding: "utf-8", + }); + const text = stdout.trim().slice(0, 120); + if (text) { + ctx.ui.setStatus?.("sf-status-line", text); + } + } catch { + /* Non-fatal — script may be missing or timing out */ + } + }; + _statusLineInterval = setInterval(tick, 5000); + tick(); +} + +// ─── Background Session Switcher ───────────────────────────────────────────── + +const BG_SESSIONS_FILE = ".sf/sessions-queue.json"; + +/** + * Open a TUI overlay listing background sessions from .sf/sessions-queue.json. + * Selecting one sends /resume to the chat. + * + * Purpose: BACKGROUND_SESSIONS parity — let users switch between concurrent + * sessions without leaving the TUI. + * Consumer: Ctrl+Alt+B keybinding (session_start hook). + */ +async function openBgSessionSwitcher(ctx) { + if (!ctx?.hasUI) return; + const { existsSync, readFileSync } = await import("node:fs"); + const { join: pathJoin } = await import("node:path"); + let root; + try { + root = projectRoot(); + } catch { + root = process.cwd(); + } + const queuePath = pathJoin(root ?? process.cwd(), BG_SESSIONS_FILE); + let sessions = []; + if (existsSync(queuePath)) { + try { + sessions = JSON.parse(readFileSync(queuePath, "utf-8")); + } catch { + /* malformed */ + } + } + if (!sessions.length) { + ctx.ui.notify( + "No background sessions queued.\nStart one with /resume or via BACKGROUND_SESSIONS controls.", + "info", + ); + return; + } + const labels = sessions.map( + (s, i) => + `${(i + 1).toString().padStart(2)}. ${s.id ?? "unknown"} — ${s.summary ?? "no summary"}`, + ); + const chosen = await ctx.ui.select("Switch to session:", labels); + if (!chosen) return; + const idx = labels.indexOf(chosen); + const session = sessions[idx]; + if (session?.id) { + ctx.sendMessage?.(`/resume ${session.id}`); + } +} diff --git a/src/resources/extensions/sf/ui/marketplace.js b/src/resources/extensions/sf/ui/marketplace.js new file mode 100644 index 000000000..01a732b85 --- /dev/null +++ b/src/resources/extensions/sf/ui/marketplace.js @@ -0,0 +1,346 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, +} from "@singularity-forge/tui"; +import { getExperimentalFlag } from "../experimental.js"; + +const CATEGORIES = ["all", "extension", "skill", "theme"]; +const FEATURED = [ + { + id: "agents-filter-output", + name: "Filter Output", + source: "featured", + category: "extension", + description: "Redact secrets from tool results", + }, + { + id: "agents-security", + name: "Security", + source: "featured", + category: "extension", + description: "Block dangerous commands and protected paths", + }, + { + id: "pi-hooks-permission", + name: "Permission", + source: "featured", + category: "extension", + description: "4-level permission control for bash/write/edit", + }, + { + id: "shitty-usage-bar", + name: "Usage Bar", + source: "featured", + category: "extension", + description: "Live AI provider quota & status", + }, + { + id: "rhubarb-bg-notify", + name: "Background Notify", + source: "featured", + category: "extension", + description: "Notify when background tasks complete", + }, + { + id: "pi-dcp", + name: "Dynamic Context Pruning", + source: "featured", + category: "extension", + description: "Intelligent conversation context pruning", + }, + { + id: "pi-powerline-footer", + name: "Powerline Footer", + source: "featured", + category: "extension", + description: "Git-integrated status bar components", + }, +]; +function scanInstalledExtensions(dir, sourceLabel) { + if (!existsSync(dir)) return []; + const items = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const extPath = join(dir, entry.name); + const pkgPath = join(extPath, "package.json"); + let name = entry.name; + let description = ""; + try { + if (existsSync(pkgPath)) { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + name = pkg.name || name; + description = pkg.description || ""; + } + } catch { + /* ignore */ + } + items.push({ + id: entry.name, + name, + source: sourceLabel, + category: "extension", + description, + path: extPath, + }); + } + return items; +} +function buildCatalog() { + const installed = scanInstalledExtensions( + join(homedir(), ".sf", "agent", "extensions"), + "installed", + ); + const piCompat = scanInstalledExtensions( + join(homedir(), ".pi", "agent", "extensions"), + "pi-compat", + ); + const piLegacy = scanInstalledExtensions( + join(homedir(), ".pi", "extensions"), + "pi-compat", + ); + const all = [...installed, ...piCompat, ...piLegacy]; + const seen = new Set(all.map((i) => i.id)); + for (const f of FEATURED) { + if (!seen.has(f.id)) all.push(f); + } + return all.sort((a, b) => { + if (a.source === "installed" && b.source !== "installed") return -1; + if (b.source === "installed" && a.source !== "installed") return 1; + return a.name.localeCompare(b.name); + }); +} +class MarketplaceOverlay { + tui; + theme; + onClose; + items; + filtered; + sel = 0; + catIdx = 0; + scroll = 0; + cacheW = 0; + cacheL = []; + constructor(tui, theme, items, onClose) { + this.tui = tui; + this.theme = theme; + this.items = items; + this.onClose = onClose; + this.filtered = this.applyFilter(); + } + get category() { + return CATEGORIES[this.catIdx]; + } + applyFilter() { + if (this.category === "all") return this.items; + return this.items.filter((i) => i.category === this.category); + } + handleInput(data) { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + this.onClose(); + return; + } + if (matchesKey(data, Key.down) || data === "j") { + this.sel = Math.min(this.filtered.length - 1, this.sel + 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.up) || data === "k") { + this.sel = Math.max(0, this.sel - 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + if (data === "f") { + this.catIdx = (this.catIdx + 1) % CATEGORIES.length; + this.sel = 0; + this.scroll = 0; + this.filtered = this.applyFilter(); + this.invalidate(); + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.return) || matchesKey(data, Key.enter)) { + const item = this.filtered[this.sel]; + if (item) { + if (item.source === "installed") { + // Already installed — close + this.onClose(); + } else { + // Trigger install via npm + this.onClose(); + installExtensionNpm(item.id, item.name); + } + } else { + this.onClose(); + } + } + } + invalidate() { + this.cacheW = 0; + } + render(width) { + if (this.cacheW === width) return this.cacheL; + const th = this.theme; + const bw = Math.min(90, width - 4); + const iw = bw - 4; + const maxRows = Math.max(6, (process.stdout.rows || 24) - 10); + const pad = (s) => s + " ".repeat(Math.max(0, width - visibleWidth(s))); + const box = (s) => { + const len = visibleWidth(s); + return ( + th.fg("dim", "│ ") + + s + + " ".repeat(Math.max(0, bw - 2 - len)) + + th.fg("dim", " │") + ); + }; + const lines = []; + lines.push(pad(th.fg("dim", "╭" + "─".repeat(bw) + "╮"))); + lines.push(pad(box(th.bold(th.fg("accent", "📦 Marketplace"))))); + lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); + const filterLabel = + this.category === "all" + ? th.fg("dim", "all") + : th.fg("accent", this.category); + lines.push( + pad( + box( + `${th.fg("dim", "filter:")} ${filterLabel} ${th.fg("dim", "↑/jk navigate • f filter • Enter install • Esc close")}`, + ), + ), + ); + lines.push(pad(box(""))); + const visibleItems = this.filtered; + if (!visibleItems.length) { + lines.push(pad(box(th.fg("dim", "No packages found.")))); + } else { + this.scroll = Math.min( + this.scroll, + Math.max(0, visibleItems.length - maxRows), + ); + this.sel = Math.min(this.sel, visibleItems.length - 1); + if (this.sel < this.scroll) this.scroll = this.sel; + if (this.sel >= this.scroll + maxRows) + this.scroll = this.sel - maxRows + 1; + for ( + let i = this.scroll; + i < Math.min(visibleItems.length, this.scroll + maxRows); + i++ + ) { + const item = visibleItems[i]; + const ptr = i === this.sel ? th.fg("accent", "❯ ") : " "; + const srcIcon = + item.source === "installed" + ? th.fg("success", "● ") + : item.source === "pi-compat" + ? th.fg("warning", "◐ ") + : th.fg("dim", "○ "); + const name = + i === this.sel + ? th.fg("accent", item.name) + : th.fg("text", item.name); + const desc = th.fg( + "dim", + truncateToWidth( + item.description, + Math.max(10, iw - visibleWidth(`${ptr}${srcIcon}${item.name} `)), + ), + ); + lines.push(pad(box(`${ptr}${srcIcon}${name} ${desc}`))); + } + } + lines.push(pad(box(""))); + lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); + lines.push( + pad( + box( + th.fg( + "dim", + `${visibleItems.length} packages • ${this.items.filter((i) => i.source === "installed").length} installed`, + ), + ), + ), + ); + lines.push(pad(th.fg("dim", "╰" + "─".repeat(bw) + "╯"))); + lines.push(""); + this.cacheL = lines; + this.cacheW = width; + return lines; + } +} +export async function openMarketplaceOverlay(ctx) { + if (!ctx.hasUI) { + ctx.ui.notify("Marketplace requires interactive mode", "error"); + return; + } + const items = buildCatalog(); + await ctx.ui.custom( + (tui, theme, _kb, done) => { + const overlay = new MarketplaceOverlay(tui, theme, items, () => + done(true), + ); + return { + render: (w) => overlay.render(w), + invalidate: () => overlay.invalidate(), + handleInput: (d) => overlay.handleInput(d), + }; + }, + { + overlay: true, + overlayOptions: { + width: "92%", + minWidth: 70, + maxHeight: "88%", + anchor: "center", + backdrop: true, + }, + }, + ); +} + +/** + * Install a marketplace extension via npm into ~/.sf/agent/extensions/. + * + * Purpose: wire the EXTENSIONS experimental flag — press Enter on a featured + * package to trigger `npm install` into the user extensions directory. + * Consumer: MarketplaceOverlay.handleInput on Enter for non-installed items. + */ +export function installExtensionNpm(packageId, displayName) { + if (!getExperimentalFlag("extensions")) { + // Silently skip if EXTENSIONS flag is off; overlay already closed + return; + } + import("node:child_process").then(({ spawn }) => { + const target = join(homedir(), ".sf", "agent", "extensions"); + const proc = spawn("npm", ["install", "--prefix", target, packageId], { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + let stderr = ""; + proc.stderr?.on("data", (d) => { + stderr += d.toString(); + }); + proc.on("close", (code) => { + if (code === 0) { + process.stdout.write( + `\r\n\x1b[32m✓ Installed ${displayName || packageId}\x1b[0m\r\n`, + ); + } else { + process.stdout.write( + `\r\n\x1b[31m✗ Install failed for ${displayName || packageId}: ${stderr.slice(0, 200)}\x1b[0m\r\n`, + ); + } + }); + proc.on("error", () => { + process.stdout.write( + `\r\n\x1b[33m⚠ npm not found — install npm to enable extension installs\x1b[0m\r\n`, + ); + }); + }); +} diff --git a/src/resources/extensions/sf/ui/powerline.js b/src/resources/extensions/sf/ui/powerline.js new file mode 100644 index 000000000..31f14ff24 --- /dev/null +++ b/src/resources/extensions/sf/ui/powerline.js @@ -0,0 +1,157 @@ +import { truncateToWidth, visibleWidth } from "@singularity-forge/tui"; + +const RESET = "\x1b[0m"; +function fgCode(color) { + switch (color) { + case "black": + return "30"; + case "red": + return "31"; + case "green": + return "32"; + case "yellow": + return "33"; + case "blue": + return "34"; + case "magenta": + return "35"; + case "cyan": + return "36"; + case "white": + return "37"; + case "brightBlack": + return "90"; + case "brightRed": + return "91"; + case "brightGreen": + return "92"; + case "brightYellow": + return "93"; + case "brightBlue": + return "94"; + case "brightMagenta": + return "95"; + case "brightCyan": + return "96"; + case "brightWhite": + return "97"; + default: + return "39"; + } +} +function bgCode(color) { + switch (color) { + case "black": + return "40"; + case "red": + return "41"; + case "green": + return "42"; + case "yellow": + return "43"; + case "blue": + return "44"; + case "magenta": + return "45"; + case "cyan": + return "46"; + case "white": + return "47"; + case "brightBlack": + return "100"; + case "brightRed": + return "101"; + case "brightGreen": + return "102"; + case "brightYellow": + return "103"; + case "brightBlue": + return "104"; + case "brightMagenta": + return "105"; + case "brightCyan": + return "106"; + case "brightWhite": + return "107"; + default: + return "49"; + } +} +function ansi(fg, bg, bold) { + const codes = []; + if (bold) codes.push("1"); + if (fg) codes.push(fgCode(fg)); + if (bg) codes.push(bgCode(bg)); + return codes.length ? `\x1b[${codes.join(";")}m` : RESET; +} +export function renderPowerline(segments, width, theme) { + if (!segments.length) return ""; + const SEP = ""; + const _SEP_WIDTH = visibleWidth(SEP); + // Build raw segments with separators + const parts = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const next = segments[i + 1]; + const text = ` ${seg.text} `; + const segAnsi = ansi(seg.fg, seg.bg, seg.bold); + parts.push(segAnsi + text); + if (next) { + // Separator uses current bg as fg, next bg as bg + const sepAnsi = ansi(seg.bg, next.bg, false); + parts.push(sepAnsi + SEP); + } else { + // Final separator: current bg as fg, default bg + const sepAnsi = ansi(seg.bg, undefined, false); + parts.push(sepAnsi + SEP); + } + } + const line = parts.join("") + RESET; + const vis = visibleWidth(line); + // If too wide, drop non-essential segments from the right + if (vis > width && segments.length > 2) { + const trimmed = segments.slice(0, -1); + return renderPowerline(trimmed, width, theme); + } + if (vis > width) return truncateToWidth(line, width, ""); + // Pad right to fill width + if (vis < width) { + return line + " ".repeat(width - vis) + RESET; + } + return line; +} +export function renderPowerlineRight(segments, width, theme) { + if (!segments.length) return ""; + const SEP = ""; + // Build right-to-left + const parts = []; + // Start separator: default bg -> first segment bg + const first = segments[0]; + parts.push( + ansi(first.bg, undefined, false) + + SEP + + ansi(first.fg, first.bg, first.bold) + + ` ${first.text} `, + ); + for (let i = 1; i < segments.length; i++) { + const seg = segments[i]; + const prev = segments[i - 1]; + parts.push( + ansi(prev.bg, seg.bg, false) + + SEP + + ansi(seg.fg, seg.bg, seg.bold) + + ` ${seg.text} `, + ); + } + const line = parts.join("") + RESET; + const vis = visibleWidth(line); + if (vis > width && segments.length > 1) { + const trimmed = segments.slice(1); + return renderPowerlineRight(trimmed, width, theme); + } + if (vis > width) return truncateToWidth(line, width, ""); + if (vis < width) { + return " ".repeat(width - vis) + line + RESET; + } + return line; +} diff --git a/src/resources/extensions/sf/ui/prompt-history.js b/src/resources/extensions/sf/ui/prompt-history.js new file mode 100644 index 000000000..9050eaa04 --- /dev/null +++ b/src/resources/extensions/sf/ui/prompt-history.js @@ -0,0 +1,243 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, +} from "@singularity-forge/tui"; + +const LIMIT = 20; +const SCAN_LINE_LIMIT = 2000; +function promptHistoryPath() { + return join(homedir(), ".sf", "agent", "prompt-history.jsonl"); +} +function isEnvTruthy(value) { + return ["1", "true", "TRUE", "yes", "YES"].includes(String(value ?? "")); +} +function parseEntryLine(line) { + try { + const text = line.trim(); + if (!text) return []; + const entry = JSON.parse(text); + if ( + !entry || + typeof entry !== "object" || + entry.version !== 1 || + typeof entry.prompt !== "string" || + entry.prompt.trim().length === 0 || + typeof entry.projectRoot !== "string" || + entry.projectRoot.trim().length === 0 + ) { + return []; + } + return [entry]; + } catch { + return []; + } +} +function readEntries() { + try { + const path = promptHistoryPath(); + if (!existsSync(path)) return []; + return readFileSync(path, "utf-8") + .split(/\r?\n/) + .reverse() + .slice(0, SCAN_LINE_LIMIT) + .flatMap(parseEntryLine); + } catch { + return []; + } +} +function normalizeHistory(history) { + const seen = new Set(); + const merged = []; + for (const item of history) { + const text = String(item ?? "").trim(); + if (!text || seen.has(text)) continue; + seen.add(text); + merged.push(text); + if (merged.length >= LIMIT) break; + } + return merged; +} +function appendEntries(entries) { + try { + const path = promptHistoryPath(); + mkdirSync(dirname(path), { recursive: true }); + appendFileSync( + path, + entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n", + { encoding: "utf-8", mode: 0o600 }, + ); + } catch { + /* non-fatal */ + } +} +export function readPromptHistory(basePath) { + if (!basePath) return []; + return normalizeHistory( + readEntries() + .filter((entry) => entry.projectRoot === basePath) + .map((entry) => entry.prompt), + ); +} +export function appendPromptHistory(prompt, basePath, sessionId) { + if (isEnvTruthy(process.env.SF_SKIP_PROMPT_HISTORY)) return; + if (!basePath) return; + const normalized = normalizeHistory([prompt]); + if (!normalized.length) return; + const now = Date.now(); + const entries = normalized.toReversed().map((prompt, index) => ({ + version: 1, + prompt, + projectRoot: basePath, + sessionId: sessionId ?? null, + timestamp: now - (normalized.length - index - 1), + })); + appendEntries(entries); +} +export function pushPromptHistory(history, text) { + const t = text.trim(); + if (!t || history[0] === t) return; + history.unshift(t); + if (history.length > LIMIT) { + history.length = LIMIT; + } +} +function preview(text, maxWidth) { + const c = text.replace(/\s+/g, " ").trim(); + return c ? truncateToWidth(c, maxWidth, "…") : "(empty)"; +} +class PromptHistoryOverlay { + tui; + theme; + done; + items; + sel = 0; + cacheW = 0; + cacheL = []; + constructor(tui, theme, items, done) { + this.tui = tui; + this.theme = theme; + this.items = items; + this.done = done; + } + handleInput(data) { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + this.done(null); + return; + } + if (matchesKey(data, Key.return) || matchesKey(data, Key.enter)) { + this.done(this.items[this.sel] ?? null); + return; + } + if (matchesKey(data, Key.down) || data === "j") { + this.sel = Math.min(this.items.length - 1, this.sel + 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.up) || data === "k") { + this.sel = Math.max(0, this.sel - 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + if (data >= "1" && data <= "9") { + const idx = parseInt(data, 10) - 1; + if (idx >= 0 && idx < this.items.length) { + this.done(this.items[idx] ?? null); + } + } + } + invalidate() { + this.cacheW = 0; + } + render(width) { + if (this.cacheW === width) return this.cacheL; + const th = this.theme; + const bw = Math.min(84, width - 4); + const iw = bw - 4; + const pad = (s) => s + " ".repeat(Math.max(0, width - visibleWidth(s))); + const box = (s) => { + const len = visibleWidth(s); + return ( + th.fg("dim", "│ ") + + s + + " ".repeat(Math.max(0, bw - 2 - len)) + + th.fg("dim", " │") + ); + }; + const lines = []; + lines.push(pad(th.fg("dim", "╭" + "─".repeat(bw) + "╮"))); + lines.push(pad(box(th.bold(th.fg("accent", "📜 Prompt History"))))); + lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); + lines.push( + pad( + box( + th.fg( + "dim", + "↑/jk navigate • 1-9 quick pick • Enter insert • Esc cancel", + ), + ), + ), + ); + lines.push(pad(box(""))); + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + const p = preview(item, iw - 8); + const ptr = i === this.sel ? th.fg("accent", "❯ ") : " "; + const num = i < 9 ? th.fg("dim", `${i + 1}`) : " "; + const label = i === this.sel ? th.fg("accent", p) : p; + lines.push(pad(box(`${ptr}${num}. ${label}`))); + } + lines.push(pad(box(""))); + lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); + lines.push(pad(box(th.fg("dim", `${this.items.length} prompts`)))); + lines.push(pad(th.fg("dim", "╰" + "─".repeat(bw) + "╯"))); + lines.push(""); + this.cacheL = lines; + this.cacheW = width; + return lines; + } +} +export async function openPromptHistoryOverlay(ctx, basePath) { + if (!ctx.hasUI) { + ctx.ui.notify("Prompt history requires interactive mode", "error"); + return; + } + const items = readPromptHistory(basePath ?? undefined); + if (!items.length) { + ctx.ui.notify( + "No prompt history yet. Send a message to build history.", + "info", + ); + return; + } + const selected = await ctx.ui.custom( + (tui, theme, _kb, done) => { + const o = new PromptHistoryOverlay(tui, theme, items, done); + return { + render: (w) => o.render(w), + invalidate: () => o.invalidate(), + handleInput: (d) => o.handleInput(d), + }; + }, + { + overlay: true, + overlayOptions: { + width: "90%", + minWidth: 60, + maxHeight: "85%", + anchor: "center", + backdrop: true, + }, + }, + ); + if (selected) { + ctx.ui.setEditorText(selected); + ctx.ui.notify("Inserted prompt from history", "info"); + } +} diff --git a/src/resources/extensions/sf/ui/shared.js b/src/resources/extensions/sf/ui/shared.js new file mode 100644 index 000000000..31ceb53c9 --- /dev/null +++ b/src/resources/extensions/sf/ui/shared.js @@ -0,0 +1,7 @@ +import { visibleWidth } from "@singularity-forge/tui"; +export function rightAlign(left, right, width) { + const leftVis = visibleWidth(left); + const rightVis = visibleWidth(right); + const gap = Math.max(1, width - leftVis - rightVis); + return left + " ".repeat(gap) + right; +}