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