From c1c3195f75d7e4ce07a2cbd160936df080cdf9c3 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 9 May 2026 05:33:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(sf):=20Tier=202=20=E2=80=94=20SHOW=5FFILE?= =?UTF-8?q?=20tool,=20STATUS=5FLINE=20runner,=20/keep-alive,=20/sidekicks,?= =?UTF-8?q?=20Ctrl+G/T/X=20keybindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sf-tui/index.js: - Import getExperimentalFlag / setExperimentalFlag from experimental.js - Ctrl+G — open project root in $EDITOR - Ctrl+T — toggle show_reasoning experimental flag - Ctrl+Alt+B — open /tasks background surface - Ctrl+Alt+O — open last URL from agent output in browser - STATUS_LINE runner: setInterval 5s, execFile user script, pipe stdout to ctx.ui.setStatus - SHOW_FILE tool: pi.registerTool({name:'show_file',...}) gated on show_file flag; reads file slice, renders as fenced code block handlers/ops.js: - /keep-alive [off] — spawns caffeinate (macOS) or systemd-inhibit (Linux) as detached process; /keep-alive off kills it handlers/core.js: - /sidekicks — reads .sf/parallel/ subdirs, shows STATUS per worker catalog.js: - Add /sidekicks and /keep-alive to TOP_LEVEL_SUBCOMMANDS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/resources/extensions/sf-tui/index.js | 151 ++++++++++++++++++ .../extensions/sf/commands/catalog.js | 8 + .../extensions/sf/commands/handlers/core.js | 49 ++++++ .../extensions/sf/commands/handlers/ops.js | 73 +++++++++ 4 files changed, 281 insertions(+) diff --git a/src/resources/extensions/sf-tui/index.js b/src/resources/extensions/sf-tui/index.js index 7214b4b19..c18080589 100644 --- a/src/resources/extensions/sf-tui/index.js +++ b/src/resources/extensions/sf-tui/index.js @@ -12,6 +12,10 @@ import { Key } from "@singularity-forge/pi-tui"; import { getAutoSession } from "../sf/auto/session.js"; import { isAutoActive } from "../sf/auto.js"; import { projectRoot } from "../sf/commands/context.js"; +import { + getExperimentalFlag, + setExperimentalFlag, +} from "../sf/experimental.js"; import { registerSessionColor } from "./color-band.js"; import { registerSessionEmoji } from "./emoji.js"; import { renderAutoFooter, renderFooter } from "./footer.js"; @@ -169,6 +173,74 @@ export default function sfTui(pi) { "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; + } + const { spawn } = require("node:child_process"); + 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 tasks surface (/tasks) + pi.registerShortcut(Key.ctrlAlt("b"), { + description: "Open background tasks surface", + handler: () => { + ctx.sendMessage?.("/tasks"); + }, + }); + // 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 { execSync } = require("node:child_process"); + const os = require("node:os"); + 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 = + os.platform() === "darwin" + ? "open" + : os.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) => { @@ -205,4 +277,83 @@ export default function sfTui(pi) { } 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, + }); +} + +/** 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(); } diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index af44f23c7..6f9dfb019 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -277,6 +277,14 @@ export const TOP_LEVEL_SUBCOMMANDS = [ cmd: "instructions", desc: "List instruction files loaded into the agent context", }, + { + cmd: "sidekicks", + desc: "List background parallel worker jobs and their status", + }, + { + cmd: "keep-alive", + desc: "Prevent system sleep during long runs (caffeinate / systemd-inhibit)", + }, ]; export const DIRECT_SF_COMMANDS = TOP_LEVEL_SUBCOMMANDS.filter( diff --git a/src/resources/extensions/sf/commands/handlers/core.js b/src/resources/extensions/sf/commands/handlers/core.js index 13002ab7f..e70f2bf69 100644 --- a/src/resources/extensions/sf/commands/handlers/core.js +++ b/src/resources/extensions/sf/commands/handlers/core.js @@ -849,6 +849,10 @@ export async function handleCoreCommand(trimmed, ctx, pi) { ); return true; } + if (trimmed === "sidekicks" || trimmed.startsWith("sidekicks ")) { + await handleSidekicksCommand(ctx); + return true; + } return false; } @@ -1288,3 +1292,48 @@ export function formatTextStatus(state) { } return lines.join("\n"); } + +// ─── /sidekicks ────────────────────────────────────────────────────────────── + +async function handleSidekicksCommand(ctx) { + try { + const { readdirSync, readFileSync, existsSync } = await import("node:fs"); + const { join: pathJoin } = await import("node:path"); + const root = projectRoot(); + const parallelDir = pathJoin(root, ".sf", "parallel"); + if (!existsSync(parallelDir)) { + ctx.ui.notify( + "No background workers running. Start with /parallel start.", + "info", + ); + return; + } + const entries = readdirSync(parallelDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + if (entries.length === 0) { + ctx.ui.notify("No sidekick workers found.", "info"); + return; + } + const lines = [`Sidekicks (${entries.length})\n`]; + for (const name of entries) { + const statusPath = pathJoin(parallelDir, name, "STATUS"); + const status = existsSync(statusPath) + ? readFileSync(statusPath, "utf-8").trim() + : "unknown"; + const icon = + status === "running" + ? "▶" + : status === "done" + ? "✓" + : status === "error" + ? "✗" + : "○"; + lines.push(` ${icon} ${name.padEnd(16)} ${status}`); + } + lines.push("\nUse /parallel status for details."); + ctx.ui.notify(lines.join("\n"), "info"); + } catch (e) { + ctx.ui.notify(`Sidekicks error: ${e.message}`, "error"); + } +} diff --git a/src/resources/extensions/sf/commands/handlers/ops.js b/src/resources/extensions/sf/commands/handlers/ops.js index 074a6e9d2..8053239c0 100644 --- a/src/resources/extensions/sf/commands/handlers/ops.js +++ b/src/resources/extensions/sf/commands/handlers/ops.js @@ -495,5 +495,78 @@ Examples: ctx.ui.notify("Usage: /plan promote|list|diff|specs ...", "info"); return true; } + if (trimmed === "keep-alive" || trimmed.startsWith("keep-alive ")) { + await handleKeepAlive(trimmed.replace(/^keep-alive\s*/, "").trim(), ctx); + return true; + } return false; } + +// ─── /keep-alive ───────────────────────────────────────────────────────────── + +let _keepAliveProcess = null; + +async function handleKeepAlive(args, ctx) { + const os = await import("node:os"); + const { spawn } = await import("node:child_process"); + if (args === "off" || args === "stop") { + if (_keepAliveProcess) { + _keepAliveProcess.kill(); + _keepAliveProcess = null; + ctx.ui.notify("Keep-alive stopped.", "info"); + } else { + ctx.ui.notify("Keep-alive is not running.", "info"); + } + return; + } + if (_keepAliveProcess) { + ctx.ui.notify( + "Keep-alive is already running. Use /keep-alive off to stop it.", + "info", + ); + return; + } + const platform = os.platform(); + let cmd, cmdArgs; + if (platform === "darwin") { + cmd = "caffeinate"; + cmdArgs = ["-dims"]; + } else if (platform === "linux") { + // systemd-inhibit keeps the session alive (no-op if unavailable) + cmd = "systemd-inhibit"; + cmdArgs = [ + "--what=idle:sleep", + "--who=SF", + "--why=Active SF session", + "sleep", + "infinity", + ]; + } else { + ctx.ui.notify( + "Keep-alive is supported on macOS (caffeinate) and Linux (systemd-inhibit).", + "info", + ); + return; + } + try { + _keepAliveProcess = spawn(cmd, cmdArgs, { + stdio: "ignore", + detached: false, + }); + _keepAliveProcess.on("error", () => { + _keepAliveProcess = null; + }); + _keepAliveProcess.on("exit", () => { + _keepAliveProcess = null; + }); + ctx.ui.notify( + `Keep-alive started (${cmd}). Run /keep-alive off to stop.`, + "info", + ); + } catch { + ctx.ui.notify( + `Could not start ${cmd}. Make sure it is installed.`, + "warning", + ); + } +}