Skip to main content

codetether_agent/tui/app/
run.rs

1use std::sync::Arc;
2
3use crossterm::{
4    event::{EnableBracketedPaste, KeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
5    execute,
6    terminal::{EnterAlternateScreen, enable_raw_mode},
7};
8use ratatui::{Terminal, backend::CrosstermBackend};
9use tokio::sync::mpsc;
10
11use crate::bus::{AgentBus, s3_sink::spawn_bus_s3_sink};
12use crate::provider::ProviderRegistry;
13use crate::session::{Session, SessionEvent};
14use crate::tui::app::event_loop::run_event_loop;
15use crate::tui::app::message_text::sync_messages_from_session;
16use crate::tui::app::panic_cleanup::install_panic_cleanup_hook;
17use crate::tui::app::state::App;
18use crate::tui::app::terminal_state::{TerminalGuard, restore_terminal_state};
19use crate::tui::ui::main::ui;
20use crate::tui::worker_bridge::TuiWorkerBridge;
21
22/// Outcome of trying to resume the most recent workspace session at startup.
23///
24/// Used to populate an informative status line so the user can distinguish
25/// "no prior session existed" from "prior session loaded with 0 messages".
26enum SessionLoadOutcome {
27    Loaded {
28        msg_count: usize,
29        title: Option<String>,
30        dropped: usize,
31        file_bytes: u64,
32        /// When `Some`, the resumed session was truncated and has been
33        /// forked to a new UUID; this is the original on-disk id that
34        /// remains intact with the full history.
35        original_id: Option<String>,
36    },
37    NewFallback {
38        reason: String,
39    },
40}
41
42/// Default number of trailing messages + tool uses kept when resuming a
43/// prior session. Older entries are dropped to bound startup memory; the
44/// user is notified in the status line when truncation occurs.
45const DEFAULT_SESSION_RESUME_WINDOW: usize = 1_000;
46const MAX_SESSION_RESUME_WINDOW: usize = 10_000;
47
48fn session_resume_window() -> usize {
49    let parsed = std::env::var("CODETETHER_SESSION_RESUME_WINDOW")
50        .ok()
51        .and_then(|value| value.parse::<usize>().ok())
52        .filter(|value| *value > 0);
53    match parsed {
54        Some(value) if value > MAX_SESSION_RESUME_WINDOW => {
55            tracing::warn!(
56                requested = value,
57                clamped = MAX_SESSION_RESUME_WINDOW,
58                "session resume window too large; clamping"
59            );
60            MAX_SESSION_RESUME_WINDOW
61        }
62        Some(value) => value,
63        None => DEFAULT_SESSION_RESUME_WINDOW,
64    }
65}
66
67async fn init_tui_secrets_manager() {
68    if crate::secrets::secrets_manager().is_some() {
69        return;
70    }
71
72    match crate::secrets::SecretsManager::from_env().await {
73        Ok(secrets_manager) => {
74            if secrets_manager.is_connected() {
75                tracing::info!("Connected to HashiCorp Vault for secrets management");
76            }
77            if let Err(err) = crate::secrets::init_from_manager(secrets_manager) {
78                tracing::debug!(error = %err, "Secrets manager already initialized");
79            }
80        }
81        Err(err) => {
82            tracing::warn!(error = %err, "Vault not configured for TUI startup");
83            tracing::warn!("Set VAULT_ADDR and VAULT_TOKEN environment variables to connect");
84        }
85    }
86}
87
88pub async fn run(project: Option<std::path::PathBuf>, allow_network: bool) -> anyhow::Result<()> {
89    if allow_network {
90        unsafe {
91            std::env::set_var("CODETETHER_SANDBOX_BASH_ALLOW_NETWORK", "1");
92        }
93    }
94
95    if let Some(project) = project {
96        // Validate with a clear error before touching process state — the
97        // bare `set_current_dir` error on Windows is "The system cannot
98        // find the file specified. (os error 2)", which is opaque.
99        let project = project.as_path();
100        if !project.exists() {
101            anyhow::bail!(
102                "project directory does not exist: {}\n\
103                 hint: `tui` takes an optional path to an existing workspace, \
104                 not a subcommand. Run `codetether tui` from inside your project, \
105                 or pass a directory that already exists.",
106                project.display(),
107            );
108        }
109        if !project.is_dir() {
110            anyhow::bail!("project path is not a directory: {}", project.display(),);
111        }
112        std::env::set_current_dir(project).map_err(|e| {
113            anyhow::anyhow!(
114                "failed to enter project directory {}: {e}",
115                project.display(),
116            )
117        })?;
118    }
119
120    restore_terminal_state();
121    enable_raw_mode()?;
122    let _guard = TerminalGuard;
123    let _panic_guard = install_panic_cleanup_hook();
124
125    let mut stdout = std::io::stdout();
126    // NOTE: We intentionally do NOT enable mouse capture. Capturing
127    // mouse events breaks native terminal text selection (users can't
128    // click-drag to select chat output and copy it). Keyboard scrolling
129    // via ↑↓ / PageUp / PageDown is already bound. Hold the
130    // terminal-specific modifier (Shift on most emulators, Option/Alt
131    // on macOS Terminal.app) during drag if mouse capture is ever
132    // re-enabled in the future.
133    execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
134    // Request the kitty keyboard protocol so terminals that support it
135    // (kitty, foot, WezTerm, Ghostty, modern Konsole, Alacritty ≥ 0.13)
136    // report modifier bits on Enter, enabling Shift+Enter to insert a
137    // newline in chat input instead of being indistinguishable from
138    // plain Enter. Failure is non-fatal — Alt+Enter still works on
139    // dumber terminals and we fall back to bracketed paste for
140    // multi-line input.
141    let _ = execute!(
142        stdout,
143        PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
144    );
145
146    let backend = CrosstermBackend::new(stdout);
147    let mut terminal = Terminal::new(backend)?;
148    terminal.clear()?;
149
150    let cwd = std::env::current_dir().unwrap_or_default();
151    let bus = AgentBus::new().into_arc();
152    crate::bus::set_global(bus.clone());
153    spawn_bus_s3_sink(bus.clone());
154    let mut session = Session::new().await?.with_bus(bus.clone());
155    let mut app = App::default();
156    app.state.cwd_display = cwd.display().to_string();
157    app.state.allow_network = allow_network;
158    app.state.session_id = Some(session.id.clone());
159    app.state.status = "Loading providers and workspace…".to_string();
160    terminal.draw(|f| ui(f, &mut app, &session))?;
161
162    let registry_task = async {
163        init_tui_secrets_manager().await;
164        ProviderRegistry::from_vault().await.ok().map(Arc::new)
165    };
166    let worker_bridge_task = TuiWorkerBridge::spawn(None, None, None, Arc::clone(&bus));
167    // Hard cap the session scan so a workspace full of old/huge session
168    // files can never block the TUI from coming up. If the scan exceeds
169    // the budget we start a fresh session; the scan task is detached and
170    // its result (if any) is simply dropped.
171    const SESSION_SCAN_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
172    let resume_window = session_resume_window();
173    let session_task = tokio::time::timeout(
174        SESSION_SCAN_BUDGET,
175        Session::last_for_directory_tail(Some(&cwd), resume_window),
176    );
177    let config_task = crate::config::Config::load();
178    let workspace_task = tokio::task::spawn_blocking({
179        let cwd = cwd.clone();
180        move || crate::tui::models::WorkspaceSnapshot::capture(&cwd, 18)
181    });
182
183    let (registry, worker_bridge_result, session_timeout_result, config, workspace_snapshot) = tokio::join!(
184        registry_task,
185        worker_bridge_task,
186        session_task,
187        config_task,
188        workspace_task,
189    );
190
191    let loaded_session = match session_timeout_result {
192        Ok(inner) => inner,
193        Err(_) => {
194            tracing::warn!(
195                budget_secs = SESSION_SCAN_BUDGET.as_secs(),
196                "session scan exceeded budget; starting fresh session",
197            );
198            Err(anyhow::anyhow!(
199                "session scan timed out after {}s",
200                SESSION_SCAN_BUDGET.as_secs(),
201            ))
202        }
203    };
204
205    let worker_bridge = worker_bridge_result.ok().flatten();
206    let mut bus_handle = bus.handle("tui");
207    let session_load_outcome = match loaded_session {
208        Ok(load) => {
209            let title = load.session.title.clone();
210            let dropped = load.dropped;
211            let file_bytes = load.file_bytes;
212            let original_id = load.session.id.clone();
213            session = load.session.with_bus(bus.clone());
214            // If the on-disk transcript was truncated to fit in memory,
215            // FORK to a new UUID so a later `.save()` cannot clobber the
216            // full-history file on disk with our shortened window.
217            if dropped > 0 {
218                let new_id = uuid::Uuid::new_v4().to_string();
219                tracing::warn!(
220                    original_id = %original_id,
221                    new_id = %new_id,
222                    dropped,
223                    file_bytes,
224                    "forked large session on resume to protect on-disk history",
225                );
226                session.id = new_id;
227                session.title = Some(format!(
228                    "{} (continued)",
229                    title.as_deref().unwrap_or("large session"),
230                ));
231            }
232            let msg_count = session.history().len();
233            SessionLoadOutcome::Loaded {
234                msg_count,
235                title,
236                dropped,
237                file_bytes,
238                original_id: if dropped > 0 { Some(original_id) } else { None },
239            }
240        }
241        Err(err) => SessionLoadOutcome::NewFallback {
242            reason: err.to_string(),
243        },
244    };
245
246    if let Ok(cfg) = config {
247        session.apply_config(&cfg, registry.as_deref());
248    }
249
250    let (event_tx, event_rx) = mpsc::channel::<SessionEvent>(256);
251    let (result_tx, result_rx) = mpsc::channel::<anyhow::Result<Session>>(8);
252
253    app.state.workspace = workspace_snapshot.unwrap_or_else(|err| {
254        tracing::warn!(error = %err, "Workspace snapshot task failed");
255        crate::tui::models::WorkspaceSnapshot::capture(&cwd, 18)
256    });
257    app.state.auto_apply_edits = session.metadata.auto_apply_edits;
258    app.state.allow_network = session.metadata.allow_network || allow_network;
259    app.state.slash_autocomplete = session.metadata.slash_autocomplete;
260    app.state.use_worktree = session.metadata.use_worktree;
261    app.state.session_id = Some(session.id.clone());
262    session.metadata.allow_network = app.state.allow_network;
263    sync_messages_from_session(&mut app, &session);
264    if let Some(bridge) = worker_bridge.as_ref() {
265        app.state
266            .set_worker_bridge(bridge.worker_id.clone(), bridge.worker_name.clone());
267        app.state.register_worker_agent("tui".to_string());
268    }
269    app.state.refresh_slash_suggestions();
270    app.state.move_cursor_end();
271    app.state.status = match &session_load_outcome {
272        SessionLoadOutcome::Loaded { msg_count: 0, .. } => format!(
273            "Loaded session {} (empty — type a message to start)",
274            session.id
275        ),
276        SessionLoadOutcome::Loaded {
277            msg_count,
278            title,
279            dropped,
280            file_bytes,
281            original_id,
282        } => {
283            let label = title.as_deref().unwrap_or("(untitled)");
284            if *dropped > 0 {
285                let mb = *file_bytes as f64 / (1024.0 * 1024.0);
286                let orig = original_id.as_deref().unwrap_or("?");
287                format!(
288                    "⚠ Large session ({mb:.1} MiB): showing last {msg_count} of {total} entries from \"{label}\". Forked to a new session — original {orig} preserved on disk.",
289                    total = msg_count + *dropped,
290                )
291            } else {
292                format!("Loaded previous session: {label} — {msg_count} messages")
293            }
294        }
295        SessionLoadOutcome::NewFallback { reason } => {
296            format!("New session (no prior session for this workspace: {reason})")
297        }
298    };
299
300    run_event_loop(
301        &mut terminal,
302        &mut app,
303        &cwd,
304        registry,
305        &mut session,
306        &mut bus_handle,
307        worker_bridge,
308        event_tx,
309        event_rx,
310        result_tx,
311        result_rx,
312    )
313    .await?;
314
315    terminal.show_cursor()?;
316    Ok(())
317}