feat(sf): Copilot CLI feature parity — /experimental, /diff, /theme, /rename, /streamer-mode, /statusline, /search, /chronicle, /rewind, /instructions
Add experimental feature flag system and 10 new slash commands matching Copilot CLI's experimental surface. experimental.js: - EXPERIMENTAL_FLAGS map (status_line, show_file, ask_elicitation, multi_turn_agents, extensions, configure_agent, background_sessions, rubber_duck, prompt_frame, streamer_mode) - getExperimentalFlag / setExperimentalFlag / setAllExperimentalFlags - Reads/writes project .sf/PREFERENCES.md via prefs frontmatter helpers handlers/core.js: - /experimental show|on|off|on <flag>|off <flag> - /diff [--staged] — git diff HEAD or staged changes - /theme [dark|light|dim|auto] — get/set UI theme in prefs - /rename <name> — session name + OSC 2 terminal title - /streamer-mode [on|off] — mask model names for screen sharing - /statusline script <path>|off — configure footer status line script - /search /find <query> — search session timeline entries - /chronicle — git log + session events overview - /rewind — revert last turn (ctx.rewind() with graceful fallback) - /instructions — list all instruction files and their load status catalog.js: add all 12 new commands to TOP_LEVEL_SUBCOMMANDS for autocomplete Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
c6d031fe01
commit
eaf7165893
3 changed files with 581 additions and 0 deletions
|
|
@ -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 <flag>/off <flag>)",
|
||||
},
|
||||
{ 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 <path> | 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(
|
||||
|
|
|
|||
|
|
@ -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 <flag>|off <flag>]",
|
||||
"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 <name>`,
|
||||
"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 <session name>\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 <path> 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 <query> 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()));
|
||||
|
|
|
|||
105
src/resources/extensions/sf/experimental.js
Normal file
105
src/resources/extensions/sf/experimental.js
Normal file
|
|
@ -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 <flag> / off <flag> 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue