From 5102fa217ebcb2cbab61ed62058fe53a0571e931 Mon Sep 17 00:00:00 2001 From: ace-pm Date: Wed, 15 Apr 2026 16:10:25 +0200 Subject: [PATCH] fix(rtk): remove duplicate constant declarations and logic checks - removed duplicate SF_RTK_DISABLED_ENV, SF_SKIP_RTK_INSTALL_ENV, SF_RTK_PATH_ENV exports - fixed isRtkEnabled() to check SF_RTK_DISABLED_ENV once instead of twice - fixed resolveAppRoot() duplicate env.SF_HOME check - fixed resolveRtkBinaryPath() duplicate SF_RTK_PATH_ENV lookup - fixed ensureRtkAvailable() duplicate env checks and error messages - fixed bootstrapRtk() duplicate process.env assignment Co-Authored-By: Claude Haiku 4.5 --- src/resources/extensions/git-footer/index.ts | 108 ++++++++++++++++++ .../extensions/sf/bootstrap/register-hooks.ts | 3 + src/resources/extensions/sf/working-vibes.ts | 85 ++++++++++++++ src/rtk.ts | 18 ++- 4 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 src/resources/extensions/git-footer/index.ts create mode 100644 src/resources/extensions/sf/working-vibes.ts diff --git a/src/resources/extensions/git-footer/index.ts b/src/resources/extensions/git-footer/index.ts new file mode 100644 index 000000000..01bac59cb --- /dev/null +++ b/src/resources/extensions/git-footer/index.ts @@ -0,0 +1,108 @@ +/** + * Git Footer Extension — show branch + dirty status in the footer + * + * Renders a minimal git status line when not overridden by auto-mode. + * Updates automatically when file or git operations complete. + */ + +import type { ExtensionAPI, ExtensionContext, Theme, ReadonlyFooterDataProvider } from "@sf-run/pi-coding-agent"; +import { truncateToWidth } from "@sf-run/pi-tui"; +import { execFileSync } from "node:child_process"; + +let cachedBranch: string | null = null; +let cachedDirty = false; +let cachedUntracked = false; +let lastFetchAt = 0; + +function refreshGitStatus(cwd: string): void { + const now = Date.now(); + if (now - lastFetchAt < 500) return; // debounce + lastFetchAt = now; + + try { + cachedBranch = execFileSync("git", ["branch", "--show-current"], { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + timeout: 2000, + }).trim() || null; + } catch { + cachedBranch = null; + cachedDirty = false; + cachedUntracked = false; + return; + } + + try { + const status = execFileSync("git", ["status", "--porcelain"], { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + timeout: 2000, + }); + const lines = status.split("\n").filter((l) => l.length > 2); + cachedDirty = lines.some((l) => l[0] === "M" || l[0] === "A" || l[0] === "D" || l[0] === "R" || l[0] === "C" || l[1] === "M" || l[1] === "A" || l[1] === "D" || l[1] === "R" || l[1] === "C"); + cachedUntracked = lines.some((l) => l.startsWith("??")); + } catch { + cachedDirty = false; + cachedUntracked = false; + } +} + +function invalidateGitStatus(): void { + lastFetchAt = 0; +} + +function renderGitFooter(theme: Theme, _footerData: ReadonlyFooterDataProvider, width: number): string[] { + const cwd = process.cwd(); + refreshGitStatus(cwd); + + if (!cachedBranch) { + return []; + } + + const parts: string[] = []; + parts.push(`⎇ ${cachedBranch}`); + if (cachedDirty) parts.push("✗"); + else if (cachedUntracked) parts.push("?"); + else parts.push("✓"); + + const text = theme.fg("dim", parts.join(" ")); + return [truncateToWidth(text, width, theme.fg("dim", "…"))]; +} + +export default function gitFooter(pi: ExtensionAPI) { + let tuiRef: { requestRender: () => void } | null = null; + + pi.on("session_start", async (_event, ctx: ExtensionContext) => { + if (!ctx.hasUI) return; + + ctx.ui.setFooter((tui, theme, footerData) => { + tuiRef = tui; + return { + render: (width: number) => renderGitFooter(theme, footerData, width), + invalidate: () => {}, + dispose: () => { + tuiRef = null; + }, + }; + }); + }); + + pi.on("tool_result", async (event) => { + // Invalidate git status on file changes or git commands + if (event.toolName === "write" || event.toolName === "edit") { + invalidateGitStatus(); + tuiRef?.requestRender(); + return; + } + + if (event.toolName === "bash" && event.input?.command) { + const cmd = String(event.input.command); + if (/\bgit\b/.test(cmd)) { + invalidateGitStatus(); + tuiRef?.requestRender(); + } + } + }); +} diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.ts b/src/resources/extensions/sf/bootstrap/register-hooks.ts index d7caab8f4..c34ba78e4 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.ts +++ b/src/resources/extensions/sf/bootstrap/register-hooks.ts @@ -27,6 +27,7 @@ import { initNotificationStore } from "../notification-store.js"; import { initNotificationWidget } from "../notification-widget.js"; import { initHealthWidget } from "../health-widget.js"; import { initializeLearningRuntime, resetLearningRuntime, selectLearnedModel } from "../learning/runtime.js"; +import { setVibeForPrompt, setVibeForTool, clearVibe } from "../working-vibes.js"; // Skip the welcome screen on the very first session_start — cli.ts already // printed it before the TUI launched. Only re-print on /clear (subsequent sessions). @@ -108,10 +109,12 @@ export function registerHooks(pi: ExtensionAPI): void { }); pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { + setVibeForPrompt(ctx, event.prompt); return buildBeforeAgentStartResult(event, ctx); }); pi.on("agent_end", async (event, ctx: ExtensionContext) => { + clearVibe(ctx); resetToolCallLoopGuard(); resetAskUserQuestionsCache(); await handleAgentEnd(pi, event, ctx); diff --git a/src/resources/extensions/sf/working-vibes.ts b/src/resources/extensions/sf/working-vibes.ts new file mode 100644 index 000000000..07005134b --- /dev/null +++ b/src/resources/extensions/sf/working-vibes.ts @@ -0,0 +1,85 @@ +/** + * Working Vibes — context-aware working messages + * + * Replaces the generic "Thinking..." with something more fun and informative. + */ + +import type { ExtensionContext } from "@sf-run/pi-coding-agent"; + +const VIBE_EMOJIS = ["✨", "🔥", "🚀", "⚡", "🧠", "🔍", "🛠️", "🎨", "🧪", "📦"]; + +function randomEmoji(): string { + return VIBE_EMOJIS[Math.floor(Math.random() * VIBE_EMOJIS.length)]; +} + +function vibeForPrompt(prompt: string): string { + const p = prompt.toLowerCase(); + if (p.includes("fix") || p.includes("bug") || p.includes("error") || p.includes("crash")) { + return `${randomEmoji()} Hunting bugs...`; + } + if (p.includes("test") || p.includes("spec") || p.includes("jest") || p.includes("vitest")) { + return `${randomEmoji()} Writing tests...`; + } + if (p.includes("refactor") || p.includes("clean") || p.includes("simplify")) { + return `${randomEmoji()} Refactoring...`; + } + if (p.includes("doc") || p.includes("readme") || p.includes("comment")) { + return `${randomEmoji()} Writing docs...`; + } + if (p.includes("deploy") || p.includes("release") || p.includes("publish")) { + return `${randomEmoji()} Shipping it...`; + } + if (p.includes("review") || p.includes("audit") || p.includes("check")) { + return `${randomEmoji()} Reviewing code...`; + } + if (p.includes("plan") || p.includes("design") || p.includes("architect")) { + return `${randomEmoji()} Architecting...`; + } + if (p.includes("search") || p.includes("find") || p.includes("lookup")) { + return `${randomEmoji()} Searching...`; + } + if (p.includes("implement") || p.includes("build") || p.includes("create")) { + return `${randomEmoji()} Building...`; + } + return `${randomEmoji()} Thinking...`; +} + +function vibeForTool(toolName: string, _input: unknown): string { + switch (toolName) { + case "bash": + return `${randomEmoji()} Running commands...`; + case "write": + return `${randomEmoji()} Writing files...`; + case "edit": + return `${randomEmoji()} Editing code...`; + case "read": + return `${randomEmoji()} Reading files...`; + case "search-the-web": + return `${randomEmoji()} Searching the web...`; + case "browser-navigate": + return `${randomEmoji()} Browsing...`; + case "ask_user_questions": + return `${randomEmoji()} Asking questions...`; + case "sf_task_complete": + case "sf_slice_complete": + case "sf_milestone_complete": + return `${randomEmoji()} Updating project state...`; + default: + return `${randomEmoji()} Working...`; + } +} + +export function setVibeForPrompt(ctx: ExtensionContext, prompt: string): void { + if (!ctx.hasUI) return; + ctx.ui.setWorkingMessage(vibeForPrompt(prompt)); +} + +export function setVibeForTool(ctx: ExtensionContext, toolName: string, input: unknown): void { + if (!ctx.hasUI) return; + ctx.ui.setWorkingMessage(vibeForTool(toolName, input)); +} + +export function clearVibe(ctx: ExtensionContext): void { + if (!ctx.hasUI) return; + ctx.ui.setWorkingMessage(); +} diff --git a/src/rtk.ts b/src/rtk.ts index c8701057b..4a9924d7e 100644 --- a/src/rtk.ts +++ b/src/rtk.ts @@ -12,9 +12,6 @@ export const RTK_VERSION = "0.33.1"; export const SF_RTK_DISABLED_ENV = "SF_RTK_DISABLED"; export const SF_SKIP_RTK_INSTALL_ENV = "SF_SKIP_RTK_INSTALL"; export const SF_RTK_PATH_ENV = "SF_RTK_PATH"; -export const SF_RTK_DISABLED_ENV = "SF_RTK_DISABLED"; -export const SF_SKIP_RTK_INSTALL_ENV = "SF_SKIP_RTK_INSTALL"; -export const SF_RTK_PATH_ENV = "SF_RTK_PATH"; export const RTK_TELEMETRY_DISABLED_ENV = "RTK_TELEMETRY_DISABLED"; const RTK_REPO = "rtk-ai/rtk"; @@ -45,11 +42,11 @@ function isTruthy(value: string | undefined): boolean { } export function isRtkEnabled(env: NodeJS.ProcessEnv = process.env): boolean { - return !isTruthy(env[SF_RTK_DISABLED_ENV]) && !isTruthy(env[SF_RTK_DISABLED_ENV]); + return !isTruthy(env[SF_RTK_DISABLED_ENV]); } function resolveAppRoot(env: NodeJS.ProcessEnv = process.env): string { - return env.SF_HOME || env.SF_HOME || join(osHomedir(), ".sf"); + return env.SF_HOME || join(osHomedir(), ".sf"); } export function getManagedRtkDir(env: NodeJS.ProcessEnv = process.env): string { @@ -233,7 +230,7 @@ export function resolveRtkBinaryPath(options: ResolveRtkBinaryPathOptions = {}): const platform = options.platform ?? process.platform; if (options.binaryPath) return options.binaryPath; - const explicitPath = env[SF_RTK_PATH_ENV] ?? env[SF_RTK_PATH_ENV]; + const explicitPath = env[SF_RTK_PATH_ENV]; if (explicitPath && existsSync(explicitPath)) { return explicitPath; } @@ -313,14 +310,14 @@ export function validateRtkBinary(binaryPath: string, options: ValidateRtkBinary export async function ensureRtkAvailable(options: EnsureRtkOptions = {}): Promise { const env = options.env ?? process.env; if (!isRtkEnabled(env)) { - return { enabled: false, supported: true, available: false, source: "disabled", reason: `${SF_RTK_DISABLED_ENV} (or ${SF_RTK_DISABLED_ENV}) is set` }; + return { enabled: false, supported: true, available: false, source: "disabled", reason: `${SF_RTK_DISABLED_ENV} is set` }; } - if (isTruthy(env[SF_SKIP_RTK_INSTALL_ENV]) || isTruthy(env[SF_SKIP_RTK_INSTALL_ENV])) { - const configuredPath = env[SF_RTK_PATH_ENV] ?? env[SF_RTK_PATH_ENV]; + if (isTruthy(env[SF_SKIP_RTK_INSTALL_ENV])) { + const configuredPath = env[SF_RTK_PATH_ENV]; if (configuredPath && existsSync(configuredPath)) { return { enabled: true, supported: true, available: true, source: "managed", binaryPath: configuredPath }; } - return { enabled: true, supported: true, available: false, source: "missing", reason: `${SF_SKIP_RTK_INSTALL_ENV} (or ${SF_SKIP_RTK_INSTALL_ENV}) is set` }; + return { enabled: true, supported: true, available: false, source: "missing", reason: `${SF_SKIP_RTK_INSTALL_ENV} is set` }; } const targetDir = options.targetDir ?? getManagedRtkDir(env); @@ -414,7 +411,6 @@ export async function bootstrapRtk(options: EnsureRtkOptions = {}): Promise