codetether_agent/tui/app/
run.rs1use 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
22enum SessionLoadOutcome {
27 Loaded {
28 msg_count: usize,
29 title: Option<String>,
30 dropped: usize,
31 file_bytes: u64,
32 original_id: Option<String>,
36 },
37 NewFallback {
38 reason: String,
39 },
40}
41
42const 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 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 execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
134 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 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 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}