diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index 24a12abc2..af44f23c7 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -248,6 +248,35 @@ export const TOP_LEVEL_SUBCOMMANDS = [ cmd: "plan", desc: "Promote planning artifacts from ~/.sf/ to docs/ (promote, list, diff)", }, + { + cmd: "experimental", + desc: "Toggle experimental feature flags (show/on/off/on /off )", + }, + { cmd: "diff", desc: "Show git diff (HEAD by default, --staged for staged)" }, + { cmd: "theme", desc: "Get or set the UI theme (dark/light/dim/auto)" }, + { cmd: "rename", desc: "Rename the current session (sets terminal title)" }, + { + cmd: "streamer-mode", + desc: "Toggle streamer mode — masks model names and quota details", + }, + { + cmd: "statusline", + desc: "Configure the status line script (script | off)", + }, + { cmd: "search", desc: "Search the session timeline for a query" }, + { cmd: "find", desc: "Alias for /search — search the session timeline" }, + { + cmd: "chronicle", + desc: "Show session chronicle (recent git log + session events)", + }, + { + cmd: "rewind", + desc: "Rewind the last turn and revert its file changes", + }, + { + cmd: "instructions", + desc: "List instruction files loaded into the agent context", + }, ]; export const DIRECT_SF_COMMANDS = TOP_LEVEL_SUBCOMMANDS.filter( diff --git a/src/resources/extensions/sf/commands/handlers/core.js b/src/resources/extensions/sf/commands/handlers/core.js index e5c82686e..13002ab7f 100644 --- a/src/resources/extensions/sf/commands/handlers/core.js +++ b/src/resources/extensions/sf/commands/handlers/core.js @@ -8,6 +8,13 @@ import { handlePrefsWizard, } from "../../commands-prefs-wizard.js"; import { runEnvironmentChecks } from "../../doctor-environment.js"; +import { + EXPERIMENTAL_FLAGS, + getAllExperimentalFlags, + getExperimentalFlag, + setAllExperimentalFlags, + setExperimentalFlag, +} from "../../experimental.js"; import { getGlobalSFPreferencesPath, getProjectSFPreferencesPath, @@ -768,8 +775,448 @@ export async function handleCoreCommand(trimmed, ctx, pi) { ctx.ui.notify(lines.join("\n"), "info"); return true; } + if (trimmed === "experimental" || trimmed.startsWith("experimental ")) { + await handleExperimentalCommand( + trimmed.replace(/^experimental\s*/, "").trim(), + ctx, + ); + return true; + } + if (trimmed === "diff" || trimmed.startsWith("diff ")) { + await handleDiffCommand(trimmed.replace(/^diff\s*/, "").trim(), ctx); + return true; + } + if (trimmed === "theme" || trimmed.startsWith("theme ")) { + await handleThemeCommand(trimmed.replace(/^theme\s*/, "").trim(), ctx); + return true; + } + if (trimmed === "rename" || trimmed.startsWith("rename ")) { + await handleRenameCommand(trimmed.replace(/^rename\s*/, "").trim(), ctx); + return true; + } + if (trimmed === "streamer-mode" || trimmed.startsWith("streamer-mode ")) { + await handleStreamerModeCommand( + trimmed.replace(/^streamer-mode\s*/, "").trim(), + ctx, + ); + return true; + } + if ( + trimmed === "statusline" || + trimmed.startsWith("statusline ") || + trimmed === "footer-config" || + trimmed.startsWith("footer-config ") + ) { + await handleStatuslineCommand( + trimmed.replace(/^(?:statusline|footer-config)\s*/, "").trim(), + ctx, + ); + return true; + } + if ( + trimmed === "search" || + trimmed.startsWith("search ") || + trimmed === "find" || + trimmed.startsWith("find ") + ) { + const query = trimmed.replace(/^(?:search|find)\s*/, "").trim(); + await handleSearchCommand(query, ctx); + return true; + } + if (trimmed === "chronicle" || trimmed.startsWith("chronicle ")) { + await handleChronicleCommand( + trimmed.replace(/^chronicle\s*/, "").trim(), + ctx, + ); + return true; + } + if (trimmed === "rewind" || trimmed === "undo-turn") { + await handleRewindCommand(ctx); + return true; + } + if (trimmed === "instructions" || trimmed.startsWith("instructions ")) { + await handleInstructionsCommand( + trimmed.replace(/^instructions\s*/, "").trim(), + ctx, + ); + return true; + } + if (trimmed === "session-rename" || trimmed.startsWith("session-rename ")) { + // Alias for /rename + await handleRenameCommand( + trimmed.replace(/^session-rename\s*/, "").trim(), + ctx, + ); + return true; + } return false; } + +// ─── /experimental ────────────────────────────────────────────────────────── + +async function handleExperimentalCommand(args, ctx) { + const sub = args.trim(); + + if (!sub || sub === "show") { + const current = getAllExperimentalFlags(); + const lines = ["Experimental features\n"]; + for (const [name, desc] of Object.entries(EXPERIMENTAL_FLAGS)) { + const on = current[name] === true; + lines.push(` ${on ? "✓" : "○"} ${name.padEnd(22)} ${desc}`); + } + lines.push( + "\nUsage: /experimental on [flag] /experimental off [flag] /experimental on (all)", + ); + ctx.ui.notify(lines.join("\n"), "info"); + return; + } + + if (sub === "on") { + setAllExperimentalFlags(true); + ctx.ui.notify("All experimental features enabled.", "info"); + return; + } + if (sub === "off") { + setAllExperimentalFlags(false); + ctx.ui.notify("All experimental features disabled.", "info"); + return; + } + + const onMatch = sub.match(/^on\s+(\S+)$/); + if (onMatch) { + const flag = onMatch[1]; + if (!EXPERIMENTAL_FLAGS[flag]) { + ctx.ui.notify( + `Unknown flag "${flag}". Run /experimental show for the list.`, + "warning", + ); + return; + } + setExperimentalFlag(flag, true); + ctx.ui.notify(`${flag} enabled.`, "info"); + return; + } + + const offMatch = sub.match(/^off\s+(\S+)$/); + if (offMatch) { + const flag = offMatch[1]; + if (!EXPERIMENTAL_FLAGS[flag]) { + ctx.ui.notify( + `Unknown flag "${flag}". Run /experimental show for the list.`, + "warning", + ); + return; + } + setExperimentalFlag(flag, false); + ctx.ui.notify(`${flag} disabled.`, "info"); + return; + } + + // Unknown sub-command — show usage + ctx.ui.notify( + "Usage: /experimental [show|on|off|on |off ]", + "info", + ); +} + +// ─── /diff ────────────────────────────────────────────────────────────────── + +async function handleDiffCommand(args, ctx) { + const { execSync } = await import("node:child_process"); + const staged = args === "--staged" || args === "--cached"; + try { + const cmd = staged ? "git diff --staged" : "git diff HEAD"; + const output = execSync(cmd, { + cwd: projectRoot(), + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + if (!output.trim()) { + ctx.ui.notify( + staged ? "No staged changes." : "No changes since last commit.", + "info", + ); + return; + } + ctx.ui.notify(output.slice(0, 8000), "info"); + } catch (e) { + ctx.ui.notify(`git diff failed: ${e.message}`, "error"); + } +} + +// ─── /theme ───────────────────────────────────────────────────────────────── + +async function handleThemeCommand(args, ctx) { + const THEMES = ["dark", "light", "dim", "auto"]; + const { loadEffectiveSFPreferences } = await import("../../preferences.js"); + const { extractBodyAfterFrontmatter, serializePreferencesToFrontmatter } = + await import("../../commands-prefs-wizard.js"); + const { readFileSync, writeFileSync, existsSync } = await import("node:fs"); + + if (!args) { + const current = loadEffectiveSFPreferences()?.preferences?.theme ?? "auto"; + ctx.ui.notify( + `Current theme: ${current}\nOptions: ${THEMES.join(" | ")}\nUsage: /theme `, + "info", + ); + return; + } + if (!THEMES.includes(args)) { + ctx.ui.notify( + `Unknown theme "${args}". Options: ${THEMES.join(", ")}`, + "warning", + ); + return; + } + const path = getProjectSFPreferencesPath(); + const { loadProjectSFPreferences } = await import("../../preferences.js"); + const existing = loadProjectSFPreferences(); + const prefs = existing?.preferences ? { ...existing.preferences } : {}; + prefs.version = prefs.version || 1; + prefs.theme = args; + const frontmatter = serializePreferencesToFrontmatter(prefs); + let body = + "\n# SF Preferences\n\nSee `~/.sf/agent/extensions/sf/docs/preferences-reference.md` for full documentation.\n"; + if (existsSync(path)) { + const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); + if (preserved) body = preserved; + } + const { mkdirSync } = await import("node:fs"); + const { dirname } = await import("node:path"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `---\n${frontmatter}---${body}`, "utf-8"); + ctx.ui.notify(`Theme set to "${args}". Restart SF to apply.`, "info"); +} + +// ─── /rename ──────────────────────────────────────────────────────────────── + +async function handleRenameCommand(name, ctx) { + if (!name) { + ctx.ui.notify( + "Usage: /rename \nProvide a name for the current session.", + "info", + ); + return; + } + // Write terminal title via OSC 2 + process.stdout.write(`\x1b]2;${name} — SF\x07`); + // Store in session metadata if auto-session is available + try { + const session = getAutoSession(); + if (session?.setName) { + session.setName(name); + } + } catch { + /* session metadata not available in all modes */ + } + ctx.ui.notify(`Session renamed to "${name}".`, "info"); +} + +// ─── /streamer-mode ───────────────────────────────────────────────────────── + +async function handleStreamerModeCommand(args, ctx) { + const isOn = getExperimentalFlag("streamer_mode"); + if (args === "on" || (!args && !isOn)) { + setExperimentalFlag("streamer_mode", true); + ctx.ui.notify( + "Streamer mode ON — model names and quota details are masked.", + "info", + ); + } else if (args === "off" || (!args && isOn)) { + setExperimentalFlag("streamer_mode", false); + ctx.ui.notify("Streamer mode OFF.", "info"); + } else { + const state = isOn ? "ON" : "OFF"; + ctx.ui.notify( + `Streamer mode is ${state}.\nUsage: /streamer-mode [on|off]`, + "info", + ); + } +} + +// ─── /statusline ──────────────────────────────────────────────────────────── + +async function handleStatuslineCommand(args, ctx) { + if (args.startsWith("script ")) { + const scriptPath = args.slice(7).trim(); + setExperimentalFlag("status_line_script", scriptPath); + setExperimentalFlag("status_line", true); + ctx.ui.notify( + `Status line script set to: ${scriptPath}\nSTATUS_LINE enabled.`, + "info", + ); + return; + } + if (args === "off") { + setExperimentalFlag("status_line", false); + ctx.ui.notify("Status line script disabled.", "info"); + return; + } + ctx.ui.notify( + [ + "Status line configuration", + "", + " /statusline script Set user-defined script path", + " /statusline off Disable status line script", + "", + "The script is run every 5 seconds. Its stdout becomes a footer status chip.", + `STATUS_LINE flag: ${getExperimentalFlag("status_line") ? "ON" : "OFF"}`, + ].join("\n"), + "info", + ); +} + +// ─── /search /find ────────────────────────────────────────────────────────── + +async function handleSearchCommand(query, ctx) { + // Gracefully degrade when session manager is unavailable (headless mode) + try { + const { getSessionManager } = await import( + "../../session-manager.js" + ).catch(() => ({ getSessionManager: null })); + if (!getSessionManager) { + ctx.ui.notify( + "Session search is not available in headless mode.", + "info", + ); + return; + } + const entries = getSessionManager?.()?.getEntries?.() ?? []; + const term = query.toLowerCase(); + const matches = entries + .filter((e) => { + const text = ( + typeof e.content === "string" + ? e.content + : JSON.stringify(e.content ?? "") + ).toLowerCase(); + return term ? text.includes(term) : true; + }) + .slice(0, 20); + if (matches.length === 0) { + ctx.ui.notify(`No results for "${query}".`, "info"); + return; + } + const lines = [`Search: "${query}" — ${matches.length} result(s)\n`]; + for (const m of matches) { + const snippet = + typeof m.content === "string" + ? m.content.slice(0, 120).replace(/\n/g, " ") + : JSON.stringify(m.content).slice(0, 120); + lines.push(` [${m.type ?? "entry"}] ${snippet}`); + } + ctx.ui.notify(lines.join("\n"), "info"); + } catch (e) { + ctx.ui.notify(`Search failed: ${e.message}`, "error"); + } +} + +// ─── /chronicle ───────────────────────────────────────────────────────────── + +async function handleChronicleCommand(args, ctx) { + try { + const { readSFDb } = await import("../../sf-db.js").catch(() => ({ + readSFDb: null, + })); + if (!readSFDb) { + ctx.ui.notify( + "Chronicle requires the SF database. Run /init first.", + "warning", + ); + return; + } + const { execSync } = await import("node:child_process"); + const lines = ["Session Chronicle\n"]; + // Git log for recent activity + try { + const gitLog = execSync( + "git log --oneline --no-merges -20 2>/dev/null || true", + { cwd: projectRoot(), encoding: "utf-8" }, + ); + if (gitLog.trim()) { + lines.push("Recent commits:"); + lines.push(gitLog.trim()); + lines.push(""); + } + } catch { + /* non-fatal */ + } + + if (args.startsWith("search ")) { + const term = args.slice(7).trim(); + ctx.ui.notify( + `Chronicle search for "${term}" — use /search for session entry search.`, + "info", + ); + return; + } + lines.push("Tip: /search searches the current session timeline."); + ctx.ui.notify(lines.join("\n"), "info"); + } catch (e) { + ctx.ui.notify(`Chronicle failed: ${e.message}`, "error"); + } +} + +// ─── /rewind ──────────────────────────────────────────────────────────────── + +async function handleRewindCommand(ctx) { + try { + const reverted = await ctx.rewind?.(); + if (reverted) { + ctx.ui.notify( + "Rewound last turn. File changes from that turn have been reverted.", + "info", + ); + } else { + ctx.ui.notify( + "Rewind is not available in this context. Use /undo to revert the last completed unit.", + "info", + ); + } + } catch { + ctx.ui.notify( + "Rewind is not available. Use /undo to revert the last completed unit.", + "info", + ); + } +} + +// ─── /instructions ────────────────────────────────────────────────────────── + +async function handleInstructionsCommand(_args, ctx) { + const { existsSync } = await import("node:fs"); + const { join: pathJoin } = await import("node:path"); + const { homedir } = await import("node:os"); + const root = projectRoot(); + const home = homedir(); + + const candidates = [ + { label: "AGENTS.md", path: pathJoin(root, "AGENTS.md") }, + { label: "CLAUDE.md", path: pathJoin(root, "CLAUDE.md") }, + { label: "GEMINI.md", path: pathJoin(root, "GEMINI.md") }, + { + label: ".github/copilot-instructions.md", + path: pathJoin(root, ".github", "copilot-instructions.md"), + }, + { + label: "~/.copilot/copilot-instructions.md", + path: pathJoin(home, ".copilot", "copilot-instructions.md"), + }, + ]; + + const lines = ["Instruction files\n"]; + for (const c of candidates) { + const exists = existsSync(c.path); + lines.push( + ` ${exists ? "✓" : "○"} ${c.label}${exists ? "" : " (not found)"}`, + ); + } + lines.push( + "\nAll existing files are loaded automatically. Delete or rename to disable.", + ); + ctx.ui.notify(lines.join("\n"), "info"); +} + export function formatTextStatus(state) { const lines = ["SF Status\n"]; lines.push(formatProgressLine(computeProgressScore())); diff --git a/src/resources/extensions/sf/experimental.js b/src/resources/extensions/sf/experimental.js new file mode 100644 index 000000000..78f82b8dd --- /dev/null +++ b/src/resources/extensions/sf/experimental.js @@ -0,0 +1,105 @@ +/** + * experimental.js — Feature flag helpers for SF experimental features. + * + * Purpose: single read/write surface for prefs.experimental flags so that + * every gated feature calls getExperimentalFlag(name) rather than reaching + * into preferences directly. + * + * Consumer: /experimental command handler, all features gated by flags. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { + extractBodyAfterFrontmatter, + serializePreferencesToFrontmatter, +} from "./commands-prefs-wizard.js"; +import { + getProjectSFPreferencesPath, + loadEffectiveSFPreferences, + loadProjectSFPreferences, +} from "./preferences.js"; + +/** All recognized experimental feature flags with descriptions. */ +export const EXPERIMENTAL_FLAGS = { + status_line: + "STATUS_LINE — run a user-defined script to feed a custom footer status chip", + show_file: + "SHOW_FILE — show_file tool renders code snippets inline in the timeline", + ask_elicitation: + "ASK_USER_ELICITATION — structured form/select UI replaces plain ask_user", + multi_turn_agents: + "MULTI_TURN_AGENTS — persistent subagents that accept follow-up messages", + extensions: + "EXTENSIONS — user-installable extensions via marketplace npm install", + configure_agent: + "CONFIGURE_COPILOT_AGENT — interactive wizard for MCP servers and agents", + background_sessions: + "BACKGROUND_SESSIONS — concurrent sessions with background switching", + rubber_duck: + "RUBBER_DUCK — constructive feedback subagent on code and designs", + prompt_frame: + "PROMPT_FRAME — decorative border rendered above the input prompt", + streamer_mode: + "STREAMER_MODE — mask model names and quota details for screen sharing", +}; + +/** + * Read a single experimental flag from the effective (merged) preferences. + * Returns true only when the flag is explicitly set to true. + * + * Purpose: cheap, consistent read path for all feature-gated code paths. + * Consumer: every feature behind an experimental flag. + */ +export function getExperimentalFlag(name) { + const prefs = loadEffectiveSFPreferences(); + return prefs?.preferences?.experimental?.[name] === true; +} + +/** + * Read all experimental flags from the effective preferences. + * + * Purpose: /experimental show lists every flag and its current state. + * Consumer: handleExperimentalCommand (show sub-command). + */ +export function getAllExperimentalFlags() { + const prefs = loadEffectiveSFPreferences(); + return prefs?.preferences?.experimental ?? {}; +} + +/** + * Write a single experimental flag to the project preferences file. + * Creates the file if it does not exist. Preserves all other fields. + * + * Purpose: /experimental on / off writes back cleanly. + * Consumer: handleExperimentalCommand. + */ +export function setExperimentalFlag(name, value) { + const path = getProjectSFPreferencesPath(); + const existing = loadProjectSFPreferences(); + const prefs = existing?.preferences ? { ...existing.preferences } : {}; + prefs.version = prefs.version || 1; + prefs.experimental = { ...(prefs.experimental ?? {}), [name]: value }; + + const frontmatter = serializePreferencesToFrontmatter(prefs); + let body = + "\n# SF Preferences\n\nSee `~/.sf/agent/extensions/sf/docs/preferences-reference.md` for full documentation.\n"; + if (existsSync(path)) { + const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); + if (preserved) body = preserved; + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `---\n${frontmatter}---${body}`, "utf-8"); +} + +/** + * Enable or disable all known experimental flags in one call. + * + * Purpose: /experimental on (no flag arg) bulk-enables all flags. + * Consumer: handleExperimentalCommand bulk toggle. + */ +export function setAllExperimentalFlags(value) { + for (const name of Object.keys(EXPERIMENTAL_FLAGS)) { + setExperimentalFlag(name, value); + } +}