From ba9452925f6806aa01fcf372e9506990d7ab4d42 Mon Sep 17 00:00:00 2001 From: Toby Rosen Date: Thu, 2 Jul 2026 07:22:05 +0700 Subject: [PATCH 1/2] feat(bot): add HIDE_RUN_FOOTER env var to preset run footer default The assistant run footer (agent, provider/model, elapsed time) can be toggled at runtime from /settings, but there is no way to preset its default for headless or declarative (.env-based) deployments. Add a HIDE_RUN_FOOTER boolean env var, read via the existing getOptionalBooleanEnvVar helper, that seeds the default for the showAssistantRunFooter setting. It defaults to false (footer shown), so behavior is unchanged unless the variable is set. The runtime /settings toggle still takes precedence and is persisted in settings.json. Document the variable in README and .env.example, and add config and settings-store tests. --- .env.example | 7 +++++++ README.md | 2 ++ src/app/stores/settings-store.ts | 3 ++- src/config.ts | 1 + tests/app/stores/settings-store.test.ts | 13 +++++++++++++ tests/config.test.ts | 24 ++++++++++++++++++++++++ 6 files changed, 49 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index dccb58b3..d884ec8d 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,13 @@ OPENCODE_MODEL_ID=big-pickle # raw = show assistant replies as plain text # MESSAGE_FORMAT_MODE=markdown +# Hide the assistant run footer by default (default: false) +# The run footer is the small stamp sent after each assistant reply +# (agent, provider/model, and elapsed time). Set to true to hide it by default. +# This only seeds the initial default; the footer can still be toggled at +# runtime from /settings, and that choice is persisted in settings.json. +# HIDE_RUN_FOOTER=false + # Directory Browser Roots (optional) # Comma-separated list of paths that /open is allowed to browse. # Supports ~ for home directory. Defaults to ~ when not set. diff --git a/README.md b/README.md index 3edd6ee7..8de7f4ef 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ When installed via npm, the configuration wizard handles the initial setup. The | `TRACK_BACKGROUND_SESSIONS` | Track detached/non-current sessions in the current selected project/worktree and send short notifications | No | `true` | | `RESPONSE_STREAM_THROTTLE_MS` | Stream update throttle in milliseconds for assistant, thinking, and tool message edits | No | `1000` | | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` | +| `HIDE_RUN_FOOTER` | Hide the per-reply run footer (agent, provider/model, elapsed) by default; still toggleable via `/settings` | No | `false` | | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` | | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — | | `STT_API_KEY` | API key for your STT provider | No | — | @@ -258,6 +259,7 @@ Runtime preferences are changed from `/settings` and stored in `settings.json`: - Compact output mode - Thinking content display +- Assistant run footer display (its default can be preset with the `HIDE_RUN_FOOTER` environment variable) - Diff file attachments - Response streaming mode: `edit` or `draft (experimental)`; applies only to final assistant replies, not thinking messages - Audio replies: `off`, `all`, or `auto` when TTS is configured diff --git a/src/app/stores/settings-store.ts b/src/app/stores/settings-store.ts index 4c470042..dd54e12f 100644 --- a/src/app/stores/settings-store.ts +++ b/src/app/stores/settings-store.ts @@ -8,6 +8,7 @@ import type { ScheduledTaskSessionIgnoreInfo, Settings, } from "../types/settings.js"; +import { config } from "../../config.js"; import { getRuntimePaths } from "../../runtime/paths.js"; import { logger } from "../../utils/logger.js"; @@ -119,7 +120,7 @@ export function setShowThinkingContent(enabled: boolean): void { } export function getShowAssistantRunFooter(): boolean { - return currentSettings.showAssistantRunFooter ?? true; + return currentSettings.showAssistantRunFooter ?? !config.bot.hideRunFooter; } export function setShowAssistantRunFooter(enabled: boolean): void { diff --git a/src/config.ts b/src/config.ts index 229efce7..f4c842fa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -167,6 +167,7 @@ export const config = { locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"), trackBackgroundSessions: getOptionalBooleanEnvVar("TRACK_BACKGROUND_SESSIONS", true), messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"), + hideRunFooter: getOptionalBooleanEnvVar("HIDE_RUN_FOOTER", false), }, files: { maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10), diff --git a/tests/app/stores/settings-store.test.ts b/tests/app/stores/settings-store.test.ts index 1a2c6d25..3473bffd 100644 --- a/tests/app/stores/settings-store.test.ts +++ b/tests/app/stores/settings-store.test.ts @@ -78,6 +78,19 @@ describe("app/stores/settings-store", () => { expect(getShowAssistantRunFooter()).toBe(true); }); + it("hides the assistant run footer by default when HIDE_RUN_FOOTER is enabled", async () => { + vi.resetModules(); + vi.stubEnv("HIDE_RUN_FOOTER", "true"); + + const store = await import("../../../src/app/stores/settings-store.js"); + await store.loadSettings(); + + expect(store.getShowAssistantRunFooter()).toBe(false); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + it("loads thinking content setting from settings.json", async () => { await writeFile(path.join(tempHome, "settings.json"), JSON.stringify({ showThinkingContent: false })); diff --git a/tests/config.test.ts b/tests/config.test.ts index 5dbe5555..e4e4f43d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -72,6 +72,30 @@ describe("config boolean env parsing", () => { expect(config.bot.messageFormatMode).toBe("markdown"); }); + it("does not hide the run footer by default", async () => { + vi.stubEnv("HIDE_RUN_FOOTER", ""); + + const config = await loadConfig(); + + expect(config.bot.hideRunFooter).toBe(false); + }); + + it("parses HIDE_RUN_FOOTER as a boolean", async () => { + vi.stubEnv("HIDE_RUN_FOOTER", "true"); + + const config = await loadConfig(); + + expect(config.bot.hideRunFooter).toBe(true); + }); + + it("falls back to a visible run footer on invalid HIDE_RUN_FOOTER", async () => { + vi.stubEnv("HIDE_RUN_FOOTER", "banana"); + + const config = await loadConfig(); + + expect(config.bot.hideRunFooter).toBe(false); + }); + it("parses supported locale from BOT_LOCALE", async () => { vi.stubEnv("BOT_LOCALE", "fr"); From 289b8818541b2ff2ae0118aaa73023b0fdd220b4 Mon Sep 17 00:00:00 2001 From: Toby Rosen <46664805+tobyrosen@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:38:06 +0700 Subject: [PATCH 2/2] feat(bot): INITIAL_SETTINGS_PRESET env var to seed default settings Replace HIDE_RUN_FOOTER with a single INITIAL_SETTINGS_PRESET env var that accepts a JSON object and seeds any runtime /settings value on first run. Only keys absent from settings.json are affected; settings the user has already changed via /settings are left untouched. Supported keys: ttsMode, compactOutputMode, showThinkingContent, showAssistantRunFooter, responseStreamingMode, sendDiffFileAttachments. Invalid JSON falls back to built-in defaults without crashing. Unknown keys and type mismatches are logged as warnings and skipped. Co-Authored-By: Toby Rosen <46664805+tobyrosen@users.noreply.github.com> --- .env.example | 17 ++++-- README.md | 10 +++- src/app/stores/settings-store.ts | 77 ++++++++++++++++++++++++- src/config.ts | 25 +++++++- tests/app/stores/settings-store.test.ts | 56 +++++++++++++++++- tests/config.test.ts | 40 ++++++++++--- 6 files changed, 204 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index d884ec8d..9020c6a1 100644 --- a/.env.example +++ b/.env.example @@ -105,12 +105,17 @@ OPENCODE_MODEL_ID=big-pickle # raw = show assistant replies as plain text # MESSAGE_FORMAT_MODE=markdown -# Hide the assistant run footer by default (default: false) -# The run footer is the small stamp sent after each assistant reply -# (agent, provider/model, and elapsed time). Set to true to hide it by default. -# This only seeds the initial default; the footer can still be toggled at -# runtime from /settings, and that choice is persisted in settings.json. -# HIDE_RUN_FOOTER=false +# Initial runtime settings preset (optional) +# A JSON object that seeds the bot's default /settings values on first run. +# Keys not yet persisted in settings.json are initialised to these values; +# any setting the user has already changed via /settings is left untouched. +# Supported keys: ttsMode ("off"|"all"|"auto"), compactOutputMode (bool), +# showThinkingContent (bool), showAssistantRunFooter (bool), +# responseStreamingMode ("edit"|"draft"), sendDiffFileAttachments (bool) +# Unknown keys and type mismatches are warned and ignored; invalid JSON falls +# back to built-in defaults without crashing the bot. +# Example — hide the run footer and enable compact mode by default: +# INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true} # Directory Browser Roots (optional) # Comma-separated list of paths that /open is allowed to browse. diff --git a/README.md b/README.md index 8de7f4ef..48357bf0 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ When installed via npm, the configuration wizard handles the initial setup. The | `TRACK_BACKGROUND_SESSIONS` | Track detached/non-current sessions in the current selected project/worktree and send short notifications | No | `true` | | `RESPONSE_STREAM_THROTTLE_MS` | Stream update throttle in milliseconds for assistant, thinking, and tool message edits | No | `1000` | | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` | -| `HIDE_RUN_FOOTER` | Hide the per-reply run footer (agent, provider/model, elapsed) by default; still toggleable via `/settings` | No | `false` | +| `INITIAL_SETTINGS_PRESET` | JSON object that seeds default `/settings` values on first run (keys not yet persisted); see [Runtime Settings](#runtime-settings) | No | `{}` | | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` | | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — | | `STT_API_KEY` | API key for your STT provider | No | — | @@ -259,11 +259,17 @@ Runtime preferences are changed from `/settings` and stored in `settings.json`: - Compact output mode - Thinking content display -- Assistant run footer display (its default can be preset with the `HIDE_RUN_FOOTER` environment variable) +- Assistant run footer display - Diff file attachments - Response streaming mode: `edit` or `draft (experimental)`; applies only to final assistant replies, not thinking messages - Audio replies: `off`, `all`, or `auto` when TTS is configured +You can seed the initial defaults for any of these settings without hard-coding them in your Docker image by setting `INITIAL_SETTINGS_PRESET` to a JSON object. Only keys not yet persisted in `settings.json` are affected — settings the user has already changed via `/settings` are left untouched: + +```env +INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto"} +``` + ### Reverse Proxy (Optional) For environments that block `api.telegram.org` but allow your own HTTPS endpoint (corporate networks, restricted regions), you can route Bot API traffic through a reverse proxy you control. This is an alternative to the SOCKS/HTTP forward proxy configured with `TELEGRAM_PROXY_URL`. diff --git a/src/app/stores/settings-store.ts b/src/app/stores/settings-store.ts index dd54e12f..0ebaef29 100644 --- a/src/app/stores/settings-store.ts +++ b/src/app/stores/settings-store.ts @@ -120,7 +120,7 @@ export function setShowThinkingContent(enabled: boolean): void { } export function getShowAssistantRunFooter(): boolean { - return currentSettings.showAssistantRunFooter ?? !config.bot.hideRunFooter; + return currentSettings.showAssistantRunFooter ?? true; } export function setShowAssistantRunFooter(enabled: boolean): void { @@ -229,6 +229,79 @@ export function __resetSettingsForTests(): void { settingsWriteQueue = Promise.resolve(); } +const VALID_TTS_MODES: readonly TtsMode[] = ["off", "all", "auto"]; +const VALID_STREAMING_MODES: readonly ResponseStreamingMode[] = ["edit", "draft"]; + +function applyInitialSettingsPreset(preset: Record): void { + const knownKeys = new Set([ + "ttsMode", + "compactOutputMode", + "showThinkingContent", + "showAssistantRunFooter", + "responseStreamingMode", + "sendDiffFileAttachments", + ]); + + for (const [key, value] of Object.entries(preset)) { + if (!knownKeys.has(key)) { + logger.warn( + `[SettingsManager] INITIAL_SETTINGS_PRESET: unknown key "${key}" — ignoring.`, + ); + continue; + } + if (key === "ttsMode") { + if (typeof value !== "string" || !VALID_TTS_MODES.includes(value as TtsMode)) { + logger.warn( + `[SettingsManager] INITIAL_SETTINGS_PRESET: invalid value for "ttsMode"; expected one of ${VALID_TTS_MODES.join(", ")} — ignoring.`, + ); + continue; + } + if (currentSettings.ttsMode === undefined) { + currentSettings.ttsMode = value as TtsMode; + } + } else if (key === "responseStreamingMode") { + if ( + typeof value !== "string" || + !VALID_STREAMING_MODES.includes(value as ResponseStreamingMode) + ) { + logger.warn( + `[SettingsManager] INITIAL_SETTINGS_PRESET: invalid value for "responseStreamingMode"; expected one of ${VALID_STREAMING_MODES.join(", ")} — ignoring.`, + ); + continue; + } + if (currentSettings.responseStreamingMode === undefined) { + currentSettings.responseStreamingMode = value as ResponseStreamingMode; + } + } else { + // Boolean settings: compactOutputMode, showThinkingContent, showAssistantRunFooter, sendDiffFileAttachments + if (typeof value !== "boolean") { + logger.warn( + `[SettingsManager] INITIAL_SETTINGS_PRESET: "${key}" must be a boolean — ignoring.`, + ); + continue; + } + switch (key) { + case "compactOutputMode": + if (currentSettings.compactOutputMode === undefined) + currentSettings.compactOutputMode = value; + break; + case "showThinkingContent": + if (currentSettings.showThinkingContent === undefined) + currentSettings.showThinkingContent = value; + break; + case "showAssistantRunFooter": + if (currentSettings.showAssistantRunFooter === undefined) + currentSettings.showAssistantRunFooter = value; + break; + case "sendDiffFileAttachments": + if (currentSettings.sendDiffFileAttachments === undefined) + currentSettings.sendDiffFileAttachments = value; + break; + } + } + } +} + export async function loadSettings(): Promise { const loadedSettings = (await readSettingsFile()) as Settings & { serverProcess?: unknown; @@ -260,6 +333,8 @@ export async function loadSettings(): Promise { currentSettings.scheduledTaskSessionIgnores = cloneScheduledTaskSessionIgnores(loadedSettings.scheduledTaskSessionIgnores) ?? []; + applyInitialSettingsPreset(config.bot.initialSettingsPreset); + if (requiresRewrite) { void writeSettingsFile(currentSettings); } diff --git a/src/config.ts b/src/config.ts index f4c842fa..6e6af007 100644 --- a/src/config.ts +++ b/src/config.ts @@ -76,6 +76,29 @@ function getOptionalMessageFormatModeEnvVar( return defaultValue; } +function parseInitialSettingsPreset(): Record { + const raw = getEnvVar("INITIAL_SETTINGS_PRESET", false).trim(); + if (!raw) { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.warn( + "[config] INITIAL_SETTINGS_PRESET contains invalid JSON — ignoring preset.", + ); + return {}; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + console.warn( + "[config] INITIAL_SETTINGS_PRESET must be a JSON object — ignoring preset.", + ); + return {}; + } + return parsed as Record; +} + const VALID_TTS_PROVIDERS: TtsProvider[] = ["openai", "google", "elevenlabs"]; function getOptionalTtsProviderEnvVar(key: string, defaultValue: TtsProvider): TtsProvider { @@ -167,7 +190,7 @@ export const config = { locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"), trackBackgroundSessions: getOptionalBooleanEnvVar("TRACK_BACKGROUND_SESSIONS", true), messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"), - hideRunFooter: getOptionalBooleanEnvVar("HIDE_RUN_FOOTER", false), + initialSettingsPreset: parseInitialSettingsPreset(), }, files: { maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10), diff --git a/tests/app/stores/settings-store.test.ts b/tests/app/stores/settings-store.test.ts index 3473bffd..0b1fb009 100644 --- a/tests/app/stores/settings-store.test.ts +++ b/tests/app/stores/settings-store.test.ts @@ -78,14 +78,66 @@ describe("app/stores/settings-store", () => { expect(getShowAssistantRunFooter()).toBe(true); }); - it("hides the assistant run footer by default when HIDE_RUN_FOOTER is enabled", async () => { + it("applies INITIAL_SETTINGS_PRESET for settings not yet persisted", async () => { vi.resetModules(); - vi.stubEnv("HIDE_RUN_FOOTER", "true"); + vi.stubEnv( + "INITIAL_SETTINGS_PRESET", + '{"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto","responseStreamingMode":"draft","sendDiffFileAttachments":false,"showThinkingContent":false}', + ); const store = await import("../../../src/app/stores/settings-store.js"); await store.loadSettings(); expect(store.getShowAssistantRunFooter()).toBe(false); + expect(store.getCompactOutputMode()).toBe(true); + expect(store.getTtsMode()).toBe("auto"); + expect(store.getResponseStreamingMode()).toBe("draft"); + expect(store.getSendDiffFileAttachments()).toBe(false); + expect(store.getShowThinkingContent()).toBe(false); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("does not overwrite a persisted setting with INITIAL_SETTINGS_PRESET", async () => { + await writeFile( + path.join(tempHome, "settings.json"), + JSON.stringify({ showAssistantRunFooter: true }), + ); + vi.resetModules(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", '{"showAssistantRunFooter":false}'); + vi.stubEnv("OPENCODE_TELEGRAM_HOME", tempHome); + + const store = await import("../../../src/app/stores/settings-store.js"); + await store.loadSettings(); + + expect(store.getShowAssistantRunFooter()).toBe(true); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("ignores unknown keys in INITIAL_SETTINGS_PRESET without crashing", async () => { + vi.resetModules(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", '{"unknownKey":true,"compactOutputMode":true}'); + + const store = await import("../../../src/app/stores/settings-store.js"); + await store.loadSettings(); + + expect(store.getCompactOutputMode()).toBe(true); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("ignores a preset key with the wrong type without crashing", async () => { + vi.resetModules(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", '{"compactOutputMode":"yes"}'); + + const store = await import("../../../src/app/stores/settings-store.js"); + await store.loadSettings(); + + expect(store.getCompactOutputMode()).toBe(false); vi.unstubAllEnvs(); vi.resetModules(); diff --git a/tests/config.test.ts b/tests/config.test.ts index e4e4f43d..8abfc632 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -72,28 +72,50 @@ describe("config boolean env parsing", () => { expect(config.bot.messageFormatMode).toBe("markdown"); }); - it("does not hide the run footer by default", async () => { - vi.stubEnv("HIDE_RUN_FOOTER", ""); + it("returns an empty preset when INITIAL_SETTINGS_PRESET is not set", async () => { + vi.stubEnv("INITIAL_SETTINGS_PRESET", ""); const config = await loadConfig(); - expect(config.bot.hideRunFooter).toBe(false); + expect(config.bot.initialSettingsPreset).toEqual({}); }); - it("parses HIDE_RUN_FOOTER as a boolean", async () => { - vi.stubEnv("HIDE_RUN_FOOTER", "true"); + it("parses a valid INITIAL_SETTINGS_PRESET JSON object", async () => { + vi.stubEnv( + "INITIAL_SETTINGS_PRESET", + '{"showAssistantRunFooter":false,"compactOutputMode":true}', + ); const config = await loadConfig(); - expect(config.bot.hideRunFooter).toBe(true); + expect(config.bot.initialSettingsPreset).toEqual({ + showAssistantRunFooter: false, + compactOutputMode: true, + }); }); - it("falls back to a visible run footer on invalid HIDE_RUN_FOOTER", async () => { - vi.stubEnv("HIDE_RUN_FOOTER", "banana"); + it("returns an empty preset when INITIAL_SETTINGS_PRESET contains invalid JSON", async () => { + vi.stubEnv("INITIAL_SETTINGS_PRESET", "{not valid json}"); const config = await loadConfig(); - expect(config.bot.hideRunFooter).toBe(false); + expect(config.bot.initialSettingsPreset).toEqual({}); + }); + + it("returns an empty preset when INITIAL_SETTINGS_PRESET is a JSON array", async () => { + vi.stubEnv("INITIAL_SETTINGS_PRESET", '["not","an","object"]'); + + const config = await loadConfig(); + + expect(config.bot.initialSettingsPreset).toEqual({}); + }); + + it("returns an empty preset when INITIAL_SETTINGS_PRESET is a JSON scalar", async () => { + vi.stubEnv("INITIAL_SETTINGS_PRESET", "true"); + + const config = await loadConfig(); + + expect(config.bot.initialSettingsPreset).toEqual({}); }); it("parses supported locale from BOT_LOCALE", async () => {