1use crate::commands::augment_limit_hint;
44use crate::output::{self, one_line};
45use crate::tail::EventRenderer;
46use anyhow::{Context, Result};
47use crossterm::event::{
48 self as ct_event, DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers,
49 MouseEventKind,
50};
51use crossterm::execute;
52use crossterm::terminal::{
53 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
54};
55use kranz_engine::cost;
56use kranz_engine::error::EngineError;
57use kranz_engine::event_log::EventLog;
58use kranz_engine::orchestrator::{MissionEngine, PlanRequest};
59use kranz_engine::types::Plan;
60use ratatui::backend::CrosstermBackend;
61use ratatui::layout::{Constraint, Layout, Position, Rect};
62use ratatui::style::{Color, Modifier, Style};
63use ratatui::text::{Line, Span};
64use ratatui::widgets::Paragraph;
65use ratatui::{Frame, Terminal};
66use std::collections::VecDeque;
67use std::future::Future;
68use std::io::Stdout;
69use std::path::PathBuf;
70use std::pin::Pin;
71use std::sync::atomic::{AtomicBool, Ordering};
72use std::sync::Arc;
73use std::time::{Duration, Instant};
74use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
75
76const TICK: Duration = Duration::from_millis(250);
78
79const INPUT_POLL: Duration = Duration::from_millis(100);
81
82const WHEEL_LINES: usize = 3;
84
85#[derive(Debug, Default)]
100pub struct InputEditor {
101 buf: Vec<char>,
102 cursor: usize,
103 history: Vec<String>,
104 nav: Option<usize>,
105 edited: bool,
106}
107
108impl InputEditor {
109 pub fn new() -> Self {
110 Self::default()
111 }
112
113 pub fn text(&self) -> String {
115 self.buf.iter().collect()
116 }
117
118 pub fn cursor(&self) -> usize {
120 self.cursor
121 }
122
123 pub fn is_empty(&self) -> bool {
124 self.buf.is_empty()
125 }
126
127 pub fn is_edited(&self) -> bool {
129 self.edited
130 }
131
132 pub fn insert(&mut self, c: char) {
133 self.buf.insert(self.cursor, c);
134 self.cursor += 1;
135 self.touch();
136 }
137
138 pub fn backspace(&mut self) {
139 if self.cursor > 0 {
140 self.cursor -= 1;
141 self.buf.remove(self.cursor);
142 self.touch();
143 }
144 }
145
146 pub fn delete(&mut self) {
148 if self.cursor < self.buf.len() {
149 self.buf.remove(self.cursor);
150 self.touch();
151 }
152 }
153
154 pub fn clear_line(&mut self) {
156 self.buf.clear();
157 self.cursor = 0;
158 self.touch();
159 }
160
161 pub fn left(&mut self) {
162 self.cursor = self.cursor.saturating_sub(1);
163 }
164
165 pub fn right(&mut self) {
166 if self.cursor < self.buf.len() {
167 self.cursor += 1;
168 }
169 }
170
171 pub fn home(&mut self) {
172 self.cursor = 0;
173 }
174
175 pub fn end(&mut self) {
176 self.cursor = self.buf.len();
177 }
178
179 pub fn word_left(&mut self) {
181 while self.cursor > 0 && self.buf[self.cursor - 1].is_whitespace() {
182 self.cursor -= 1;
183 }
184 while self.cursor > 0 && !self.buf[self.cursor - 1].is_whitespace() {
185 self.cursor -= 1;
186 }
187 }
188
189 pub fn word_right(&mut self) {
191 let n = self.buf.len();
192 while self.cursor < n && self.buf[self.cursor].is_whitespace() {
193 self.cursor += 1;
194 }
195 while self.cursor < n && !self.buf[self.cursor].is_whitespace() {
196 self.cursor += 1;
197 }
198 }
199
200 pub fn history_up(&mut self) {
202 if self.edited || self.history.is_empty() {
203 return;
204 }
205 let next = match self.nav {
206 None => self.history.len() - 1,
207 Some(0) => 0,
208 Some(i) => i - 1,
209 };
210 self.recall(next);
211 }
212
213 pub fn history_down(&mut self) {
215 if self.edited {
216 return;
217 }
218 match self.nav {
219 None => {}
220 Some(i) if i + 1 < self.history.len() => self.recall(i + 1),
221 Some(_) => {
222 self.nav = None;
223 self.buf.clear();
224 self.cursor = 0;
225 }
226 }
227 }
228
229 pub fn submit(&mut self) -> Option<String> {
232 let text = self.text().trim().to_string();
233 self.buf.clear();
234 self.cursor = 0;
235 self.nav = None;
236 self.edited = false;
237 if text.is_empty() {
238 return None;
239 }
240 if self.history.last() != Some(&text) {
241 self.history.push(text.clone());
242 }
243 Some(text)
244 }
245
246 fn recall(&mut self, index: usize) {
247 self.nav = Some(index);
248 self.buf = self.history[index].chars().collect();
249 self.cursor = self.buf.len();
250 }
251
252 fn touch(&mut self) {
255 self.nav = None;
256 self.edited = !self.buf.is_empty();
257 }
258}
259
260#[derive(Debug, Default)]
268pub struct PendingQueue {
269 items: VecDeque<String>,
270}
271
272impl PendingQueue {
273 pub fn new() -> Self {
274 Self::default()
275 }
276
277 pub fn push(&mut self, line: String) {
278 self.items.push_back(line);
279 }
280
281 pub fn pop(&mut self) -> Option<String> {
282 self.items.pop_front()
283 }
284
285 pub fn depth(&self) -> usize {
286 self.items.len()
287 }
288
289 pub fn is_empty(&self) -> bool {
290 self.items.is_empty()
291 }
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
300pub enum SubmitDisposition {
301 Quit,
303 RequestPlan,
305 Turn(String),
307 Queued(String),
309 Unknown(String),
311}
312
313pub fn classify_submission(line: &str, busy: bool) -> SubmitDisposition {
317 let line = line.trim();
318 if line == "/quit" {
319 return SubmitDisposition::Quit;
320 }
321 if line.starts_with('/') && line != "/plan" {
322 return SubmitDisposition::Unknown(line.to_string());
323 }
324 if busy {
325 return SubmitDisposition::Queued(line.to_string());
326 }
327 if line == "/plan" {
328 SubmitDisposition::RequestPlan
329 } else {
330 SubmitDisposition::Turn(line.to_string())
331 }
332}
333
334#[derive(Debug)]
344pub struct ScrollState {
345 follow: bool,
346 top: usize,
347}
348
349impl Default for ScrollState {
350 fn default() -> Self {
351 ScrollState {
352 follow: true,
353 top: 0,
354 }
355 }
356}
357
358impl ScrollState {
359 pub fn new() -> Self {
360 Self::default()
361 }
362
363 pub fn is_detached(&self) -> bool {
364 !self.follow
365 }
366
367 pub fn top(&self, total: usize, height: usize) -> usize {
370 let max_top = total.saturating_sub(height);
371 if self.follow {
372 max_top
373 } else {
374 self.top.min(max_top)
375 }
376 }
377
378 pub fn scroll_up(&mut self, n: usize, total: usize, height: usize) {
379 if total <= height {
380 self.follow = true; return;
382 }
383 self.top = self.top(total, height).saturating_sub(n);
384 self.follow = false;
385 }
386
387 pub fn scroll_down(&mut self, n: usize, total: usize, height: usize) {
388 let max_top = total.saturating_sub(height);
389 let new_top = self.top(total, height).saturating_add(n).min(max_top);
390 self.top = new_top;
391 self.follow = new_top >= max_top;
392 }
393
394 pub fn to_follow(&mut self) {
396 self.follow = true;
397 }
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum ApprovalKey {
407 Approve,
408 Reject,
409 Ignore,
410}
411
412pub fn approval_key(code: KeyCode) -> ApprovalKey {
415 match code {
416 KeyCode::Char('y') | KeyCode::Char('Y') => ApprovalKey::Approve,
417 KeyCode::Char('n') | KeyCode::Char('N') => ApprovalKey::Reject,
418 _ => ApprovalKey::Ignore,
419 }
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum PlanningOutcome {
431 ApprovedRun,
433 ApprovedExit,
435 NotApproved,
437}
438
439pub fn post_approval_key(code: KeyCode) -> Option<PlanningOutcome> {
443 match code {
444 KeyCode::Char('y') | KeyCode::Char('Y') => Some(PlanningOutcome::ApprovedRun),
445 KeyCode::Char('n') | KeyCode::Char('N') => Some(PlanningOutcome::ApprovedExit),
446 _ => None,
447 }
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum BusyKind {
457 Turn,
458 PlanRequest,
459}
460
461pub const SPINNER_FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
463
464pub const IDLE_STATUS: &str = "● ready — type a message; /plan to request the plan; /quit to exit";
466
467pub const APPROVAL_BAR: &str = "approve this plan? [y] approve & commit [n] back to conversation";
469
470pub const POST_APPROVAL_BAR: &str = "plan committed — start execution now? [y] run [n] exit";
473
474pub const DETACHED_MARKER: &str = "▼ new output below — End to follow";
476
477pub const PLAN_NOT_READY_NOTICE: &str = "not ready to emit — answer above, then /plan again";
481
482pub const PLAN_WRONG_PLAN_NOTICE: &str =
487 "planner escalated: the plan is likely wrong — reframe the goal above, then /plan again";
488
489pub fn busy_status_line(
491 kind: BusyKind,
492 elapsed_secs: u64,
493 spinner_frame: usize,
494 queued: usize,
495) -> String {
496 let frame = SPINNER_FRAMES[spinner_frame % SPINNER_FRAMES.len()];
497 let doing = match kind {
498 BusyKind::Turn => "orchestrator working…",
499 BusyKind::PlanRequest => "requesting plan…",
500 };
501 let queue = match queued {
502 0 => String::new(),
503 1 => " — 1 message queued, sends when this turn finishes".to_string(),
504 n => format!(" — {n} messages queued, send in order when this turn finishes"),
505 };
506 format!("{frame} {doing} {elapsed_secs}s — typing is safe, Enter queues your message{queue}")
507}
508
509pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
516 if width == 0 {
517 return text.split('\n').map(str::to_string).collect();
518 }
519 let mut out = Vec::new();
520 for raw in text.split('\n') {
521 let chars: Vec<char> = raw.chars().collect();
522 if chars.is_empty() {
523 out.push(String::new());
524 continue;
525 }
526 let mut start = 0;
527 while start < chars.len() {
528 let hard_end = (start + width).min(chars.len());
529 let end = if hard_end < chars.len() {
530 match chars[start..hard_end].iter().rposition(|c| *c == ' ') {
531 Some(p) if p > 0 => start + p,
532 _ => hard_end,
533 }
534 } else {
535 hard_end
536 };
537 let line: String = chars[start..end].iter().collect();
538 out.push(line.trim_end().to_string());
539 start = end;
540 while start < chars.len() && chars[start] == ' ' {
541 start += 1;
542 }
543 }
544 }
545 out
546}
547
548#[derive(Debug, Clone, PartialEq, Eq)]
554pub enum TranscriptEntry {
555 User { text: String, queued: bool },
557 Orch(String),
559 Activity(String),
561 Block(String),
563 Notice(String),
565 Error(String),
567}
568
569fn entry_lines(entry: &TranscriptEntry, width: usize, out: &mut Vec<Line<'static>>) {
571 let dim = Style::new().fg(Color::DarkGray);
572 match entry {
573 TranscriptEntry::User { text, queued } => {
574 let prefix = "you> ";
575 let body = if *queued {
576 format!("{text} (queued)")
577 } else {
578 text.clone()
579 };
580 let body_width = width.saturating_sub(prefix.len()).max(1);
581 for (i, line) in wrap_text(&body, body_width).into_iter().enumerate() {
582 let lead = if i == 0 {
583 prefix.to_string()
584 } else {
585 " ".repeat(prefix.len())
586 };
587 out.push(Line::from(vec![
588 Span::styled(
589 lead,
590 Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
591 ),
592 Span::styled(line, Style::new().add_modifier(Modifier::BOLD)),
593 ]));
594 }
595 }
596 TranscriptEntry::Orch(text) => {
597 let prefix = "orch> ";
598 let body_width = width.saturating_sub(prefix.len()).max(1);
599 for (i, line) in wrap_text(text, body_width).into_iter().enumerate() {
600 let lead = if i == 0 {
601 prefix.to_string()
602 } else {
603 " ".repeat(prefix.len())
604 };
605 out.push(Line::from(vec![Span::styled(lead, dim), Span::raw(line)]));
606 }
607 }
608 TranscriptEntry::Activity(text) => {
609 for (i, line) in wrap_text(text, width.saturating_sub(2).max(1))
610 .into_iter()
611 .enumerate()
612 {
613 let lead = if i == 0 { "" } else { " " };
614 out.push(Line::from(Span::styled(format!("{lead}{line}"), dim)));
615 }
616 }
617 TranscriptEntry::Block(text) => {
618 for line in wrap_text(text, width.max(1)) {
619 out.push(Line::from(Span::raw(line)));
620 }
621 }
622 TranscriptEntry::Notice(text) => {
623 for line in wrap_text(text, width.max(1)) {
624 out.push(Line::from(Span::styled(line, dim)));
625 }
626 }
627 TranscriptEntry::Error(text) => {
628 for line in wrap_text(text, width.max(1)) {
629 out.push(Line::from(Span::styled(line, Style::new().fg(Color::Red))));
630 }
631 }
632 }
633}
634
635fn total_visual_lines(entries: &[TranscriptEntry], width: usize) -> usize {
637 let mut lines = Vec::new();
638 for entry in entries {
639 entry_lines(entry, width, &mut lines);
640 }
641 lines.len()
642}
643
644struct App {
649 transcript: Vec<TranscriptEntry>,
650 editor: InputEditor,
651 queue: PendingQueue,
652 scroll: ScrollState,
653 error: Option<String>,
655 spinner: usize,
656}
657
658impl App {
659 fn new() -> Self {
660 App {
661 transcript: Vec::new(),
662 editor: InputEditor::new(),
663 queue: PendingQueue::new(),
664 scroll: ScrollState::new(),
665 error: None,
666 spinner: 0,
667 }
668 }
669
670 fn push(&mut self, entry: TranscriptEntry) {
671 self.transcript.push(entry);
672 }
673}
674
675type TurnFut = Pin<Box<dyn Future<Output = (Box<MissionEngine>, TurnOutput)>>>;
682
683enum TurnOutput {
684 Reply(std::result::Result<String, EngineError>),
685 Plan(std::result::Result<PlanRequest, EngineError>),
686}
687
688enum Phase {
689 Idle(Box<MissionEngine>),
691 Busy {
693 kind: BusyKind,
694 started: Instant,
695 fut: TurnFut,
696 },
697 Approval {
699 engine: Box<MissionEngine>,
700 plan: Plan,
701 },
702 PostApproval { _engine: Box<MissionEngine> },
706 Transitioning,
708}
709
710enum PhaseView {
712 Idle,
713 Busy { kind: BusyKind, elapsed_secs: u64 },
714 Approval,
715 PostApproval,
716}
717
718impl PhaseView {
719 fn of(phase: &Phase) -> Self {
720 match phase {
721 Phase::Busy { kind, started, .. } => PhaseView::Busy {
722 kind: *kind,
723 elapsed_secs: started.elapsed().as_secs(),
724 },
725 Phase::Approval { .. } => PhaseView::Approval,
726 Phase::PostApproval { .. } => PhaseView::PostApproval,
727 Phase::Idle(_) | Phase::Transitioning => PhaseView::Idle,
728 }
729 }
730}
731
732fn start_turn(mut engine: Box<MissionEngine>, text: String) -> Phase {
733 Phase::Busy {
734 kind: BusyKind::Turn,
735 started: Instant::now(),
736 fut: Box::pin(async move {
737 let out = engine.planning_turn(&text).await;
738 (engine, TurnOutput::Reply(out))
739 }),
740 }
741}
742
743fn start_plan_request(mut engine: Box<MissionEngine>) -> Phase {
744 Phase::Busy {
745 kind: BusyKind::PlanRequest,
746 started: Instant::now(),
747 fut: Box::pin(async move {
748 let out = engine.request_plan().await;
749 (engine, TurnOutput::Plan(out))
750 }),
751 }
752}
753
754static TUI_ACTIVE: AtomicBool = AtomicBool::new(false);
761
762fn restore_terminal() {
764 if TUI_ACTIVE.swap(false, Ordering::SeqCst) {
765 let _ = disable_raw_mode();
766 let _ = execute!(
767 std::io::stdout(),
768 DisableMouseCapture,
769 LeaveAlternateScreen,
770 crossterm::cursor::Show
771 );
772 }
773}
774
775struct TerminalGuard;
777
778impl Drop for TerminalGuard {
779 fn drop(&mut self) {
780 restore_terminal();
781 }
782}
783
784fn enter_terminal() -> Result<TerminalGuard> {
785 enable_raw_mode().context("enabling raw mode")?;
786 TUI_ACTIVE.store(true, Ordering::SeqCst);
787 if let Err(e) = execute!(std::io::stdout(), EnterAlternateScreen, EnableMouseCapture) {
788 restore_terminal();
789 return Err(anyhow::Error::new(e).context("entering the alternate screen"));
790 }
791 Ok(TerminalGuard)
792}
793
794fn install_panic_hook() {
798 static HOOK: std::sync::Once = std::sync::Once::new();
799 HOOK.call_once(|| {
800 let previous = std::panic::take_hook();
801 std::panic::set_hook(Box::new(move |info| {
802 restore_terminal();
803 previous(info);
804 }));
805 });
806}
807
808struct InputThread {
816 stop: Arc<AtomicBool>,
817 handle: Option<std::thread::JoinHandle<()>>,
818}
819
820impl InputThread {
821 fn spawn() -> (Self, UnboundedReceiver<ct_event::Event>) {
822 let stop = Arc::new(AtomicBool::new(false));
823 let flag = Arc::clone(&stop);
824 let (tx, rx): (UnboundedSender<ct_event::Event>, _) =
825 tokio::sync::mpsc::unbounded_channel();
826 let handle = std::thread::spawn(move || {
827 while !flag.load(Ordering::Relaxed) {
828 match ct_event::poll(INPUT_POLL) {
829 Ok(true) => match ct_event::read() {
830 Ok(event) => {
831 if tx.send(event).is_err() {
832 return;
833 }
834 }
835 Err(_) => return,
836 },
837 Ok(false) => {}
838 Err(_) => return,
839 }
840 }
841 });
842 (
843 InputThread {
844 stop,
845 handle: Some(handle),
846 },
847 rx,
848 )
849 }
850
851 async fn shutdown(mut self) {
854 self.stop.store(true, Ordering::Relaxed);
855 if let Some(handle) = self.handle.take() {
856 let _ = tokio::task::spawn_blocking(move || {
857 let _ = handle.join();
858 })
859 .await;
860 }
861 }
862}
863
864type Tui = Terminal<CrosstermBackend<Stdout>>;
869
870enum LoopEvent {
871 Term(ct_event::Event),
872 Tick,
873 Done(Box<MissionEngine>, TurnOutput),
874 InputClosed,
875}
876
877struct TuiRun {
878 app: App,
879 phase: Phase,
880 renderer: EventRenderer,
881 events_path: PathBuf,
882 last_seq: u64,
883 title: String,
884 dims: (u16, u16),
886 quit: bool,
887 approved_branch: Option<String>,
889 run_now: bool,
892}
893
894pub async fn run(engine: MissionEngine, intro: String) -> Result<PlanningOutcome> {
901 let mission_id = engine.mission_id().to_string();
902 let title = format!(
903 "KRANZ PLANNING — {} — {}",
904 mission_id,
905 engine.state().mission.goal
906 );
907 let events_path = engine.paths().events_file();
908 let last_seq = engine.state().last_seq;
909 let renderer = EventRenderer::planning(engine.state(), false);
911
912 install_panic_hook();
913 let guard = enter_terminal()?;
914 let terminal_result = Terminal::new(CrosstermBackend::new(std::io::stdout()));
915 let mut terminal = match terminal_result {
916 Ok(t) => t,
917 Err(e) => {
918 drop(guard);
919 return Err(anyhow::Error::new(e).context("initializing the terminal"));
920 }
921 };
922 let (input_thread, mut keys) = InputThread::spawn();
923
924 let mut app = App::new();
925 app.push(TranscriptEntry::Notice(intro));
926
927 let mut state = TuiRun {
928 app,
929 phase: Phase::Idle(Box::new(engine)),
930 renderer,
931 events_path,
932 last_seq,
933 title,
934 dims: (80, 20),
935 quit: false,
936 approved_branch: None,
937 run_now: false,
938 };
939
940 let loop_result = state.run_loop(&mut terminal, &mut keys).await;
941
942 let approved_branch = state.approved_branch.take();
949 let run_now = state.run_now;
950 let leftover: Vec<String> = std::iter::from_fn(|| state.app.queue.pop()).collect();
951 drop(state); drop(terminal);
953 drop(guard);
954 input_thread.shutdown().await;
955
956 loop_result?;
957 let outcome = match (&approved_branch, run_now) {
958 (None, _) => PlanningOutcome::NotApproved,
959 (Some(_), true) => PlanningOutcome::ApprovedRun,
960 (Some(_), false) => PlanningOutcome::ApprovedExit,
961 };
962 match &approved_branch {
963 Some(branch) if run_now => {
964 println!("plan approved and committed on {branch}.");
965 }
966 Some(branch) => {
967 println!("plan approved and committed on {branch}. run 'kranz run' to execute.");
968 }
969 None => {
970 println!(
971 "leaving planning; mission {mission_id} was not approved. \
972 Resume anytime with `kranz plan`."
973 );
974 }
975 }
976 if !leftover.is_empty() {
980 println!(
981 "note: {} queued message(s) were never sent (planning ended first):",
982 leftover.len()
983 );
984 for message in &leftover {
985 println!(" - {message}");
986 }
987 }
988 Ok(outcome)
989}
990
991impl TuiRun {
992 async fn run_loop(
993 &mut self,
994 terminal: &mut Tui,
995 keys: &mut UnboundedReceiver<ct_event::Event>,
996 ) -> Result<()> {
997 let mut tick = tokio::time::interval(TICK);
998 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
999 loop {
1000 let view = PhaseView::of(&self.phase);
1001 terminal
1002 .draw(|frame| self.dims = draw_ui(frame, &self.app, &view, &self.title))
1003 .context("drawing the planning TUI")?;
1004
1005 let event = match &mut self.phase {
1006 Phase::Busy { fut, .. } => tokio::select! {
1007 maybe = keys.recv() => match maybe {
1008 Some(e) => LoopEvent::Term(e),
1009 None => LoopEvent::InputClosed,
1010 },
1011 _ = tick.tick() => LoopEvent::Tick,
1012 (engine, out) = fut.as_mut() => LoopEvent::Done(engine, out),
1013 },
1014 _ => tokio::select! {
1015 maybe = keys.recv() => match maybe {
1016 Some(e) => LoopEvent::Term(e),
1017 None => LoopEvent::InputClosed,
1018 },
1019 _ = tick.tick() => LoopEvent::Tick,
1020 },
1021 };
1022
1023 match event {
1024 LoopEvent::InputClosed => self.quit = true,
1025 LoopEvent::Tick => self.on_tick(),
1026 LoopEvent::Term(e) => self.on_term_event(e),
1027 LoopEvent::Done(engine, out) => self.on_turn_done(engine, out),
1028 }
1029
1030 if self.quit {
1031 return Ok(());
1032 }
1033 }
1034 }
1035
1036 fn on_tick(&mut self) {
1039 self.app.spinner = self.app.spinner.wrapping_add(1);
1040 if let Ok(events) = EventLog::read_events_after(&self.events_path, self.last_seq) {
1041 for event in &events {
1042 self.last_seq = event.seq;
1043 let line = self.renderer.render(event);
1044 if !line.is_empty() {
1045 self.app.push(TranscriptEntry::Activity(line));
1046 }
1047 }
1048 }
1049 }
1050
1051 fn on_term_event(&mut self, event: ct_event::Event) {
1052 match event {
1053 ct_event::Event::Key(key)
1054 if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
1055 {
1056 self.on_key(key.code, key.modifiers);
1057 }
1058 ct_event::Event::Mouse(mouse) => match mouse.kind {
1059 MouseEventKind::ScrollUp => self.scroll_up(WHEEL_LINES),
1060 MouseEventKind::ScrollDown => self.scroll_down(WHEEL_LINES),
1061 _ => {}
1062 },
1063 _ => {}
1064 }
1065 }
1066
1067 fn on_key(&mut self, code: KeyCode, mods: KeyModifiers) {
1068 if code == KeyCode::Char('c') && mods.contains(KeyModifiers::CONTROL) {
1072 self.quit = true;
1073 return;
1074 }
1075 if matches!(self.phase, Phase::Approval { .. }) {
1076 self.on_approval_key(code);
1077 return;
1078 }
1079 if matches!(self.phase, Phase::PostApproval { .. }) {
1080 if let Some(outcome) = post_approval_key(code) {
1081 self.run_now = outcome == PlanningOutcome::ApprovedRun;
1082 self.quit = true;
1083 }
1084 return;
1085 }
1086 self.app.error = None;
1087 match (code, mods) {
1088 (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => {
1089 if self.app.editor.is_empty() {
1090 self.quit = true; } else {
1092 self.app.editor.delete();
1093 }
1094 }
1095 (KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => self.app.editor.home(),
1096 (KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => self.app.editor.end(),
1097 (KeyCode::Char('u'), m) if m.contains(KeyModifiers::CONTROL) => {
1098 self.app.editor.clear_line()
1099 }
1100 (KeyCode::Left, m) if m.contains(KeyModifiers::ALT) => self.app.editor.word_left(),
1101 (KeyCode::Right, m) if m.contains(KeyModifiers::ALT) => self.app.editor.word_right(),
1102 (KeyCode::Left, _) => self.app.editor.left(),
1103 (KeyCode::Right, _) => self.app.editor.right(),
1104 (KeyCode::Home, _) => self.app.editor.home(),
1105 (KeyCode::End, _) => {
1106 if self.app.scroll.is_detached() {
1109 self.app.scroll.to_follow();
1110 } else {
1111 self.app.editor.end();
1112 }
1113 }
1114 (KeyCode::Up, _) => self.app.editor.history_up(),
1115 (KeyCode::Down, _) => self.app.editor.history_down(),
1116 (KeyCode::PageUp, _) => self.scroll_up(self.page()),
1117 (KeyCode::PageDown, _) => self.scroll_down(self.page()),
1118 (KeyCode::Backspace, _) => self.app.editor.backspace(),
1119 (KeyCode::Delete, _) => self.app.editor.delete(),
1120 (KeyCode::Enter, _) => self.on_submit(),
1121 (KeyCode::Char(c), m)
1122 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1123 {
1124 self.app.editor.insert(c);
1125 }
1126 _ => {}
1127 }
1128 }
1129
1130 fn on_submit(&mut self) {
1131 let Some(line) = self.app.editor.submit() else {
1132 return;
1133 };
1134 let busy = matches!(self.phase, Phase::Busy { .. });
1135 match classify_submission(&line, busy) {
1136 SubmitDisposition::Quit => self.quit = true,
1137 SubmitDisposition::Unknown(cmd) => {
1138 self.app.push(TranscriptEntry::Notice(format!(
1139 "unknown command {cmd}; use /plan or /quit"
1140 )));
1141 }
1142 SubmitDisposition::Queued(text) => {
1143 self.app.push(TranscriptEntry::User {
1144 text: text.clone(),
1145 queued: true,
1146 });
1147 self.app.queue.push(text);
1148 }
1149 SubmitDisposition::RequestPlan => {
1150 self.app.push(TranscriptEntry::User {
1151 text: line,
1152 queued: false,
1153 });
1154 self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1155 Phase::Idle(engine) => start_plan_request(engine),
1156 other => other,
1157 };
1158 }
1159 SubmitDisposition::Turn(text) => {
1160 self.app.push(TranscriptEntry::User {
1161 text: text.clone(),
1162 queued: false,
1163 });
1164 self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1165 Phase::Idle(engine) => start_turn(engine, text),
1166 other => other,
1167 };
1168 }
1169 }
1170 }
1171
1172 fn on_turn_done(&mut self, mut engine: Box<MissionEngine>, out: TurnOutput) {
1173 if let Some(seed) = engine.take_seed_reply() {
1178 self.app.push(TranscriptEntry::Orch(seed));
1179 }
1180 match out {
1181 TurnOutput::Reply(Ok(text)) => {
1182 self.app.push(TranscriptEntry::Orch(text));
1183 self.phase = Phase::Idle(engine);
1184 }
1185 TurnOutput::Reply(Err(e)) => {
1186 let message = format!("{:#}", augment_limit_hint(e.into()));
1187 self.app.push(TranscriptEntry::Error(format!(
1188 "orchestrator turn failed: {message}"
1189 )));
1190 self.app.error = Some(format!("orchestrator turn failed: {message}"));
1191 self.phase = Phase::Idle(engine);
1192 }
1193 TurnOutput::Plan(Ok(PlanRequest::NotReady(text))) => {
1194 self.app.push(TranscriptEntry::Orch(text));
1197 self.app
1198 .push(TranscriptEntry::Notice(PLAN_NOT_READY_NOTICE.to_string()));
1199 self.phase = Phase::Idle(engine);
1200 }
1201 TurnOutput::Plan(Ok(PlanRequest::WrongPlan { reason })) => {
1202 self.app.push(TranscriptEntry::Orch(reason));
1205 self.app
1206 .push(TranscriptEntry::Notice(PLAN_WRONG_PLAN_NOTICE.to_string()));
1207 self.phase = Phase::Idle(engine);
1208 }
1209 TurnOutput::Plan(Ok(PlanRequest::Ready(plan))) => {
1210 self.app.push(TranscriptEntry::Block(
1211 output::render_plan(&plan).trim_end().to_string(),
1212 ));
1213 let calibration = cost::calibrate(&engine.paths().repo_root);
1216 let estimate = cost::estimate(&plan, &engine.state().config, &calibration.params);
1217 let estimate = cost::apply_shape(estimate, &plan, &calibration);
1218 self.app
1219 .push(TranscriptEntry::Block(output::render_cost_estimate(
1220 &estimate,
1221 calibration.missions_used,
1222 )));
1223 self.phase = Phase::Approval { engine, plan };
1224 return; }
1226 TurnOutput::Plan(Err(e)) => {
1227 let message = format!("{:#}", augment_limit_hint(e.into()));
1228 self.app.push(TranscriptEntry::Error(format!(
1229 "plan request failed: {message}"
1230 )));
1231 self.app.error = Some(format!("plan request failed: {message}"));
1232 self.phase = Phase::Idle(engine);
1233 }
1234 }
1235 self.dispatch_queued();
1236 }
1237
1238 fn on_approval_key(&mut self, code: KeyCode) {
1239 match approval_key(code) {
1240 ApprovalKey::Ignore => {}
1241 ApprovalKey::Approve => {
1242 self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1243 Phase::Approval { mut engine, plan } => {
1244 match engine.approve_plan(plan.clone()) {
1245 Ok(()) => {
1246 let branch = engine.state().mission.mission_branch.clone();
1247 self.app.push(TranscriptEntry::Notice(format!(
1248 "plan approved and committed on {branch}."
1249 )));
1250 self.approved_branch = Some(branch);
1251 Phase::PostApproval { _engine: engine }
1254 }
1255 Err(e) => {
1256 self.app.push(TranscriptEntry::Error(format!(
1257 "plan approval failed: {e}"
1258 )));
1259 self.app.push(TranscriptEntry::Notice(
1260 "back to the conversation.".into(),
1261 ));
1262 Phase::Idle(engine)
1263 }
1264 }
1265 }
1266 other => other,
1267 };
1268 if self.approved_branch.is_none() {
1269 self.dispatch_queued();
1270 }
1271 }
1272 ApprovalKey::Reject => {
1273 self.app.push(TranscriptEntry::Notice(
1274 "not approved — back to the conversation.".into(),
1275 ));
1276 self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1277 Phase::Approval { engine, .. } => Phase::Idle(engine),
1278 other => other,
1279 };
1280 self.dispatch_queued();
1281 }
1282 }
1283 }
1284
1285 fn dispatch_queued(&mut self) {
1287 if !matches!(self.phase, Phase::Idle(_)) {
1288 return;
1289 }
1290 while let Some(line) = self.app.queue.pop() {
1291 match classify_submission(&line, false) {
1292 SubmitDisposition::Turn(text) => {
1293 self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1294 Phase::Idle(engine) => start_turn(engine, text),
1295 other => other,
1296 };
1297 return;
1298 }
1299 SubmitDisposition::RequestPlan => {
1300 self.phase = match std::mem::replace(&mut self.phase, Phase::Transitioning) {
1301 Phase::Idle(engine) => start_plan_request(engine),
1302 other => other,
1303 };
1304 return;
1305 }
1306 _ => continue,
1309 }
1310 }
1311 }
1312
1313 fn page(&self) -> usize {
1315 (self.dims.1 as usize).saturating_sub(1).max(1)
1316 }
1317
1318 fn scroll_up(&mut self, n: usize) {
1319 let (total, height) = self.scroll_geometry();
1320 self.app.scroll.scroll_up(n, total, height);
1321 }
1322
1323 fn scroll_down(&mut self, n: usize) {
1324 let (total, height) = self.scroll_geometry();
1325 self.app.scroll.scroll_down(n, total, height);
1326 }
1327
1328 fn scroll_geometry(&self) -> (usize, usize) {
1329 let width = (self.dims.0 as usize).max(1);
1330 let total = total_visual_lines(&self.app.transcript, width);
1331 (total, self.dims.1 as usize)
1332 }
1333}
1334
1335fn draw_ui(frame: &mut Frame, app: &App, view: &PhaseView, title: &str) -> (u16, u16) {
1342 let chunks = Layout::vertical([
1343 Constraint::Length(1),
1344 Constraint::Min(1),
1345 Constraint::Length(1),
1346 Constraint::Length(1),
1347 ])
1348 .split(frame.area());
1349
1350 draw_title(frame, chunks[0], title);
1351 draw_transcript(frame, chunks[1], app);
1352 draw_status(frame, chunks[2], app, view);
1353 draw_input(frame, chunks[3], app, view);
1354
1355 (chunks[1].width, chunks[1].height)
1356}
1357
1358fn draw_title(frame: &mut Frame, area: Rect, title: &str) {
1359 let text = one_line(title, area.width as usize);
1360 frame.render_widget(
1361 Paragraph::new(Span::styled(
1362 text,
1363 Style::new().add_modifier(Modifier::BOLD),
1364 ))
1365 .style(Style::new().bg(Color::DarkGray).fg(Color::White)),
1366 area,
1367 );
1368}
1369
1370fn draw_transcript(frame: &mut Frame, area: Rect, app: &App) {
1371 let width = (area.width as usize).max(1);
1372 let height = area.height as usize;
1373 let mut lines: Vec<Line<'static>> = Vec::new();
1374 for entry in &app.transcript {
1375 entry_lines(entry, width, &mut lines);
1376 }
1377 let total = lines.len();
1378 let top = app.scroll.top(total, height);
1379 let bottom = (top + height).min(total);
1380 let visible: Vec<Line<'static>> = lines[top..bottom].to_vec();
1381 frame.render_widget(Paragraph::new(visible), area);
1382
1383 if app.scroll.is_detached() && bottom < total && height > 0 {
1385 let marker = Rect {
1386 x: area.x,
1387 y: area.y + area.height - 1,
1388 width: area.width,
1389 height: 1,
1390 };
1391 frame.render_widget(
1392 Paragraph::new(Span::styled(
1393 DETACHED_MARKER,
1394 Style::new().fg(Color::DarkGray),
1395 )),
1396 marker,
1397 );
1398 }
1399}
1400
1401fn draw_status(frame: &mut Frame, area: Rect, app: &App, view: &PhaseView) {
1402 let paragraph = match view {
1403 PhaseView::Approval => Paragraph::new(Span::styled(
1404 APPROVAL_BAR,
1405 Style::new().add_modifier(Modifier::BOLD),
1406 ))
1407 .style(Style::new().bg(Color::Yellow).fg(Color::Black)),
1408 PhaseView::PostApproval => Paragraph::new(Span::styled(
1409 POST_APPROVAL_BAR,
1410 Style::new().add_modifier(Modifier::BOLD),
1411 ))
1412 .style(Style::new().bg(Color::Yellow).fg(Color::Black)),
1413 PhaseView::Busy { kind, elapsed_secs } => Paragraph::new(Span::styled(
1414 busy_status_line(*kind, *elapsed_secs, app.spinner, app.queue.depth()),
1415 Style::new().fg(Color::Yellow),
1416 )),
1417 PhaseView::Idle => match &app.error {
1418 Some(error) => Paragraph::new(Span::styled(
1419 format!(
1420 "✖ {}",
1421 one_line(error, (area.width as usize).saturating_sub(2))
1422 ),
1423 Style::new().fg(Color::Red),
1424 )),
1425 None => Paragraph::new(Span::styled(IDLE_STATUS, Style::new().fg(Color::Green))),
1426 },
1427 };
1428 frame.render_widget(paragraph, area);
1429}
1430
1431fn draw_input(frame: &mut Frame, area: Rect, app: &App, view: &PhaseView) {
1432 if matches!(view, PhaseView::Approval | PhaseView::PostApproval) {
1433 frame.render_widget(
1434 Paragraph::new(Span::styled(
1435 "(input paused — press y or n)",
1436 Style::new().fg(Color::DarkGray),
1437 )),
1438 area,
1439 );
1440 return; }
1442 let prompt = "> ";
1443 let window = (area.width as usize).saturating_sub(prompt.len()).max(1);
1444 let chars: Vec<char> = app.editor.text().chars().collect();
1445 let cursor = app.editor.cursor();
1446 let start = if cursor >= window {
1447 cursor + 1 - window
1448 } else {
1449 0
1450 };
1451 let end = (start + window).min(chars.len());
1452 let visible: String = chars[start.min(chars.len())..end].iter().collect();
1453 frame.render_widget(
1454 Paragraph::new(Line::from(vec![
1455 Span::styled(prompt, Style::new().add_modifier(Modifier::BOLD)),
1456 Span::raw(visible),
1457 ])),
1458 area,
1459 );
1460 let x = area.x + (prompt.len() + (cursor - start)) as u16;
1461 frame.set_cursor_position(Position::new(x.min(area.right().saturating_sub(1)), area.y));
1462}