mirror of
https://github.com/openai/codex.git
synced 2026-05-02 20:32:04 +03:00
Remove the legacy TUI split (#15922)
This is the part 1 of 2 PRs that will delete the `tui` / `tui_app_server` split. This part simply deletes the existing `tui` directory and marks the `tui_app_server` feature flag as removed. I left the `tui_app_server` feature flag in place for now so its presence doesn't result in an error. It is simply ignored. Part 2 will rename the `tui_app_server` directory `tui`. I did this as two parts to reduce visible code churn.
This commit is contained in:
@@ -1,580 +0,0 @@
|
||||
//! Application-level events used to coordinate UI actions.
|
||||
//!
|
||||
//! `AppEvent` is the internal message bus between UI components and the top-level `App` loop.
|
||||
//! Widgets emit events to request actions that must be handled at the app layer (like opening
|
||||
//! pickers, persisting configuration, or shutting down the agent), without needing direct access to
|
||||
//! `App` internals.
|
||||
//!
|
||||
//! Exit is modelled explicitly via `AppEvent::Exit(ExitMode)` so callers can request shutdown-first
|
||||
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_chatgpt::connectors::AppInfo;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::ApprovalPreset;
|
||||
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::TerminalTitleItem;
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
use codex_core::config::types::ApprovalsReviewer;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RealtimeAudioDeviceKind {
|
||||
Microphone,
|
||||
Speaker,
|
||||
}
|
||||
|
||||
impl RealtimeAudioDeviceKind {
|
||||
pub(crate) fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Microphone => "Microphone",
|
||||
Self::Speaker => "Speaker",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn noun(self) -> &'static str {
|
||||
match self {
|
||||
Self::Microphone => "microphone",
|
||||
Self::Speaker => "speaker",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) enum WindowsSandboxEnableMode {
|
||||
Elevated,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) struct ConnectorsSnapshot {
|
||||
pub(crate) connectors: Vec<AppInfo>,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AppEvent {
|
||||
CodexEvent(Event),
|
||||
/// Open the agent picker for switching active threads.
|
||||
OpenAgentPicker,
|
||||
/// Switch the active thread to the selected agent.
|
||||
SelectAgentThread(ThreadId),
|
||||
|
||||
/// Submit an op to the specified thread, regardless of current focus.
|
||||
SubmitThreadOp {
|
||||
thread_id: ThreadId,
|
||||
op: codex_protocol::protocol::Op,
|
||||
},
|
||||
|
||||
/// Forward an event from a non-primary thread into the app-level thread router.
|
||||
ThreadEvent {
|
||||
thread_id: ThreadId,
|
||||
event: Event,
|
||||
},
|
||||
|
||||
/// Start a new session.
|
||||
NewSession,
|
||||
|
||||
/// Clear the terminal UI (screen + scrollback), start a fresh session, and keep the
|
||||
/// previous chat resumable.
|
||||
ClearUi,
|
||||
|
||||
/// Open the resume picker inside the running TUI session.
|
||||
OpenResumePicker,
|
||||
|
||||
/// Fork the current session into a new thread.
|
||||
ForkCurrentSession,
|
||||
|
||||
/// Request to exit the application.
|
||||
///
|
||||
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
|
||||
/// UI exits only after `ShutdownComplete`. `Immediate` is a last-resort
|
||||
/// escape hatch that skips shutdown and may drop in-flight work (e.g.,
|
||||
/// background tasks, rollout flush, or child process cleanup).
|
||||
Exit(ExitMode),
|
||||
|
||||
/// Request to exit the application due to a fatal error.
|
||||
FatalExitRequest(String),
|
||||
|
||||
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
|
||||
/// bubbling channels through layers of widgets.
|
||||
CodexOp(codex_protocol::protocol::Op),
|
||||
|
||||
/// Kick off an asynchronous file search for the given query (text after
|
||||
/// the `@`). Previous searches may be cancelled by the app layer so there
|
||||
/// is at most one in-flight search.
|
||||
StartFileSearch(String),
|
||||
|
||||
/// Result of a completed asynchronous file search. The `query` echoes the
|
||||
/// original search term so the UI can decide whether the results are
|
||||
/// still relevant.
|
||||
FileSearchResult {
|
||||
query: String,
|
||||
matches: Vec<FileMatch>,
|
||||
},
|
||||
|
||||
/// Result of refreshing rate limits
|
||||
RateLimitSnapshotFetched(RateLimitSnapshot),
|
||||
|
||||
/// Result of prefetching connectors.
|
||||
ConnectorsLoaded {
|
||||
result: Result<ConnectorsSnapshot, String>,
|
||||
is_final: bool,
|
||||
},
|
||||
|
||||
/// Result of computing a `/diff` command.
|
||||
DiffResult(String),
|
||||
|
||||
/// Open the app link view in the bottom pane.
|
||||
OpenAppLink {
|
||||
app_id: String,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
instructions: String,
|
||||
url: String,
|
||||
is_installed: bool,
|
||||
is_enabled: bool,
|
||||
},
|
||||
|
||||
/// Open the provided URL in the user's browser.
|
||||
OpenUrlInBrowser {
|
||||
url: String,
|
||||
},
|
||||
|
||||
/// Refresh app connector state and mention bindings.
|
||||
RefreshConnectors {
|
||||
force_refetch: bool,
|
||||
},
|
||||
|
||||
/// Fetch plugin marketplace state for the provided working directory.
|
||||
FetchPluginsList {
|
||||
cwd: PathBuf,
|
||||
},
|
||||
|
||||
/// Result of fetching plugin marketplace state.
|
||||
PluginsLoaded {
|
||||
cwd: PathBuf,
|
||||
result: Result<PluginListResponse, String>,
|
||||
},
|
||||
|
||||
/// Replace the plugins popup with a plugin-detail loading state.
|
||||
OpenPluginDetailLoading {
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Fetch detail for a specific plugin from a marketplace.
|
||||
FetchPluginDetail {
|
||||
cwd: PathBuf,
|
||||
params: PluginReadParams,
|
||||
},
|
||||
|
||||
/// Result of fetching plugin detail.
|
||||
PluginDetailLoaded {
|
||||
cwd: PathBuf,
|
||||
result: Result<PluginReadResponse, String>,
|
||||
},
|
||||
|
||||
/// Replace the plugins popup with an install loading state.
|
||||
OpenPluginInstallLoading {
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Replace the plugins popup with an uninstall loading state.
|
||||
OpenPluginUninstallLoading {
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Install a specific plugin from a marketplace.
|
||||
FetchPluginInstall {
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Result of installing a plugin.
|
||||
PluginInstallLoaded {
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginInstallResponse, String>,
|
||||
},
|
||||
|
||||
/// Uninstall a specific plugin by canonical plugin id.
|
||||
FetchPluginUninstall {
|
||||
cwd: PathBuf,
|
||||
plugin_id: String,
|
||||
plugin_display_name: String,
|
||||
},
|
||||
|
||||
/// Result of uninstalling a plugin.
|
||||
PluginUninstallLoaded {
|
||||
cwd: PathBuf,
|
||||
plugin_id: String,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginUninstallResponse, String>,
|
||||
},
|
||||
|
||||
/// Advance the post-install plugin app-auth flow.
|
||||
PluginInstallAuthAdvance {
|
||||
refresh_connectors: bool,
|
||||
},
|
||||
|
||||
/// Abandon the post-install plugin app-auth flow.
|
||||
PluginInstallAuthAbandon,
|
||||
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
|
||||
/// Apply rollback semantics to local transcript cells.
|
||||
///
|
||||
/// This is emitted when rollback was not initiated by the current
|
||||
/// backtrack flow so trimming occurs in AppEvent queue order relative to
|
||||
/// inserted history cells.
|
||||
ApplyThreadRollback {
|
||||
num_turns: u32,
|
||||
},
|
||||
|
||||
StartCommitAnimation,
|
||||
StopCommitAnimation,
|
||||
CommitTick,
|
||||
|
||||
/// Update the current reasoning effort in the running app and widget.
|
||||
UpdateReasoningEffort(Option<ReasoningEffort>),
|
||||
|
||||
/// Update the current model slug in the running app and widget.
|
||||
UpdateModel(String),
|
||||
|
||||
/// Update the active collaboration mask in the running app and widget.
|
||||
UpdateCollaborationMode(CollaborationModeMask),
|
||||
|
||||
/// Update the current personality in the running app and widget.
|
||||
UpdatePersonality(Personality),
|
||||
|
||||
/// Persist the selected model and reasoning effort to the appropriate config.
|
||||
PersistModelSelection {
|
||||
model: String,
|
||||
effort: Option<ReasoningEffort>,
|
||||
},
|
||||
|
||||
/// Persist the selected personality to the appropriate config.
|
||||
PersistPersonalitySelection {
|
||||
personality: Personality,
|
||||
},
|
||||
|
||||
/// Persist the selected service tier to the appropriate config.
|
||||
PersistServiceTierSelection {
|
||||
service_tier: Option<ServiceTier>,
|
||||
},
|
||||
|
||||
/// Open the device picker for a realtime microphone or speaker.
|
||||
OpenRealtimeAudioDeviceSelection {
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
},
|
||||
|
||||
/// Persist the selected realtime microphone or speaker to top-level config.
|
||||
#[cfg_attr(
|
||||
any(target_os = "linux", not(feature = "voice-input")),
|
||||
allow(dead_code)
|
||||
)]
|
||||
PersistRealtimeAudioDeviceSelection {
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Restart the selected realtime microphone or speaker locally.
|
||||
RestartRealtimeAudioDevice {
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
},
|
||||
|
||||
/// Open the reasoning selection popup after picking a model.
|
||||
OpenReasoningPopup {
|
||||
model: ModelPreset,
|
||||
},
|
||||
|
||||
/// Open the Plan-mode reasoning scope prompt for the selected model/effort.
|
||||
OpenPlanReasoningScopePrompt {
|
||||
model: String,
|
||||
effort: Option<ReasoningEffort>,
|
||||
},
|
||||
|
||||
/// Open the full model picker (non-auto models).
|
||||
OpenAllModelsPopup {
|
||||
models: Vec<ModelPreset>,
|
||||
},
|
||||
|
||||
/// Open the confirmation prompt before enabling full access mode.
|
||||
OpenFullAccessConfirmation {
|
||||
preset: ApprovalPreset,
|
||||
return_to_permissions: bool,
|
||||
},
|
||||
|
||||
/// Open the Windows world-writable directories warning.
|
||||
/// If `preset` is `Some`, the confirmation will apply the provided
|
||||
/// approval/sandbox configuration on Continue; if `None`, it performs no
|
||||
/// policy change and only acknowledges/dismisses the warning.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
OpenWorldWritableWarningConfirmation {
|
||||
preset: Option<ApprovalPreset>,
|
||||
/// Up to 3 sample world-writable directories to display in the warning.
|
||||
sample_paths: Vec<String>,
|
||||
/// If there are more than `sample_paths`, this carries the remaining count.
|
||||
extra_count: usize,
|
||||
/// True when the scan failed (e.g. ACL query error) and protections could not be verified.
|
||||
failed_scan: bool,
|
||||
},
|
||||
|
||||
/// Prompt to enable the Windows sandbox feature before using Agent mode.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
OpenWindowsSandboxEnablePrompt {
|
||||
preset: ApprovalPreset,
|
||||
},
|
||||
|
||||
/// Open the Windows sandbox fallback prompt after declining or failing elevation.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
OpenWindowsSandboxFallbackPrompt {
|
||||
preset: ApprovalPreset,
|
||||
},
|
||||
|
||||
/// Begin the elevated Windows sandbox setup flow.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
BeginWindowsSandboxElevatedSetup {
|
||||
preset: ApprovalPreset,
|
||||
},
|
||||
|
||||
/// Begin the non-elevated Windows sandbox setup flow.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
BeginWindowsSandboxLegacySetup {
|
||||
preset: ApprovalPreset,
|
||||
},
|
||||
|
||||
/// Begin a non-elevated grant of read access for an additional directory.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
BeginWindowsSandboxGrantReadRoot {
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Result of attempting to grant read access for an additional directory.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
WindowsSandboxGrantReadRootCompleted {
|
||||
path: PathBuf,
|
||||
error: Option<String>,
|
||||
},
|
||||
|
||||
/// Enable the Windows sandbox feature and switch to Agent mode.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
EnableWindowsSandboxForAgentMode {
|
||||
preset: ApprovalPreset,
|
||||
mode: WindowsSandboxEnableMode,
|
||||
},
|
||||
|
||||
/// Update the Windows sandbox feature mode without changing approval presets.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
|
||||
/// Update the current approval policy in the running app and widget.
|
||||
UpdateAskForApprovalPolicy(AskForApproval),
|
||||
|
||||
/// Update the current sandbox policy in the running app and widget.
|
||||
UpdateSandboxPolicy(SandboxPolicy),
|
||||
|
||||
/// Update the current approvals reviewer in the running app and widget.
|
||||
UpdateApprovalsReviewer(ApprovalsReviewer),
|
||||
|
||||
/// Update feature flags and persist them to the top-level config.
|
||||
UpdateFeatureFlags {
|
||||
updates: Vec<(Feature, bool)>,
|
||||
},
|
||||
|
||||
/// Update whether the full access warning prompt has been acknowledged.
|
||||
UpdateFullAccessWarningAcknowledged(bool),
|
||||
|
||||
/// Update whether the world-writable directories warning has been acknowledged.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
UpdateWorldWritableWarningAcknowledged(bool),
|
||||
|
||||
/// Update whether the rate limit switch prompt has been acknowledged for the session.
|
||||
UpdateRateLimitSwitchPromptHidden(bool),
|
||||
|
||||
/// Update the Plan-mode-specific reasoning effort in memory.
|
||||
UpdatePlanModeReasoningEffort(Option<ReasoningEffort>),
|
||||
|
||||
/// Persist the acknowledgement flag for the full access warning prompt.
|
||||
PersistFullAccessWarningAcknowledged,
|
||||
|
||||
/// Persist the acknowledgement flag for the world-writable directories warning.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
PersistWorldWritableWarningAcknowledged,
|
||||
|
||||
/// Persist the acknowledgement flag for the rate limit switch prompt.
|
||||
PersistRateLimitSwitchPromptHidden,
|
||||
|
||||
/// Persist the Plan-mode-specific reasoning effort.
|
||||
PersistPlanModeReasoningEffort(Option<ReasoningEffort>),
|
||||
|
||||
/// Persist the acknowledgement flag for the model migration prompt.
|
||||
PersistModelMigrationPromptAcknowledged {
|
||||
from_model: String,
|
||||
to_model: String,
|
||||
},
|
||||
|
||||
/// Skip the next world-writable scan (one-shot) after a user-confirmed continue.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
SkipNextWorldWritableScan,
|
||||
|
||||
/// Re-open the approval presets popup.
|
||||
OpenApprovalsPopup,
|
||||
|
||||
/// Open the skills list popup.
|
||||
OpenSkillsList,
|
||||
|
||||
/// Open the skills enable/disable picker.
|
||||
OpenManageSkillsPopup,
|
||||
|
||||
/// Enable or disable a skill by path.
|
||||
SetSkillEnabled {
|
||||
path: PathBuf,
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Enable or disable an app by connector ID.
|
||||
SetAppEnabled {
|
||||
id: String,
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Notify that the manage skills popup was closed.
|
||||
ManageSkillsClosed,
|
||||
|
||||
/// Re-open the permissions presets popup.
|
||||
OpenPermissionsPopup,
|
||||
|
||||
/// Live update for the in-progress voice recording placeholder. Carries
|
||||
/// the placeholder `id` and the text to display (e.g., an ASCII meter).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
UpdateRecordingMeter {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Voice transcription finished for the given placeholder id.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg_attr(not(feature = "voice-input"), allow(dead_code))]
|
||||
TranscriptionComplete {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Voice transcription failed; remove the placeholder identified by `id`.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
TranscriptionFailed {
|
||||
id: String,
|
||||
#[allow(dead_code)]
|
||||
error: String,
|
||||
},
|
||||
|
||||
/// Open the branch picker option from the review popup.
|
||||
OpenReviewBranchPicker(PathBuf),
|
||||
|
||||
/// Open the commit picker option from the review popup.
|
||||
OpenReviewCommitPicker(PathBuf),
|
||||
|
||||
/// Open the custom prompt option from the review popup.
|
||||
OpenReviewCustomPrompt,
|
||||
|
||||
/// Submit a user message with an explicit collaboration mask.
|
||||
SubmitUserMessageWithMode {
|
||||
text: String,
|
||||
collaboration_mode: CollaborationModeMask,
|
||||
},
|
||||
|
||||
/// Open the approval popup.
|
||||
FullScreenApprovalRequest(ApprovalRequest),
|
||||
|
||||
/// Open the feedback note entry overlay after the user selects a category.
|
||||
OpenFeedbackNote {
|
||||
category: FeedbackCategory,
|
||||
include_logs: bool,
|
||||
},
|
||||
|
||||
/// Open the upload consent popup for feedback after selecting a category.
|
||||
OpenFeedbackConsent {
|
||||
category: FeedbackCategory,
|
||||
},
|
||||
|
||||
/// Launch the external editor after a normal draw has completed.
|
||||
LaunchExternalEditor,
|
||||
|
||||
/// Async update of the current git branch for status line rendering.
|
||||
StatusLineBranchUpdated {
|
||||
cwd: PathBuf,
|
||||
branch: Option<String>,
|
||||
},
|
||||
/// Apply a user-confirmed status-line item ordering/selection.
|
||||
StatusLineSetup {
|
||||
items: Vec<StatusLineItem>,
|
||||
},
|
||||
/// Dismiss the status-line setup UI without changing config.
|
||||
StatusLineSetupCancelled,
|
||||
/// Apply a user-confirmed terminal-title item ordering/selection.
|
||||
TerminalTitleSetup {
|
||||
items: Vec<TerminalTitleItem>,
|
||||
},
|
||||
/// Apply a temporary terminal-title preview while the setup UI is open.
|
||||
TerminalTitleSetupPreview {
|
||||
items: Vec<TerminalTitleItem>,
|
||||
},
|
||||
/// Dismiss the terminal-title setup UI without changing config.
|
||||
TerminalTitleSetupCancelled,
|
||||
|
||||
/// Apply a user-confirmed syntax theme selection.
|
||||
SyntaxThemeSelected {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The exit strategy requested by the UI layer.
|
||||
///
|
||||
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
|
||||
/// after core acknowledges completion. `Immediate` is an escape hatch for cases where shutdown has
|
||||
/// already completed (or is being bypassed) and the UI loop should terminate right away.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ExitMode {
|
||||
/// Shutdown core and exit after completion.
|
||||
ShutdownFirst,
|
||||
/// Exit the UI loop immediately without waiting for shutdown.
|
||||
///
|
||||
/// This skips `Op::Shutdown`, so any in-flight work may be dropped and
|
||||
/// cleanup that normally runs before `ShutdownComplete` can be missed.
|
||||
Immediate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum FeedbackCategory {
|
||||
BadResult,
|
||||
GoodResult,
|
||||
Bug,
|
||||
SafetyCheck,
|
||||
Other,
|
||||
}
|
||||
Reference in New Issue
Block a user