sf-tui + sf-permissions: gate footer-indicator side-effects on ctx.hasUI

Three TUI-only decorations were running their full session-lifecycle
handlers even in headless mode, where there is no footer to render
into. Most visibly, the emoji extension's AI auto-assign path made a
real LLM call to pick a 🚀//🎯 that nothing would ever see.

- sf-tui/emoji.ts: session_start and agent_start handlers early-return
  when !ctx.hasUI. Commands stay registered so /emoji still works if
  someone runs it, but the lifecycle work (state loading, AI emoji
  selection, setStatus emission) is skipped.

- sf-tui/color-band.ts: session_start and session_switch handlers
  early-return when !ctx.hasUI. Avoids unnecessary state-file writes
  and resize-listener attachment in headless runs.

- sf-permissions/index.ts:setLevel: guards the setStatus("authority",
  …) call behind ctx.hasUI. The existing session_start path was
  already gated — this closes the permission-change code path.

Headless stderr was already filtering these keys, so the user-visible
output is unchanged. This eliminates the underlying RPC traffic and
— more importantly — stops spending LLM tokens on decorative emoji
selection in headless runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-19 07:59:36 +02:00
parent e1461f45b8
commit 5957d5c2b6
3 changed files with 19 additions and 3 deletions

View file

@ -205,7 +205,9 @@ function setLevel(
if (saveGlobally) {
saveGlobalPermission(level);
}
if (ctx.ui?.setStatus) {
// Only emit the footer indicator when there's a real TUI to render into.
// In headless mode the "authority" badge has no consumer.
if (ctx.hasUI && ctx.ui?.setStatus) {
ctx.ui.setStatus("authority", getStatusText(level));
}
}

View file

@ -91,12 +91,17 @@ export function registerSessionColor(pi: ExtensionAPI): void {
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);

View file

@ -78,8 +78,17 @@ export function registerSessionEmoji(pi: ExtensionAPI): void {
registerCommands(pi, state);
pi.on("session_start", (_, ctx) => initSession(ctx, pi, state));
pi.on("agent_start", (_, ctx) => handleAgentStart(ctx, pi, state));
// Gate the session-lifecycle work on having a real TUI. Headless mode
// (sf headless auto, --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);
});
}
// ─────────────────────────────────────────────────────────────────────────────