feat(sf): Tier 2 — SHOW_FILE tool, STATUS_LINE runner, /keep-alive, /sidekicks, Ctrl+G/T/X keybindings
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>
This commit is contained in:
parent
eaf7165893
commit
c1c3195f75
4 changed files with 281 additions and 0 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue