mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
refactor(tui): begin ui composition-root split
This commit is contained in:
1 parent
049986bc53
commit
de14f1976d
4 files changed
+457
-357
No files matched your search
+4
-357
@@ -1,6 +1,5 @@
|
||||
//! TUI event loop and rendering logic for `DeepSeek` CLI.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::fmt::Write as _;
|
||||
use std::future::Future;
|
||||
@@ -11,7 +10,6 @@ use std::sync::{
|
||||
Arc, LazyLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, ErrorSeverity};
|
||||
@@ -264,116 +262,6 @@ const REQUIRED_RELEASE_ASSETS: &[&str] = &[
|
||||
"codewhale-artifacts-sha256.txt",
|
||||
];
|
||||
|
||||
fn is_session_approved_for_tool(app: &App, tool_name: &str, grouping_key: &str) -> bool {
|
||||
app.approval_session_approved.contains(grouping_key)
|
||||
|| app.approval_session_approved.contains(tool_name)
|
||||
}
|
||||
|
||||
fn is_session_denied_for_key(app: &App, approval_key: &str) -> bool {
|
||||
app.approval_session_denied.contains(approval_key)
|
||||
}
|
||||
|
||||
fn session_denied_notice(app: &App, tool_name: &str) -> String {
|
||||
app.tr(MessageId::ApprovalAutoDeniedSession)
|
||||
.replace("{tool}", tool_name)
|
||||
}
|
||||
|
||||
fn surface_session_denied_notice(app: &mut App, tool_name: &str) {
|
||||
let notice = session_denied_notice(app, tool_name);
|
||||
app.status_message = Some(notice.clone());
|
||||
app.push_status_toast(notice.clone(), StatusToastLevel::Warning, Some(12_000));
|
||||
|
||||
// Tool completion and turn completion can replace the one-line status
|
||||
// before the next frame is painted. Keep the recovery path in the
|
||||
// transcript as a settled receipt as well, where it survives that event
|
||||
// ordering and remains available to screen readers and scrollback.
|
||||
let latest_transcript_cell = app
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.and_then(|cell| cell.entries().last())
|
||||
.or_else(|| app.history.last());
|
||||
let already_latest_receipt = matches!(
|
||||
latest_transcript_cell,
|
||||
Some(HistoryCell::System { content }) if content == ¬ice
|
||||
);
|
||||
if !already_latest_receipt {
|
||||
let receipt = HistoryCell::System { content: notice };
|
||||
if let Some(active_cell) = app.active_cell.as_mut() {
|
||||
// Never grow committed history underneath an active cell: tool
|
||||
// lookup indices address `history ++ active_cell`, so changing
|
||||
// history.len() mid-turn would retarget the pending completion.
|
||||
active_cell.push_untracked(receipt);
|
||||
app.bump_active_cell_revision();
|
||||
} else {
|
||||
app.add_message(receipt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn auto_deny_session_approval(
|
||||
app: &mut App,
|
||||
engine_handle: &EngineHandle,
|
||||
id: &str,
|
||||
tool_name: &str,
|
||||
approval_key: &str,
|
||||
) {
|
||||
log_sensitive_event(
|
||||
"tool.approval.auto_deny_session",
|
||||
serde_json::json!({
|
||||
"tool_name": tool_name,
|
||||
"approval_key": approval_key,
|
||||
"session_id": app.current_session_id,
|
||||
}),
|
||||
);
|
||||
let _ = engine_handle.deny_tool_call(id.to_string()).await;
|
||||
surface_session_denied_notice(app, tool_name);
|
||||
}
|
||||
|
||||
fn app_auto_approve_enabled(app: &App) -> bool {
|
||||
app.mode == AppMode::Yolo || app.approval_mode == ApprovalMode::Bypass
|
||||
}
|
||||
|
||||
/// Build the UI-side TurnAuthority for approval disposition (#4412).
|
||||
///
|
||||
/// Shell/trust bits do not affect disposition; mode + approval_mode + the
|
||||
/// full-access shape (Yolo/Bypass) are what the shared resolver consults.
|
||||
fn app_turn_authority_for_approvals(app: &App) -> crate::core::authority::TurnAuthority {
|
||||
crate::core::authority::TurnAuthority::from_effective_fields(
|
||||
app.mode,
|
||||
true,
|
||||
false,
|
||||
app_auto_approve_enabled(app),
|
||||
app.approval_mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_ui_approval_disposition(
|
||||
app: &App,
|
||||
tool_name: &str,
|
||||
grouping_key: &str,
|
||||
approval_key: &str,
|
||||
approval_force_prompt: bool,
|
||||
) -> crate::core::authority::ApprovalRequestDisposition {
|
||||
crate::core::authority::resolve_approval_request_disposition(
|
||||
&app_turn_authority_for_approvals(app),
|
||||
is_session_approved_for_tool(app, tool_name, grouping_key),
|
||||
is_session_denied_for_key(app, approval_key),
|
||||
approval_force_prompt,
|
||||
)
|
||||
}
|
||||
|
||||
fn should_suppress_user_input_prompt(app: &App) -> bool {
|
||||
// Legacy hosts may still report Yolo/auto-approve with a stale `Auto`
|
||||
// enum. Canonicalize that shape to Full Access before applying the one
|
||||
// posture that suppresses questions: genuine Auto-Review.
|
||||
let effective_posture = if app_auto_approve_enabled(app) {
|
||||
ApprovalMode::Bypass
|
||||
} else {
|
||||
app.approval_mode
|
||||
};
|
||||
!crate::core::authority::permission_posture_allows_questions(effective_posture)
|
||||
}
|
||||
|
||||
type AppTerminal = Terminal<ColorCompatBackend<Stdout>>;
|
||||
|
||||
type PendingToolUses = Vec<(String, String, serde_json::Value)>;
|
||||
@@ -420,254 +308,9 @@ const BEGIN_SYNC_UPDATE: &[u8] = b"\x1b[?2026h";
|
||||
/// End synchronized update (DEC 2026): tell the terminal to render
|
||||
/// the complete frame now.
|
||||
const END_SYNC_UPDATE: &[u8] = b"\x1b[?2026l";
|
||||
const TERMINAL_INPUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
const TERMINAL_INPUT_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const TERMINAL_INPUT_STALL_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const TERMINAL_INPUT_RECOVERY_COOLDOWN: Duration = Duration::from_secs(10);
|
||||
const TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL: Duration = Duration::from_millis(5);
|
||||
/// Upper bound on engine events processed before yielding to terminal input.
|
||||
const MAX_ENGINE_EVENTS_PER_DRAIN: usize = 16;
|
||||
/// Wall-clock budget for one engine drain batch (#1830 / #2317 input fairness).
|
||||
const ENGINE_DRAIN_TIME_BUDGET: Duration = Duration::from_millis(8);
|
||||
/// Throttled in-progress checkpoint while a turn is live (#1830 progress loss).
|
||||
const RECOVERY_SNAPSHOT_INTERVAL: Duration = Duration::from_secs(45);
|
||||
|
||||
enum TerminalInputMessage {
|
||||
Event(Event),
|
||||
Heartbeat,
|
||||
Error(io::Error),
|
||||
}
|
||||
|
||||
pub(crate) struct TerminalInputPump {
|
||||
rx: std::sync::mpsc::Receiver<TerminalInputMessage>,
|
||||
stop: Arc<AtomicBool>,
|
||||
paused: Arc<AtomicBool>,
|
||||
paused_ack: Arc<AtomicBool>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
last_alive_at: Cell<Instant>,
|
||||
}
|
||||
|
||||
struct TerminalInputPumpParts {
|
||||
rx: std::sync::mpsc::Receiver<TerminalInputMessage>,
|
||||
stop: Arc<AtomicBool>,
|
||||
paused: Arc<AtomicBool>,
|
||||
paused_ack: Arc<AtomicBool>,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TerminalInputPump {
|
||||
fn spawn() -> io::Result<Self> {
|
||||
let parts = Self::spawn_parts()?;
|
||||
Ok(Self {
|
||||
rx: parts.rx,
|
||||
stop: parts.stop,
|
||||
paused: parts.paused,
|
||||
paused_ack: parts.paused_ack,
|
||||
handle: Some(parts.handle),
|
||||
last_alive_at: Cell::new(Instant::now()),
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_parts() -> io::Result<TerminalInputPumpParts> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let paused = Arc::new(AtomicBool::new(false));
|
||||
let paused_ack = Arc::new(AtomicBool::new(false));
|
||||
let thread_stop = Arc::clone(&stop);
|
||||
let thread_paused = Arc::clone(&paused);
|
||||
let thread_paused_ack = Arc::clone(&paused_ack);
|
||||
let handle = thread::Builder::new()
|
||||
.name("codewhale-terminal-input".to_string())
|
||||
.spawn(move || {
|
||||
let mut last_heartbeat = Instant::now();
|
||||
while !thread_stop.load(Ordering::Acquire) {
|
||||
if thread_paused.load(Ordering::Acquire) {
|
||||
thread_paused_ack.store(true, Ordering::Release);
|
||||
thread::sleep(TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL);
|
||||
continue;
|
||||
}
|
||||
thread_paused_ack.store(false, Ordering::Release);
|
||||
match event::poll(TERMINAL_INPUT_POLL_INTERVAL) {
|
||||
Ok(true) => match event::read() {
|
||||
Ok(event) => {
|
||||
last_heartbeat = Instant::now();
|
||||
if tx.send(TerminalInputMessage::Event(event)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(TerminalInputMessage::Error(err));
|
||||
break;
|
||||
}
|
||||
},
|
||||
Ok(false) => {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_heartbeat)
|
||||
>= TERMINAL_INPUT_HEARTBEAT_INTERVAL
|
||||
{
|
||||
last_heartbeat = now;
|
||||
if tx.send(TerminalInputMessage::Heartbeat).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(TerminalInputMessage::Error(err));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(TerminalInputPumpParts {
|
||||
rx,
|
||||
stop,
|
||||
paused,
|
||||
paused_ack,
|
||||
handle,
|
||||
})
|
||||
}
|
||||
|
||||
fn recv_timeout(&self, timeout: Duration) -> io::Result<Option<Event>> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
match self.rx.recv_timeout(remaining) {
|
||||
Ok(TerminalInputMessage::Event(event)) => {
|
||||
self.mark_alive();
|
||||
return Ok(Some(event));
|
||||
}
|
||||
Ok(TerminalInputMessage::Heartbeat) => {
|
||||
self.mark_alive();
|
||||
if remaining.is_zero() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(TerminalInputMessage::Error(err)) => {
|
||||
self.mark_alive();
|
||||
return Err(err);
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Ok(None),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
"terminal input pump disconnected",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_recv(&self) -> io::Result<Option<Event>> {
|
||||
loop {
|
||||
match self.rx.try_recv() {
|
||||
Ok(TerminalInputMessage::Event(event)) => {
|
||||
self.mark_alive();
|
||||
return Ok(Some(event));
|
||||
}
|
||||
Ok(TerminalInputMessage::Heartbeat) => {
|
||||
self.mark_alive();
|
||||
}
|
||||
Ok(TerminalInputMessage::Error(err)) => {
|
||||
self.mark_alive();
|
||||
return Err(err);
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(None),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_alive(&self) {
|
||||
self.last_alive_at.set(Instant::now());
|
||||
}
|
||||
|
||||
fn stalled_for(&self, now: Instant) -> Duration {
|
||||
now.saturating_duration_since(self.last_alive_at.get())
|
||||
}
|
||||
|
||||
fn pause_for_child_terminal(&self) -> io::Result<()> {
|
||||
self.paused.store(true, Ordering::Release);
|
||||
if self.handle.is_none() {
|
||||
self.paused_ack.store(true, Ordering::Release);
|
||||
self.mark_alive();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT;
|
||||
while !self.paused_ack.load(Ordering::Acquire) {
|
||||
if Instant::now() >= deadline {
|
||||
self.paused_ack.store(false, Ordering::Release);
|
||||
self.paused.store(false, Ordering::Release);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"terminal input pump did not pause before launching editor",
|
||||
));
|
||||
}
|
||||
thread::sleep(TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL);
|
||||
}
|
||||
self.mark_alive();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resume_after_child_terminal(&self) {
|
||||
self.paused_ack.store(false, Ordering::Release);
|
||||
self.paused.store(false, Ordering::Release);
|
||||
self.mark_alive();
|
||||
}
|
||||
|
||||
/// Replace a wedged pump thread with a freshly spawned one.
|
||||
///
|
||||
/// The old thread may be blocked forever inside crossterm's blocking
|
||||
/// `event::read` (a stalled Windows console poll, or a Unix tty that
|
||||
/// stopped delivering bytes), so it can never be joined. Instead it is
|
||||
/// detached: `stop` is flagged and the `JoinHandle` dropped, so if the
|
||||
/// thread ever wakes it exits on its own (its send fails once `rx` is
|
||||
/// replaced, and the stop flag covers the poll loop).
|
||||
fn restart_detached(&mut self) -> io::Result<()> {
|
||||
self.detach_current_thread();
|
||||
let parts = Self::spawn_parts()?;
|
||||
self.install_parts(parts);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flag the current pump thread to stop and drop its handle without
|
||||
/// joining (the thread may be wedged in a blocking terminal read).
|
||||
fn detach_current_thread(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = self.handle.take();
|
||||
}
|
||||
|
||||
/// Adopt freshly spawned pump parts and reset the liveness clock.
|
||||
fn install_parts(&mut self, parts: TerminalInputPumpParts) {
|
||||
self.rx = parts.rx;
|
||||
self.stop = parts.stop;
|
||||
self.paused = parts.paused;
|
||||
self.paused_ack = parts.paused_ack;
|
||||
self.handle = Some(parts.handle);
|
||||
self.last_alive_at.set(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalInputPump {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
if let Some(handle) = self.handle.take() {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
drop(handle);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn engine_drain_budget_exhausted(events_drained: usize, started: Instant, now: Instant) -> bool {
|
||||
events_drained >= MAX_ENGINE_EVENTS_PER_DRAIN
|
||||
|| now.saturating_duration_since(started) >= ENGINE_DRAIN_TIME_BUDGET
|
||||
}
|
||||
|
||||
/// Where a key goes while onboarding owns the screen (#4763).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum OnboardingKeyRoute {
|
||||
@@ -2729,6 +2372,8 @@ use std::process::{Command, Stdio};
|
||||
// `ui.rs` had grown past 19k lines. These three modules hold the same code,
|
||||
// moved verbatim, and are re-exported so every existing path still resolves.
|
||||
mod apply;
|
||||
mod approval_routing;
|
||||
use approval_routing::*;
|
||||
mod event_loop;
|
||||
mod handlers;
|
||||
|
||||
@@ -2744,6 +2389,8 @@ pub(crate) mod fatal_signal_guard;
|
||||
mod motion;
|
||||
mod release_check;
|
||||
mod terminal;
|
||||
mod terminal_input;
|
||||
use terminal_input::*;
|
||||
|
||||
pub(crate) use dispatch::*;
|
||||
pub(crate) use motion::*;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! UI-side approval disposition and durable denial receipts.
|
||||
|
||||
use crate::audit::log_sensitive_event;
|
||||
use crate::core::engine::EngineHandle;
|
||||
use crate::localization::MessageId;
|
||||
use crate::tui::app::{App, AppMode, StatusToastLevel};
|
||||
use crate::tui::approval::ApprovalMode;
|
||||
use crate::tui::history::HistoryCell;
|
||||
|
||||
pub(super) fn is_session_approved_for_tool(app: &App, tool_name: &str, grouping_key: &str) -> bool {
|
||||
app.approval_session_approved.contains(grouping_key)
|
||||
|| app.approval_session_approved.contains(tool_name)
|
||||
}
|
||||
|
||||
pub(super) fn is_session_denied_for_key(app: &App, approval_key: &str) -> bool {
|
||||
app.approval_session_denied.contains(approval_key)
|
||||
}
|
||||
|
||||
pub(super) fn session_denied_notice(app: &App, tool_name: &str) -> String {
|
||||
app.tr(MessageId::ApprovalAutoDeniedSession)
|
||||
.replace("{tool}", tool_name)
|
||||
}
|
||||
|
||||
pub(super) fn surface_session_denied_notice(app: &mut App, tool_name: &str) {
|
||||
let notice = session_denied_notice(app, tool_name);
|
||||
app.status_message = Some(notice.clone());
|
||||
app.push_status_toast(notice.clone(), StatusToastLevel::Warning, Some(12_000));
|
||||
|
||||
// Tool completion and turn completion can replace the one-line status
|
||||
// before the next frame is painted. Keep the recovery path in the
|
||||
// transcript as a settled receipt as well, where it survives that event
|
||||
// ordering and remains available to screen readers and scrollback.
|
||||
let latest_transcript_cell = app
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.and_then(|cell| cell.entries().last())
|
||||
.or_else(|| app.history.last());
|
||||
let already_latest_receipt = matches!(
|
||||
latest_transcript_cell,
|
||||
Some(HistoryCell::System { content }) if content == ¬ice
|
||||
);
|
||||
if !already_latest_receipt {
|
||||
let receipt = HistoryCell::System { content: notice };
|
||||
if let Some(active_cell) = app.active_cell.as_mut() {
|
||||
// Never grow committed history underneath an active cell: tool
|
||||
// lookup indices address `history ++ active_cell`, so changing
|
||||
// history.len() mid-turn would retarget the pending completion.
|
||||
active_cell.push_untracked(receipt);
|
||||
app.bump_active_cell_revision();
|
||||
} else {
|
||||
app.add_message(receipt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn auto_deny_session_approval(
|
||||
app: &mut App,
|
||||
engine_handle: &EngineHandle,
|
||||
id: &str,
|
||||
tool_name: &str,
|
||||
approval_key: &str,
|
||||
) {
|
||||
log_sensitive_event(
|
||||
"tool.approval.auto_deny_session",
|
||||
serde_json::json!({
|
||||
"tool_name": tool_name,
|
||||
"approval_key": approval_key,
|
||||
"session_id": app.current_session_id,
|
||||
}),
|
||||
);
|
||||
let _ = engine_handle.deny_tool_call(id.to_string()).await;
|
||||
surface_session_denied_notice(app, tool_name);
|
||||
}
|
||||
|
||||
pub(super) fn app_auto_approve_enabled(app: &App) -> bool {
|
||||
app.mode == AppMode::Yolo || app.approval_mode == ApprovalMode::Bypass
|
||||
}
|
||||
|
||||
/// Build the UI-side TurnAuthority for approval disposition (#4412).
|
||||
///
|
||||
/// Shell/trust bits do not affect disposition; mode + approval_mode + the
|
||||
/// full-access shape (Yolo/Bypass) are what the shared resolver consults.
|
||||
fn app_turn_authority_for_approvals(app: &App) -> crate::core::authority::TurnAuthority {
|
||||
crate::core::authority::TurnAuthority::from_effective_fields(
|
||||
app.mode,
|
||||
true,
|
||||
false,
|
||||
app_auto_approve_enabled(app),
|
||||
app.approval_mode,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_ui_approval_disposition(
|
||||
app: &App,
|
||||
tool_name: &str,
|
||||
grouping_key: &str,
|
||||
approval_key: &str,
|
||||
approval_force_prompt: bool,
|
||||
) -> crate::core::authority::ApprovalRequestDisposition {
|
||||
crate::core::authority::resolve_approval_request_disposition(
|
||||
&app_turn_authority_for_approvals(app),
|
||||
is_session_approved_for_tool(app, tool_name, grouping_key),
|
||||
is_session_denied_for_key(app, approval_key),
|
||||
approval_force_prompt,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn should_suppress_user_input_prompt(app: &App) -> bool {
|
||||
// Legacy hosts may still report Yolo/auto-approve with a stale `Auto`
|
||||
// enum. Canonicalize that shape to Full Access before applying the one
|
||||
// posture that suppresses questions: genuine Auto-Review.
|
||||
let effective_posture = if app_auto_approve_enabled(app) {
|
||||
ApprovalMode::Bypass
|
||||
} else {
|
||||
app.approval_mode
|
||||
};
|
||||
!crate::core::authority::permission_posture_allows_questions(effective_posture)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Terminal input ingestion and fairness controls for the TUI event loop.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::io;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{self, Event};
|
||||
|
||||
const TERMINAL_INPUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
const TERMINAL_INPUT_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(500);
|
||||
pub(super) const TERMINAL_INPUT_STALL_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub(super) const TERMINAL_INPUT_RECOVERY_COOLDOWN: Duration = Duration::from_secs(10);
|
||||
const TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL: Duration = Duration::from_millis(5);
|
||||
/// Upper bound on engine events processed before yielding to terminal input.
|
||||
pub(super) const MAX_ENGINE_EVENTS_PER_DRAIN: usize = 16;
|
||||
/// Wall-clock budget for one engine drain batch (#1830 / #2317 input fairness).
|
||||
pub(super) const ENGINE_DRAIN_TIME_BUDGET: Duration = Duration::from_millis(8);
|
||||
|
||||
pub(super) enum TerminalInputMessage {
|
||||
Event(Event),
|
||||
Heartbeat,
|
||||
Error(io::Error),
|
||||
}
|
||||
|
||||
pub(crate) struct TerminalInputPump {
|
||||
pub(super) rx: std::sync::mpsc::Receiver<TerminalInputMessage>,
|
||||
pub(super) stop: Arc<AtomicBool>,
|
||||
pub(super) paused: Arc<AtomicBool>,
|
||||
pub(super) paused_ack: Arc<AtomicBool>,
|
||||
pub(super) handle: Option<JoinHandle<()>>,
|
||||
pub(super) last_alive_at: Cell<Instant>,
|
||||
}
|
||||
|
||||
pub(super) struct TerminalInputPumpParts {
|
||||
pub(super) rx: std::sync::mpsc::Receiver<TerminalInputMessage>,
|
||||
pub(super) stop: Arc<AtomicBool>,
|
||||
pub(super) paused: Arc<AtomicBool>,
|
||||
pub(super) paused_ack: Arc<AtomicBool>,
|
||||
pub(super) handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TerminalInputPump {
|
||||
pub(super) fn spawn() -> io::Result<Self> {
|
||||
let parts = Self::spawn_parts()?;
|
||||
Ok(Self {
|
||||
rx: parts.rx,
|
||||
stop: parts.stop,
|
||||
paused: parts.paused,
|
||||
paused_ack: parts.paused_ack,
|
||||
handle: Some(parts.handle),
|
||||
last_alive_at: Cell::new(Instant::now()),
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_parts() -> io::Result<TerminalInputPumpParts> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let paused = Arc::new(AtomicBool::new(false));
|
||||
let paused_ack = Arc::new(AtomicBool::new(false));
|
||||
let thread_stop = Arc::clone(&stop);
|
||||
let thread_paused = Arc::clone(&paused);
|
||||
let thread_paused_ack = Arc::clone(&paused_ack);
|
||||
let handle = thread::Builder::new()
|
||||
.name("codewhale-terminal-input".to_string())
|
||||
.spawn(move || {
|
||||
let mut last_heartbeat = Instant::now();
|
||||
while !thread_stop.load(Ordering::Acquire) {
|
||||
if thread_paused.load(Ordering::Acquire) {
|
||||
thread_paused_ack.store(true, Ordering::Release);
|
||||
thread::sleep(TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL);
|
||||
continue;
|
||||
}
|
||||
thread_paused_ack.store(false, Ordering::Release);
|
||||
match event::poll(TERMINAL_INPUT_POLL_INTERVAL) {
|
||||
Ok(true) => match event::read() {
|
||||
Ok(event) => {
|
||||
last_heartbeat = Instant::now();
|
||||
if tx.send(TerminalInputMessage::Event(event)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(TerminalInputMessage::Error(err));
|
||||
break;
|
||||
}
|
||||
},
|
||||
Ok(false) => {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_heartbeat)
|
||||
>= TERMINAL_INPUT_HEARTBEAT_INTERVAL
|
||||
{
|
||||
last_heartbeat = now;
|
||||
if tx.send(TerminalInputMessage::Heartbeat).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(TerminalInputMessage::Error(err));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(TerminalInputPumpParts {
|
||||
rx,
|
||||
stop,
|
||||
paused,
|
||||
paused_ack,
|
||||
handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn recv_timeout(&self, timeout: Duration) -> io::Result<Option<Event>> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
match self.rx.recv_timeout(remaining) {
|
||||
Ok(TerminalInputMessage::Event(event)) => {
|
||||
self.mark_alive();
|
||||
return Ok(Some(event));
|
||||
}
|
||||
Ok(TerminalInputMessage::Heartbeat) => {
|
||||
self.mark_alive();
|
||||
if remaining.is_zero() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(TerminalInputMessage::Error(err)) => {
|
||||
self.mark_alive();
|
||||
return Err(err);
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Ok(None),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
"terminal input pump disconnected",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn try_recv(&self) -> io::Result<Option<Event>> {
|
||||
loop {
|
||||
match self.rx.try_recv() {
|
||||
Ok(TerminalInputMessage::Event(event)) => {
|
||||
self.mark_alive();
|
||||
return Ok(Some(event));
|
||||
}
|
||||
Ok(TerminalInputMessage::Heartbeat) => {
|
||||
self.mark_alive();
|
||||
}
|
||||
Ok(TerminalInputMessage::Error(err)) => {
|
||||
self.mark_alive();
|
||||
return Err(err);
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(None),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mark_alive(&self) {
|
||||
self.last_alive_at.set(Instant::now());
|
||||
}
|
||||
|
||||
pub(super) fn stalled_for(&self, now: Instant) -> Duration {
|
||||
now.saturating_duration_since(self.last_alive_at.get())
|
||||
}
|
||||
|
||||
pub(super) fn pause_for_child_terminal(&self) -> io::Result<()> {
|
||||
self.paused.store(true, Ordering::Release);
|
||||
if self.handle.is_none() {
|
||||
self.paused_ack.store(true, Ordering::Release);
|
||||
self.mark_alive();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT;
|
||||
while !self.paused_ack.load(Ordering::Acquire) {
|
||||
if Instant::now() >= deadline {
|
||||
self.paused_ack.store(false, Ordering::Release);
|
||||
self.paused.store(false, Ordering::Release);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"terminal input pump did not pause before launching editor",
|
||||
));
|
||||
}
|
||||
thread::sleep(TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL);
|
||||
}
|
||||
self.mark_alive();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn resume_after_child_terminal(&self) {
|
||||
self.paused_ack.store(false, Ordering::Release);
|
||||
self.paused.store(false, Ordering::Release);
|
||||
self.mark_alive();
|
||||
}
|
||||
|
||||
/// Replace a wedged pump thread with a freshly spawned one.
|
||||
///
|
||||
/// The old thread may be blocked forever inside crossterm's blocking
|
||||
/// `event::read` (a stalled Windows console poll, or a Unix tty that
|
||||
/// stopped delivering bytes), so it can never be joined. Instead it is
|
||||
/// detached: `stop` is flagged and the `JoinHandle` dropped, so if the
|
||||
/// thread ever wakes it exits on its own (its send fails once `rx` is
|
||||
/// replaced, and the stop flag covers the poll loop).
|
||||
pub(super) fn restart_detached(&mut self) -> io::Result<()> {
|
||||
self.detach_current_thread();
|
||||
let parts = Self::spawn_parts()?;
|
||||
self.install_parts(parts);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flag the current pump thread to stop and drop its handle without
|
||||
/// joining (the thread may be wedged in a blocking terminal read).
|
||||
pub(super) fn detach_current_thread(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = self.handle.take();
|
||||
}
|
||||
|
||||
/// Adopt freshly spawned pump parts and reset the liveness clock.
|
||||
pub(super) fn install_parts(&mut self, parts: TerminalInputPumpParts) {
|
||||
self.rx = parts.rx;
|
||||
self.stop = parts.stop;
|
||||
self.paused = parts.paused;
|
||||
self.paused_ack = parts.paused_ack;
|
||||
self.handle = Some(parts.handle);
|
||||
self.last_alive_at.set(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalInputPump {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
if let Some(handle) = self.handle.take() {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
drop(handle);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn engine_drain_budget_exhausted(
|
||||
events_drained: usize,
|
||||
started: Instant,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
events_drained >= MAX_ENGINE_EVENTS_PER_DRAIN
|
||||
|| now.saturating_duration_since(started) >= ENGINE_DRAIN_TIME_BUDGET
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# TUI modularization plan
|
||||
|
||||
## Objective
|
||||
|
||||
Turn `crates/tui/src/tui/ui.rs` into a small composition root without changing
|
||||
the observable terminal contract or creating a second turn loop. The final file
|
||||
should own shared UI types, module wiring, and top-level orchestration only.
|
||||
|
||||
The baseline at `c93d21373` was 4,248 lines. The first extraction checkpoint
|
||||
moves terminal input/fairness and approval disposition into owned modules,
|
||||
leaving 3,895 lines in `ui.rs` after rebasing onto the latest integration head.
|
||||
|
||||
## Rules for every extraction
|
||||
|
||||
- Move one cohesive responsibility at a time; do not redesign behavior while
|
||||
moving it.
|
||||
- Give the new module explicit imports. Temporary parent re-exports are allowed
|
||||
only where existing sibling modules or tests still depend on them.
|
||||
- Keep `Engine::run_turn` as the only turn loop.
|
||||
- Run formatting, a `codewhale-tui` library check, and the focused tests that
|
||||
protect the moved behavior before starting the next extraction.
|
||||
- Land conflict-prone slices only after the active owner has finished. In
|
||||
particular, the remote-control bridge must incorporate the current `/rc`
|
||||
recovery work rather than overwriting it.
|
||||
|
||||
## Ordered slices
|
||||
|
||||
1. **Terminal input and event fairness — extracted**
|
||||
`ui/terminal_input.rs` owns the input thread, liveness recovery, child-terminal
|
||||
pause/resume, and the engine-drain budget.
|
||||
|
||||
2. **Approval routing — extracted**
|
||||
`ui/approval_routing.rs` owns session approval/denial resolution and durable
|
||||
denial receipts.
|
||||
|
||||
3. **Remote-control bridge**
|
||||
Move enrollment events, local-turn attachment, command acknowledgement, and
|
||||
start/stop UI projection to `ui/remote_control_bridge.rs`. Do this after the
|
||||
active `/rc` changes in the v0.9.11 integration lane are committed, because
|
||||
those edits currently overlap this exact block.
|
||||
|
||||
4. **Observer hooks**
|
||||
Move subagent and turn-end hook payload construction, preview bounding, and
|
||||
completion classification to `ui/observer_hooks.rs`.
|
||||
|
||||
5. **Task and shell projection**
|
||||
Move task-panel refresh, shell live-output reconciliation, detached-job
|
||||
projection, and RLM task entries to `ui/task_projection.rs`.
|
||||
|
||||
6. **Paused-command and dispatch preparation**
|
||||
Move pause/resume planning and the dispatch preparation/outcome types to
|
||||
`ui/dispatch_prepare.rs`; keep actual dispatch execution in `dispatch.rs`.
|
||||
|
||||
7. **Compaction UI state**
|
||||
Move manual/automatic compaction queueing, settlement, receipts, and cancel
|
||||
behavior to `ui/compaction_flow.rs`.
|
||||
|
||||
8. **Provider configuration**
|
||||
Move web-config event draining, rollback snapshots, key verification, and
|
||||
provider setup tests to `ui/provider_setup.rs` and
|
||||
`ui/provider_setup/tests.rs`.
|
||||
|
||||
9. **Small residual policies**
|
||||
Move context-pressure warnings, update notices, and notification decisions
|
||||
into their existing domain modules. Leave `ui.rs` as wiring plus genuinely
|
||||
shared aliases.
|
||||
|
||||
## Follow-on debt
|
||||
|
||||
`ui/event_loop.rs` and `ui/tests.rs` are larger than `ui.rs`. Once the composition
|
||||
root split is complete, partition the event loop by event source and move tests
|
||||
next to the modules they exercise. This is a second phase so it does not hide
|
||||
the `ui.rs` extraction behind a wholesale rewrite.
|
||||
Reference in new issue
Block a user