mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Summary `ChatWidget` has been carrying several independent domains in one large state bag: transcript bookkeeping, turn lifecycle, queued input, status surfaces, connectors, review mode, and protocol dispatch. That makes otherwise-local changes hard to reason about because unrelated fields and side effects live beside each other in `chatwidget.rs`. This is the first cleanup PR in a larger decomposition effort. It does not try to make `chatwidget.rs` small in one sweep; instead, it establishes focused state boundaries that later handler, popup, rendering, and effect-synchronization extractions can build on. This PR keeps `ChatWidget` as the composition layer while moving focused state into smaller `codex-tui` modules. The widget still owns effects that touch the bottom pane, app events, command submission, redraw scheduling, and terminal-title updates. ## Changes - Add focused state modules under `codex-rs/tui/src/chatwidget/` for input queues, turn lifecycle, transcript bookkeeping, status state, connectors, review mode, and app-server protocol dispatch. - Update `ChatWidget` to hold grouped state structs and route input/lifecycle/status operations through those focused helpers. - Move app-server notification dispatch into `chatwidget/protocol.rs` while leaving feature handlers and side effects on `ChatWidget`. - Replace the large manual `ChatWidget` test literal with the normal constructor plus narrow test overrides, so future state moves do not require every field to be restated in test setup. - Update existing tests to access the new grouped state or narrower helpers without changing snapshot behavior. ## Longer-term direction Follow-up PRs can continue shrinking `chatwidget.rs` by moving behavior, not just state, into focused modules: - Extract input/submission flow, turn/stream handling, and tool-cell lifecycles into domain modules that call the new state reducers. - Move popup/settings builders and rendering helpers out of the main widget file so `ChatWidget` stays focused on composition. - Reduce direct `BottomPane` mutation by applying domain-specific sync outputs at clearer boundaries.
180 lines
5.7 KiB
Rust
180 lines
5.7 KiB
Rust
//! Status indicator and terminal-title state for `ChatWidget`.
|
|
|
|
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(super) struct StatusIndicatorState {
|
|
pub(super) header: String,
|
|
pub(super) details: Option<String>,
|
|
pub(super) details_max_lines: usize,
|
|
}
|
|
|
|
impl StatusIndicatorState {
|
|
pub(super) fn working() -> Self {
|
|
Self {
|
|
header: String::from("Working"),
|
|
details: None,
|
|
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
|
|
}
|
|
}
|
|
|
|
pub(super) fn is_guardian_review(&self) -> bool {
|
|
self.header == "Reviewing approval request" || self.header.starts_with("Reviewing ")
|
|
}
|
|
}
|
|
|
|
/// Compact runtime states that can be rendered into the terminal title.
|
|
///
|
|
/// This is intentionally smaller than the full status-header vocabulary. The
|
|
/// title needs short, stable labels, so callers map richer lifecycle events
|
|
/// onto one of these buckets before rendering.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub(super) enum TerminalTitleStatusKind {
|
|
Working,
|
|
WaitingForBackgroundTerminal,
|
|
#[default]
|
|
Thinking,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub(super) struct PendingGuardianReviewStatus {
|
|
entries: Vec<PendingGuardianReviewStatusEntry>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct PendingGuardianReviewStatusEntry {
|
|
id: String,
|
|
detail: String,
|
|
}
|
|
|
|
impl PendingGuardianReviewStatus {
|
|
pub(super) fn start_or_update(&mut self, id: String, detail: String) {
|
|
if let Some(existing) = self.entries.iter_mut().find(|entry| entry.id == id) {
|
|
existing.detail = detail;
|
|
} else {
|
|
self.entries
|
|
.push(PendingGuardianReviewStatusEntry { id, detail });
|
|
}
|
|
}
|
|
|
|
pub(super) fn finish(&mut self, id: &str) -> bool {
|
|
let original_len = self.entries.len();
|
|
self.entries.retain(|entry| entry.id != id);
|
|
self.entries.len() != original_len
|
|
}
|
|
|
|
pub(super) fn is_empty(&self) -> bool {
|
|
self.entries.is_empty()
|
|
}
|
|
|
|
// Guardian review status is derived from the full set of currently pending
|
|
// review entries. The generic status cache on `ChatWidget` stores whichever
|
|
// footer is currently rendered; this helper computes the guardian-specific
|
|
// footer snapshot that should replace it while reviews remain in flight.
|
|
pub(super) fn status_indicator_state(&self) -> Option<StatusIndicatorState> {
|
|
let details = if self.entries.len() == 1 {
|
|
self.entries.first().map(|entry| entry.detail.clone())
|
|
} else if self.entries.is_empty() {
|
|
None
|
|
} else {
|
|
let mut lines = self
|
|
.entries
|
|
.iter()
|
|
.take(3)
|
|
.map(|entry| format!("• {}", entry.detail))
|
|
.collect::<Vec<_>>();
|
|
let remaining = self.entries.len().saturating_sub(3);
|
|
if remaining > 0 {
|
|
lines.push(format!("+{remaining} more"));
|
|
}
|
|
Some(lines.join("\n"))
|
|
};
|
|
let details = details?;
|
|
let header = if self.entries.len() == 1 {
|
|
String::from("Reviewing approval request")
|
|
} else {
|
|
format!("Reviewing {} approval requests", self.entries.len())
|
|
};
|
|
let details_max_lines = if self.entries.len() == 1 { 1 } else { 4 };
|
|
Some(StatusIndicatorState {
|
|
header,
|
|
details: Some(details),
|
|
details_max_lines,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(super) struct StatusState {
|
|
pub(super) current_status: StatusIndicatorState,
|
|
pub(super) pending_guardian_review_status: PendingGuardianReviewStatus,
|
|
pub(super) terminal_title_status_kind: TerminalTitleStatusKind,
|
|
pub(super) retry_status_header: Option<String>,
|
|
pub(super) pending_status_indicator_restore: bool,
|
|
}
|
|
|
|
impl Default for StatusState {
|
|
fn default() -> Self {
|
|
Self {
|
|
current_status: StatusIndicatorState::working(),
|
|
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
|
|
terminal_title_status_kind: TerminalTitleStatusKind::Working,
|
|
retry_status_header: None,
|
|
pending_status_indicator_restore: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl StatusState {
|
|
pub(super) fn set_status(&mut self, status: StatusIndicatorState) {
|
|
self.current_status = status;
|
|
}
|
|
|
|
pub(super) fn take_retry_status_header(&mut self) -> Option<String> {
|
|
self.retry_status_header.take()
|
|
}
|
|
|
|
pub(super) fn remember_retry_status_header(&mut self) {
|
|
if self.retry_status_header.is_none() {
|
|
self.retry_status_header = Some(self.current_status.header.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn guardian_status_aggregates_parallel_reviews() {
|
|
let mut state = PendingGuardianReviewStatus::default();
|
|
state.start_or_update("a".to_string(), "first".to_string());
|
|
state.start_or_update("b".to_string(), "second".to_string());
|
|
|
|
assert_eq!(
|
|
state.status_indicator_state(),
|
|
Some(StatusIndicatorState {
|
|
header: "Reviewing 2 approval requests".to_string(),
|
|
details: Some("• first\n• second".to_string()),
|
|
details_max_lines: 4,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn retry_status_header_is_taken_once() {
|
|
let mut state = StatusState::default();
|
|
state.current_status.header = "Thinking".to_string();
|
|
|
|
state.remember_retry_status_header();
|
|
|
|
assert_eq!(
|
|
state.take_retry_status_header(),
|
|
Some("Thinking".to_string())
|
|
);
|
|
assert_eq!(state.take_retry_status_header(), None);
|
|
}
|
|
}
|