Skip to content

feat(ai): overhaul the AI subsystem - #79

Merged
noahbclarkson merged 7 commits into
mainfrom
feat/ai-subsystem-overhaul
Sep 7, 2026
Merged

feat(ai): overhaul the AI subsystem#79
noahbclarkson merged 7 commits into
mainfrom
feat/ai-subsystem-overhaul

Conversation

@noahbclarkson

Copy link
Copy Markdown
Owner

Implements the AI subsystem overhaul plan in full, bar one item noted below.

The four bugs that were reachable out of the box

What happened
Anthropic had never worked The tool loop sent messages: [] on iteration 1, which the Messages API rejects with a 400. use_tools defaults to true, so that was the default path — this provider had never produced a commit message for anyone.
A fresh install showed no model selected The shipped default was gemini-2.0-flash, which appeared in no picker, and pill_group marks a pill selected on exact string equality. Three separate places disagreed about what the default was.
The model row overflowed the window pill_group was the one pill row in the file without flex_wrap(). Flex children default to min-width: auto, so the row grew past its card and was sliced at the window edge. At the 880px default the default provider's own model list did not fit.
The tool sandbox would exfiltrate secrets The traversal check was sound, but there was no content policy: get_file_content("../.env") was correctly rejected and get_file_content(".env") was accepted, uploading DATABASE_URL=postgres://user:pass@… to the provider and echoing it back into the conversation for the remaining iterations.

What changed

Correctness. build_request_body is a pure function per provider, guarded by a test asserting none of them sends an empty conversation on the first iteration. String provider dispatch became an AiProvider enum owning endpoint, auth shape and default model, with unknown ids in settings.json reported through load_warnings rather than surfacing after a click. Every Gemini response field is now optional and all parts are iterated, so a safety block, a MAX_TOKENS stop, a leading thought part and parallel function calls stop collapsing into one opaque parse error. Response bodies are capped at 8 MB, the Gemini key moved from the query string to x-goog-api-key, and the diff walker uses from_utf8_lossy so non-UTF-8 lines stop silently vanishing from what the model reasons about.

Threading. The whole provider dispatch — HTTP, JSON parsing, git spawns, read_dir walks — moved onto the background executor, with tool progress arriving over an mpsc channel that a small foreground task drains. Every request has a 60s deadline and its Task is held, so a stalled provider no longer leaves the spinner running until the app is restarted. Every event carries a GenerationId { sequence, repo_path } and routes by repo path rather than active_tab, so a message generated for one tab cannot land in another's commit box. The cooldown is stamped on completion rather than dispatch, and reported as info without clearing an in-flight generation's spinner.

Security. get_file_content denies .git/, a credentials denylist, git-ignored paths and non-UTF-8 content before reading anything, and re-checks the canonicalised path so an in-repo symlink cannot reach .git/config.

Per-provider credentials. One keychain slot per provider, migrated from the single shared slot, with per-provider model pins — switching provider no longer destroys the previous provider's key or its model choice, and "connected" is no longer asserted for a provider the app has no credential for.

Features. Live model catalogue with stale-while-revalidate caching, a bundled fallback and a generation guard, labelled so "three weeks old" is distinguishable from "shipped with the app". OpenRouter, via the existing OpenAI-compatible path widened with an endpoint struct. base_url_override for that family, validated, warning inline which host the key will reach.

UI. A provider accordion replacing six flat cards, each row owning its key field, connection status and model pin, with a connection test that can tell a working key from a typo. The AI button states its own reason when it cannot be used, stays reachable when the fix is one click away, and finally shows the shortcut that was registered all along. Tool progress is routed to the panel chip, with cancel, regenerate-with-style, and undo of an AI overwrite. Errors stop auto-dismissing at 3s and can carry an action. The Save button is gone — text inputs flush on Enter and on blur, so the same value no longer persists or vanishes depending on how it arrived. tab_index now exists on the settings page, which previously had none across 4,215 lines.

