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 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 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 execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
114 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 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 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}