From 9e4feada322a3c69d576c2cc68707536280c8ccb Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Thu, 1 Oct 2026 13:35:04 -0400 Subject: [PATCH 1/4] Reread settings after a change in the TUI A change can move other values, such as a model default that follows the provider, so the settings screen rereads config show after each write instead of updating only the edited row. --- tui/packages/hunk/src/diffr/config.ts | 9 +++++ tui/packages/hunk/src/ui/Settings.test.tsx | 43 +++++++++++++++++++++- tui/packages/hunk/src/ui/Settings.tsx | 6 ++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/tui/packages/hunk/src/diffr/config.ts b/tui/packages/hunk/src/diffr/config.ts index a899870b2..8e95a74b7 100644 --- a/tui/packages/hunk/src/diffr/config.ts +++ b/tui/packages/hunk/src/diffr/config.ts @@ -96,6 +96,15 @@ export function flattenSchema(rawSchema: unknown, rawValues: unknown): Setting[] walk(root, rawValues, ""); return settings; } +/** Each setting's value as `values`, the output of `config show`, has it. */ +export function withValues(settings: Setting[], values: unknown): Setting[] { + return settings.map((setting) => ({ + ...setting, + value: setting.key + .split(".") + .reduce((value, part) => (value && typeof value === "object" ? (value as Record)[part] : undefined), values), + })); +} /** Subsequence match: every query character in order, scored by tightness and word starts. */ export function fuzzyScore(query: string, text: string): number | null { const q = query.toLowerCase(), t = text.toLowerCase(); diff --git a/tui/packages/hunk/src/ui/Settings.test.tsx b/tui/packages/hunk/src/ui/Settings.test.tsx index 1e755375a..dd8977761 100644 --- a/tui/packages/hunk/src/ui/Settings.test.tsx +++ b/tui/packages/hunk/src/ui/Settings.test.tsx @@ -3,7 +3,7 @@ import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import { Settings, displayValue, isSecret, nextValue } from "./Settings"; import { schemaFixture, valuesFixture } from "../diffr/config.test"; -import { flattenSchema, type ConfigClient } from "../diffr/config"; +import { flattenSchema, parseValue, type ConfigClient } from "../diffr/config"; test("values show as not set, secrets only as stored, and toggles flip or cycle", () => { const [minLines, collapse, , provider, apiKey] = flattenSchema(schemaFixture, valuesFixture); @@ -20,11 +20,17 @@ test("values show as not set, secrets only as stored, and toggles flip or cycle" test("rows lead with titles under group headings; toggles change in place and typed values open a prompt", async () => { const writes: [string, string][] = []; + const values = structuredClone(valuesFixture); + const settings = flattenSchema(schemaFixture, valuesFixture); const client: ConfigClient = { schema: () => schemaFixture, - show: () => structuredClone(valuesFixture), + show: () => structuredClone(values), set: (key, value) => { writes.push([key, value]); + // Store it as diffr would, typed by the setting. + const parts = key.split("."); + const parent = parts.slice(0, -1).reduce>((object, part) => object[part] as Record, values); + parent[parts.at(-1)!] = parseValue(settings.find((setting) => setting.key === key)!, value); }, }; let quit = false; @@ -135,3 +141,36 @@ test("rows lead with titles under group headings; toggles change in place and ty await act(async () => { t.renderer.destroy(); }); } }); + +test("a change shows every value it moves, such as a default that follows the provider", async () => { + const values = structuredClone(valuesFixture); + const client: ConfigClient = { + schema: () => schemaFixture, + show: () => structuredClone(values), + set: (key, value) => { + if (key === "plugins.bundled.summarize.provider") { + values.plugins.bundled.summarize.provider = value; + values.plugins.bundled.summarize.model = `${value}-model`; + } + }, + }; + const t = await testRender( + {}} />, + { width: 100, height: 24 }, + ); + const press = async (key: string) => { + await act(async () => { t.mockInput.pressKey(key); }); + await act(async () => { await t.renderOnce(); }); + }; + const line = (text: string) => t.captureCharFrame().split("\n").find((l) => l.includes(text)) ?? ""; + try { + await act(async () => { await t.renderOnce(); }); + for (const char of "provider") await press(char); + await press("RETURN"); + for (let i = 0; i < 8; i++) await press("BACKSPACE"); + expect(line("Provider")).toContain("none"); + expect(line("Model")).toContain("none-model"); + } finally { + t.renderer.destroy(); + } +}); diff --git a/tui/packages/hunk/src/ui/Settings.tsx b/tui/packages/hunk/src/ui/Settings.tsx index 74aaa4be9..6b59b0b23 100644 --- a/tui/packages/hunk/src/ui/Settings.tsx +++ b/tui/packages/hunk/src/ui/Settings.tsx @@ -7,6 +7,7 @@ import { formatValue, isDefault, parseValue, + withValues, type ConfigClient, type Setting, } from "../diffr/config"; @@ -99,12 +100,13 @@ export function Settings({ const current = visible[selected]; const theme = dark; - /** Write through the CLI first; the row changes only once the write succeeded. */ + /** Write through the CLI first, then reread every value: a change can move + * others, such as a default that follows the provider. */ const save = (setting: Setting, text: string) => { const value = parseValue(setting, text); client.set(setting.key, text); update({ - settings: state.current.settings.map((s) => (s.key === setting.key ? { ...s, value } : s)), + settings: withValues(state.current.settings, client.show()), status: { text: `${setting.title}: ${displayValue({ ...setting, value })}`, error: false }, }); }; From dcb9f17ef6a25abb1ad5be709e6be587f6ac8ba0 Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Thu, 1 Oct 2026 14:09:39 -0400 Subject: [PATCH 2/4] Clear a provider's key, endpoint and model when the provider changes Options marked x-reset-by = "provider" are cleared by config set when the provider takes a new value, so a switch in the TUI or the CLI never sends the old vendor's key to the new one or to its endpoint. The prompt and general settings stay. --- plugins/summarize/plugin.toml | 6 +++- src/config/prune.rs | 2 +- src/config/store.rs | 58 +++++++++++++++++++++++++++++++++++ src/plugin/config.rs | 31 +++++++++++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/plugins/summarize/plugin.toml b/plugins/summarize/plugin.toml index 7f633cd92..d9db261d1 100644 --- a/plugins/summarize/plugin.toml +++ b/plugins/summarize/plugin.toml @@ -19,6 +19,7 @@ default = "gemini" [options.model] type = "string" +"x-reset-by" = "provider" title = "Model" description = "The model name sent to the provider. Unset uses the provider's default." @@ -35,11 +36,13 @@ default = 20 [options.api_key] type = "string" +"x-reset-by" = "provider" title = "API key" description = "Sent to the selected provider. The provider's variable in the environment is used when this is unset. Optional for `openai` with a custom `endpoint`." [options.endpoint] type = "string" +"x-reset-by" = "provider" title = "Endpoint URL" description = "Override the provider's base URL, for proxies, OpenAI-compatible servers (for example `https://openrouter.ai/api/v1` or `http://localhost:11434/v1`) and tests. Empty uses the provider's default." @@ -68,7 +71,7 @@ default = 3 [options.system_prompt] type = "string" title = "System prompt" -description = "The system instruction sent with every request; diffr builds each request's message from the file's numbered lines and the folds to summarize. Unset uses the default: https://github.com/devdotfast/diffr/blob/main/plugins/summarize/plugin.toml#L72-L78" +description = "The system instruction sent with every request; diffr builds each request's message from the file's numbered lines and the folds to summarize. Unset uses the default: https://github.com/devdotfast/diffr/blob/main/plugins/summarize/plugin.toml#L75-L81" default = """\ For each listed fold, rewrite that function body as short \ pseudocode. Keep the names. No prose, no comments, no code fences. Use as few lines as \ @@ -94,6 +97,7 @@ default = 20 # screens do not edit an object option, so this is a file-only override. [options.provider_details] type = "object" +"x-reset-by" = "provider" title = "Provider details" description = "The provider's default endpoint, the environment variables that hold its key, and whether a custom endpoint may go without a key." required = ["endpoint", "key_variables", "keyless_custom_endpoint"] diff --git a/src/config/prune.rs b/src/config/prune.rs index d69cd55c6..9c790f44e 100644 --- a/src/config/prune.rs +++ b/src/config/prune.rs @@ -154,7 +154,7 @@ fn get<'a>(table: &'a dyn TableLike, path: &[&str]) -> Option<&'a Item> { } } -fn remove(table: &mut dyn TableLike, path: &[&str]) { +pub(crate) fn remove(table: &mut dyn TableLike, path: &[&str]) { match path { [key] => { table.remove(key); diff --git a/src/config/store.rs b/src/config/store.rs index f7d377c8d..5716d59e1 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -44,6 +44,7 @@ pub(crate) fn set(path: &Path, key: &str, value: &str) -> Result<(), ConfigError let mut document: toml_edit::DocumentMut = existing .parse() .map_err(|error| ConfigError(format!("{}: {error}", path.display())))?; + reset(&mut document, key, &typed, &existing, directory)?; assign(&mut document, key, &typed)?; prune(&mut document, directory)?; write(path, document) @@ -57,6 +58,34 @@ fn read(path: &Path) -> Result { } } +/// When `key` takes a new value, clear the options of its plugin that +/// belong to it: a provider's key, endpoint and model go with the provider. +fn reset( + document: &mut toml_edit::DocumentMut, + key: &str, + value: &toml::Value, + existing: &str, + directory: &Path, +) -> Result<(), ConfigError> { + let ["plugins", namespace, name, field] = key.split('.').collect::>()[..] else { + return Ok(()); + }; + let config = Config::from_toml_in(existing, directory)?; + let Some(entry) = config.plugins.entries.get(&format!("{namespace}.{name}")) else { + return Ok(()); + }; + if entry.options.get(field) == serde_json::to_value(value).ok().as_ref() { + return Ok(()); + } + for option in entry.folder().manifest.reset_by(field) { + super::prune::remove( + document.as_table_mut(), + &["plugins", namespace, name, option], + ); + } + Ok(()) +} + /// Validate the document, then keep only what differs from the defaults, /// and the file's `version`. fn prune(document: &mut toml_edit::DocumentMut, directory: &Path) -> Result<(), ConfigError> { @@ -609,6 +638,35 @@ mod sparse_tests { ); } + #[test] + fn a_new_provider_clears_the_old_ones_settings_but_keeps_the_prompt() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let summarize = |key: &str, value: &str| { + set(&path, &format!("plugins.bundled.summarize.{key}"), value).unwrap() + }; + summarize("provider", "anthropic"); + summarize("api_key", "anthropic-key"); + summarize("endpoint", "https://proxy.example/anthropic"); + summarize("model", "claude-sonnet-5-5"); + summarize("system_prompt", "Be terse."); + summarize("min_lines", "7"); + // The same provider again changes nothing. + summarize("provider", "anthropic"); + let raw = read_toml(&path); + assert_eq!( + raw["plugins"]["bundled"]["summarize"]["api_key"].as_str(), + Some("anthropic-key") + ); + summarize("provider", "gemini"); + let raw = read_toml(&path); + let entry = raw["plugins"]["bundled"]["summarize"].as_table().unwrap(); + let mut keys: Vec<&str> = entry.keys().map(String::as_str).collect(); + keys.sort_unstable(); + // Gemini is the default provider, so it is pruned too. + assert_eq!(keys, ["min_lines", "system_prompt"], "{raw}"); + } + #[test] fn an_explicit_plugin_list_still_pins_membership() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/plugin/config.rs b/src/plugin/config.rs index c184a55c6..f2f3c2c6c 100644 --- a/src/plugin/config.rs +++ b/src/plugin/config.rs @@ -29,6 +29,8 @@ pub(crate) const MANIFEST_FILE: &str = "plugin.toml"; /// An option's default that depends on another option's value: /// `{ key = "