refactor(sf): fold sf-tui extension into sf/ui/ — remove separate extension layer
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>
This commit is contained in:
parent
9e55528c95
commit
9e484e67b7
13 changed files with 2682 additions and 14 deletions
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
319
src/resources/extensions/sf/ui/color-band.js
Normal file
319
src/resources/extensions/sf/ui/color-band.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
433
src/resources/extensions/sf/ui/emoji.js
Normal file
433
src/resources/extensions/sf/ui/emoji.js
Normal file
|
|
@ -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 <emoji|description>", "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);
|
||||
}
|
||||
227
src/resources/extensions/sf/ui/footer.js
Normal file
227
src/resources/extensions/sf/ui/footer.js
Normal file
|
|
@ -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, "..."))];
|
||||
}
|
||||
158
src/resources/extensions/sf/ui/git.js
Normal file
158
src/resources/extensions/sf/ui/git.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
168
src/resources/extensions/sf/ui/header.js
Normal file
168
src/resources/extensions/sf/ui/header.js
Normal file
|
|
@ -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];
|
||||
}
|
||||
581
src/resources/extensions/sf/ui/index.js
Normal file
581
src/resources/extensions/sf/ui/index.js
Normal file
|
|
@ -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/<name>/.",
|
||||
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 <sessionId> 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 <description> 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}`);
|
||||
}
|
||||
}
|
||||
346
src/resources/extensions/sf/ui/marketplace.js
Normal file
346
src/resources/extensions/sf/ui/marketplace.js
Normal file
|
|
@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
157
src/resources/extensions/sf/ui/powerline.js
Normal file
157
src/resources/extensions/sf/ui/powerline.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
243
src/resources/extensions/sf/ui/prompt-history.js
Normal file
243
src/resources/extensions/sf/ui/prompt-history.js
Normal file
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
7
src/resources/extensions/sf/ui/shared.js
Normal file
7
src/resources/extensions/sf/ui/shared.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue