1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5
6use crate::command::TuiCommand;
7use crate::file_picker::{FileIndex, FilePickerState};
8use crate::widgets::command_palette::CommandPaletteState;
9use crate::widgets::slash_autocomplete::{SlashAutocompleteState, command_id_to_slash_form};
10
11use super::{
12 AgentViewTarget, App, ChatMessage, InputMode, MAX_INPUT_HISTORY, MessageRole, Panel,
13 PasteState, format_security_report, oneshot,
14};
15
16impl App {
17 pub(super) fn handle_key(&mut self, key: KeyEvent) {
18 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
19 self.should_quit = true;
20 return;
21 }
22
23 if self.show_help {
24 match key.code {
25 KeyCode::Char('?') | KeyCode::Esc => self.show_help = false,
26 _ => {}
27 }
28 return;
29 }
30
31 if self.confirm_state.is_some() {
32 self.handle_confirm_key(key);
33 return;
34 }
35
36 if self.elicitation_state.is_some() {
37 self.handle_elicitation_key(key);
38 return;
39 }
40
41 if self.command_palette.is_some() {
42 self.handle_palette_key(key);
43 return;
44 }
45
46 if self.file_picker_state.is_some() {
47 self.handle_file_picker_key(key);
48 return;
49 }
50
51 match self.sessions.current().input_mode {
52 InputMode::Normal => self.handle_normal_key(key),
53 InputMode::Insert => self.handle_insert_key(key),
54 }
55 }
56
57 fn handle_confirm_key(&mut self, key: KeyEvent) {
58 let response = match key.code {
59 KeyCode::Char('y' | 'Y') | KeyCode::Enter => Some(true),
60 KeyCode::Char('n' | 'N') | KeyCode::Esc => Some(false),
61 _ => None,
62 };
63 if let Some(answer) = response
64 && let Some(mut state) = self.confirm_state.take()
65 && let Some(tx) = state.response_tx.take()
66 {
67 let _ = tx.send(answer);
68 }
69 }
70
71 fn handle_elicitation_key(&mut self, key: KeyEvent) {
72 use crossterm::event::KeyModifiers;
73 use zeph_core::channel::ElicitationResponse;
74
75 let Some(state) = self.elicitation_state.as_mut() else {
76 return;
77 };
78
79 match key.code {
80 KeyCode::Esc => {
81 if let Some(mut st) = self.elicitation_state.take()
83 && let Some(tx) = st.response_tx.take()
84 {
85 let _ = tx.send(ElicitationResponse::Cancelled);
86 }
87 }
88 KeyCode::Enter => {
89 if let Some(value) = state.dialog.build_submission()
90 && let Some(mut st) = self.elicitation_state.take()
91 && let Some(tx) = st.response_tx.take()
92 {
93 let _ = tx.send(ElicitationResponse::Accepted(value));
94 }
95 }
97 KeyCode::Tab => {
98 if key.modifiers.contains(KeyModifiers::SHIFT) {
99 state.dialog.prev_field();
100 } else {
101 state.dialog.next_field();
102 }
103 }
104 KeyCode::BackTab => {
105 state.dialog.prev_field();
106 }
107 KeyCode::Up => {
108 state.dialog.enum_prev();
109 }
110 KeyCode::Down => {
111 state.dialog.enum_next();
112 }
113 KeyCode::Char(' ') => {
114 state.dialog.toggle_bool();
115 }
116 KeyCode::Char(c) => {
117 state.dialog.push_char(c);
118 }
119 KeyCode::Backspace => {
120 state.dialog.pop_char();
121 }
122 _ => {}
123 }
124 }
125
126 fn handle_palette_key(&mut self, key: KeyEvent) {
127 let Some(palette) = self.command_palette.as_mut() else {
128 return;
129 };
130 match key.code {
131 KeyCode::Esc => {
132 self.command_palette = None;
133 }
134 KeyCode::Enter => {
135 if let Some(entry) = palette.selected_entry() {
136 let cmd = entry.command.clone();
137 self.execute_command(cmd);
138 }
139 self.command_palette = None;
140 }
141 KeyCode::Up => {
142 palette.move_up();
143 }
144 KeyCode::Down => {
145 palette.move_down();
146 }
147 KeyCode::Backspace => {
148 palette.pop_char();
149 }
150 KeyCode::Char(c) => {
151 palette.push_char(c);
152 }
153 _ => {}
154 }
155 }
156
157 pub(super) fn execute_command(&mut self, cmd: TuiCommand) {
158 match cmd {
159 TuiCommand::SkillList => self.push_system_message(self.format_skill_list()),
160 TuiCommand::McpList => self.push_system_message(self.format_mcp_list()),
161 TuiCommand::MemoryStats => self.push_system_message(self.format_memory_stats()),
162 TuiCommand::ViewCost => self.push_system_message(self.format_cost_stats()),
163 TuiCommand::ViewTools => self.push_system_message(self.format_tool_list()),
164 TuiCommand::ViewConfig | TuiCommand::ViewAutonomy => {
165 if let Some(ref tx) = self.command_tx {
166 let _ = tx.try_send(cmd);
168 } else {
169 self.push_system_message(
170 "Config not available (no command channel).".to_owned(),
171 );
172 }
173 }
174 TuiCommand::Quit => {
175 self.should_quit = true;
176 }
177 TuiCommand::Help => {
178 self.show_help = true;
179 }
180 TuiCommand::NewSession => {
181 self.sessions.current_mut().messages.clear();
182 self.push_system_message("New conversation started.".to_owned());
183 }
184 TuiCommand::ToggleTheme => {
185 self.push_system_message("Theme switching is not yet implemented.".to_owned());
186 }
187 TuiCommand::SessionBrowser => {
188 if let Some(ref tx) = self.command_tx {
189 let _ = tx.try_send(cmd);
190 } else {
191 self.push_system_message(
192 "Session browser not available (no command channel).".to_owned(),
193 );
194 }
195 }
196 TuiCommand::DaemonConnect | TuiCommand::DaemonDisconnect | TuiCommand::DaemonStatus => {
197 self.push_system_message(
198 "Daemon commands are not yet implemented in this mode.".to_owned(),
199 );
200 }
201 TuiCommand::ViewFilters => {
202 self.push_system_message(
203 "Filter statistics are displayed in the Resources panel.".to_owned(),
204 );
205 }
206 TuiCommand::Ingest => {
207 self.push_system_message(
208 "Use: zeph ingest <path> [--chunk-size N] [--collection NAME]".to_owned(),
209 );
210 }
211 TuiCommand::GatewayStatus => {
212 self.push_system_message(
213 "Gateway status is not yet available in TUI mode.".to_owned(),
214 );
215 }
216 TuiCommand::AgentList => {
217 let _ = self.user_input_tx.try_send("/agent list".to_owned());
218 }
219 TuiCommand::AgentStatus => {
220 let _ = self.user_input_tx.try_send("/agent status".to_owned());
221 }
222 TuiCommand::AgentCancelPrompt => self.prefill_input("/agent cancel "),
223 TuiCommand::AgentSpawnPrompt => self.prefill_input("/agent spawn "),
224 TuiCommand::AgentsShow => self.prefill_input("/agents show "),
225 TuiCommand::AgentsCreate => self.prefill_input("/agents create "),
226 TuiCommand::AgentsEdit => self.prefill_input("/agents edit "),
227 TuiCommand::AgentsDelete => self.prefill_input("/agents delete "),
228 TuiCommand::SchedulerList => self.push_system_message(self.format_scheduler_list()),
229 TuiCommand::RouterStats => self.push_system_message(self.format_router_stats()),
230 TuiCommand::SecurityEvents => {
231 self.push_system_message(format_security_report(&self.metrics));
232 }
233 TuiCommand::TaskPanel => {
234 self.show_task_panel = !self.show_task_panel;
235 }
236 cmd => self.execute_plan_graph_command(cmd),
237 }
238 }
239
240 fn execute_plan_graph_command(&mut self, cmd: TuiCommand) {
241 if self.handle_plan_command(&cmd) {
242 return;
243 }
244 if self.handle_graph_command(&cmd) {
245 return;
246 }
247 if self.handle_experiment_command(&cmd) {
248 return;
249 }
250 if self.handle_memory_command(&cmd) {
251 return;
252 }
253 if self.handle_plugin_command(&cmd) {
254 return;
255 }
256 self.handle_acp_command(cmd);
257 }
258
259 fn handle_plan_command(&mut self, cmd: &TuiCommand) -> bool {
260 match cmd {
261 TuiCommand::PlanStatus => {
262 let _ = self.user_input_tx.try_send("/plan status".to_owned());
263 }
264 TuiCommand::PlanConfirm => {
265 let _ = self.user_input_tx.try_send("/plan confirm".to_owned());
266 }
267 TuiCommand::PlanCancel => {
268 let _ = self.user_input_tx.try_send("/plan cancel".to_owned());
269 }
270 TuiCommand::PlanList => {
271 let _ = self.user_input_tx.try_send("/plan list".to_owned());
272 }
273 TuiCommand::PlanToggleView => {
274 self.sessions.current_mut().plan_view_active =
275 !self.sessions.current().plan_view_active;
276 }
277 _ => return false,
278 }
279 true
280 }
281
282 fn handle_graph_command(&mut self, cmd: &TuiCommand) -> bool {
283 match cmd {
284 TuiCommand::GraphStats => {
285 self.push_system_message("Loading graph stats...".to_owned());
286 let _ = self.user_input_tx.try_send("/graph".to_owned());
287 }
288 TuiCommand::GraphEntities => {
289 self.push_system_message("Loading graph entities...".to_owned());
290 let _ = self.user_input_tx.try_send("/graph entities".to_owned());
291 }
292 TuiCommand::GraphCommunities => {
293 self.push_system_message("Loading graph communities...".to_owned());
294 let _ = self.user_input_tx.try_send("/graph communities".to_owned());
295 }
296 TuiCommand::GraphFactsPrompt => self.prefill_input("/graph facts "),
297 TuiCommand::GraphBackfillPrompt => self.prefill_input("/graph backfill"),
298 _ => return false,
299 }
300 true
301 }
302
303 fn handle_experiment_command(&mut self, cmd: &TuiCommand) -> bool {
304 match cmd {
305 TuiCommand::ExperimentStart => self.prefill_input("/experiment start "),
306 TuiCommand::ExperimentStop => {
307 let _ = self.user_input_tx.try_send("/experiment stop".to_owned());
308 }
309 TuiCommand::ExperimentStatus => {
310 let _ = self.user_input_tx.try_send("/experiment status".to_owned());
311 }
312 TuiCommand::ExperimentReport => {
313 let _ = self.user_input_tx.try_send("/experiment report".to_owned());
314 }
315 TuiCommand::ExperimentBest => {
316 let _ = self.user_input_tx.try_send("/experiment best".to_owned());
317 }
318 _ => return false,
319 }
320 true
321 }
322
323 fn handle_memory_command(&mut self, cmd: &TuiCommand) -> bool {
324 match cmd {
325 TuiCommand::ServerCompactionStatus => {
326 let _ = self.user_input_tx.try_send("/server-compaction".to_owned());
327 }
328 TuiCommand::ViewGuidelines => {
329 let _ = self.user_input_tx.try_send("/guidelines".to_owned());
330 }
331 TuiCommand::ForgettingSweep => {
332 let _ = self.user_input_tx.try_send("/forgetting-sweep".to_owned());
333 }
334 TuiCommand::TrajectoryStats => {
335 let _ = self.user_input_tx.try_send("/memory trajectory".to_owned());
336 }
337 TuiCommand::MemoryTreeStats => {
338 let _ = self.user_input_tx.try_send("/memory tree".to_owned());
339 }
340 _ => return false,
341 }
342 true
343 }
344
345 fn handle_plugin_command(&mut self, cmd: &TuiCommand) -> bool {
346 match cmd {
347 TuiCommand::PluginList => {
348 let _ = self.user_input_tx.try_send("/plugins list".to_owned());
349 }
350 TuiCommand::PluginAdd => self.prefill_input("/plugins add "),
351 TuiCommand::PluginRemove => self.prefill_input("/plugins remove "),
352 TuiCommand::PluginListOverlay => {
353 let _ = self.user_input_tx.try_send("/plugins overlay".to_owned());
354 }
355 TuiCommand::SessionSwitchNext
356 | TuiCommand::SessionSwitchPrev
357 | TuiCommand::SessionClose => self.try_switch(cmd),
358 _ => return false,
359 }
360 true
361 }
362
363 fn handle_acp_command(&mut self, cmd: TuiCommand) -> bool {
364 match cmd {
365 TuiCommand::AcpDirsList => {
366 self.push_system_message("Querying ACP runtime...".to_owned());
367 let _ = self.user_input_tx.try_send("/acp dirs".to_owned());
368 }
369 TuiCommand::AcpAuthMethodsView => {
370 self.push_system_message("Querying ACP runtime...".to_owned());
371 let _ = self.user_input_tx.try_send("/acp auth-methods".to_owned());
372 }
373 TuiCommand::AcpStatus => {
374 self.push_system_message("Querying ACP runtime...".to_owned());
375 let _ = self.user_input_tx.try_send("/acp status".to_owned());
376 }
377 TuiCommand::SubagentSpawn { command } => {
378 if command.is_empty() {
379 self.prefill_input("/subagent spawn ");
380 } else {
381 let _ = self
382 .user_input_tx
383 .try_send(format!("/subagent spawn {command}"));
384 }
385 }
386 TuiCommand::LspStatus => {
387 self.push_system_message("Checking LSP context injection status...".to_owned());
388 let _ = self.user_input_tx.try_send("/lsp".to_owned());
389 }
390 TuiCommand::ViewLog => {
391 let _ = self.user_input_tx.try_send("/log".to_owned());
392 }
393 TuiCommand::MigrateConfig => {
394 self.push_system_message(
395 "To preview missing config parameters, run:\n zeph migrate-config --diff\n\
396 To apply changes in-place:\n zeph migrate-config --in-place"
397 .to_owned(),
398 );
399 }
400 _ => return false,
401 }
402 true
403 }
404
405 fn try_switch(&mut self, cmd: &TuiCommand) {
408 if self.confirm_state.is_some() || self.elicitation_state.is_some() {
409 self.push_system_message(
410 "Resolve the current confirmation dialog before switching sessions.".to_owned(),
411 );
412 return;
413 }
414 self.command_palette = None;
416 self.file_picker_state = None;
417 self.slash_autocomplete = None;
418 let prev = self.sessions.active();
419 match cmd {
420 TuiCommand::SessionSwitchNext => self.sessions.switch_next(),
421 TuiCommand::SessionSwitchPrev => self.sessions.switch_prev(),
422 TuiCommand::SessionClose => {
423 let active = self.sessions.active();
424 if !self.sessions.close(active) {
425 self.push_system_message("Cannot close the last remaining session.".to_owned());
426 }
427 }
428 _ => {}
429 }
430 if self.sessions.active() != prev {
432 self.sessions.current_mut().render_cache.clear();
433 }
434 }
435
436 fn parse_session_slash(text: &str) -> Option<TuiCommand> {
437 let tokens: Vec<&str> = text.split_whitespace().collect();
438 match tokens.as_slice() {
439 [cmd, "next"] if cmd.eq_ignore_ascii_case("/session") => {
440 Some(TuiCommand::SessionSwitchNext)
441 }
442 [cmd, "prev"] if cmd.eq_ignore_ascii_case("/session") => {
443 Some(TuiCommand::SessionSwitchPrev)
444 }
445 [cmd, "close"] if cmd.eq_ignore_ascii_case("/session") => {
446 Some(TuiCommand::SessionClose)
447 }
448 [cmd, "dirs"] if cmd.eq_ignore_ascii_case("/acp") => Some(TuiCommand::AcpDirsList),
449 [cmd, "auth-methods"] if cmd.eq_ignore_ascii_case("/acp") => {
450 Some(TuiCommand::AcpAuthMethodsView)
451 }
452 [cmd, "status"] if cmd.eq_ignore_ascii_case("/acp") => Some(TuiCommand::AcpStatus),
453 [cmd, "spawn", rest @ ..] if cmd.eq_ignore_ascii_case("/subagent") => {
454 Some(TuiCommand::SubagentSpawn {
455 command: rest.join(" "),
456 })
457 }
458 _ => None,
459 }
460 }
461
462 fn prefill_input(&mut self, prefix: &str) {
463 self.sessions.current_mut().input.clear();
464 self.sessions.current_mut().input.push_str(prefix);
465 self.sessions.current_mut().cursor_position = self.sessions.current().input.len();
466 }
467
468 fn format_skill_list(&self) -> String {
469 if self.metrics.active_skills.is_empty() {
470 return "No skills loaded.".to_owned();
471 }
472 let lines: Vec<String> = self
473 .metrics
474 .active_skills
475 .iter()
476 .map(|s| format!(" - {s}"))
477 .collect();
478 format!(
479 "Loaded skills ({}):\n{}",
480 self.metrics.active_skills.len(),
481 lines.join("\n")
482 )
483 }
484
485 fn format_mcp_list(&self) -> String {
486 if self.metrics.active_mcp_tools.is_empty() {
487 return "No MCP tools available.".to_owned();
488 }
489 let lines: Vec<String> = self
490 .metrics
491 .active_mcp_tools
492 .iter()
493 .map(|t| format!(" - {t}"))
494 .collect();
495 format!(
496 "MCP servers: {} Tools ({}):\n{}",
497 self.metrics.mcp_server_count,
498 self.metrics.active_mcp_tools.len(),
499 lines.join("\n")
500 )
501 }
502
503 fn format_memory_stats(&self) -> String {
504 let vector_status = if self.metrics.qdrant_available {
505 format!("{} (connected)", self.metrics.vector_backend)
506 } else if !self.metrics.vector_backend.is_empty() {
507 format!("{} (offline)", self.metrics.vector_backend)
508 } else {
509 "none".into()
510 };
511 format!(
512 "Memory stats:\n SQLite messages: {}\n Vector store: {vector_status}\n Embeddings generated: {}",
513 self.metrics.sqlite_message_count, self.metrics.embeddings_generated,
514 )
515 }
516
517 fn format_cost_stats(&self) -> String {
518 use std::fmt::Write as _;
519 let cps_line = match self.metrics.cost_cps_cents {
520 Some(cps) => format!("\n CPS: ${:.4}", cps / 100.0),
521 None => String::new(),
522 };
523 let mut out = format!(
524 "Cost:\n Spent: ${:.4}{}\n Successful tasks today: {}\n Prompt tokens: {}\n Completion tokens: {}\n Total tokens: {}\n Cache read: {}\n Cache creation: {}",
525 self.metrics.cost_spent_cents / 100.0,
526 cps_line,
527 self.metrics.cost_successful_tasks,
528 self.metrics.prompt_tokens,
529 self.metrics.completion_tokens,
530 self.metrics.total_tokens,
531 self.metrics.cache_read_tokens,
532 self.metrics.cache_creation_tokens,
533 );
534 if !self.metrics.provider_cost_breakdown.is_empty() {
535 let _ = write!(out, "\n\nPer-provider breakdown:");
536 let _ = write!(
537 out,
538 "\n {:<16} {:<28} {:>8} {:>9} {:>9} {:>8} {:>8}",
539 "Provider", "Model", "Input", "Cache-R", "Cache-W", "Output", "Cost"
540 );
541 for (name, usage) in &self.metrics.provider_cost_breakdown {
542 let model_display = if usage.model.chars().count() > 26 {
543 format!("{}…", usage.model.chars().take(25).collect::<String>())
544 } else {
545 usage.model.clone()
546 };
547 let _ = write!(
548 out,
549 "\n {:<16} {:<28} {:>8} {:>9} {:>9} {:>8} {:>8}",
550 name,
551 model_display,
552 usage.input_tokens,
553 usage.cache_read_tokens,
554 usage.cache_write_tokens,
555 usage.output_tokens,
556 format!("${:.4}", usage.cost_cents / 100.0),
557 );
558 }
559 let _ = write!(
560 out,
561 "\n\n Note: excludes subsystem calls (compaction, graph extraction, planning)"
562 );
563 }
564 out
565 }
566
567 fn format_tool_list(&self) -> String {
568 if self.metrics.active_mcp_tools.is_empty() {
569 return "No tools available.".to_owned();
570 }
571 let lines: Vec<String> = self
572 .metrics
573 .active_mcp_tools
574 .iter()
575 .map(|t| format!(" - {t}"))
576 .collect();
577 format!(
578 "Available tools ({}):\n{}",
579 self.metrics.active_mcp_tools.len(),
580 lines.join("\n")
581 )
582 }
583
584 fn format_scheduler_list(&self) -> String {
585 if self.metrics.scheduled_tasks.is_empty() {
586 return "No scheduled tasks.".to_owned();
587 }
588 let lines: Vec<String> = self
589 .metrics
590 .scheduled_tasks
591 .iter()
592 .map(|t| {
593 let next = if t[3].is_empty() {
594 "—".to_owned()
595 } else {
596 t[3].clone()
597 };
598 format!(" {:30} {:15} {:8} {}", t[0], t[1], t[2], next)
599 })
600 .collect();
601 format!(
602 "Scheduled tasks ({}):\n {:30} {:15} {:8} {}\n{}",
603 self.metrics.scheduled_tasks.len(),
604 "NAME",
605 "KIND",
606 "MODE",
607 "NEXT RUN",
608 lines.join("\n")
609 )
610 }
611
612 fn format_router_stats(&self) -> String {
613 if self.metrics.router_thompson_stats.is_empty() {
614 return "Router: no Thompson state available.\n\
615 (Thompson strategy not active, or no LLM calls made yet)"
616 .to_owned();
617 }
618 let total_mean: f64 = self
619 .metrics
620 .router_thompson_stats
621 .iter()
622 .map(|(_, a, b)| a / (a + b))
623 .sum();
624 let lines: Vec<String> = self
625 .metrics
626 .router_thompson_stats
627 .iter()
628 .map(|(name, alpha, beta)| {
629 let mean = alpha / (alpha + beta);
630 let pct = if total_mean > 0.0 {
631 mean / total_mean * 100.0
632 } else {
633 0.0
634 };
635 format!(" {name:<28} α={alpha:.2} β={beta:.2} Mean={pct:.1}%")
636 })
637 .collect();
638 let n = self.metrics.router_thompson_stats.len();
639 format!(
640 "Thompson Sampling state ({n} providers):\n{}",
641 lines.join("\n")
642 )
643 }
644
645 fn push_system_message(&mut self, content: String) {
646 self.sessions.current_mut().show_splash = false;
647 self.sessions
648 .current_mut()
649 .messages
650 .push(ChatMessage::new(MessageRole::System, content));
651 self.sessions.current_mut().scroll_offset = 0;
652 }
653
654 #[must_use]
656 pub fn has_recent_security_events(&self) -> bool {
657 let now = std::time::SystemTime::now()
658 .duration_since(std::time::UNIX_EPOCH)
659 .unwrap_or_default()
660 .as_secs();
661 self.metrics
662 .security_events
663 .back()
664 .is_some_and(|ev| now.saturating_sub(ev.timestamp) <= 60)
665 }
666
667 fn handle_subagent_panel_key(&mut self, key: KeyEvent) -> bool {
670 if self.active_panel == Panel::SubAgents {
671 match key.code {
672 KeyCode::Char('j') | KeyCode::Down => {
673 let count = self.metrics.sub_agents.len();
674 self.subagent_sidebar.select_next(count);
675 return true;
676 }
677 KeyCode::Char('k') | KeyCode::Up => {
678 let count = self.metrics.sub_agents.len();
679 self.subagent_sidebar.select_prev(count);
680 return true;
681 }
682 KeyCode::Enter => {
683 if let Some(idx) = self.subagent_sidebar.selected()
684 && let Some(sa) = self.metrics.sub_agents.get(idx)
685 {
686 let target = AgentViewTarget::SubAgent {
687 id: sa.id.clone(),
688 name: sa.name.clone(),
689 };
690 self.set_view_target(target);
691 }
692 return true;
693 }
694 KeyCode::Esc => {
695 self.active_panel = Panel::Chat;
696 return true;
697 }
698 _ => {}
699 }
700 }
701 if key.code == KeyCode::Esc && !self.sessions.current().view_target.is_main() {
703 self.set_view_target(AgentViewTarget::Main);
704 return true;
705 }
706 false
707 }
708
709 fn handle_normal_key(&mut self, key: KeyEvent) {
710 if self.handle_subagent_panel_key(key) {
711 return;
712 }
713 match key.code {
714 KeyCode::Esc if self.is_agent_busy() => {
715 if let Some(ref signal) = self.cancel_signal {
716 signal.notify_waiters();
717 }
718 }
719 KeyCode::Char('q') => self.should_quit = true,
720 KeyCode::Char('H') => self.execute_command(TuiCommand::SessionBrowser),
721 KeyCode::Char('i') => self.sessions.current_mut().input_mode = InputMode::Insert,
722 KeyCode::Char(':') => {
723 self.command_palette = Some(CommandPaletteState::new());
724 }
725 KeyCode::Up | KeyCode::Char('k') => {
726 self.sessions.current_mut().scroll_offset =
727 self.sessions.current().scroll_offset.saturating_add(1);
728 }
729 KeyCode::Down | KeyCode::Char('j') => {
730 self.sessions.current_mut().scroll_offset =
731 self.sessions.current().scroll_offset.saturating_sub(1);
732 }
733 KeyCode::PageUp => {
734 self.sessions.current_mut().scroll_offset =
735 self.sessions.current().scroll_offset.saturating_add(10);
736 }
737 KeyCode::PageDown => {
738 self.sessions.current_mut().scroll_offset =
739 self.sessions.current().scroll_offset.saturating_sub(10);
740 }
741 KeyCode::Home => {
742 self.sessions.current_mut().scroll_offset =
743 if let Some(cache) = &self.sessions.current().transcript_cache {
744 cache.entries.len()
745 } else {
746 self.sessions.current().messages.len()
747 };
748 }
749 KeyCode::End => {
750 self.sessions.current_mut().scroll_offset = 0;
751 }
752 KeyCode::Char('d') => {
753 self.show_side_panels = !self.show_side_panels;
754 }
755 KeyCode::Char('e') => {
756 self.tool_expanded = !self.tool_expanded;
757 self.sessions.current_mut().render_cache.clear();
758 }
759 KeyCode::Char('c') => {
760 self.compact_tools = !self.compact_tools;
761 self.sessions.current_mut().render_cache.clear();
762 }
763 KeyCode::Tab => {
764 self.active_panel = match self.active_panel {
765 Panel::Chat => Panel::Skills,
766 Panel::Skills => Panel::Memory,
767 Panel::Memory => Panel::Resources,
768 Panel::Resources => Panel::SubAgents,
769 Panel::SubAgents | Panel::Tasks => Panel::Chat,
770 };
771 }
772 KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
773 if self.sessions.current().view_target.is_main() {
774 self.sessions.current_mut().messages.clear();
775 }
776 self.sessions.current_mut().render_cache.clear();
777 self.sessions.current_mut().scroll_offset = 0;
778 }
779 KeyCode::Char('?') => {
780 self.show_help = true;
781 }
782 KeyCode::Char('p') => {
783 self.sessions.current_mut().plan_view_active =
784 !self.sessions.current().plan_view_active;
785 }
786 KeyCode::Char('a') => {
787 self.active_panel = Panel::SubAgents;
788 if self.subagent_sidebar.selected().is_none() && !self.metrics.sub_agents.is_empty()
790 {
791 self.subagent_sidebar.list_state.select(Some(0));
792 }
793 }
794 _ => {}
795 }
796 }
797
798 fn byte_offset_of_char(&self, char_idx: usize) -> usize {
800 self.sessions
801 .current()
802 .input
803 .char_indices()
804 .nth(char_idx)
805 .map_or(self.sessions.current().input.len(), |(i, _)| i)
806 }
807
808 pub(super) fn char_count(&self) -> usize {
809 self.sessions.current().input.chars().count()
810 }
811
812 pub(super) fn prev_word_boundary(&self) -> usize {
813 let chars: Vec<char> = self.sessions.current().input.chars().collect();
814 let mut pos = self.sessions.current().cursor_position;
815 while pos > 0 && !chars[pos - 1].is_alphanumeric() {
816 pos -= 1;
817 }
818 while pos > 0 && chars[pos - 1].is_alphanumeric() {
819 pos -= 1;
820 }
821 pos
822 }
823
824 pub(super) fn next_word_boundary(&self) -> usize {
825 let chars: Vec<char> = self.sessions.current().input.chars().collect();
826 let len = chars.len();
827 let mut pos = self.sessions.current().cursor_position;
828 while pos < len && chars[pos].is_alphanumeric() {
829 pos += 1;
830 }
831 while pos < len && !chars[pos].is_alphanumeric() {
832 pos += 1;
833 }
834 pos
835 }
836
837 pub(super) fn handle_paste(&mut self, text: &str) {
838 if self.sessions.current().input_mode != InputMode::Insert {
839 return;
840 }
841 self.slash_autocomplete = None;
842 let byte_offset = self.byte_offset_of_char(self.sessions.current().cursor_position);
843 self.sessions
844 .current_mut()
845 .input
846 .insert_str(byte_offset, text);
847 self.sessions.current_mut().cursor_position += text.chars().count();
848
849 let line_count = text.matches('\n').count() + 1;
850 if line_count >= 2 {
851 self.sessions.current_mut().paste_state = Some(PasteState {
853 line_count,
854 byte_len: text.len(),
855 });
856 } else {
857 self.sessions.current_mut().paste_state = None;
858 }
859 }
860
861 fn handle_insert_key(&mut self, key: KeyEvent) {
862 if self.slash_autocomplete.is_some() {
863 self.handle_slash_autocomplete_key(key);
864 return;
865 }
866 if self.handle_insert_text_keys(key) {
867 return;
868 }
869 if self.handle_insert_delete_keys(key) {
870 return;
871 }
872 if self.handle_insert_history_keys(key) {
873 return;
874 }
875 if self.handle_insert_cursor_keys(key) {
876 return;
877 }
878 self.handle_insert_control_keys(key);
879 }
880
881 fn insert_newline_at_cursor(&mut self) {
885 self.sessions.current_mut().paste_state = None;
886 let byte_offset = self.byte_offset_of_char(self.sessions.current().cursor_position);
887 self.sessions.current_mut().input.insert(byte_offset, '\n');
888 self.sessions.current_mut().cursor_position += 1;
889 }
890
891 fn handle_insert_text_keys(&mut self, key: KeyEvent) -> bool {
895 match key.code {
896 KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
897 self.insert_newline_at_cursor();
898 }
899 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
900 self.insert_newline_at_cursor();
901 }
902 KeyCode::Enter => self.submit_input(),
903 KeyCode::Esc => self.sessions.current_mut().input_mode = InputMode::Normal,
904 _ => return false,
905 }
906 true
907 }
908
909 fn handle_insert_delete_keys(&mut self, key: KeyEvent) -> bool {
913 match key.code {
914 KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {
915 self.sessions.current_mut().paste_state = None;
917 let boundary = self.prev_word_boundary();
918 if boundary < self.sessions.current().cursor_position {
919 let start = self.byte_offset_of_char(boundary);
920 let end = self.byte_offset_of_char(self.sessions.current().cursor_position);
921 self.sessions.current_mut().input.drain(start..end);
922 self.sessions.current_mut().cursor_position = boundary;
923 }
924 }
925 KeyCode::Backspace => {
926 self.sessions.current_mut().paste_state = None;
928 if self.sessions.current().cursor_position > 0 {
929 let byte_offset =
930 self.byte_offset_of_char(self.sessions.current().cursor_position - 1);
931 self.sessions.current_mut().input.remove(byte_offset);
932 self.sessions.current_mut().cursor_position -= 1;
933 }
934 }
935 KeyCode::Delete => {
936 self.sessions.current_mut().paste_state = None;
938 if self.sessions.current().cursor_position < self.char_count() {
939 let byte_offset =
940 self.byte_offset_of_char(self.sessions.current().cursor_position);
941 self.sessions.current_mut().input.remove(byte_offset);
942 }
943 }
944 _ => return false,
945 }
946 true
947 }
948
949 fn handle_insert_history_keys(&mut self, key: KeyEvent) -> bool {
953 match key.code {
954 KeyCode::Up => {
955 self.handle_history_up();
956 }
957 KeyCode::Down => {
958 self.sessions.current_mut().paste_state = None;
959 let Some(i) = self.sessions.current().history_index else {
960 return true;
961 };
962 let prefix = &self.sessions.current().draft_input;
963 let found = self.sessions.current().input_history[i + 1..]
964 .iter()
965 .position(|e| prefix.is_empty() || e.starts_with(prefix))
966 .map(|offset| i + 1 + offset);
967 if let Some(idx) = found {
968 self.sessions.current_mut().history_index = Some(idx);
969 let text = self.sessions.current().input_history[idx].clone();
970 self.sessions.current_mut().input = text;
971 } else {
972 self.sessions.current_mut().history_index = None;
973 self.sessions.current_mut().input =
974 std::mem::take(&mut self.sessions.current_mut().draft_input);
975 }
976 self.sessions.current_mut().cursor_position = self.char_count();
977 }
978 _ => return false,
979 }
980 true
981 }
982
983 fn handle_insert_cursor_keys(&mut self, key: KeyEvent) -> bool {
987 match key.code {
988 KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => {
989 self.sessions.current_mut().paste_state = None;
990 self.sessions.current_mut().cursor_position = self.prev_word_boundary();
991 }
992 KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => {
993 self.sessions.current_mut().paste_state = None;
994 self.sessions.current_mut().cursor_position = self.next_word_boundary();
995 }
996 KeyCode::Left => {
997 self.sessions.current_mut().paste_state = None;
998 self.sessions.current_mut().cursor_position =
999 self.sessions.current().cursor_position.saturating_sub(1);
1000 }
1001 KeyCode::Right => {
1002 self.sessions.current_mut().paste_state = None;
1003 if self.sessions.current().cursor_position < self.char_count() {
1004 self.sessions.current_mut().cursor_position += 1;
1005 }
1006 }
1007 KeyCode::Home => {
1008 self.sessions.current_mut().paste_state = None;
1009 self.sessions.current_mut().cursor_position = 0;
1010 }
1011 KeyCode::End => {
1012 self.sessions.current_mut().paste_state = None;
1013 self.sessions.current_mut().cursor_position = self.char_count();
1014 }
1015 _ => return false,
1016 }
1017 true
1018 }
1019
1020 fn handle_insert_control_keys(&mut self, key: KeyEvent) -> bool {
1024 match key.code {
1025 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1026 self.sessions.current_mut().paste_state = None;
1027 self.sessions.current_mut().cursor_position = 0;
1028 }
1029 KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1030 self.sessions.current_mut().paste_state = None;
1031 self.sessions.current_mut().cursor_position = self.char_count();
1032 }
1033 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1034 self.sessions.current_mut().paste_state = None;
1035 self.sessions.current_mut().input.clear();
1036 self.sessions.current_mut().cursor_position = 0;
1037 }
1038 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1039 let _ = self.user_input_tx.try_send("/clear-queue".to_owned());
1040 }
1041 KeyCode::Char('@') => {
1042 self.open_file_picker();
1043 }
1044 KeyCode::Char(c) => {
1045 self.sessions.current_mut().paste_state = None;
1047 let was_empty = self.sessions.current().input.is_empty();
1048 let byte_offset = self.byte_offset_of_char(self.sessions.current().cursor_position);
1049 self.sessions.current_mut().input.insert(byte_offset, c);
1050 self.sessions.current_mut().cursor_position += 1;
1051 if c == '/' && was_empty {
1052 self.slash_autocomplete = Some(SlashAutocompleteState::new());
1053 }
1054 }
1055 _ => return false,
1056 }
1057 true
1058 }
1059
1060 fn handle_slash_autocomplete_key(&mut self, key: KeyEvent) {
1061 let Some(state) = self.slash_autocomplete.as_mut() else {
1062 return;
1063 };
1064 match key.code {
1065 KeyCode::Esc => {
1066 self.slash_autocomplete = None;
1067 }
1068 KeyCode::Tab | KeyCode::Enter => {
1069 let entry = state.selected_entry().map(|e| e.id);
1070 self.slash_autocomplete = None;
1071 if let Some(id) = entry {
1072 let slash_form = command_id_to_slash_form(id);
1073 self.sessions.current_mut().input = slash_form;
1074 self.sessions.current_mut().cursor_position = self.char_count();
1075 }
1076 if key.code == KeyCode::Enter {
1077 self.submit_input();
1078 }
1079 }
1080 KeyCode::Down => {
1081 if let Some(s) = self.slash_autocomplete.as_mut() {
1082 s.move_down();
1083 }
1084 }
1085 KeyCode::Up | KeyCode::BackTab => {
1086 if let Some(s) = self.slash_autocomplete.as_mut() {
1087 s.move_up();
1088 }
1089 }
1090 KeyCode::Backspace => {
1091 let dismiss = self
1092 .slash_autocomplete
1093 .as_mut()
1094 .is_none_or(SlashAutocompleteState::pop_char);
1095 if dismiss {
1096 self.sessions.current_mut().input.clear();
1097 self.sessions.current_mut().cursor_position = 0;
1098 self.slash_autocomplete = None;
1099 } else {
1100 let query = self
1101 .slash_autocomplete
1102 .as_ref()
1103 .map_or(String::new(), |s| s.query.clone());
1104 self.sessions.current_mut().input = format!("/{query}");
1105 self.sessions.current_mut().cursor_position = self.char_count();
1106 if self
1107 .slash_autocomplete
1108 .as_ref()
1109 .is_none_or(|s| s.filtered.is_empty())
1110 {
1111 self.slash_autocomplete = None;
1112 }
1113 }
1114 }
1115 KeyCode::Char(c) => {
1116 if let Some(s) = self.slash_autocomplete.as_mut() {
1117 s.push_char(c);
1118 }
1119 let query = self
1120 .slash_autocomplete
1121 .as_ref()
1122 .map_or(String::new(), |s| s.query.clone());
1123 self.sessions.current_mut().input = format!("/{query}");
1124 self.sessions.current_mut().cursor_position = self.char_count();
1125 if self
1126 .slash_autocomplete
1127 .as_ref()
1128 .is_none_or(|s| s.filtered.is_empty())
1129 {
1130 self.slash_autocomplete = None;
1131 }
1132 }
1133 _ => {}
1134 }
1135 }
1136
1137 fn handle_history_up(&mut self) {
1138 self.sessions.current_mut().paste_state = None;
1139 if self.sessions.current().input.is_empty()
1140 && self.pending_count > 0
1141 && self.sessions.current().history_index.is_none()
1142 {
1143 if let Some(last) = self.sessions.current_mut().input_history.pop() {
1144 self.sessions.current_mut().input = last;
1145 self.sessions.current_mut().cursor_position = self.char_count();
1146 self.pending_count -= 1;
1147 self.queued_count = self.queued_count.saturating_sub(1);
1148 self.editing_queued = true;
1149 if let Some(pos) = self
1150 .sessions
1151 .current_mut()
1152 .messages
1153 .iter()
1154 .rposition(|m| m.role == MessageRole::User)
1155 {
1156 self.sessions.current_mut().messages.remove(pos);
1157 }
1158 let _ = self.user_input_tx.try_send("/drop-last-queued".to_owned());
1159 }
1160 return;
1161 }
1162 match self.sessions.current().history_index {
1163 None => {
1164 if self.sessions.current().input_history.is_empty() {
1165 return;
1166 }
1167 self.sessions.current_mut().draft_input = self.sessions.current().input.clone();
1168 let prefix = &self.sessions.current().draft_input;
1169 let found = self
1170 .sessions
1171 .current()
1172 .input_history
1173 .iter()
1174 .rposition(|e| prefix.is_empty() || e.starts_with(prefix));
1175 let Some(idx) = found else { return };
1176 self.sessions.current_mut().history_index = Some(idx);
1177 let text = self.sessions.current().input_history[idx].clone();
1178 self.sessions.current_mut().input = text;
1179 }
1180 Some(i) => {
1181 let prefix = &self.sessions.current().draft_input;
1182 let found = self.sessions.current().input_history[..i]
1183 .iter()
1184 .rposition(|e| prefix.is_empty() || e.starts_with(prefix));
1185 let Some(idx) = found else { return };
1186 self.sessions.current_mut().history_index = Some(idx);
1187 let text = self.sessions.current().input_history[idx].clone();
1188 self.sessions.current_mut().input = text;
1189 }
1190 }
1191 self.sessions.current_mut().cursor_position = self.char_count();
1192 }
1193
1194 fn open_file_picker(&mut self) {
1195 let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1196 let needs_rebuild = self.file_index.as_ref().is_none_or(FileIndex::is_stale);
1197 if needs_rebuild && self.pending_file_index.is_none() {
1198 self.sessions.current_mut().status_label = Some("indexing files...".to_owned());
1199 let (tx, rx) = oneshot::channel();
1200 tokio::task::spawn_blocking(move || {
1201 let _ = tx.send(FileIndex::build(&root));
1202 });
1203 self.pending_file_index = Some(rx);
1204 return;
1205 }
1206 if let Some(idx) = &self.file_index {
1207 self.file_picker_state = Some(FilePickerState::new(idx));
1208 }
1209 }
1210
1211 pub fn poll_pending_file_index(&mut self) {
1214 let Some(rx) = self.pending_file_index.as_mut() else {
1215 return;
1216 };
1217 match rx.try_recv() {
1218 Ok(idx) => {
1219 let picker = FilePickerState::new(&idx);
1220 self.file_index = Some(idx);
1221 self.file_picker_state = Some(picker);
1222 self.pending_file_index = None;
1223 self.sessions.current_mut().status_label = None;
1224 }
1225 Err(oneshot::error::TryRecvError::Empty) => {}
1226 Err(oneshot::error::TryRecvError::Closed) => {
1227 self.pending_file_index = None;
1228 self.sessions.current_mut().status_label = None;
1229 }
1230 }
1231 }
1232
1233 fn handle_file_picker_key(&mut self, key: KeyEvent) {
1234 let Some(state) = self.file_picker_state.as_mut() else {
1235 return;
1236 };
1237 match key.code {
1238 KeyCode::Esc => {
1239 self.file_picker_state = None;
1240 }
1241 KeyCode::Enter | KeyCode::Tab => {
1242 if let Some(path) = state.selected_path().map(ToOwned::to_owned) {
1243 let byte_offset =
1244 self.byte_offset_of_char(self.sessions.current().cursor_position);
1245 self.sessions
1246 .current_mut()
1247 .input
1248 .insert_str(byte_offset, &path);
1249 self.sessions.current_mut().cursor_position += path.chars().count();
1250 }
1251 self.file_picker_state = None;
1252 }
1253 KeyCode::Up => {
1254 state.move_selection(-1);
1255 }
1256 KeyCode::Down => {
1257 state.move_selection(1);
1258 }
1259 KeyCode::Char(c) => {
1260 state.push_char(c);
1261 }
1262 KeyCode::Backspace if !state.pop_char() => {
1263 self.file_picker_state = None;
1264 }
1265 _ => {}
1266 }
1267 }
1268
1269 pub(super) fn submit_input(&mut self) {
1270 let text = self.sessions.current().input.trim().to_string();
1271 if text.is_empty() {
1272 return;
1273 }
1274 if let Some(cmd) = Self::parse_session_slash(&text) {
1276 self.sessions.current_mut().input.clear();
1277 self.sessions.current_mut().cursor_position = 0;
1278 self.execute_command(cmd);
1279 return;
1280 }
1281 self.sessions.current_mut().show_splash = false;
1282 self.sessions.current_mut().input_history.push(text.clone());
1283 if self.sessions.current().input_history.len() > MAX_INPUT_HISTORY {
1284 let excess = self.sessions.current().input_history.len() - MAX_INPUT_HISTORY;
1285 self.sessions.current_mut().input_history.drain(0..excess);
1286 }
1287 self.sessions.current_mut().history_index = None;
1288 self.sessions.current_mut().draft_input.clear();
1289 let paste_lines = self
1290 .sessions
1291 .current_mut()
1292 .paste_state
1293 .take()
1294 .map(|p| p.line_count);
1295 let mut msg = ChatMessage::new(MessageRole::User, text.clone());
1296 msg.paste_line_count = paste_lines;
1297 self.sessions.current_mut().messages.push(msg);
1298 self.trim_messages();
1299 self.sessions.current_mut().input.clear();
1300 self.sessions.current_mut().cursor_position = 0;
1301 self.sessions.current_mut().scroll_offset = 0;
1302 self.editing_queued = false;
1303 self.pending_count += 1;
1304
1305 let _ = self.user_input_tx.try_send(text);
1308 }
1309}