Removals. last_result/last_error had no callers; uuid was one call site whose id was never transmitted; http_client was never referenced by path; fuzzy_score is now one shared implementation rather than three.

Migration

CURRENT_SETTINGS_VERSION 2 → 3. The v2→v3 step remaps known-retired model ids to successors, and migrate_legacy_secrets promotes the shared ai/default key into the active provider's slot. ai/default is deliberately left in place so a downgrade still finds its key; it can be deleted a release later. Every new AiSettings field is #[serde(default)], so v2 files load unchanged.

Verification

cargo fmt --all, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace all pass — 1665 tests, up from ~1180. New coverage is pure and display-free per the CLAUDE.md convention: request-body shape per provider, endpoint and attribution-header resolution, base_url_override validation, Gemini parsing against the four shapes that used to produce one error, catalogue parsing against a trimmed real OpenRouter fixture (negative-price BYOK row, null top_provider.context_length, records missing reasoning/benchmarks), filter/freshness/pinned-model classification, the tool denylist and output budget, and the shared AI guard.

Two things to know before merging

Streaming is not included. Everything else in that plan item — the overwrite guard, undo, read-only editors during generation — is. Streaming needs per-provider SSE parsing including tool-call deltas across three different wire formats, with no way to exercise it offline; the ToolCallStarted → chip re-route covers the feedback gap in the meantime. Happy to take it on separately.

The default model ids are unverified. They come from the plan's research, which flags them ⚠️ and says to confirm against a live API; I could not make real requests. The structural fix means a wrong id is now visible and correctable — the picker classifies it as Missing and offers the closest match — rather than silently rendering an unselected row, but each default still wants one real request to confirm before release.

🤖 Generated with Claude Code

Anthropic had never once produced a commit message: its tool loop sent
`messages: []` on the first iteration, which the Messages API rejects with a
400, and `use_tools` defaults to true so that was the default path. The
shipped default model was a retired id that appeared in no picker, so a fresh
install rendered the Model row with nothing selected at all. The model pill
row had no `flex_wrap()`, so the default window could not render the default
provider's own model list. `get_file_content` correctly rejected `../.env` and
then happily read `.env`. All four are fixed here, along with the structural
problems underneath them.

Correctness

- Seed an opening user turn for every provider, guarded by a test asserting
  no provider sends an empty conversation on iteration 1.
- Replace string provider dispatch with an `AiProvider` enum owning endpoint,
  auth shape and default model; unknown ids in settings.json are reported
  through `load_warnings` instead of failing at click time.
- Make every Gemini response field optional and iterate all parts, so a
  safety block, a `MAX_TOKENS` stop, a leading thought part and parallel
  function calls stop collapsing into one opaque parse error.
- Bail on a `tool_calls` finish with an empty array rather than burning
  iterations to reach a generic message.
- Cap response bodies at 8 MB, move the Gemini key from the query string to
  `x-goog-api-key`, and use `from_utf8_lossy` in the diff walker so non-UTF-8
  lines stop vanishing from what the model reasons about.

Threading and lifecycle

- Run the whole provider dispatch, tool execution and JSON parsing on the
  background executor, reporting tool progress over an mpsc channel that a
  small foreground task drains. `execute_tool` spawns `git` and walks
  directories synchronously; none of that belongs on the render thread.
- Give every request a 60s deadline and hold its `Task`, so a stalled provider
  no longer leaves the spinner running until the app is restarted, and a
  cancel button can drop it.
- Carry a `GenerationId { sequence, repo_path }` on every event and route by
  repo path rather than `active_tab`, so a message generated for one tab
  cannot land in another's commit box.
- Stamp the cooldown on completion rather than dispatch, and report it as
  info without clearing an in-flight generation's spinner.
- Skip unchanged keychain writes, debounce the rest by 400ms, and resolve all
  providers' keys in one pass.
- Retry 429/5xx honouring `Retry-After`, set an Anthropic cache breakpoint
  after the system prompt, lower the diff cap to 40 KB, and give tool output a
  per-generation budget.

