-
Notifications
You must be signed in to change notification settings - Fork 22
feat(tui): Settings screen read/edit bosun.config.json fields inline with validation #439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b1c82b2
df1b666
ed9f3d2
a3bea20
ebb79af
20e3d81
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -224,7 +224,7 @@ import { | |
| getAllTasks as getAllInternalTasks, | ||
| } from "../task/task-store.mjs"; | ||
| import { createAgentEndpoint } from "../agent/agent-endpoint.mjs"; | ||
| import { createAgentEventBus } from "../agent/agent-event-bus.mjs"; | ||
| import { createAgentEventBus } from "../agent/agent-event-bus.mjs";`r`nimport { onConfigReload } from "../ui/tui/config-events.js"; | ||
| import { createReviewAgent } from "../agent/review-agent.mjs"; | ||
|
|
||
| import { createErrorDetector } from "./error-detector.mjs"; | ||
|
|
@@ -13286,6 +13286,10 @@ async function reloadConfig(reason) { | |
| } | ||
| } | ||
|
|
||
| const stopTuiConfigReloadListener = onConfigReload((payload = {}) => { | ||
| runDetached("config-reload:tui", () => reloadConfig(payload.reason || "tui-settings")); | ||
| }); | ||
|
|
||
| process.on("SIGINT", async () => { | ||
| shuttingDown = true; | ||
| stopWorkspaceSyncTimers(); | ||
|
|
@@ -13333,7 +13337,7 @@ process.on("SIGINT", async () => { | |
| }); | ||
|
|
||
| // Windows: closing the terminal window doesn't send SIGINT/SIGTERM reliably. | ||
| process.on("exit", () => { | ||
| process.on("exit", () => {`r`n try { stopTuiConfigReloadListener?.(); } catch { /* best effort */ } | ||
| shuttingDown = true; | ||
|
Comment on lines
+13340
to
13341
|
||
| stopWorkspaceSyncTimers(); | ||
| stopAgentAlertTailer(); | ||
|
|
@@ -15037,3 +15041,5 @@ export { | |
| }; | ||
|
|
||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,121 @@ | ||||||
| import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; | ||||||
| import { tmpdir } from "node:os"; | ||||||
| import { join } from "node:path"; | ||||||
|
|
||||||
| import React from "react"; | ||||||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||||||
|
|
||||||
| import SettingsScreen from "../ui/tui/SettingsScreen.js"; | ||||||
| import { renderInk } from "./tui/render-ink.mjs"; | ||||||
| import { waitFor } from "./tui/render-helpers.mjs"; | ||||||
|
|
||||||
| function createConfigDir(config) { | ||||||
| const dir = mkdtempSync(join(tmpdir(), "bosun-settings-")); | ||||||
| const path = join(dir, "bosun.config.json"); | ||||||
| writeFileSync(path, JSON.stringify(config, null, 2)); | ||||||
| return { dir, path }; | ||||||
| } | ||||||
|
|
||||||
| function readJson(path) { | ||||||
| return JSON.parse(readFileSync(path, "utf8")); | ||||||
| } | ||||||
|
|
||||||
| describe("tui settings screen", () => { | ||||||
|
||||||
| describe("tui settings screen", () => { | |
| describe.skip("tui settings screen (moved to tests/tui/settings-screen.test.mjs)", () => { |
Copilot
AI
Mar 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
originalEnv is captured as a reference (const originalEnv = process.env) and later restored via process.env = originalEnv, which can reintroduce mutations from other tests. Use the repo’s common pattern of snapshotting (const originalEnv = { ...process.env }) and restoring with a shallow copy in afterEach/beforeEach.
Check failure on line 59 in tests/tui-settings-screen.test.mjs
GitHub Actions / Build + Tests
tests/tui-settings-screen.test.mjs > tui settings screen > renders grouped config fields with masked secrets and source labels
AssertionError: expected 'Settings\nEdit bosun.config.json inli…' to contain 'Cost Rates'
- Expected
+ Received
- Cost Rates
+ Settings
+ Edit bosun.config.json inline. Enter edits, Ctrl+S saves, Esc cancels, Space toggles booleans, arrows cycle enums, U unmasks secrets.
+
+ General
+ > $schema: - (default)
+ projectName: - (default)
+ mode: - (default)
+ orchestratorScript: - (default)
+ orchestratorArgs: - (default)
+ logDir: - (default)
+ logMaxSizeMb: 500 (default)
+ logCleanupIntervalMin: 30 (default)
+ telegramVerbosity: summary (default)
+ watchPath: - (default)
+ watchEnabled: - (default)
+ echoLogs: - (default)
+ autoFixEnabled: - (default)
+ workflowFirst: false (default)
+ workflowOwnsTaskLifecycle: false (default)
+ workflowWorktreeRecoveryCooldownMin: 15 (default)
+ workflowDefaults.profile: - (default)
+ workflowDefaults.autoInstall: - (default)
+ workflowDefaults.templates: - (default)
+ workflowDefaults.templateOverridesById: - (default)
+ workflows: - (default)
+ prAutomation.attachMode: all (default)
+ prAutomation.trustedAuthors: - (default)
+ prAutomation.allowTrustedFixes: false (default)
+ prAutomation.allowTrustedMerges: false (default)
+ prAutomation.assistiveActions.installOnSetup: false (default)
+ gates.prs.repoVisibility: unknown (default)
+ gates.prs.automationPreference: runtime-first (default)
+ gates.prs.githubActionsBudget: ask-user (default)
+ gates.checks.mode: all (default)
+ gates.checks.requiredPatterns: - (default)
+ gates.checks.optionalPatterns: - (default)
+ gates.checks.ignorePatterns: - (default)
+ gates.checks.requireAnyRequiredCheck: true (default)
+ gates.checks.treatPendingRequiredAsBlocking: true (default)
+ gates.checks.treatNeutralAsPass: false (default)
+ gates.execution.sandboxMode: - (default)
+ gates.execution.containerIsolationEnabled: false (default)
+ gates.execution.containerRuntime: - (default)
+ gates.execution.networkAccess: - (default)
+ gates.worktrees.requireBootstrap: - (default)
+ gates.worktrees.requireReadiness: - (default)
+ gates.worktrees.enforcePushHook: true (default)
+ gates.runtime.enforceBacklog: true (default)
+ gates.runtime.agentTriggerControl: true (default)
+ markdownSafety.enabled: true (default)
+ markdownSafety.auditLogPath: - (default)
+ markdownSafety.allowlist: - (default)
+ telegramUiTunnel: named (default)
+ telegramUiAllowQuickTunnelFallback: false (default)
+ cloudflareTunnelCredentials: - (default)
+ cloudflareBaseDomain: - (default)
+ cloudflareTunnelHostname: - (default)
+ cloudflareUsernameHostnamePolicy: per-user-fixed (default)
+ cloudflareDnsSyncEnabled: true (default)
+ cloudflareDnsMaxRetries: 3 (default)
+ cloudflareDnsRetryBaseMs: 750 (default)
+ telegramUiFallbackAuthEnabled: true (default)
+ telegramUiFallbackAuthRateLimitIpPerMin: 10 (default)
+ telegramUiFallbackAuthRateLimitGlobalPerMin: 60 (default)
+ telegramUiFallbackAuthMaxFailures: 5 (default)
+ telegramUiFallbackAuthLockoutMs: 600000 (default)
+ telegramUiFallbackAuthRotateDays: 30 (default)
+ telegramUiFallbackAuthTransientCooldownMs: 5000 (default)
+ voice.enabled: true (default)
+ voice.provider: auto (default)
+ voice.providers: - (default)
+ voice.model: gpt-4o-realtime-preview-2024-12-17 (default)
+ voice.visionModel: gpt-4.1-mini (default)
+ voice.openaiApiKey: - (default)
+ voice.openaiAccessToken: - (default)
+ voice.azureApiKey: - (default)
+ voice.azureAccessToken: - (default)
+ voice.azureEndpoint: - (default)
+ voice.azureDeployment: gpt-4o-realtime-preview (default)
+ voice.claudeApiKey: - (default)
+ voice.claudeAccessToken: - (default)
+ voice.geminiApiKey: - (default)
+ voice.geminiAccessToken: - (default)
+ voice.voiceId: alloy (default)
+ voice.turnDetection: semantic_vad (default)
+ voice.instructions: - (default)
+ voice.fallbackMode: browser (default)
+ voice.failover.enabled: true (default)
+ voice.failover.maxAttempts: 2 (default)
+ voice.voiceEndpoints: - (default
Check failure on line 59 in tests/tui-settings-screen.test.mjs
GitHub Actions / 🔁 Existing E2E Suite
tests/tui-settings-screen.test.mjs > tui settings screen > renders grouped config fields with masked secrets and source labels
AssertionError: expected 'Settings\nEdit bosun.config.json inli…' to contain 'Cost Rates'
- Expected
+ Received
- Cost Rates
+ Settings
+ Edit bosun.config.json inline. Enter edits, Ctrl+S saves, Esc cancels, Space toggles booleans, arrows cycle enums, U unmasks secrets.
+
+ General
+ > $schema: - (default)
+ projectName: - (default)
+ mode: - (default)
+ orchestratorScript: - (default)
+ orchestratorArgs: - (default)
+ logDir: - (default)
+ logMaxSizeMb: 500 (default)
+ logCleanupIntervalMin: 30 (default)
+ telegramVerbosity: summary (default)
+ watchPath: - (default)
+ watchEnabled: - (default)
+ echoLogs: - (default)
+ autoFixEnabled: - (default)
+ workflowFirst: false (default)
+ workflowOwnsTaskLifecycle: false (default)
+ workflowWorktreeRecoveryCooldownMin: 15 (default)
+ workflowDefaults.profile: - (default)
+ workflowDefaults.autoInstall: - (default)
+ workflowDefaults.templates: - (default)
+ workflowDefaults.templateOverridesById: - (default)
+ workflows: - (default)
+ prAutomation.attachMode: all (default)
+ prAutomation.trustedAuthors: - (default)
+ prAutomation.allowTrustedFixes: false (default)
+ prAutomation.allowTrustedMerges: false (default)
+ prAutomation.assistiveActions.installOnSetup: false (default)
+ gates.prs.repoVisibility: unknown (default)
+ gates.prs.automationPreference: runtime-first (default)
+ gates.prs.githubActionsBudget: ask-user (default)
+ gates.checks.mode: all (default)
+ gates.checks.requiredPatterns: - (default)
+ gates.checks.optionalPatterns: - (default)
+ gates.checks.ignorePatterns: - (default)
+ gates.checks.requireAnyRequiredCheck: true (default)
+ gates.checks.treatPendingRequiredAsBlocking: true (default)
+ gates.checks.treatNeutralAsPass: false (default)
+ gates.execution.sandboxMode: - (default)
+ gates.execution.containerIsolationEnabled: false (default)
+ gates.execution.containerRuntime: - (default)
+ gates.execution.networkAccess: - (default)
+ gates.worktrees.requireBootstrap: - (default)
+ gates.worktrees.requireReadiness: - (default)
+ gates.worktrees.enforcePushHook: true (default)
+ gates.runtime.enforceBacklog: true (default)
+ gates.runtime.agentTriggerControl: true (default)
+ markdownSafety.enabled: true (default)
+ markdownSafety.auditLogPath: - (default)
+ markdownSafety.allowlist: - (default)
+ telegramUiTunnel: named (default)
+ telegramUiAllowQuickTunnelFallback: false (default)
+ cloudflareTunnelCredentials: - (default)
+ cloudflareBaseDomain: - (default)
+ cloudflareTunnelHostname: - (default)
+ cloudflareUsernameHostnamePolicy: per-user-fixed (default)
+ cloudflareDnsSyncEnabled: true (default)
+ cloudflareDnsMaxRetries: 3 (default)
+ cloudflareDnsRetryBaseMs: 750 (default)
+ telegramUiFallbackAuthEnabled: true (default)
+ telegramUiFallbackAuthRateLimitIpPerMin: 10 (default)
+ telegramUiFallbackAuthRateLimitGlobalPerMin: 60 (default)
+ telegramUiFallbackAuthMaxFailures: 5 (default)
+ telegramUiFallbackAuthLockoutMs: 600000 (default)
+ telegramUiFallbackAuthRotateDays: 30 (default)
+ telegramUiFallbackAuthTransientCooldownMs: 5000 (default)
+ voice.enabled: true (default)
+ voice.provider: auto (default)
+ voice.providers: - (default)
+ voice.model: gpt-4o-realtime-preview-2024-12-17 (default)
+ voice.visionModel: gpt-4.1-mini (default)
+ voice.openaiApiKey: - (default)
+ voice.openaiAccessToken: - (default)
+ voice.azureApiKey: - (default)
+ voice.azureAccessToken: - (default)
+ voice.azureEndpoint: - (default)
+ voice.azureDeployment: gpt-4o-realtime-preview (default)
+ voice.claudeApiKey: - (default)
+ voice.claudeAccessToken: - (default)
+ voice.geminiApiKey: - (default)
+ voice.geminiAccessToken: - (default)
+ voice.voiceId: alloy (default)
+ voice.turnDetection: semantic_vad (default)
+ voice.instructions: - (default)
+ voice.fallbackMode: browser (default)
+ voice.failover.enabled: true (default)
+ voice.failover.maxAttempts: 2 (default)
+ voice.voiceEndpoints: - (default
Copilot
AI
Mar 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test expects schema-driven fields like telegram.uiPort / telegram.token and costRates.*, but bosun.schema.json does not define a telegram object or costRates, so SettingsScreen won’t render these rows and waitFor(() => view.text().includes("uiPort: 3080")) will time out. Update the test to use fields that exist in the schema (e.g., cloudflareDnsMaxRetries, kanban.backend, kanban.github.project.webhook.secret, etc.) and the correct env var names.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import React from "react"; | ||
| import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import SettingsScreen from "../../ui/tui/SettingsScreen.js"; | ||
| import { renderInk } from "./render-ink.mjs"; | ||
|
|
||
| function makeConfigDir(config) { | ||
| const dir = mkdtempSync(join(tmpdir(), "bosun-settings-")); | ||
| writeFileSync(join(dir, "bosun.config.json"), `${JSON.stringify(config, null, 2)}\n`, "utf8"); | ||
| return dir; | ||
| } | ||
|
|
||
| describe("tui settings screen", () => { | ||
| const originalEnv = process.env; | ||
|
|
||
| beforeEach(() => { | ||
| process.env = { ...originalEnv }; | ||
| delete process.env.TELEGRAM_BOT_TOKEN; | ||
| delete process.env.KANBAN_BACKEND; | ||
| delete process.env.LINEAR_API_KEY; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| process.env = originalEnv; | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it("renders grouped schema fields and masks secrets by default", async () => { | ||
| process.env.TELEGRAM_BOT_TOKEN = "env-secret-token"; | ||
|
|
||
| const configDir = makeConfigDir({ | ||
| projectName: "demo", | ||
| kanban: { backend: "github" }, | ||
| linear: { apiKey: "super-secret" }, | ||
| telegramBotToken: "local-secret", | ||
| costRates: { inputPer1M: 1, outputPer1M: 2 }, | ||
| }); | ||
|
|
||
| const view = await renderInk( | ||
| React.createElement(SettingsScreen, { configDir, config: {} }), | ||
| { columns: 220, waitMs: 120 }, | ||
| ); | ||
|
|
||
| const text = view.text(); | ||
| expect(text).toContain("General"); | ||
| expect(text).toContain("Kanban"); | ||
| expect(text).toContain("Integrations"); | ||
| expect(text).toContain("Cost Rates"); | ||
|
Check failure on line 51 in tests/tui/settings-screen.test.mjs
|
||
| expect(text).toContain("linear.apiKey"); | ||
| expect(text).toContain("telegramBotToken"); | ||
| expect(text).toContain("from env"); | ||
| expect(text).toContain("****"); | ||
| expect(text).not.toContain("env-secret-token"); | ||
|
|
||
| await view.unmount(); | ||
| }); | ||
|
|
||
| it("writes enum changes to bosun.config.json and emits reload", async () => { | ||
|
Check failure on line 61 in tests/tui/settings-screen.test.mjs
|
||
| const configDir = makeConfigDir({ kanban: { backend: "github" } }); | ||
| const emitReload = vi.fn(); | ||
|
|
||
| const view = await renderInk( | ||
| React.createElement(SettingsScreen, { configDir, config: {}, onConfigReload: emitReload }), | ||
| { columns: 220, waitMs: 120 }, | ||
| ); | ||
|
|
||
| for (let index = 0; index < 120 && !view.text().includes("> kanban.backend"); index += 1) { | ||
| await view.press("j", 5); | ||
| } | ||
| await view.press("\u001b[C", 120); | ||
|
|
||
| const updated = JSON.parse(readFileSync(join(configDir, "bosun.config.json"), "utf8")); | ||
| expect(updated.kanban.backend).not.toBe("github"); | ||
| expect(emitReload).toHaveBeenCalledTimes(1); | ||
| expect(emitReload).toHaveBeenCalledWith(expect.objectContaining({ | ||
| configPath: join(configDir, "bosun.config.json"), | ||
| reason: "settings-save", | ||
| })); | ||
|
|
||
| await view.unmount(); | ||
| }); | ||
|
|
||
| it("prevents invalid numeric saves and leaves file unchanged", async () => { | ||
| const configDir = makeConfigDir({ cloudflareDnsMaxRetries: 3 }); | ||
| const before = readFileSync(join(configDir, "bosun.config.json"), "utf8"); | ||
| const view = await renderInk( | ||
| React.createElement(SettingsScreen, { configDir, config: {} }), | ||
| { columns: 220, waitMs: 120 }, | ||
| ); | ||
|
|
||
| for (let index = 0; index < 220 && !view.text().includes("> cloudflareDnsMaxRetries"); index += 1) { | ||
| await view.press("j", 5); | ||
| } | ||
| await view.press("\r", 60); | ||
| await view.press("abc", 20); | ||
| await view.press("\u0013", 120); | ||
|
|
||
| expect(view.text()).toContain("Validation error"); | ||
|
Check failure on line 101 in tests/tui/settings-screen.test.mjs
|
||
| const after = readFileSync(join(configDir, "bosun.config.json"), "utf8"); | ||
| expect(after).toBe(before); | ||
|
|
||
| await view.unmount(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new import line contains literal
rncharacters (likely from an escaped CRLF) which makes the file invalid JavaScript. Split this into a normal standaloneimport { onConfigReload ... }` statement on its own line and remove the stray characters/backticks.