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:
Mikael Hugo 2026-05-10 22:04:00 +02:00
parent 9e55528c95
commit 9e484e67b7
13 changed files with 2682 additions and 14 deletions

View file

@ -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"
]
}
}

View file

@ -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)}`,
);
}
}

View file

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

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

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

View 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, "..."))];
}

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

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

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

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

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

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

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