返回 CodeWhale
editor.rs
根目录 / crates / tui / src / tui / views / automations / editor.rs
1 //! Draft-only automation form. Save is the only path to the canonical manager.
2
3 use std::cell::{Cell, RefCell};
4 use std::path::{Path, PathBuf};
5 use std::time::{Duration, Instant};
6
7 use chrono::{DateTime, Local, NaiveDate, NaiveTime, Timelike, Utc, Weekday};
8 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
9 use ratatui::{
10 buffer::Buffer,
11 layout::Rect,
12 style::{Modifier, Style},
13 text::{Line, Span},
14 widgets::{Block, Borders, Paragraph, Widget},
15 };
16 use unicode_segmentation::UnicodeSegmentation;
17
18 use crate::automation_manager::{
19 AutomationManager, AutomationRecord, AutomationSchedule, AutomationStatus,
20 CreateAutomationRequest, UpdateAutomationRequest,
21 };
22 use crate::config::Config;
23 use crate::tui::ui_text::{grapheme_display_width, text_display_width};
24 use codewhale_localization::{Locale, MessageId, tr};
25 use codewhale_palette as palette;
26
27 const DAYS: [Weekday; 7] = [
28 Weekday::Mon,
29 Weekday::Tue,
30 Weekday::Wed,
31 Weekday::Thu,
32 Weekday::Fri,
33 Weekday::Sat,
34 Weekday::Sun,
35 ];
36 const DAY_CODES: [&str; 7] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
37 const DAY_LABELS: [MessageId; 7] = [
38 MessageId::AutomationEditorMonday,
39 MessageId::AutomationEditorTuesday,
40 MessageId::AutomationEditorWednesday,
41 MessageId::AutomationEditorThursday,
42 MessageId::AutomationEditorFriday,
43 MessageId::AutomationEditorSaturday,
44 MessageId::AutomationEditorSunday,
45 ];
46
47 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
48 enum Preset {
49 Daily,
50 Weekly,
51 Hourly,
52 Once,
53 Custom,
54 }
55
56 impl Preset {
57 const ALL: [Self; 5] = [
58 Self::Daily,
59 Self::Weekly,
60 Self::Hourly,
61 Self::Once,
62 Self::Custom,
63 ];
64 fn label(self) -> MessageId {
65 match self {
66 Self::Daily => MessageId::AutomationEditorDaily,
67 Self::Weekly => MessageId::AutomationEditorWeekly,
68 Self::Hourly => MessageId::AutomationEditorHourly,
69 Self::Once => MessageId::AutomationEditorOnce,
70 Self::Custom => MessageId::AutomationEditorCustom,
71 }
72 }
73 }
74
75 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
76 enum Field {
77 Name,
78 Prompt,
79 Schedule,
80 Time,
81 Days,
82 Date,
83 Rrule,
84 Model,
85 Workspace,
86 Enabled,
87 Save,
88 Cancel,
89 }
90
91 #[derive(Clone, Copy)]
92 enum Hit {
93 Field(Field),
94 Day(usize),
95 Time(i32),
96 Model(usize),
97 }
98
99 pub(super) enum EditorAction {
100 None,
101 Cancel,
102 Save,
103 }
104
105 #[derive(Clone, Debug, Default, PartialEq, Eq)]
106 struct ModelChoice {
107 model: Option<String>,
108 provider: Option<String>,
109 provider_id: Option<String>,
110 }
111
112 impl ModelChoice {
113 fn from_record(record: &AutomationRecord) -> Self {
114 Self {
115 model: record.model.clone(),
116 provider: record.model_provider.clone(),
117 provider_id: record.model_provider_id.clone(),
118 }
119 }
120 }
121
122 /// A small grapheme-aware form buffer; the composer owns its own history and
123 /// tool-input semantics, which must not run while a schedule draft is open.
124 #[derive(Clone, Debug)]
125 struct TextField {
126 value: String,
127 cursor: usize,
128 selected: bool,
129 }
130
131 impl TextField {
132 fn new(value: String) -> Self {
133 Self {
134 cursor: value.len(),
135 value,
136 selected: false,
137 }
138 }
139
140 fn insert(&mut self, text: &str, multiline: bool) {
141 let text: String = text
142 .replace("\r\n", "\n")
143 .chars()
144 .filter(|ch| !ch.is_control() || (multiline && *ch == '\n'))
145 .collect();
146 let retained = if self.selected { 0 } else { self.value.len() };
147 if retained.saturating_add(text.len()) > 64 * 1024 {
148 return;
149 }
150 self.erase_selection();
151 self.value.insert_str(self.cursor, &text);
152 self.cursor += text.len();
153 }
154
155 fn erase_selection(&mut self) -> bool {
156 if !self.selected {
157 return false;
158 }
159 self.value.clear();
160 self.cursor = 0;
161 self.selected = false;
162 true
163 }
164
165 fn key(&mut self, key: KeyEvent, multiline: bool) {
166 let control = key.modifiers == KeyModifiers::CONTROL;
167 match key.code {
168 KeyCode::Char('a') if control => self.selected = true,
169 KeyCode::Char('u') if control => {
170 self.selected = true;
171 self.erase_selection();
172 }
173 KeyCode::Char(ch)
174 if crate::tui::widgets::key_hint::is_altgr(key.modifiers)
175 || !key
176 .modifiers
177 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
178 {
179 self.insert(&ch.to_string(), multiline)
180 }
181 KeyCode::Enter if multiline => self.insert("\n", true),
182 KeyCode::Backspace => {
183 if !self.erase_selection() {
184 let previous = self.value[..self.cursor]
185 .grapheme_indices(true)
186 .next_back()
187 .map_or(0, |(i, _)| i);
188 self.value.replace_range(previous..self.cursor, "");
189 self.cursor = previous;
190 }
191 }
192 KeyCode::Delete => {
193 if !self.erase_selection() {
194 let next = self.cursor
195 + self.value[self.cursor..]
196 .graphemes(true)
197 .next()
198 .map_or(0, str::len);
199 self.value.replace_range(self.cursor..next, "");
200 }
201 }
202 KeyCode::Left => {
203 self.cursor = self.value[..self.cursor]
204 .grapheme_indices(true)
205 .next_back()
206 .map_or(0, |(i, _)| i);
207 self.selected = false;
208 }
209 KeyCode::Right => {
210 self.cursor += self.value[self.cursor..]
211 .graphemes(true)
212 .next()
213 .map_or(0, str::len);
214 self.selected = false;
215 }
216 KeyCode::Home => {
217 self.cursor = if control {
218 0
219 } else {
220 self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1)
221 };
222 self.selected = false;
223 }
224 KeyCode::End => {
225 self.cursor = if control {
226 self.value.len()
227 } else {
228 self.cursor
229 + self.value[self.cursor..]
230 .find('\n')
231 .unwrap_or(self.value.len() - self.cursor)
232 };
233 self.selected = false;
234 }
235 KeyCode::Up | KeyCode::Down if multiline => {
236 let start = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1);
237 let column = self.value[start..self.cursor].graphemes(true).count();
238 let target = if key.code == KeyCode::Up {
239 if start == 0 {
240 return;
241 }
242 let previous = self.value[..start - 1].rfind('\n').map_or(0, |i| i + 1);
243 previous..start - 1
244 } else {
245 let Some(end) = self.value[self.cursor..]
246 .find('\n')
247 .map(|i| self.cursor + i + 1)
248 else {
249 return;
250 };
251 end..end
252 + self.value[end..]
253 .find('\n')
254 .unwrap_or(self.value.len() - end)
255 };
256 self.cursor = target.start
257 + self.value[target.clone()]
258 .graphemes(true)
259 .take(column)
260 .map(str::len)
261 .sum::<usize>();
262 self.selected = false;
263 }
264 _ => {}
265 }
266 }
267
268 fn render(&self, area: Rect, buf: &mut Buffer, focused: bool) {
269 if area.width == 0 || area.height == 0 {
270 return;
271 }
272 let mut lines = vec![Line::default()];
273 let mut width = 0;
274 let mut cursor_line = 0;
275 for (offset, grapheme) in self
276 .value
277 .grapheme_indices(true)
278 .chain(std::iter::once((self.value.len(), " ")))
279 {
280 let caret = focused && offset == self.cursor;
281 let shown = if grapheme == "\n" || grapheme.chars().any(char::is_control) {
282 " "
283 } else {
284 grapheme
285 };
286 let columns = grapheme_display_width(shown);
287 if width + columns > usize::from(area.width) {
288 lines.push(Line::default());
289 width = 0;
290 }
291 if caret {
292 cursor_line = lines.len() - 1;
293 }
294 let style = if focused && (caret || self.selected) {
295 Style::default().add_modifier(Modifier::REVERSED)
296 } else {
297 Style::default()
298 };
299 lines
300 .last_mut()
301 .unwrap()
302 .spans
303 .push(Span::styled(shown.to_string(), style));
304 width += columns;
305 if grapheme == "\n" {
306 lines.push(Line::default());
307 width = 0;
308 }
309 }
310 let scroll = if focused {
311 cursor_line.saturating_sub(usize::from(area.height) - 1)
312 } else {
313 0
314 };
315 Paragraph::new(lines.into_iter().skip(scroll).collect::<Vec<_>>()).render(area, buf);
316 }
317 }
318
319 pub(super) struct AutomationEditor {
320 original: Option<AutomationRecord>,
321 locale: Locale,
322 workspace_root: PathBuf,
323 provider: String,
324 name: TextField,
325 prompt: TextField,
326 time: TextField,
327 date: TextField,
328 rrule: TextField,
329 workspace: TextField,
330 preset: Preset,
331 days: [bool; 7],
332 day_cursor: usize,
333 schedule_changed: bool,
334 enabled: bool,
335 models: Vec<ModelChoice>,
336 model: usize,
337 picking_model: bool,
338 model_query: TextField,
339 model_row: usize,
340 focus: Field,
341 scroll: Cell<usize>,
342 hits: RefCell<Vec<(Rect, Hit)>>,
343 preview_cache: RefCell<Option<(String, bool, Instant, String)>>,
344 pub(super) problem: Option<String>,
345 }
346
347 impl AutomationEditor {
348 pub(super) fn new(
349 config: &Config,
350 workspace: &Path,
351 locale: Locale,
352 original: Option<AutomationRecord>,
353 ) -> Self {
354 let provider = config.api_provider();
355 let identity = config
356 .active_provider_identity(provider)
357 .map(|identity| identity.key)
358 .unwrap_or_else(|_| {
359 config
360 .provider
361 .clone()
362 .unwrap_or_else(|| provider.as_str().to_string())
363 });
364 let mut models = vec![ModelChoice::default()];
365 for (route, model, _) in super::super::fleet_setup::cross_provider_model_routes(
366 config,
367 provider,
368 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
369 ) {
370 // `auto` may use configured cross-provider routing. It is not a
371 // concrete provider/model pin; legacy records remain selectable.
372 if model.trim().eq_ignore_ascii_case("auto") {
373 continue;
374 }
375 if let Ok(identity) = config.resolve_provider_identity(&route) {
376 let choice = ModelChoice {
377 model: Some(model),
378 provider: Some(identity.provider.as_str().to_string()),
379 provider_id: Some(identity.key),
380 };
381 if !models.contains(&choice) {
382 models.push(choice);
383 }
384 }
385 }
386 if let Some(choice) = original.as_ref().map(ModelChoice::from_record)
387 && !models.contains(&choice)
388 {
389 models.push(choice);
390 }
391 let model = original
392 .as_ref()
393 .and_then(|row| {
394 models
395 .iter()
396 .position(|model| *model == ModelChoice::from_record(row))
397 })
398 .unwrap_or(0);
399 let tomorrow = Local::now().date_naive() + chrono::Duration::days(1);
400 let mut editor = Self {
401 name: TextField::new(
402 original
403 .as_ref()
404 .map(|r| r.name.clone())
405 .unwrap_or_default(),
406 ),
407 prompt: TextField::new(
408 original
409 .as_ref()
410 .map(|r| r.prompt.clone())
411 .unwrap_or_default(),
412 ),
413 rrule: TextField::new(
414 original
415 .as_ref()
416 .map(|r| r.rrule.clone())
417 .unwrap_or_default(),
418 ),
419 workspace: TextField::new(original.as_ref().and_then(|r| r.cwds.first()).map_or_else(
420 || workspace.display().to_string(),
421 |p| p.display().to_string(),
422 )),
423 enabled: original
424 .as_ref()
425 .is_none_or(|r| r.status == AutomationStatus::Active),
426 original,
427 locale,
428 workspace_root: workspace.to_path_buf(),
429 provider: identity,
430 time: TextField::new("09:00".to_string()),
431 date: TextField::new(tomorrow.to_string()),
432 preset: Preset::Daily,
433 days: [true, false, false, false, false, false, false],
434 day_cursor: 0,
435 schedule_changed: false,
436 models,
437 model,
438 picking_model: false,
439 model_query: TextField::new(String::new()),
440 model_row: 0,
441 focus: Field::Name,
442 scroll: Cell::new(0),
443 hits: RefCell::new(Vec::new()),
444 preview_cache: RefCell::new(None),
445 problem: None,
446 };
447 if editor.original.is_some() {
448 editor.load_schedule();
449 }
450 editor
451 }
452
453 fn load_schedule(&mut self) {
454 self.preset = Preset::Custom;
455 match AutomationSchedule::parse_rrule(&self.rrule.value) {
456 Ok(AutomationSchedule::Weekly {
457 byday,
458 byhour,
459 byminute,
460 }) => {
461 self.days = DAYS.map(|day| byday.contains(&day));
462 self.preset = if self.days.iter().all(|day| *day) {
463 Preset::Daily
464 } else {
465 Preset::Weekly
466 };
467 self.time = TextField::new(format!("{byhour:02}:{byminute:02}"));
468 }
469 Ok(AutomationSchedule::Hourly {
470 interval_hours,
471 byday: None,
472 anchor_hour: Some(hour),
473 anchor_minute: Some(minute),
474 }) if interval_hours == 1 || interval_hours == 24 => {
475 self.preset = if interval_hours == 1 {
476 Preset::Hourly
477 } else {
478 Preset::Daily
479 };
480 self.time = TextField::new(format!("{hour:02}:{minute:02}"));
481 }
482 Ok(AutomationSchedule::Once { at }) => {
483 self.preset = Preset::Once;
484 let local = at.with_timezone(&Local);
485 self.time = TextField::new(local.format("%H:%M").to_string());
486 self.date = TextField::new(local.format("%Y-%m-%d").to_string());
487 }
488 _ => {}
489 }
490 }
491
492 fn fields(&self) -> Vec<Field> {
493 let mut fields = vec![Field::Name, Field::Prompt, Field::Schedule];
494 if self.preset == Preset::Custom {
495 fields.push(Field::Rrule);
496 } else {
497 if self.preset == Preset::Once {
498 fields.push(Field::Date);
499 }
500 fields.push(Field::Time);
501 if self.preset == Preset::Weekly {
502 fields.push(Field::Days);
503 }
504 }
505 fields.extend([
506 Field::Model,
507 Field::Workspace,
508 Field::Enabled,
509 Field::Save,
510 Field::Cancel,
511 ]);
512 fields
513 }
514
515 fn move_focus(&mut self, delta: isize) {
516 let fields = self.fields();
517 let row = fields
518 .iter()
519 .position(|field| *field == self.focus)
520 .unwrap_or(0);
521 self.focus = fields[crate::tui::list_nav::wrap_index(row, fields.len(), delta)];
522 }
523
524 fn text(&mut self) -> Option<&mut TextField> {
525 match self.focus {
526 Field::Name => Some(&mut self.name),
527 Field::Prompt => Some(&mut self.prompt),
528 Field::Time => Some(&mut self.time),
529 Field::Date => Some(&mut self.date),
530 Field::Rrule => Some(&mut self.rrule),
531 Field::Workspace => Some(&mut self.workspace),
532 _ => None,
533 }
534 }
535
536 fn rule(&self) -> anyhow::Result<String> {
537 if !self.schedule_changed
538 && let Some(original) = &self.original
539 {
540 return Ok(original.rrule.clone());
541 }
542 if self.preset == Preset::Custom {
543 return Ok(self.rrule.value.trim().to_string());
544 }
545 let time = NaiveTime::parse_from_str(self.time.value.trim(), "%H:%M")?;
546 let (hour, minute) = (time.hour(), time.minute());
547 Ok(match self.preset {
548 Preset::Once => {
549 let date = NaiveDate::parse_from_str(self.date.value.trim(), "%Y-%m-%d")?;
550 format!("FREQ=ONCE;AT={date}T{hour:02}:{minute:02}")
551 }
552 Preset::Hourly => format!("FREQ=HOURLY;INTERVAL=1;BYHOUR={hour};BYMINUTE={minute}"),
553 Preset::Daily | Preset::Weekly => {
554 let days = DAY_CODES
555 .iter()
556 .enumerate()
557 .filter(|(i, _)| self.preset == Preset::Daily || self.days[*i])
558 .map(|(_, day)| *day)
559 .collect::<Vec<_>>()
560 .join(",");
561 format!("FREQ=WEEKLY;BYDAY={days};BYHOUR={hour};BYMINUTE={minute}")
562 }
563 Preset::Custom => unreachable!(),
564 })
565 }
566
567 fn preview(&self) -> String {
568 // Cron search can span years. Paints and typing in unrelated fields
569 // reuse the canonical result rather than running that search per frame.
570 let rule = self.rule().unwrap_or_else(|_| {
571 format!("{:?}:{}:{}", self.preset, self.time.value, self.date.value)
572 });
573 if let Some((cached_rule, enabled, at, value)) = self.preview_cache.borrow().as_ref()
574 && *cached_rule == rule
575 && *enabled == self.enabled
576 && at.elapsed() < Duration::from_secs(60)
577 {
578 return value.clone();
579 }
580 let value = self.compute_preview();
581 *self.preview_cache.borrow_mut() =
582 Some((rule, self.enabled, Instant::now(), value.clone()));
583 value
584 }
585
586 fn compute_preview(&self) -> String {
587 if !self.enabled {
588 return tr(self.locale, MessageId::AutomationEditorPausedPreview).into_owned();
589 }
590 if !self.schedule_changed
591 && let Some(original) = &self.original
592 && original.status == AutomationStatus::Active
593 && let Some(next) = original.next_run_at
594 && next > Utc::now()
595 {
596 return local_time(next);
597 }
598 let now = Utc::now();
599 match self
600 .rule()
601 .and_then(|rule| AutomationSchedule::parse_rrule(&rule))
602 .and_then(|schedule| {
603 schedule.next_after_with_anchor(
604 now,
605 self.original.as_ref().map_or(now, |r| r.created_at),
606 )
607 }) {
608 Ok(next) => local_time(next),
609 Err(_) if self.original.is_some() && !self.schedule_changed => {
610 tr(self.locale, MessageId::AutomationEditorPreviewUnavailable).into_owned()
611 }
612 Err(error) => tr(self.locale, MessageId::AutomationEditorInvalidSchedule)
613 .replace("{error}", &error.to_string()),
614 }
615 }
616
617 pub(super) fn save(&self, manager: &AutomationManager) -> anyhow::Result<AutomationRecord> {
618 let rrule = self.rule()?;
619 let status = if self.enabled {
620 AutomationStatus::Active
621 } else {
622 AutomationStatus::Paused
623 };
624 let choice = self.models[self.model].clone();
625 let old_workspace = self.original.as_ref().map(|r| {
626 r.cwds
627 .first()
628 .unwrap_or(&self.workspace_root)
629 .display()
630 .to_string()
631 });
632 let workspace_changed = old_workspace.as_deref() != Some(self.workspace.value.as_str());
633 let mut cwds = self
634 .original
635 .as_ref()
636 .map(|r| r.cwds.clone())
637 .unwrap_or_default();
638 // Leave old multi-workspace definitions intact; the existing scheduler
639 // executes their first workspace. Editing that field keeps the tail.
640 if workspace_changed {
641 let path = PathBuf::from(self.workspace.value.trim());
642 let path = if path.is_absolute() {
643 path
644 } else {
645 self.workspace_root.join(path)
646 };
647 if self.workspace.value.trim().is_empty() || !path.is_dir() {
648 anyhow::bail!(
649 "{}",
650 tr(self.locale, MessageId::AutomationEditorInvalidWorkspace)
651 );
652 }
653 let path = path.canonicalize()?;
654 if cwds.is_empty() {
655 cwds.push(path);
656 } else {
657 cwds[0] = path;
658 }
659 }
660 if let Some(original) = &self.original {
661 let latest = manager.get_automation(&original.id)?;
662 let request = UpdateAutomationRequest {
663 name: (self.name.value != original.name).then(|| self.name.value.clone()),
664 prompt: (self.prompt.value != original.prompt).then(|| self.prompt.value.clone()),
665 rrule: self.schedule_changed.then_some(rrule),
666 cwds: workspace_changed.then_some(cwds),
667 model: (choice != ModelChoice::from_record(original))
668 .then(|| choice.model.clone().unwrap_or_default()),
669 model_provider: (choice != ModelChoice::from_record(original))
670 .then(|| choice.provider.clone().unwrap_or_default()),
671 model_provider_id: (choice != ModelChoice::from_record(original))
672 .then(|| choice.provider_id.clone().unwrap_or_default()),
673 status: (status != original.status).then_some(status),
674 ..Default::default()
675 };
676 if (request.name.is_some() && latest.name != original.name)
677 || (request.prompt.is_some() && latest.prompt != original.prompt)
678 || (request.rrule.is_some() && latest.rrule != original.rrule)
679 || (request.cwds.is_some() && latest.cwds != original.cwds)
680 || (request.model.is_some()
681 && ModelChoice::from_record(&latest) != ModelChoice::from_record(original))
682 || (request.status.is_some() && latest.status != original.status)
683 {
684 anyhow::bail!("{}", tr(self.locale, MessageId::AutomationEditorConflict));
685 }
686 manager.update_automation(&original.id, request)
687 } else {
688 manager.create_automation(CreateAutomationRequest {
689 name: self.name.value.clone(),
690 prompt: self.prompt.value.clone(),
691 rrule,
692 cwds,
693 model: choice.model,
694 model_provider: choice.provider,
695 model_provider_id: choice.provider_id,
696 status: Some(status),
697 mode: None,
698 allow_shell: None,
699 trust_mode: None,
700 auto_approve: None,
701 delivery_mode: None,
702 })
703 }
704 }
705
706 fn cycle_preset(&mut self, delta: isize) {
707 // Keep a custom expression available if the user cycles away and back.
708 if self.preset != Preset::Custom
709 && let Ok(rule) = self.rule()
710 {
711 self.rrule = TextField::new(rule);
712 }
713 let index = Preset::ALL
714 .iter()
715 .position(|preset| *preset == self.preset)
716 .unwrap();
717 self.preset =
718 Preset::ALL[crate::tui::list_nav::wrap_index(index, Preset::ALL.len(), delta)];
719 self.schedule_changed = true;
720 }
721
722 fn adjust_time(&mut self, delta: i32) {
723 if let Ok(time) = NaiveTime::parse_from_str(self.time.value.trim(), "%H:%M") {
724 self.time = TextField::new(
725 (time + chrono::Duration::minutes(i64::from(delta)))
726 .format("%H:%M")
727 .to_string(),
728 );
729 self.schedule_changed = true;
730 }
731 }
732
733 fn model_label(&self, index: usize) -> String {
734 let choice = &self.models[index];
735 let model = choice.model.clone().unwrap_or_else(|| {
736 tr(self.locale, MessageId::AutomationEditorDefaultModel).into_owned()
737 });
738 match choice.provider_id.as_ref().or(choice.provider.as_ref()) {
739 Some(provider) => format!("{provider} / {model}"),
740 None => format!(
741 "{model} · {}",
742 tr(self.locale, MessageId::AutomationEditorInheritedProvider)
743 ),
744 }
745 }
746
747 fn filtered_models(&self) -> Vec<usize> {
748 let query = self.model_query.value.to_lowercase();
749 (0..self.models.len())
750 .filter(|index| self.model_label(*index).to_lowercase().contains(&query))
751 .collect()
752 }
753
754 fn open_models(&mut self) {
755 self.picking_model = true;
756 self.model_query = TextField::new(String::new());
757 self.model_row = self.model;
758 }
759
760 pub(super) fn key(&mut self, key: KeyEvent) -> EditorAction {
761 // AltGr emits printable Ctrl+Alt chords on Windows. It can type into
762 // a field, but can never save, cancel, select all, or toggle a control.
763 if crate::tui::widgets::key_hint::is_altgr(key.modifiers) {
764 if self.picking_model {
765 self.model_query.key(key, false);
766 self.model_row = 0;
767 } else {
768 let multiline = self.focus == Field::Prompt;
769 let schedule = matches!(self.focus, Field::Time | Field::Date | Field::Rrule);
770 if let Some(text) = self.text() {
771 let before = text.value.clone();
772 text.key(key, multiline);
773 if schedule && before != text.value {
774 self.schedule_changed = true;
775 }
776 }
777 }
778 return EditorAction::None;
779 }
780 if key.modifiers.intersects(
781 KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META,
782 ) {
783 return EditorAction::None;
784 }
785 if self.picking_model {
786 let models = self.filtered_models();
787 match key.code {
788 KeyCode::Esc => self.picking_model = false,
789 KeyCode::Enter => {
790 if let Some(index) = models.get(self.model_row) {
791 self.model = *index;
792 self.picking_model = false;
793 }
794 }
795 KeyCode::Up | KeyCode::Down if !models.is_empty() => {
796 self.model_row = crate::tui::list_nav::wrap_index(
797 self.model_row,
798 models.len(),
799 if key.code == KeyCode::Up { -1 } else { 1 },
800 )
801 }
802 _ => {
803 self.model_query.key(key, false);
804 self.model_row = 0;
805 }
806 }
807 return EditorAction::None;
808 }
809 if key.code == KeyCode::Esc {
810 return EditorAction::Cancel;
811 }
812 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('s') {
813 return EditorAction::Save;
814 }
815 self.problem = None;
816 match key.code {
817 KeyCode::Tab => self.move_focus(1),
818 KeyCode::BackTab => self.move_focus(-1),
819 KeyCode::Enter if self.focus == Field::Save => return EditorAction::Save,
820 KeyCode::Enter if self.focus == Field::Cancel => return EditorAction::Cancel,
821 KeyCode::Left | KeyCode::Right | KeyCode::Enter if self.focus == Field::Schedule => {
822 self.cycle_preset(if key.code == KeyCode::Left { -1 } else { 1 })
823 }
824 KeyCode::Up | KeyCode::Down if self.focus == Field::Time => {
825 self.adjust_time(if key.code == KeyCode::Up { 15 } else { -15 })
826 }
827 KeyCode::Up | KeyCode::Down if self.focus == Field::Date => {
828 if let Ok(date) = NaiveDate::parse_from_str(&self.date.value, "%Y-%m-%d")
829 && let Some(next) = date.checked_add_signed(chrono::Duration::days(
830 if key.code == KeyCode::Up { 1 } else { -1 },
831 ))
832 {
833 self.date = TextField::new(next.to_string());
834 self.schedule_changed = true;
835 }
836 }
837 KeyCode::Left | KeyCode::Right if self.focus == Field::Days => {
838 self.day_cursor = crate::tui::list_nav::wrap_index(
839 self.day_cursor,
840 7,
841 if key.code == KeyCode::Left { -1 } else { 1 },
842 )
843 }
844 KeyCode::Enter | KeyCode::Char(' ') if self.focus == Field::Days => {
845 self.days[self.day_cursor] = !self.days[self.day_cursor];
846 self.schedule_changed = true;
847 }
848 KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right
849 if self.focus == Field::Enabled =>
850 {
851 self.enabled = !self.enabled
852 }
853 KeyCode::Enter | KeyCode::Char(' ') if self.focus == Field::Model => self.open_models(),
854 KeyCode::Enter if self.focus != Field::Prompt => self.move_focus(1),
855 _ => {
856 let schedule = matches!(self.focus, Field::Time | Field::Date | Field::Rrule);
857 let multiline = self.focus == Field::Prompt;
858 if let Some(text) = self.text() {
859 let before = text.value.clone();
860 text.key(key, multiline);
861 if schedule && before != text.value {
862 self.schedule_changed = true;
863 }
864 }
865 }
866 }
867 EditorAction::None
868 }
869
870 pub(super) fn paste(&mut self, value: &str) {
871 if self.picking_model {
872 self.model_query.insert(value, false);
873 self.model_row = 0;
874 return;
875 }
876 let schedule = matches!(self.focus, Field::Time | Field::Date | Field::Rrule);
877 let multiline = self.focus == Field::Prompt;
878 if let Some(text) = self.text() {
879 let before = text.value.clone();
880 text.insert(value, multiline);
881 if schedule && before != text.value {
882 self.schedule_changed = true;
883 }
884 }
885 }
886
887 pub(super) fn mouse(&mut self, mouse: MouseEvent) -> EditorAction {
888 match mouse.kind {
889 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
890 let delta = if mouse.kind == MouseEventKind::ScrollUp {
891 -1
892 } else {
893 1
894 };
895 if self.picking_model {
896 let models = self.filtered_models();
897 if !models.is_empty() {
898 self.model_row =
899 crate::tui::list_nav::wrap_index(self.model_row, models.len(), delta);
900 }
901 } else {
902 self.move_focus(delta);
903 }
904 }
905 MouseEventKind::Down(MouseButton::Left) => {
906 let hit = self
907 .hits
908 .borrow()
909 .iter()
910 .rev()
911 .find(|(rect, _)| rect.contains((mouse.column, mouse.row).into()))
912 .map(|(_, hit)| *hit);
913 match hit {
914 Some(Hit::Field(Field::Save)) => return EditorAction::Save,
915 Some(Hit::Field(Field::Cancel)) if self.picking_model => {
916 self.picking_model = false
917 }
918 Some(Hit::Field(Field::Cancel)) => return EditorAction::Cancel,
919 Some(Hit::Field(field)) => {
920 self.focus = field;
921 match field {
922 Field::Model => self.open_models(),
923 Field::Schedule => self.cycle_preset(1),
924 Field::Enabled => self.enabled = !self.enabled,
925 _ => {}
926 }
927 }
928 Some(Hit::Day(day)) => {
929 self.focus = Field::Days;
930 self.day_cursor = day;
931 self.days[day] = !self.days[day];
932 self.schedule_changed = true;
933 }
934 Some(Hit::Time(delta)) => {
935 self.focus = Field::Time;
936 self.adjust_time(delta);
937 }
938 Some(Hit::Model(index)) => {
939 self.model = index;
940 self.picking_model = false;
941 }
942 None => {}
943 }
944 }
945 _ => {}
946 }
947 EditorAction::None
948 }
949
950 pub(super) fn render(&self, area: Rect, buf: &mut Buffer) {
951 self.hits.borrow_mut().clear();
952 if area.height < 6 || area.width < 8 {
953 return;
954 }
955 let title = if self.original.is_some() {
956 MessageId::AutomationEditorEdit
957 } else {
958 MessageId::AutomationEditorNew
959 };
960 Paragraph::new(tr(self.locale, title).into_owned())
961 .style(Style::default().fg(palette::WHALE_ACTION).bold())
962 .render(Rect::new(area.x, area.y, area.width, 1), buf);
963 let subtitle = if self.picking_model {
964 tr(self.locale, MessageId::AutomationEditorProvider)
965 .replace("{provider}", &self.provider)
966 } else {
967 tr(self.locale, MessageId::AutomationEditorLocalTime)
968 .replace("{zone}", &Local::now().format("%:z").to_string())
969 };
970 Paragraph::new(subtitle)
971 .style(Style::default().fg(palette::TEXT_MUTED))
972 .render(Rect::new(area.x, area.y + 1, area.width, 1), buf);
973 let content = Rect::new(area.x, area.y + 2, area.width, area.height - 5);
974 if self.picking_model {
975 self.render_models(content, buf);
976 } else {
977 self.render_fields(content, buf);
978 }
979 let next = self.problem.clone().unwrap_or_else(|| {
980 format!(
981 "{} {}",
982 tr(self.locale, MessageId::AutomationNextLabel),
983 self.preview()
984 )
985 });
986 Paragraph::new(super::display_text(&next))
987 .style(Style::default().fg(if self.problem.is_some() {
988 palette::STATUS_ERROR
989 } else {
990 palette::TEXT_MUTED
991 }))
992 .render(Rect::new(area.x, area.bottom() - 3, area.width, 1), buf);
993 let controls = if self.picking_model {
994 MessageId::AutomationEditorModelControls
995 } else if self.focus == Field::Prompt {
996 MessageId::AutomationEditorPromptControls
997 } else {
998 MessageId::AutomationEditorControls
999 };
1000 Paragraph::new(tr(self.locale, controls).into_owned())
1001 .style(Style::default().fg(palette::TEXT_DIM))
1002 .render(Rect::new(area.x, area.bottom() - 2, area.width, 1), buf);
1003 {
1004 let mut x = area.x;
1005 for (field, key, label) in [
1006 (Field::Save, "Ctrl+S", MessageId::StatusPickerActionSave),
1007 (Field::Cancel, "Esc", MessageId::StatusPickerActionCancel),
1008 ] {
1009 if self.picking_model && field == Field::Save {
1010 continue;
1011 }
1012 let text = format!("[{} {key}] ", tr(self.locale, label).trim());
1013 let width = u16::try_from(text_display_width(&text))
1014 .unwrap_or(u16::MAX)
1015 .min(area.right().saturating_sub(x));
1016 let rect = Rect::new(x, area.bottom() - 1, width, 1);
1017 Paragraph::new(text)
1018 .style(self.style(self.focus == field))
1019 .render(rect, buf);
1020 self.hits.borrow_mut().push((rect, Hit::Field(field)));
1021 x += width;
1022 }
1023 }
1024 }
1025
1026 fn style(&self, focused: bool) -> Style {
1027 if focused {
1028 Style::default()
1029 .fg(palette::WHALE_ACTION)
1030 .add_modifier(Modifier::BOLD)
1031 } else {
1032 Style::default().fg(palette::TEXT_SECONDARY)
1033 }
1034 }
1035
1036 fn render_fields(&self, area: Rect, buf: &mut Buffer) {
1037 let fields: Vec<_> = self
1038 .fields()
1039 .into_iter()
1040 .filter(|f| !matches!(f, Field::Save | Field::Cancel))
1041 .collect();
1042 let height = |field| {
1043 if field == Field::Prompt {
1044 5
1045 } else if field == Field::Days {
1046 let columns = (area.width.saturating_sub(2) / self.day_width()).max(1);
1047 2 + 7u16.div_ceil(columns)
1048 } else {
1049 3
1050 }
1051 };
1052 let focus = fields
1053 .iter()
1054 .position(|field| *field == self.focus)
1055 .unwrap_or(fields.len() - 1);
1056 let mut start = self.scroll.get().min(focus);
1057 while start < focus
1058 && fields[start..=focus]
1059 .iter()
1060 .map(|field| height(*field))
1061 .sum::<u16>()
1062 > area.height
1063 {
1064 start += 1;
1065 }
1066 self.scroll.set(start);
1067 let mut y = area.y;
1068 for field in fields.into_iter().skip(start) {
1069 if y >= area.bottom() {
1070 break;
1071 }
1072 let rect = Rect::new(area.x, y, area.width, height(field).min(area.bottom() - y));
1073 self.render_field(field, rect, buf);
1074 y += rect.height;
1075 }
1076 }
1077
1078 fn render_field(&self, field: Field, area: Rect, buf: &mut Buffer) {
1079 let label = match field {
1080 Field::Name => MessageId::AutomationNameLabel,
1081 Field::Prompt => MessageId::AutomationPromptLabel,
1082 Field::Schedule => MessageId::AutomationEditorSchedule,
1083 Field::Time => MessageId::AutomationEditorTime,
1084 Field::Days => MessageId::AutomationEditorDays,
1085 Field::Date => MessageId::AutomationEditorDate,
1086 Field::Rrule => MessageId::AutomationRruleLabel,
1087 Field::Model => MessageId::SetupCardModelLabel,
1088 Field::Workspace => MessageId::AutomationCwdLabel,
1089 Field::Enabled => MessageId::AutomationEditorEnabled,
1090 _ => return,
1091 };
1092 let focused = field == self.focus;
1093 let title = tr(self.locale, label).into_owned();
1094 let block = Block::default()
1095 .borders(Borders::ALL)
1096 .title(title)
1097 .border_style(self.style(focused));
1098 let inner = block.inner(area);
1099 block.render(area, buf);
1100 self.hits.borrow_mut().push((area, Hit::Field(field)));
1101 let text = match field {
1102 Field::Name => Some(&self.name),
1103 Field::Prompt => Some(&self.prompt),
1104 Field::Time => Some(&self.time),
1105 Field::Date => Some(&self.date),
1106 Field::Rrule => Some(&self.rrule),
1107 Field::Workspace => Some(&self.workspace),
1108 _ => None,
1109 };
1110 if let Some(text) = text {
1111 let input = if field == Field::Time && inner.width >= 16 {
1112 Rect::new(inner.x, inner.y, inner.width - 10, inner.height)
1113 } else {
1114 inner
1115 };
1116 text.render(input, buf, focused);
1117 if field == Field::Time && inner.width >= 16 {
1118 for (offset, value, delta) in [(10, "[−]", -15), (5, "[+]", 15)] {
1119 let rect = Rect::new(inner.right() - offset, inner.y, 3, inner.height);
1120 Paragraph::new(value)
1121 .style(self.style(focused))
1122 .render(rect, buf);
1123 self.hits.borrow_mut().push((rect, Hit::Time(delta)));
1124 }
1125 }
1126 return;
1127 }
1128 match field {
1129 Field::Schedule => {
1130 Paragraph::new(format!("‹ {} ›", tr(self.locale, self.preset.label())))
1131 .style(self.style(focused))
1132 .render(inner, buf);
1133 }
1134 Field::Model => {
1135 Paragraph::new(super::display_text(&self.model_label(self.model)))
1136 .style(self.style(focused))
1137 .render(inner, buf);
1138 }
1139 Field::Enabled => {
1140 Paragraph::new(format!(
1141 "{} {}",
1142 if self.enabled { "[x]" } else { "[ ]" },
1143 tr(
1144 self.locale,
1145 if self.enabled {
1146 MessageId::AutomationStatusActive
1147 } else {
1148 MessageId::AutomationStatusPaused
1149 }
1150 )
1151 ))
1152 .style(self.style(focused))
1153 .render(inner, buf);
1154 }
1155 Field::Days => {
1156 let cell_width = self.day_width().min(inner.width).max(1);
1157 let columns = (inner.width / cell_width).max(1);
1158 for (day, label) in DAY_LABELS.iter().enumerate() {
1159 let text = format!(
1160 "{}{} ",
1161 if self.days[day] { "✓" } else { "·" },
1162 tr(self.locale, *label)
1163 );
1164 let column = day as u16 % columns;
1165 let row = day as u16 / columns;
1166 if row >= inner.height {
1167 break;
1168 }
1169 let rect =
1170 Rect::new(inner.x + column * cell_width, inner.y + row, cell_width, 1);
1171 let style = self.style(focused && self.day_cursor == day);
1172 Paragraph::new(text)
1173 .style(if focused && self.day_cursor == day {
1174 style.add_modifier(Modifier::REVERSED)
1175 } else {
1176 style
1177 })
1178 .render(rect, buf);
1179 self.hits.borrow_mut().push((rect, Hit::Day(day)));
1180 }
1181 }
1182 _ => {}
1183 }
1184 }
1185
1186 fn day_width(&self) -> u16 {
1187 DAY_LABELS
1188 .iter()
1189 .map(|label| text_display_width(&format!("✓{} ", tr(self.locale, *label))) as u16)
1190 .max()
1191 .unwrap_or(1)
1192 .max(1)
1193 }
1194
1195 fn render_models(&self, area: Rect, buf: &mut Buffer) {
1196 let search = Rect::new(area.x, area.y, area.width, area.height.min(3));
1197 let block = Block::default()
1198 .borders(Borders::ALL)
1199 .title(tr(self.locale, MessageId::ConfigSearchLabel).into_owned())
1200 .border_style(self.style(true));
1201 let inner = block.inner(search);
1202 block.render(search, buf);
1203 self.model_query.render(inner, buf, true);
1204 let rows = usize::from(area.height.saturating_sub(3));
1205 let models = self.filtered_models();
1206 if models.is_empty() {
1207 Paragraph::new(tr(self.locale, MessageId::HistoryNoMatches).into_owned()).render(
1208 Rect::new(
1209 area.x,
1210 search.bottom(),
1211 area.width,
1212 area.height.saturating_sub(3),
1213 ),
1214 buf,
1215 );
1216 }
1217 let start = self.model_row.saturating_sub(rows.saturating_sub(1));
1218 for (row, index) in models.into_iter().enumerate().skip(start).take(rows) {
1219 let rect = Rect::new(
1220 area.x,
1221 search.bottom() + u16::try_from(row - start).unwrap_or(0),
1222 area.width,
1223 1,
1224 );
1225 Paragraph::new(format!(
1226 "{} {}",
1227 if row == self.model_row { "▸" } else { " " },
1228 super::display_text(&self.model_label(index))
1229 ))
1230 .style(self.style(row == self.model_row))
1231 .render(rect, buf);
1232 self.hits.borrow_mut().push((rect, Hit::Model(index)));
1233 }
1234 }
1235 }
1236
1237 fn local_time(at: DateTime<Utc>) -> String {
1238 at.with_timezone(&Local)
1239 .format("%Y-%m-%d %H:%M %:z")
1240 .to_string()
1241 }
1242
1243 #[cfg(test)]
1244 mod tests {
1245 use super::*;
1246 use crate::automation_manager::AutomationDeliveryMode;
1247
1248 fn editor(root: &Path) -> AutomationEditor {
1249 AutomationEditor::new(&Config::default(), root, Locale::En, None)
1250 }
1251
1252 fn key(code: KeyCode) -> KeyEvent {
1253 KeyEvent::new(code, KeyModifiers::NONE)
1254 }
1255
1256 #[test]
1257 fn editor_preserves_unknown_schedule_permissions_models_and_unedited_workspace() {
1258 let _env = crate::test_support::lock_test_env();
1259 let root = tempfile::tempdir().unwrap();
1260 let manager = AutomationManager::open_for_test(root.path().join("store")).unwrap();
1261 let mut draft = editor(root.path());
1262 draft.name.insert("original", false);
1263 draft.prompt.insert("line one\nline two", true);
1264 draft.enabled = false;
1265 let mut original = draft.save(&manager).unwrap();
1266 original.rrule = "FREQ=FUTURE;PRESERVE=EXACT".to_string();
1267 original.cwds.clear();
1268 original.allow_shell = Some(true);
1269 original.trust_mode = Some(true);
1270 original.auto_approve = Some(false);
1271 original.mode = Some("plan".to_string());
1272 original.delivery_mode = Some(AutomationDeliveryMode::Task);
1273 original.model = Some("private/retired-model".to_string());
1274 original.model_provider = Some("custom".to_string());
1275 original.model_provider_id = Some("retired-route".to_string());
1276 manager.save_automation(&original).unwrap();
1277 let mut edit = AutomationEditor::new(
1278 &Config::default(),
1279 root.path(),
1280 Locale::En,
1281 Some(original.clone()),
1282 );
1283 edit.name = TextField::new("renamed".to_string());
1284 let saved = edit.save(&manager).unwrap();
1285 assert_eq!(saved.name, "renamed");
1286 let mut before = serde_json::to_value(original).unwrap();
1287 let mut after = serde_json::to_value(saved).unwrap();
1288 for key in ["name", "updated_at"] {
1289 before.as_object_mut().unwrap().remove(key);
1290 after.as_object_mut().unwrap().remove(key);
1291 }
1292 assert_eq!(before, after, "only explicitly edited fields may change");
1293 }
1294
1295 #[test]
1296 fn editor_rejects_invalid_and_conflicting_saves_without_mutation() {
1297 let _env = crate::test_support::lock_test_env();
1298 let root = tempfile::tempdir().unwrap();
1299 let manager = AutomationManager::open_for_test(root.path().join("store")).unwrap();
1300 let mut draft = editor(root.path());
1301 assert!(draft.save(&manager).is_err());
1302 assert!(manager.list_automations().unwrap().is_empty());
1303 draft.name.insert("name", false);
1304 draft.prompt.insert("prompt", true);
1305 let saved = draft.save(&manager).unwrap();
1306 let mut edit = AutomationEditor::new(
1307 &Config::default(),
1308 root.path(),
1309 Locale::En,
1310 Some(saved.clone()),
1311 );
1312 edit.time = TextField::new("25:90".to_string());
1313 edit.schedule_changed = true;
1314 assert!(edit.save(&manager).is_err());
1315 assert_eq!(
1316 manager.get_automation(&saved.id).unwrap().updated_at,
1317 saved.updated_at
1318 );
1319 edit.schedule_changed = false;
1320 edit.name = TextField::new("my edit".to_string());
1321 manager
1322 .update_automation(
1323 &saved.id,
1324 UpdateAutomationRequest {
1325 name: Some("other editor".to_string()),
1326 ..Default::default()
1327 },
1328 )
1329 .unwrap();
1330 assert!(
1331 edit.save(&manager)
1332 .unwrap_err()
1333 .to_string()
1334 .contains("changed since opening")
1335 );
1336 assert_eq!(
1337 manager.get_automation(&saved.id).unwrap().name,
1338 "other editor"
1339 );
1340 }
1341
1342 #[test]
1343 fn editor_presets_use_canonical_schedules_and_atomic_pause() {
1344 let _env = crate::test_support::lock_test_env();
1345 let root = tempfile::tempdir().unwrap();
1346 let manager = AutomationManager::open_for_test(root.path().join("store")).unwrap();
1347 let mut draft = editor(root.path());
1348 draft.name.insert("scheduled", false);
1349 draft.prompt.insert("prompt", true);
1350 for preset in Preset::ALL {
1351 draft.preset = preset;
1352 draft.schedule_changed = true;
1353 draft.rrule = TextField::new("FREQ=CRON;EXPR=30 9 * * 1-5".to_string());
1354 let rule = draft.rule().unwrap();
1355 AutomationSchedule::parse_rrule(&rule).unwrap();
1356 let saved = draft.save(&manager).unwrap();
1357 assert!(saved.next_run_at.unwrap() > Utc::now());
1358 }
1359 let saved = draft.save(&manager).unwrap();
1360 let mut edit =
1361 AutomationEditor::new(&Config::default(), root.path(), Locale::En, Some(saved));
1362 edit.preset = Preset::Once;
1363 edit.date = TextField::new("2020-01-01".to_string());
1364 edit.schedule_changed = true;
1365 edit.enabled = false;
1366 let saved = edit
1367 .save(&manager)
1368 .expect("paused one-shot need not have a future run");
1369 assert_eq!(saved.status, AutomationStatus::Paused);
1370 assert_eq!(saved.next_run_at, None);
1371 }
1372
1373 #[test]
1374 fn editor_model_choices_pin_exact_custom_routes_and_clear_atomically() {
1375 let _env = crate::test_support::lock_test_env();
1376 let root = tempfile::tempdir().unwrap();
1377 let config: Config = toml::from_str(
1378 r#"
1379 provider = "first"
1380 [providers.first]
1381 kind = "openai-compatible"
1382 base_url = "http://127.0.0.1:9/first/v1"
1383 api_key = "fixture-first"
1384 model = "same-model"
1385 [providers.second]
1386 kind = "openai-compatible"
1387 base_url = "http://127.0.0.1:9/second/v1"
1388 api_key = "fixture-second"
1389 model = "same-model"
1390 "#,
1391 )
1392 .unwrap();
1393 let manager = AutomationManager::open_for_test(root.path().join("store")).unwrap();
1394 let mut draft = AutomationEditor::new(&config, root.path(), Locale::En, None);
1395 draft.name.insert("name", false);
1396 draft.prompt.insert("prompt", true);
1397 draft.enabled = false;
1398 draft.model = draft
1399 .models
1400 .iter()
1401 .position(|m| {
1402 m.provider_id.as_deref() == Some("second")
1403 && m.model.as_deref() == Some("same-model")
1404 })
1405 .unwrap();
1406 let saved = draft.save(&manager).unwrap();
1407 assert_eq!(saved.model.as_deref(), Some("same-model"));
1408 assert_eq!(saved.model_provider.as_deref(), Some("custom"));
1409 assert_eq!(saved.model_provider_id.as_deref(), Some("second"));
1410 let mut edit = AutomationEditor::new(&config, root.path(), Locale::En, Some(saved));
1411 edit.model = 0;
1412 let saved = edit.save(&manager).unwrap();
1413 assert_eq!(
1414 (saved.model, saved.model_provider, saved.model_provider_id),
1415 (None, None, None)
1416 );
1417 }
1418
1419 #[test]
1420 fn editor_multiline_unicode_input_does_not_execute_commands() {
1421 let mut input = TextField::new(String::new());
1422 input.insert("hello\r\n👨‍👩‍👧‍👦\u{1b}", true);
1423 assert_eq!(input.value, "hello\n👨‍👩‍👧‍👦");
1424 input.key(key(KeyCode::Backspace), true);
1425 assert_eq!(input.value, "hello\n");
1426 input.key(
1427 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
1428 true,
1429 );
1430 input.insert("replacement\n/automation delete foo", true);
1431 assert_eq!(input.value, "replacement\n/automation delete foo");
1432 }
1433
1434 #[test]
1435 fn editor_altgr_cannot_save_cancel_or_erase_the_draft() {
1436 let _env = crate::test_support::lock_test_env();
1437 let root = tempfile::tempdir().unwrap();
1438 let mut draft = editor(root.path());
1439 draft.name.insert("keep-", false);
1440 for ch in ['s', 'a', 'u', 'q'] {
1441 assert!(matches!(
1442 draft.key(KeyEvent::new(
1443 KeyCode::Char(ch),
1444 KeyModifiers::CONTROL | KeyModifiers::ALT
1445 )),
1446 EditorAction::None
1447 ));
1448 assert!(draft.name.value.starts_with("keep-"));
1449 assert!(!draft.name.selected);
1450 }
1451 #[cfg(windows)]
1452 assert_eq!(draft.name.value, "keep-sauq");
1453 #[cfg(not(windows))]
1454 assert_eq!(draft.name.value, "keep-");
1455 assert!(matches!(
1456 draft.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)),
1457 EditorAction::Save
1458 ));
1459 }
1460 }
1461
1461 lines RUST