[1 of 4] tui: route primary settings writes through app server (#22913)

## Why
The TUI can run against a remote app server, but several high-traffic
settings still persisted by editing the local config file. That sends
remote sessions' preference writes to the wrong machine and lets local
disk state drift from the app-server-owned config.

This is **[1 of 4]** in a stacked series that moves TUI-owned config
mutations onto app-server APIs.

## What changed
- Added a small TUI helper for typed app-server config writes.
- Routed primary interactive preference writes through
`config/batchWrite`.
- Preserved existing profile scoping for settings that already support
`profiles.<profile>.*` overrides.

## Config keys affected
- `model`
- `model_reasoning_effort`
- `personality`
- `service_tier`
- `plan_mode_reasoning_effort`
- `approvals_reviewer`
- `notice.fast_default_opt_out`
- Profile-scoped equivalents under `profiles.<profile>.*`

## Suggested manual validation
- Connect the TUI to a remote app server, change `model` and
`model_reasoning_effort`, reconnect, and confirm the remote config
retained both values while the local `config.toml` did not change.
- Change `personality`, `plan_mode_reasoning_effort`, and the explicit
auto-review selection, then reconnect and confirm those choices persist
through the app server.
- Clear the service tier back to default and confirm `service_tier` is
cleared while `notice.fast_default_opt_out = true` is persisted
remotely.
- Repeat one setting change with an active profile and confirm the write
lands under `profiles.<profile>.*`.

## Stack
1. [#22913](https://github.com/openai/codex/pull/22913) `[1 of 4]`
primary settings writes
2. [#22914](https://github.com/openai/codex/pull/22914) `[2 of 4]` app
and skill enablement
3. [#22915](https://github.com/openai/codex/pull/22915) `[3 of 4]`
feature and memory toggles
4. [#22916](https://github.com/openai/codex/pull/22916) `[4 of 4]`
startup and onboarding bookkeeping
This commit is contained in:
Eric Traut
2026-05-16 14:27:02 -07:00
committed by GitHub
parent 061a614d85
commit 0445b290fe
5 changed files with 280 additions and 54 deletions
@@ -403,10 +403,47 @@ fn parse_key_path(path: &str) -> Result<Vec<String>, String> {
if path.trim().is_empty() {
return Err("keyPath must not be empty".to_string());
}
Ok(path
.split('.')
.map(std::string::ToString::to_string)
.collect())
let mut segments = Vec::new();
let mut segment = String::new();
let mut chars = path.chars();
let mut quoted = false;
// Split on dots unless they appear inside a quoted segment. Bare segments
// intentionally stay permissive so existing paths like `sample@catalog`
// remain valid.
while let Some(ch) = chars.next() {
match ch {
'"' if segment.is_empty() && !quoted => quoted = true,
'"' if quoted => quoted = false,
'\\' if quoted => {
// Quoted segments may escape punctuation that would otherwise
// participate in parsing, such as `.` or `"`.
let Some(escaped) = chars.next() else {
return Err("unterminated escape in keyPath".to_string());
};
segment.push(escaped);
}
'.' if !quoted => {
if segment.is_empty() {
return Err("keyPath segments must not be empty".to_string());
}
segments.push(std::mem::take(&mut segment));
}
'"' => return Err("invalid quoted keyPath segment".to_string()),
_ => segment.push(ch),
}
}
if quoted {
return Err("unterminated quoted keyPath segment".to_string());
}
if segment.is_empty() {
return Err("keyPath segments must not be empty".to_string());
}
segments.push(segment);
Ok(segments)
}
#[derive(Debug)]