Security

- Deny `.git/`, a credentials denylist, git-ignored paths and non-UTF-8
  content before `get_file_content` reads anything, and re-check the
  canonicalised path so an in-repo symlink cannot reach `.git/config`.

Per-provider credentials

- One keychain slot per provider (`ai/provider/{id}`), migrated from the
  single `ai/default` slot, with per-provider model pins so switching provider
  no longer destroys the previous provider's key or its model choice.

Features

- Live model catalogue with stale-while-revalidate caching, a bundled
  fallback, and a generation guard; the picker labels which source it is
  showing so "three weeks old" is distinguishable from "shipped with the app".
- OpenRouter, via the existing OpenAI-compatible path widened with an endpoint
  struct; the tool-support 404 gets an actionable message.
- `base_url_override` for the OpenAI-compatible family, validated, with an
  inline warning naming the host the key will be sent to.

UI

- Provider accordion replacing six flat cards, each row owning its key field,
  connection status and model pin, with a connection test that can tell a
  working key from a typo.
- `flex_wrap()` on `pill_group` and `overflow_hidden` on `setting_card`.
- The AI button states its own reason when it cannot be used, stays reachable
  when the fix is one click away, and shows the shortcut that was registered
  all along.
- Tool progress routed to the panel chip, cancel, regenerate with a style
  override, and undo of an AI overwrite.
- Errors stop auto-dismissing at 3s and can carry an action; per-level toast
  durations replace the single hardcoded one.
- Delete the Save button: text inputs now flush on Enter and on blur, so the
  same value no longer persists or vanishes depending on how it arrived.
- `tab_index` across the settings page, which previously had none.

Removals

- `last_result`/`last_error` had no callers; `uuid` was one call site whose id
  was never transmitted; `http_client` was never referenced by path.
- `fuzzy_score` is now one shared implementation rather than three.

Not included: streaming tokens into the summary field. It needs per-provider
SSE parsing including tool-call deltas across three wire formats, with no way
to exercise it offline; the progress trace covers the feedback gap meanwhile.

