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:
Mikael Hugo 2026-05-09 05:30:25 +02:00
parent c6d031fe01
commit eaf7165893
3 changed files with 581 additions and 0 deletions

View file

@ -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(

View file

@ -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()));

View 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);
}
}