Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion plugins/summarize/plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand All @@ -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."

Expand Down Expand Up @@ -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 \
Expand All @@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion src/config/prune.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
58 changes: 58 additions & 0 deletions src/config/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -57,6 +58,34 @@ fn read(path: &Path) -> Result<String, ConfigError> {
}
}

/// 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::<Vec<_>>()[..] 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> {
Expand Down Expand Up @@ -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();
Expand Down
36 changes: 34 additions & 2 deletions src/plugin/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub(crate) const MANIFEST_FILE: &str = "plugin.toml";
/// An option's default that depends on another option's value:
/// `{ key = "<option>", values = { <value> = <default>, ... } }`.
const DEFAULT_BY: &str = "x-default-by";
/// The option whose change clears this one, such as a provider's key.
const RESET_BY: &str = "x-reset-by";
pub(crate) const COMPONENT_FILE: &str = "plugin.wasm";

/// A plugin's `plugin.toml`.
Expand Down Expand Up @@ -112,9 +114,31 @@ impl Manifest {
self.check_defaults_by()
}

/// Every `x-default-by` names an option with choices, gives a default
/// for each choice, and every choice's defaults satisfy the schema.
/// The options a change of `key` clears.
pub(crate) fn reset_by<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a String> + 'a {
self.options
.iter()
.filter(move |(_, option)| option.get(RESET_BY).and_then(Value::as_str) == Some(key))
.map(|(name, _)| name)
}

/// Every `x-reset-by` names another option. Every `x-default-by` names an
/// option with choices, gives a default for each choice, and every
/// choice's defaults satisfy the schema.
fn check_defaults_by(&self) -> Result<(), String> {
for (name, option) in &self.options {
if let Some(by) = option.get(RESET_BY) {
if !by
.as_str()
.is_some_and(|key| key != name && self.options.contains_key(key))
{
return Err(format!(
"{}: option {name:?}: {RESET_BY} must name another option",
self.name
));
}
}
}
for (name, option) in &self.options {
let Some(by) = option.get(DEFAULT_BY) else {
continue;
Expand Down Expand Up @@ -712,6 +736,14 @@ mod tests {
)
.unwrap_err();
assert!(cycle.contains("cycle"), "{cycle}");
let reset = super::Manifest::parse(
"name = 'p'\ntitle = 'P'\n[options.key]\ntitle = 'Key'\ntype = 'string'\n\"x-reset-by\" = 'missing'\n",
)
.unwrap_err();
assert!(
reset.contains("x-reset-by must name another option"),
"{reset}"
);
}

#[test]
Expand Down
9 changes: 9 additions & 0 deletions tui/packages/hunk/src/diffr/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>((value, part) => (value && typeof value === "object" ? (value as Record<string, unknown>)[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();
Expand Down
44 changes: 42 additions & 2 deletions tui/packages/hunk/src/ui/Settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<Record<string, unknown>>((object, part) => object[part] as Record<string, unknown>, values);
parent[parts.at(-1)!] = parseValue(settings.find((setting) => setting.key === key)!, value);
},
};
let quit = false;
Expand Down Expand Up @@ -135,3 +141,37 @@ 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(
<Settings client={client} initial={flattenSchema(schemaFixture, values)} onQuit={() => {}} />,
{ 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(); });
const query = "provider";
for (const char of query) await press(char);
await press("RETURN");
for (const _ of query) await press("BACKSPACE");
expect(line("Provider")).toContain("none");
expect(line("Model")).toContain("none-model");
} finally {
t.renderer.destroy();
}
});
6 changes: 4 additions & 2 deletions tui/packages/hunk/src/ui/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
formatValue,
isDefault,
parseValue,
withValues,
type ConfigClient,
type Setting,
} from "../diffr/config";
Expand Down Expand Up @@ -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 },
});
};
Expand Down
Loading