The recommended model ids come from the plan's research and have not been
verified against a live API. A wrong id is now visible and correctable in the
picker rather than silently unselectable, but each still wants one real
request to confirm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-07T11:07:16.388349Z df0df7e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff71bae62d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/rgitui_ai/src/catalog.rs Outdated
Request::builder(),
),
AiProvider::OpenAi => (
"https://api.openai.com/v1/models".to_string(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor the custom base URL when fetching models

When an OpenAI-compatible provider is configured with a custom gateway, automatic catalogue refreshes and “Test connection” still send the provider's stored key to this hardcoded official endpoint. Opening the AI settings can therefore disclose a gateway credential to OpenAI and report a failed connection even though generation correctly targets the override; derive the /models endpoint from base_url_override as well.

Useful? React with 👍 / 👎.

Comment on lines +342 to +343
.find(|tab| tab.effective_repo_path(cx) == id.repo_path)
.map(|tab| tab.commit_panel.clone())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route generation events to the original panel

If the user enters, leaves, or switches an inspected worktree while generation is running, effective_repo_path no longer equals the path captured in GenerationId. Subsequent progress/completion events then find no panel, so the generated message is discarded and the original commit panel can remain stuck in its generating state; capture the originating panel/tab identity rather than looking it up through mutable inspection state.

Useful? React with 👍 / 👎.

Comment on lines +399 to +400
if let Some(panel) = commit_panel_for(this, id, cx) {
panel.update(cx, |cp, cx| cp.fail_ai_generation(cx));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the idle state after cancellation

When the user explicitly cancels a generation, this routes the cancellation through fail_ai_generation, leaving the panel displaying the red “AI failed — retry” control even though no failure occurred. Cancellation should clear the generating state without marking the operation as failed.

Useful? React with 👍 / 👎.

Comment thread crates/rgitui_ai/src/provider.rs Outdated
Comment on lines +172 to +173
let bare = host.split(':').next().unwrap_or(host);
matches!(bare, "localhost" | "127.0.0.1" | "[::1]" | "::1")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse bracketed IPv6 loopback hosts correctly

For the valid local endpoint http://[::1]:11434/v1, splitting at the first colon produces "[", so is_loopback_host rejects it as insecure even though ::1 is explicitly listed as allowed. Parse the bracketed authority before removing the port so IPv6-only Ollama or other local gateways can be configured.

Useful? React with 👍 / 👎.

Honour `base_url_override` when fetching a model catalogue. The `/models`
request went to the official host unconditionally, so opening the AI
settings sent a gateway-only key to OpenAI and then reported the provider
as unreachable while generation was correctly reaching the gateway. The
catalogue cache is keyed by the resolved endpoint too, so removing an
override no longer leaves the gateway's models on screen, and changing one
drops the stale catalogue and connection result for the family.

Route AI generation events by the panel captured at dispatch rather than by
looking a tab up through `effective_repo_path`. That path moves when the
user enters or leaves an inspected worktree, so a generation that outlived
that click matched no tab: the message was discarded and the originating
panel spun forever.

Return the panel to idle on cancellation instead of to `Failed`. Routing a
cancel through `fail_ai_generation` left a red "AI failed — retry" control
on screen after the user had done exactly what they intended.

Parse bracketed IPv6 authorities before stripping the port, so
`http://[::1]:11434/v1` — an IPv6-only Ollama — validates as loopback
instead of being rejected as insecure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahbclarkson

Copy link
Copy Markdown
Owner Author

@codex review

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39797c03d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

ai.enabled = self.ai_enabled;
ai.inject_project_context = self.ai_inject_project_context;
ai.use_tools = self.ai_use_tools;
ai.base_url_override = self.ai_base_url_override.trim().to_string();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep unvalidated base URLs out of persisted settings

After a user types an invalid override such as http://gateway.example.com/v1, the Changed handler stores it in self.ai_base_url_override; although blur displays a validation error, any subsequent checkbox, provider switch, or window-close save reaches this unconditional assignment and persists the rejected value. Generation does not re-run validate_base_url, so it can later send the provider's API key over the insecure endpoint that the UI claimed to reject. Validate here before mutating settings, or keep draft and committed values separate.

Useful? React with 👍 / 👎.

Comment on lines +129 to +130
if value == self.ai_base_url_override {
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compare base URL edits with the committed value

For every normal edit, the Changed subscription has already copied the editor text into self.ai_base_url_override, so this equality is always true when Submit or Blurred calls commit_base_url_override. The function therefore skips both save_settings and catalogue/connection invalidation: pressing Enter can leave generation using the old endpoint while the connection test uses the new local value, and a later unrelated save persists it without performing the intended invalidation.

Useful? React with 👍 / 👎.

Comment on lines +271 to +272
if !force && self.ai_catalog_loading {
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track catalogue loading per provider

If provider A is refreshing and the user expands provider B before it finishes, B's load_ai_catalog installs its cached/bundled rows but this process-wide loading flag suppresses B's network refresh. A's completion only clears the flag and applies A's result; nothing retries B, so the newly expanded provider remains on its fallback catalogue until the user manually presses Refresh. Track the in-flight provider or supersede the previous request instead of treating all providers as one load.

Useful? React with 👍 / 👎.

Comment on lines +882 to +883
.tab_index(tab_base + 4)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add keyboard activation to the model field

When navigating Settings with a keyboard, this field can receive focus through tab_index, but it only installs an on_click handler; Enter and Space therefore do nothing and the model picker cannot be opened without a pointer. Add a key handler or use a button-like component that maps keyboard activation to the same action.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applying this one — the handler would break the field rather than fix it.

gpui already maps Enter and Space to a click on any focused element that has click listeners. tab_index() sets focusable = true (gpui elements/div.rs, fn tab_index), which installs a tracked focus handle, and the paint path guarded by if is_focused registers a KeyDownEvent listener that arms on enter/space plus a KeyUpEvent listener that synthesises ClickEvent::Keyboard and runs the same click_listeners. The model field's on_click — which toggles ai_model_picker_open — therefore already fires from the keyboard.

Adding an on_key_down that toggles as well would fire on key-down and again on the synthetic click at key-up: the picker would open and immediately close.

The other three findings are fixed in 63246b9.

…per provider

The base URL editor mirrored every keystroke into the field `save_settings`
persists, so a URL the UI had just rejected still reached `settings.json` on
the next unrelated save — and from there the provider's API key reached the
insecure endpoint. The draft now lives in the editor until Enter or blur, and
only a validated value is copied out, which also makes the "unchanged" check
in `commit_base_url_override` compare against the committed value rather than
against itself, so Enter actually saves and invalidates.

`normalize_base_url` now rejects anything `validate_base_url` would. It is the
one choke point every consumer goes through, so a plain-http gateway cannot
become a live endpoint by a route that skips the field — a hand-edited
settings.json included.

Catalogue loading is tracked per provider. One shared flag and one shared task
slot meant expanding a second provider cancelled the first's fetch and then
suppressed its own, leaving the newly opened row on its bundled list until the
user pressed Refresh by hand; the refresh error is per provider too, so one
row's failure no longer annotates another's picker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahbclarkson

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63246b9c68

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

extra_headers: &[(&str, &str)],
body: &Value,
) -> Result<Value> {
let key = api_key(req)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow keyless custom endpoints

When an OpenAI-compatible override points to a keyless local service such as Ollama, this unconditional key lookup rejects generation before any request is sent. The settings and commit-panel guards likewise require a stored key, so the newly supported Ollama base URL cannot actually be used unless the user invents and stores a dummy credential; custom endpoints should be allowed to omit the authorization header when no key is configured.

Useful? React with 👍 / 👎.

// official host instead sent a gateway-only key to the provider and
// then called a working configuration broken.
let base_url = self.ai_base_url_override.clone();
self.ai_test_task = Some(cx.spawn(async move |this, cx: &mut gpui::AsyncApp| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track connection tests per provider

If provider A is being tested and the user starts a test for provider B, assigning this single task slot drops and cancels A's task after A was marked Testing. Only B's completion then updates state, leaving A permanently displayed as Testing and its Test button disabled when its row is reopened; store tasks per provider or explicitly restore the superseded provider's state.

Useful? React with 👍 / 👎.

Comment on lines +944 to +948
.on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
cx.stop_propagation();
this.select_ai_model(suggestion.clone(), cx);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply model suggestions to the row's provider

When an inactive provider's expanded row reports a missing model, clicking its suggestion calls select_ai_model, which always updates self.ai_provider and set_active_model. For example, while Gemini is active, accepting a suggestion shown in an expanded OpenAI row pins that OpenAI model to Gemini and leaves OpenAI's missing pin unchanged, causing subsequent Gemini generation to use an invalid model; activate or explicitly update the provider represented by this row.

Useful? React with 👍 / 👎.

… provider

A custom OpenAI-compatible endpoint may be a keyless local service — Ollama's
`/v1` is the motivating case — but every guard demanded a stored credential, so
the base URL the last round made reachable could only be used by inventing a
dummy key. `requires_api_key` now answers that question in one place, the
authorization header is omitted rather than sent empty, and the AI button, the
command palette and the catalogue fetch all gate on `ai_credentials_ready`.

Connection tests are tracked per provider. A single task slot dropped — and so
cancelled — the test another row was still showing as in progress, leaving it
on "Testing" with its button disabled until the window was reopened.

`select_ai_model` takes the provider it is pinning for. A suggestion accepted
from an expanded but inactive row went through `set_active_model`, which pinned
that row's model to whichever provider happened to be active and left the row's
own retired pin in place — so the next generation ran against a model the
active provider does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahbclarkson

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a76df0671

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +169 to +174
match scheme.to_ascii_lowercase().as_str() {
"https" => Ok(()),
// Ollama's `/v1` on the loopback interface is the main local case, and
// it does not serve TLS.
"http" if is_loopback_host(host) => Ok(()),
"http" => Err(BaseUrlError::InsecureScheme),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject userinfo in loopback HTTP overrides

For an override such as http://localhost:80@evil.example/v1, host_without_port sees localhost and this branch accepts plain HTTP, while a standards-compliant URL parser treats evil.example as the destination and localhost:80 as userinfo. Generation can consequently send the configured bearer key and staged diff to a remote host without TLS despite the validator claiming HTTP is loopback-only; parse the URL structurally and reject userinfo before checking the actual host.

Useful? React with 👍 / 👎.

Comment on lines +224 to +227
this.update(cx, |this, cx| match outcome {
Ok(models) => {
this.ai_connection
.insert(provider, ConnectionState::Connected);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore obsolete connection-test completions

If the user starts a test and then removes the key or commits a different base URL before the request finishes, the old task remains alive and this callback unconditionally marks the provider connected and installs the old endpoint's catalogue. The settings window then reports the new configuration as verified using a result that describes different credentials or a different server; associate tests with a generation/configuration fingerprint and discard or cancel results when either changes.

AGENTS.md reference: AGENTS.md:L1-L1

Useful? React with 👍 / 👎.

Comment on lines +728 to +730
if has_key {
body = body.child(self.render_model_row(provider, tab_base, cx));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Show model controls for keyless gateways

Although requires_api_key now permits keyless custom endpoints, has_key remains false for them, so this gate hides the model row even after their /models request succeeds. Gateways such as the explicitly supported Ollama endpoint normally expose local model IDs rather than the selected provider's built-in default, leaving the user unable to choose a usable fetched model without adding a dummy credential or hand-editing settings.

Useful? React with 👍 / 👎.

Comment on lines +2984 to +2987
cx.update_global::<rgitui_settings::SettingsState, _>(|state, _cx| {
state.settings_mut().ai.commit_style = style.id().to_string();
if let Err(error) = state.save() {
log::warn!("Failed to persist the commit style override: {}", error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep regenerate style overrides request-local

Choosing “Regenerate in a different style” is documented and modeled as a one-request override, but this assignment changes and persists the global preference. Every later generation therefore continues using the temporary style, and an already-open Settings window can later overwrite it from stale local state; pass the override into this generation instead of saving it to settings.ai.commit_style.

Useful? React with 👍 / 👎.

…rrides local

`http://localhost:80@evil.example/v1` passed validation: everything before the
`@` is userinfo, not the host, so reading `localhost` off the front let the
loopback exemption wave through a plaintext request to a remote host carrying
the API key and the staged diff. Userinfo is now rejected outright, with a
message pointing at the API key field.

Connection tests carry a generation id, and editing a key or committing a new
base URL clears the provider's in-flight entry. A result describing the
credentials or the server the user just replaced no longer reports the new
configuration as verified.

The settings row gates on whether a provider is usable, not on whether a key is
stored, so a keyless gateway shows its model picker — the one place its local
model ids can be selected — instead of onboarding.

"Regenerate in a different style" is request-local again, as its own doc
comment described. Writing it through to settings changed every later
generation and raced an already-open Settings window holding the old value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahbclarkson

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df0df7e3f2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/rgitui_ai/src/prompt.rs Outdated
Comment on lines +169 to +172
for filename in PROJECT_CONTEXT_FILES {
let file_path = repo_path.join(filename);
if let Ok(contents) = std::fs::read_to_string(&file_path) {
if !contents.trim().is_empty() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep project-context symlinks inside the repository

When a checkout contains README.md, CLAUDE.md, or AGENTS.md as a symlink to a file outside the repository, read_to_string follows it and places that external file in the AI prompt. Because project-context injection defaults to enabled, a cloned repository can therefore exfiltrate a local file (for example, by making README.md point at a credential file) the first time the user generates a commit message. Canonicalize each target and require it to remain beneath the canonical repository root before reading it.

Useful? React with 👍 / 👎.

Comment on lines +279 to +285
if let std::collections::btree_map::Entry::Vacant(e) = self.ai_catalog.entry(provider) {
let cached = catalog::read_cached(provider, &base_url);
let fresh = cached.as_ref().map(|cached| {
catalog::freshness(cached.fetched_at, catalog::now_unix())
== catalog::CatalogFreshness::Fresh
});
let (models, source) = catalog::resolve_catalog(provider, cached);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move cached catalogue reads off the UI thread

Opening a provider row calls read_cached directly from this UI update, and that function performs both std::fs::read_to_string and JSON deserialization synchronously. In particular, an unfiltered OpenRouter cache can be hundreds of kilobytes, so cold or slow storage and parsing can visibly stall the settings window. Load and parse the cache on the background executor, then apply the result on the UI thread with a stale-result guard, as required by the repository's imported threading rule.

AGENTS.md reference: AGENTS.md:L1-L1

Useful? React with 👍 / 👎.

Comment on lines +570 to +572
TextInputEvent::Changed(_) => {
this.invalidate_ai_connection(provider, cx);
this.schedule_secret_save(cx);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate model catalogues when credentials change

When a provider key is edited or removed, this only invalidates connection-test state; its current catalogue, cached catalogue, and any catalogue request using the previous key remain valid. Because /models results may be scoped to the credential, pasting a replacement key can leave the picker showing the old key's live list for the rest of the window session (or its fresh cache for up to 24 hours), and an old in-flight request can still install its result afterward. Supersede the provider's catalogue request and clear or refresh its catalogue when the key changes.

Useful? React with 👍 / 👎.

Comment thread crates/rgitui_ai/src/prompt.rs Outdated
Comment on lines +169 to +173
for filename in PROJECT_CONTEXT_FILES {
let file_path = repo_path.join(filename);
if let Ok(contents) = std::fs::read_to_string(&file_path) {
if !contents.trim().is_empty() {
combined.push_str(&format!("=== {filename} ===\n{contents}\n\n"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound project-context files before reading them

The advertised 50 KB context limit is applied only after every context file has been fully loaded and appended. A repository with an accidentally or maliciously huge README.md can therefore allocate hundreds of megabytes or exhaust process memory when generation starts, despite the final prompt being truncated. Read at most the remaining context budget from each file rather than using unbounded read_to_string.

Useful? React with 👍 / 👎.

… the UI thread

Project-context injection is on by default, so `collect_project_context` runs
against whatever a freshly cloned repository contains. It followed symlinks:
shipping `README.md` as a link to a credential file put that file in the prompt
the first time the user generated a commit message. Each target is now
canonicalised and required to stay beneath the canonical repository root, the
same rule `get_file_content` already applies to paths the model asks for.

The 50 KB budget was also applied only after every file had been read in full,
so one huge `README.md` allocated its whole size before being thrown away. Each
file is now read up to the remaining budget, header and marker included, so the
total can no longer overshoot — which also fixes the tail truncation cutting a
newline-free file back to nothing.

The settings window read and deserialised the model cache on the UI thread; an
unfiltered OpenRouter catalogue is a few hundred KB of JSON. The bundled list
renders immediately and the cache read, freshness check, request and cache
write all happen on the background executor behind one stale-result guard.

Editing a provider's key now invalidates its catalogue as well as its
connection state: `/models` results can be scoped to the credential, so the old
key's list was being shown for the rest of the session, its cache stayed fresh
for 24 hours, and an in-flight request could still install its result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahbclarkson
noahbclarkson merged commit 3f42cdc into main Sep 7, 2026
4 checks passed
@noahbclarkson
noahbclarkson deleted the feat/ai-subsystem-overhaul branch September 7, 2026 11:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant