first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+278 -314
View File
@@ -1,61 +1,45 @@
use crate::features::FeatureFlag;
use crate::report_if_error;
use crate::settings::{InputSettings, WarpPromptSeparator};
use crate::terminal::event::{BlockType, UserBlockCompleted};
use crate::terminal::model::session::{ExecuteCommandOptions, Session, SessionsEvent};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::{
debounce::debounce,
editor::EditorView,
menu::{MenuItem, MenuItemFields},
terminal::{
model::{
block::{Block, BlockMetadata},
session::Sessions,
},
session_settings::{
GithubPrPromptChipDefaultValidation, SessionSettings, SessionSettingsChangedEvent,
ToolbarChipSelection,
},
view::{ContextMenuAction, PromptPart, PromptPosition, TerminalAction},
},
};
use futures::{pin_mut, FutureExt as _};
use galaxy_completer::completer::{CommandExitStatus, CommandOutput};
use galaxy_core::user_preferences::GetUserPreferences;
use itertools::Itertools;
use settings::Setting as _;
use super::ChipResult;
use super::{
chips_to_string,
context_chip::{
ChipAvailability, ChipDisabledReason, ChipFingerprintInput, ChipRuntimeCapabilities,
ContextChip, Environment, ExternalCommandsAvailability, GeneratorContext, PromptGenerator,
RefreshConfig, ShellCommandGenerator,
},
logging::{ChipCommandLogEntry, PromptChipExecutionPhase, PromptChipLogger},
prompt::Prompt,
ChipValue, ContextChipKind,
};
#[cfg(feature = "local_fs")]
use crate::code_review::git_status_update::{GitRepoStatusEvent, GitRepoStatusModel};
#[cfg(feature = "local_fs")]
use crate::context_chips::display_chip::GitLineChanges;
#[cfg(feature = "local_fs")]
use galaxyui::WeakModelHandle;
use galaxyui::{
r#async::{SpawnedFutureHandle, Timer},
AppContext, ViewHandle,
};
use galaxyui::{Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity};
use std::collections::{HashMap, HashSet};
use std::hash::{Hash as _, Hasher as _};
use std::sync::Arc;
use std::time::Duration;
use futures::{pin_mut, FutureExt as _};
use itertools::Itertools;
use galaxy_completer::completer::CommandExitStatus;
use galaxy_core::r#async::debounce;
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::{
AppContext, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, ViewHandle,
WeakModelHandle,
};
use super::context_chip::{
ChipAvailability, ChipFingerprintInput, ChipRuntimeCapabilities, ContextChip, Environment,
ExternalCommandsAvailability, GeneratorContext, PromptGenerator, RefreshConfig,
ShellCommandGenerator,
};
use super::logging::{ChipCommandLogEntry, PromptChipExecutionPhase, PromptChipLogger};
use super::prompt::Prompt;
use super::{chips_to_string, ChipResult, ChipValue, ContextChipKind};
use crate::code_review::git_repo_model::{GitRepoStatusEvent, GitRepoStatusModel};
use crate::code_review::github_repo_model::{GitHubRepoEvent, GitHubRepoModel};
use crate::context_chips::display_chip::GitLineChanges;
use crate::editor::EditorView;
use crate::features::FeatureFlag;
use crate::menu::{MenuItem, MenuItemFields};
use crate::settings::{InputSettings, WarpPromptSeparator};
use crate::terminal::event::{BlockType, UserBlockCompleted};
use crate::terminal::model::block::{Block, BlockMetadata};
use crate::terminal::model::session::{ExecuteCommandOptions, Session, Sessions, SessionsEvent};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::session_settings::{
SessionSettings, SessionSettingsChangedEvent, ToolbarChipSelection,
};
use crate::terminal::view::{ContextMenuAction, PromptPart, PromptPosition, TerminalAction};
#[cfg(test)]
#[path = "current_prompt_test.rs"]
#[path = "current_prompt_tests.rs"]
mod tests;
const PROMPT_DEBOUNCE_PERIOD: Duration = Duration::from_millis(50);
@@ -75,13 +59,6 @@ enum ChipUpdateStatus {
Error,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum GithubPrPromptChipCommandOutcome {
Validated,
DeterministicAuthFailure,
RetryableFailure,
}
/// ChipState stores the state and point-in-time information related to a specific chip.
/// For example, it's last computed value or a refresh handle.
#[derive(Clone, Debug, Default)]
@@ -179,10 +156,13 @@ pub struct CurrentPrompt {
prompt_chip_logger: PromptChipLogger,
update_tx: async_channel::Sender<()>,
/// When set, `ShellGitBranch` chip values are driven by filesystem events from
/// `GitRepoStatusModel` instead of the 30s periodic timer.
#[cfg(feature = "local_fs")]
/// When set, branch, branch status, and diff stats are populated from
/// `GitRepoStatusModel` filesystem events.
git_repo_status: Option<WeakModelHandle<GitRepoStatusModel>>,
/// When set, the `GithubPullRequest` chip value is populated from
/// `GitHubRepoModel` for the current repository.
github_repo_model: Option<WeakModelHandle<GitHubRepoModel>>,
}
/// Context about the current terminal session, needed to update the prompt.
@@ -217,7 +197,7 @@ impl CurrentPrompt {
&SessionSettings::handle(ctx),
Self::handle_session_settings_changed,
);
ctx.subscribe_to_model(&sessions, |me, event, ctx| {
ctx.subscribe_to_model(&sessions, |me, _, event, ctx| {
if let SessionsEvent::EnvironmentVariablesUpdated { .. } = event {
me.update_states_with_new_context(ctx);
}
@@ -252,8 +232,8 @@ impl CurrentPrompt {
update_tx,
same_line_prompt_enabled: prompt.as_ref(ctx).same_line_prompt_enabled(),
separator: prompt.as_ref(ctx).separator(),
#[cfg(feature = "local_fs")]
git_repo_status: None,
github_repo_model: None,
}
}
@@ -266,7 +246,7 @@ impl CurrentPrompt {
) {
// A WeakViewHandle is used here to avoid leaking the terminal model
let weak_editor_handle = editor.downgrade();
ctx.subscribe_to_view(&editor, move |me, _, ctx| {
ctx.subscribe_to_view(&editor, move |me, _, _, ctx| {
// CurrentPrompt exists and this fn is called even if we're not using warp prompt.
// We don't need to do anything if we're honoring PS1 unless universal developer input
// or AgentView is enabled (agent view needs chips regardless of PS1 setting).
@@ -518,9 +498,21 @@ impl CurrentPrompt {
return false;
};
// A retryable failure (`Error`, `TimedOut`) is not a usable cached
// result: `last_fingerprint` is recorded before the command runs, and
// such failures intentionally do not populate `last_failure_fingerprint`.
// Without this guard, the next periodic tick would treat the failed
// attempt as a cache hit and never retry. Deterministic failures
// continue to be suppressed via `last_failure_fingerprint`.
let should_skip = self
.states
.get(chip_kind)
.filter(|state| {
!matches!(
state.update_status,
ChipUpdateStatus::Error | ChipUpdateStatus::TimedOut
)
})
.and_then(|state| state.last_fingerprint.as_ref())
.is_some_and(|existing| existing == &new_fingerprint);
@@ -619,26 +611,7 @@ impl CurrentPrompt {
&self,
values_opt: Option<Vec<String>>,
) -> Option<Vec<String>> {
values_opt.map(|values| {
let mut trimmed: Vec<String> = values
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// We want to sort the branches so the current branch is first (denoted by *).
// The rest of the branches maintain their relative order.
trimmed.sort_by(|a, b| {
let a_starts_with_star = a.starts_with('*');
let b_starts_with_star = b.starts_with('*');
b_starts_with_star.cmp(&a_starts_with_star)
});
trimmed
.into_iter()
.map(|s| s.trim_start_matches('*').trim().to_string())
.collect()
})
super::git_branch_on_click::filter_git_branch_on_click_values(values_opt)
}
/// Perform a single update of the given chip.
@@ -691,19 +664,6 @@ impl CurrentPrompt {
handle.abort();
}
}
// If the GithubPullRequest chip is disabled because `gh` is missing,
// transition validation state to Suppressed so future default
// resolution excludes it.
if matches!(chip_kind, ContextChipKind::GithubPullRequest) {
if let ChipAvailability::Disabled(ChipDisabledReason::RequiresExecutable {
ref command,
}) = availability
{
if command == "gh" {
Self::maybe_suppress_github_pr_default(ctx);
}
}
}
self.update_chip_value(chip_kind, None);
self.update_on_click_value(chip_kind, None);
self.set_chip_update_status(chip_kind, ChipUpdateStatus::Disabled);
@@ -729,7 +689,6 @@ impl CurrentPrompt {
}
}
}
match generator {
PromptGenerator::ShellCommand(cmd) => {
let Some(exec_ctx) = self.prepare_shell_command_context(cmd, ctx) else {
@@ -769,7 +728,7 @@ impl CurrentPrompt {
.await;
(value, timed_out, chip_kind, exec_ctx, chip_title)
},
move |me, (value, timed_out, chip_kind, exec_ctx, chip_title), ctx| {
move |me, (value, timed_out, chip_kind, exec_ctx, chip_title), _| {
logger.log_shell_command(&ChipCommandLogEntry {
chip_kind: &chip_kind,
chip_title: &chip_title,
@@ -780,24 +739,22 @@ impl CurrentPrompt {
output: value.as_ref(),
timed_out,
});
// GitDiffStats has two value sources that can race when entering a repo:
// this shell fallback (`git diff --shortstat HEAD`, tracked changes only)
// and a repo-status watcher that also counts untracked files. If the
// watcher attached while this fallback was in flight, drop the fallback's
// result
if matches!(chip_kind, ContextChipKind::GitDiffStats)
&& me.is_updated_externally(&chip_kind)
{
return;
}
if timed_out {
if suppress_on_failure
&& Self::should_cache_failure_fingerprint(
&chip_kind,
value.as_ref(),
timed_out,
)
{
if suppress_on_failure {
if let Some(state) = me.states.get_mut(&chip_kind) {
state.last_failure_fingerprint = current_fingerprint;
}
} else if suppress_on_failure {
if let Some(state) = me.states.get_mut(&chip_kind) {
if state.last_failure_fingerprint == current_fingerprint {
state.last_failure_fingerprint = None;
}
}
}
me.update_chip_value(&chip_kind, None);
me.set_chip_update_status(&chip_kind, ChipUpdateStatus::TimedOut);
@@ -821,29 +778,7 @@ impl CurrentPrompt {
_ => (None, ChipUpdateStatus::Error, true),
};
if matches!(chip_kind, ContextChipKind::GithubPullRequest) {
match Self::github_pr_prompt_chip_command_outcome(
value.as_ref(),
timed_out,
) {
GithubPrPromptChipCommandOutcome::Validated => {
Self::maybe_validate_github_pr_default(ctx);
}
GithubPrPromptChipCommandOutcome::DeterministicAuthFailure => {
Self::maybe_suppress_github_pr_default(ctx);
}
GithubPrPromptChipCommandOutcome::RetryableFailure => {}
}
}
if suppress_on_failure
&& failed
&& Self::should_cache_failure_fingerprint(
&chip_kind,
value.as_ref(),
timed_out,
)
{
if suppress_on_failure && failed {
if let Some(state) = me.states.get_mut(&chip_kind) {
state.last_failure_fingerprint = current_fingerprint;
}
@@ -854,7 +789,8 @@ impl CurrentPrompt {
}
}
}
me.update_chip_value(&chip_kind, output.map(ChipValue::Text));
let chip_value = output.map(ChipValue::Text);
me.update_chip_value(&chip_kind, chip_value);
me.set_chip_update_status(&chip_kind, status);
},
);
@@ -1001,7 +937,7 @@ impl CurrentPrompt {
chip_kind_clone
},
|me, chip_kind, ctx| {
me.fetch_chip_value_at_interval(&chip_kind, None, None, false, ctx);
me.fetch_chip_value_at_interval(&chip_kind, None, None, true, ctx);
},
);
@@ -1028,6 +964,43 @@ impl CurrentPrompt {
let state = ChipState::new(chip_kind);
self.states.insert(chip_kind.clone(), state);
}
if self.is_updated_externally(chip_kind) {
// For chips updated externally (e.g. by the per-repo git status
// filesystem watcher), avoid running the shell-based fallback
// generator. Doing so can briefly overwrite the structured
// watcher value with one that uses different semantics (for
// example, the `GitDiffStats` shell fallback runs `git diff
// --shortstat HEAD`, which excludes untracked files, whereas
// the watcher counts untracked files as changes), causing the
// chip to flicker between the tracked-only count and the
// all-files count when untracked files are present.
//
// If a chip provides an `initial_value_generator` that sources
// from the prompt context (rather than running a shell
// command), use it for a fast initial value until the watcher
// emits a metadata-changed event.
if let Some(initial_gen) = chip_kind.initial_value_generator() {
self.fetch_chip_value_once(
chip_kind,
&initial_gen,
chip.on_click_generator().cloned(),
true,
ctx,
);
} else {
// Externally-updated chips without an `initial_value_generator`
// are left blank after a state rebuild (`states.clear()` in
// `handle_prompt_changed`, or `clear_cache()` on a session
// change) until their backing model emits a change event.
// `GithubPullRequest` only emits when cached PR info actually
// changes, so after a rebuild there may be no event to restore
// the already-cached value until the next periodic refresh.
if matches!(chip_kind, ContextChipKind::GithubPullRequest) {
self.sync_pr_chip_from_model(ctx);
}
}
return;
}
match chip.refresh_config() {
RefreshConfig::OnDemandOnly => {
@@ -1040,25 +1013,13 @@ impl CurrentPrompt {
);
}
RefreshConfig::Periodically { .. } => {
if self.is_updated_externally(chip_kind) {
let initial_gen = chip_kind.initial_value_generator();
let generator = initial_gen.as_ref().unwrap_or(chip.generator());
self.fetch_chip_value_once(
chip_kind,
generator,
chip.on_click_generator().cloned(),
true,
ctx,
);
} else {
self.fetch_chip_value_at_interval(
chip_kind,
chip_kind.initial_value_generator(),
chip.on_click_generator().cloned(),
true,
ctx,
);
}
self.fetch_chip_value_at_interval(
chip_kind,
chip_kind.initial_value_generator(),
chip.on_click_generator().cloned(),
true,
ctx,
);
}
RefreshConfig::OnFileChanges { filepath } => {
log::debug!("Unimplemented: would've watched changes to filepath: {filepath}");
@@ -1133,8 +1094,6 @@ impl CurrentPrompt {
/// existing states map with new information.
/// This is called when the context gets updated (ie. a new block metadata is received).
fn update_states_with_new_context_and_session(&mut self, ctx: &mut ModelContext<Self>) {
self.maybe_unsuppress_github_pr_default(ctx);
// 1. Terminating existing spawned operations.
self.clear_chips_and_cache();
@@ -1148,6 +1107,7 @@ impl CurrentPrompt {
/// spot, and changing Prompt configuration most likely doesn't mean updating the context.
fn handle_prompt_changed(
&mut self,
_: ModelHandle<Prompt>,
_prompt_event: &<Prompt as Entity>::Event,
ctx: &mut ModelContext<Self>,
) {
@@ -1164,6 +1124,7 @@ impl CurrentPrompt {
fn handle_session_settings_changed(
&mut self,
_: ModelHandle<SessionSettings>,
event: &SessionSettingsChangedEvent,
ctx: &mut ModelContext<Self>,
) {
@@ -1192,6 +1153,12 @@ impl CurrentPrompt {
// Recompute which chips to run when the agent footer config changes.
self.update_states_with_new_context(ctx);
}
if let SessionSettingsChangedEvent::GithubPrChipDefaultValidation { .. } = event {
// Re-resolve the default prompt's chip list (which gates the
// PR chip on `is_suppressed()`) and re-run chips with the new
// suppression state.
self.update_states_with_new_context(ctx);
}
if let SessionSettingsChangedEvent::CLIAgentToolbarChipSelectionSetting { .. } = event {
self.update_states_with_new_context(ctx);
@@ -1220,7 +1187,6 @@ impl CurrentPrompt {
ctx: &mut galaxyui::AppContext,
) -> futures_util::future::BoxFuture<'static, ()> {
use futures_util::FutureExt;
use itertools::Itertools;
// This structure prevents the returned Future from referencing self.
let chip_futures = self
.states
@@ -1258,7 +1224,12 @@ impl CurrentPrompt {
.any(|handle| !handle.abort_handle().is_aborted())
}
fn handle_model_event(&mut self, event: &ModelEvent, ctx: &mut ModelContext<Self>) {
fn handle_model_event(
&mut self,
_: ModelHandle<ModelEventDispatcher>,
event: &ModelEvent,
ctx: &mut ModelContext<Self>,
) {
if let ModelEvent::AfterBlockCompleted(after_block_completed) = event {
if let BlockType::User(UserBlockCompleted { command, .. }) =
&after_block_completed.block_type
@@ -1407,9 +1378,9 @@ impl CurrentPrompt {
}
/// Set the per-repo git status model handle. When `Some`, subscribes to
/// metadata-changed events so `ShellGitBranch` and `GitDiffStats` are updated
/// by filesystem events instead of the 30s periodic timer.
#[cfg(feature = "local_fs")]
/// metadata events so git-backed prompt chips are updated from the
/// per-repo status model. PR info is handled separately by
/// [`Self::set_github_repo_model`].
pub fn set_git_repo_status(
&mut self,
handle: Option<WeakModelHandle<GitRepoStatusModel>>,
@@ -1422,169 +1393,162 @@ impl CurrentPrompt {
}
}
// Repo detached, clear git chips that require repository metadata.
if handle.is_none() {
for chip_kind in [
ContextChipKind::GitDiffStats,
ContextChipKind::GitBranchStatus,
] {
if let Some(state) = self.states.get_mut(&chip_kind) {
state.clear_abort_handlers();
state.clear_cache();
}
}
let _ = self.update_tx.try_send(());
return;
}
if let Some(weak) = handle {
if let Some(strong) = weak.upgrade(ctx) {
self.git_repo_status = Some(weak);
ctx.subscribe_to_model(&strong, |me, event, ctx| match event {
ctx.subscribe_to_model(&strong, |me, _, event, ctx| match event {
GitRepoStatusEvent::MetadataChanged => {
let metadata = me
.git_repo_status
.as_ref()
.and_then(|w| w.upgrade(ctx))
.and_then(|h| h.as_ref(ctx).metadata().cloned());
let Some(metadata) = metadata else {
return;
};
// Update ShellGitBranch.
let new_branch = ChipValue::Text(metadata.current_branch_name.clone());
let current_branch = me
.latest_chip_value(&ContextChipKind::ShellGitBranch)
.cloned();
if current_branch.as_ref() != Some(&new_branch) {
me.update_chip_value(
&ContextChipKind::ShellGitBranch,
Some(new_branch),
);
// Refresh the branch dropdown so it stays in sync.
let chip_kind = ContextChipKind::ShellGitBranch;
if let Some(chip) = chip_kind.to_chip() {
if let Some(on_click_gen) = chip.on_click_generator().cloned() {
me.refresh_on_click_values(&chip_kind, on_click_gen, ctx);
}
}
}
// Update GitDiffStats with structured data directly.
let new_diff_stats = ChipValue::GitDiffStats(
GitLineChanges::from_diff_stats(&metadata.stats_against_head),
);
let current_diff_stats = me
.latest_chip_value(&ContextChipKind::GitDiffStats)
.cloned();
if current_diff_stats.as_ref() != Some(&new_diff_stats) {
me.update_chip_value(
&ContextChipKind::GitDiffStats,
Some(new_diff_stats),
);
}
me.apply_git_repo_metadata(ctx);
}
});
// Eagerly populate chips if metadata is already available (the
// initial `refresh_metadata` in `GitRepoStatusModel::new` may
// have completed before we subscribed). If it hasn't finished
// yet, the subscription above will catch the `MetadataChanged`
// event when it does.
if strong.as_ref(ctx).metadata(ctx).is_some() {
self.apply_git_repo_metadata(ctx);
}
}
}
}
/// Set the per-repo GitHub-info model handle. When `Some`, subscribes to
/// its events so the `GithubPullRequest` chip value is updated.
pub fn set_github_repo_model(
&mut self,
handle: Option<WeakModelHandle<GitHubRepoModel>>,
ctx: &mut ModelContext<Self>,
) {
// Unsubscribe from the previous model, if any.
if let Some(old_weak) = self.github_repo_model.take() {
if let Some(old_strong) = old_weak.upgrade(ctx) {
ctx.unsubscribe_from_model(&old_strong);
}
}
if handle.is_none() {
// GitHub-info handle detached: clear any stale PR chip state.
if let Some(state) = self.states.get_mut(&ContextChipKind::GithubPullRequest) {
state.clear_abort_handlers();
state.clear_cache();
}
let _ = self.update_tx.try_send(());
return;
}
if let Some(weak) = handle {
if let Some(strong) = weak.upgrade(ctx) {
self.github_repo_model = Some(weak);
// Only PR info drives the chip value; repository name/owner
// changes don't affect it.
ctx.subscribe_to_model(&strong, |me, _, event, ctx| match event {
GitHubRepoEvent::PrInfoChanged => {
me.sync_pr_chip_from_model(ctx);
}
GitHubRepoEvent::RepositoryInfoChanged => {}
});
// Eagerly populate the PR chip if PR info has already landed.
self.sync_pr_chip_from_model(ctx);
}
}
}
/// Read the current `GitRepoStatusModel` metadata and push it into the
/// git-backed chip states.
fn apply_git_repo_metadata(&mut self, ctx: &mut ModelContext<Self>) {
let metadata = self
.git_repo_status
.as_ref()
.and_then(|w| w.upgrade(ctx))
.and_then(|h| h.as_ref(ctx).metadata(ctx).cloned());
let Some(metadata) = metadata else {
return;
};
// Update ShellGitBranch.
let new_branch = ChipValue::Text(metadata.current_branch_name.clone());
let current_branch = self
.latest_chip_value(&ContextChipKind::ShellGitBranch)
.cloned();
if current_branch.as_ref() != Some(&new_branch) {
self.update_chip_value(&ContextChipKind::ShellGitBranch, Some(new_branch));
// Refresh the branch dropdown so it stays in sync.
let chip_kind = ContextChipKind::ShellGitBranch;
if let Some(chip) = chip_kind.to_chip() {
if let Some(on_click_gen) = chip.on_click_generator().cloned() {
self.refresh_on_click_values(&chip_kind, on_click_gen, ctx);
}
}
}
let new_branch_status = ChipValue::GitBranchStatus(metadata.branch_tracking_status.clone());
let current_branch_status = self
.latest_chip_value(&ContextChipKind::GitBranchStatus)
.cloned();
if current_branch_status.as_ref() != Some(&new_branch_status) {
self.update_chip_value(&ContextChipKind::GitBranchStatus, Some(new_branch_status));
}
// Update GitDiffStats with structured data directly.
let new_diff_stats = ChipValue::GitDiffStats(GitLineChanges::from_diff_stats(
&metadata.stats_against_head,
));
let current_diff_stats = self
.latest_chip_value(&ContextChipKind::GitDiffStats)
.cloned();
if current_diff_stats.as_ref() != Some(&new_diff_stats) {
self.update_chip_value(&ContextChipKind::GitDiffStats, Some(new_diff_stats));
}
}
/// Reads PR info from the per-repo `GitHubRepoModel` and updates the
/// `GithubPullRequest` chip value if it differs from the current one.
fn sync_pr_chip_from_model(&mut self, ctx: &AppContext) {
let new_pr_value = self
.github_repo_model
.as_ref()
.and_then(|w| w.upgrade(ctx))
.and_then(|h| {
h.as_ref(ctx)
.pr_info(ctx)
.map(|info| ChipValue::Text(info.url.clone()))
});
let current_pr = self
.latest_chip_value(&ContextChipKind::GithubPullRequest)
.cloned();
if current_pr != new_pr_value {
self.update_chip_value(&ContextChipKind::GithubPullRequest, new_pr_value);
}
}
/// Returns `true` when the given chip's value is updated externally
/// (e.g. by a filesystem watcher) and the periodic timer should be skipped.
fn is_updated_externally(&self, chip_kind: &ContextChipKind) -> bool {
#[cfg(feature = "local_fs")]
{
if matches!(
chip_kind,
ContextChipKind::ShellGitBranch | ContextChipKind::GitDiffStats
) {
return self.git_repo_status.is_some();
}
}
let _ = chip_kind;
false
}
/// Heuristic check for `gh` CLI authentication errors in stderr output.
fn is_gh_auth_error(stderr: &str) -> bool {
let lower = stderr.to_lowercase();
lower.contains("not logged in")
|| lower.contains("authentication required")
|| lower.contains("gh auth login")
}
fn github_pr_prompt_chip_command_outcome(
output: Option<&CommandOutput>,
timed_out: bool,
) -> GithubPrPromptChipCommandOutcome {
if timed_out {
return GithubPrPromptChipCommandOutcome::RetryableFailure;
}
match output {
Some(command_output) if command_output.status == CommandExitStatus::Success => {
GithubPrPromptChipCommandOutcome::Validated
}
Some(command_output) => {
let stderr = String::from_utf8(command_output.stderr.clone()).unwrap_or_default();
if Self::is_gh_auth_error(&stderr) {
GithubPrPromptChipCommandOutcome::DeterministicAuthFailure
} else {
GithubPrPromptChipCommandOutcome::RetryableFailure
}
}
None => GithubPrPromptChipCommandOutcome::RetryableFailure,
}
}
fn should_cache_failure_fingerprint(
chip_kind: &ContextChipKind,
output: Option<&CommandOutput>,
timed_out: bool,
) -> bool {
if !matches!(chip_kind, ContextChipKind::GithubPullRequest) {
return true;
}
matches!(
Self::github_pr_prompt_chip_command_outcome(output, timed_out),
GithubPrPromptChipCommandOutcome::DeterministicAuthFailure
)
}
fn maybe_suppress_github_pr_default(ctx: &mut ModelContext<Self>) {
let current = *SessionSettings::as_ref(ctx).github_pr_chip_default_validation;
if current != GithubPrPromptChipDefaultValidation::Suppressed {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.github_pr_chip_default_validation
.set_value(GithubPrPromptChipDefaultValidation::Suppressed, ctx));
});
}
}
/// On session changes (including app startup), re-check whether a previously
/// suppressed PR chip should get another chance. Suppression is sticky across
/// restarts, but if the user has since installed `gh`, resetting to Unvalidated
/// lets the normal chip execution path re-validate or re-suppress.
fn maybe_unsuppress_github_pr_default(&self, ctx: &mut ModelContext<Self>) {
if !SessionSettings::as_ref(ctx)
.github_pr_chip_default_validation
.is_suppressed()
{
return;
}
let gh_on_path = self
.with_current_generator_context(ctx, |generator_context| {
generator_context.active_session.is_some_and(|session| {
session.has_loaded_external_commands()
&& session.executable_names().any(|name| name == "gh")
})
})
.unwrap_or(false);
if gh_on_path {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.github_pr_chip_default_validation
.set_value(GithubPrPromptChipDefaultValidation::Unvalidated, ctx));
});
}
}
fn maybe_validate_github_pr_default(ctx: &mut ModelContext<Self>) {
let current = *SessionSettings::as_ref(ctx).github_pr_chip_default_validation;
if current == GithubPrPromptChipDefaultValidation::Unvalidated {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.github_pr_chip_default_validation
.set_value(GithubPrPromptChipDefaultValidation::Validated, ctx));
});
match chip_kind {
ContextChipKind::ShellGitBranch
| ContextChipKind::GitBranchStatus
| ContextChipKind::GitDiffStats => self.git_repo_status.is_some(),
ContextChipKind::GithubPullRequest => self.github_repo_model.is_some(),
_ => false,
}
}