返回 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 active = config.active_provider_identity().ok();
355 // Invalid configuration remains visible, but does not admit any route.
356 let identity = active
357 .as_ref()
358 .map(|identity| identity.key.to_string())
359 .unwrap_or_else(|| {
360 config
361 .provider
362 .clone()
363 .unwrap_or_else(|| "unavailable".to_string())
364 });
365 let mut models = vec![ModelChoice::default()];
366 for (route, model, _) in super::super::fleet_setup::cross_provider_model_routes(
367 config,
368 active.as_ref(),
369 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
370 ) {
371 // `auto` may use configured cross-provider routing. It is not a
372 // concrete provider/model pin; legacy records remain selectable.
373 if model.trim().eq_ignore_ascii_case("auto") {
374 continue;
375 }
376 if let Ok(identity) = config.resolve_provider_identity(&route) {
377 let choice = ModelChoice {
378 model: Some(model),
379 provider: Some(identity.persisted_kind().to_string()),
380 provider_id: identity.persisted_id().map(str::to_string),
381 };
382 if !models.contains(&choice) {
383 models.push(choice);
384 }
385 }
386 }
387 if let Some(choice) = original.as_ref().map(ModelChoice::from_record)
388 && !models.contains(&choice)
389 {
390 models.push(choice);
391 }
392 let model = original
393 .as_ref()
394 .and_then(|row| {
395 models
396 .iter()
397 .position(|model| *model == ModelChoice::from_record(row))
398 })
399 .unwrap_or(0);
400 let tomorrow = Local::now().date_naive() + chrono::Duration::days(1);
401 let mut editor = Self {
402 name: TextField::new(
403 original
404 .as_ref()
405 .map(|r| r.name.clone())
406 .unwrap_or_default(),
407 ),
408 prompt: TextField::new(
409 original
410 .as_ref()
411 .map(|r| r.prompt.clone())
412 .unwrap_or_default(),
413 ),
414 rrule: TextField::new(
415 original
416 .as_ref()
417 .map(|r| r.rrule.clone())
418 .unwrap_or_default(),
419 ),
420 workspace: TextField::new(original.as_ref().and_then(|r| r.cwds.first()).map_or_else(
421 || workspace.display().to_string(),
422 |p| p.display().to_string(),
423 )),
424 enabled: original
425 .as_ref()
426 .is_none_or(|r| r.status == AutomationStatus::Active),
427 original,
428 locale,
429 workspace_root: workspace.to_path_buf(),
430 provider: identity,
431 time: TextField::new("09:00".to_string()),
432 date: TextField::new(tomorrow.to_string()),
433 preset: Preset::Daily,
434 days: [true, false, false, false, false, false, false],
435 day_cursor: 0,
436 schedule_changed: false,
437 models,
438 model,
439 picking_model: false,
440 model_query: TextField::new(String::new()),
441 model_row: 0,
442 focus: Field::Name,
443 scroll: Cell::new(0),
444 hits: RefCell::new(Vec::new()),
445 preview_cache: RefCell::new(None),
446 problem: None,
447 };
448 if editor.original.is_some() {
449 editor.load_schedule();
450 }
451 editor
452 }
453
454 fn load_schedule(&mut self) {
455 self.preset = Preset::Custom;
456 match AutomationSchedule::parse_rrule(&self.rrule.value) {
457 Ok(AutomationSchedule::Weekly {
458 byday,
459 byhour,
460 byminute,
461 }) => {
462 self.days = DAYS.map(|day| byday.contains(&day));
463 self.preset = if self.days.iter().all(|day| *day) {
464 Preset::Daily
465 } else {
466 Preset::Weekly
467 };
468 self.time = TextField::new(format!("{byhour:02}:{byminute:02}"));
469 }
470 Ok(AutomationSchedule::Hourly {
471 interval_hours,
472 byday: None,
473 anchor_hour: Some(hour),
474 anchor_minute: Some(minute),
475 }) if interval_hours == 1 || interval_hours == 24 => {
476 self.preset = if interval_hours == 1 {
477 Preset::Hourly
478 } else {
479 Preset::Daily
480 };
481 self.time = TextField::new(format!("{hour:02}:{minute:02}"));
482 }
483 Ok(AutomationSchedule::Once { at }) => {
484 self.preset = Preset::Once;
485 let local = at.with_timezone(&Local);
486 self.time = TextField::new(local.format("%H:%M").to_string());
487 self.date = TextField::new(local.format("%Y-%m-%d").to_string());
488 }
489 _ => {}
490 }
491 }
492
493 fn fields(&self) -> Vec<Field> {
494 let mut fields = vec![Field::Name, Field::Prompt, Field::Schedule];
495 if self.preset == Preset::Custom {
496 fields.push(Field::Rrule);
497 } else {
498 if self.preset == Preset::Once {
499 fields.push(Field::Date);
500 }
501 fields.push(Field::Time);
502 if self.preset == Preset::Weekly {
503 fields.push(Field::Days);
504 }
505 }
506 fields.extend([
507 Field::Model,
508 Field::Workspace,
509 Field::Enabled,
510 Field::Save,
511 Field::Cancel,
512 ]);
513 fields
514 }
515
516 fn move_focus(&mut self, delta: isize) {
517 let fields = self.fields();
518 let row = fields
519 .iter()
520 .position(|field| *field == self.focus)
521 .unwrap_or(0);
522 self.focus = fields[crate::tui::list_nav::wrap_index(row, fields.len(), delta)];
523 }
524
525 fn text(&mut self) -> Option<&mut TextField> {
526 match self.focus {
527 Field::Name => Some(&mut self.name),
528 Field::Prompt => Some(&mut self.prompt),
529 Field::Time => Some(&mut self.time),
530 Field::Date => Some(&mut self.date),
531 Field::Rrule => Some(&mut self.rrule),
532 Field::Workspace => Some(&mut self.workspace),
533 _ => None,
534 }
535 }
536
537 fn rule(&self) -> anyhow::Result<String> {
538 if !self.schedule_changed
539 && let Some(original) = &self.original
540 {
541 return Ok(original.rrule.clone());
542 }
543 if self.preset == Preset::Custom {
544 return Ok(self.rrule.value.trim().to_string());
545 }
546 let time = NaiveTime::parse_from_str(self.time.value.trim(), "%H:%M")?;
547 let (hour, minute) = (time.hour(), time.minute());
548 Ok(match self.preset {
549 Preset::Once => {
550 let date = NaiveDate::parse_from_str(self.date.value.trim(), "%Y-%m-%d")?;
551 format!("FREQ=ONCE;AT={date}T{hour:02}:{minute:02}")
552 }
553 Preset::Hourly => format!("FREQ=HOURLY;INTERVAL=1;BYHOUR={hour};BYMINUTE={minute}"),
554 Preset::Daily | Preset::Weekly => {
555 let days = DAY_CODES
556 .iter()
557 .enumerate()
558 .filter(|(i, _)| self.preset == Preset::Daily || self.days[*i])
559 .map(|(_, day)| *day)
560 .collect::<Vec<_>>()
561 .join(",");
562 format!("FREQ=WEEKLY;BYDAY={days};BYHOUR={hour};BYMINUTE={minute}")
563 }
564 Preset::Custom => unreachable!(),
565 })
566 }
567
568 fn preview(&self) -> String {
569 // Cron search can span years. Paints and typing in unrelated fields
570 // reuse the canonical result rather than running that search per frame.
571 let rule = self.rule().unwrap_or_else(|_| {
572 format!("{:?}:{}:{}", self.preset, self.time.value, self.date.value)
573 });
574 if let Some((cached_rule, enabled, at, value)) = self.preview_cache.borrow().as_ref()
575 && *cached_rule == rule
576 && *enabled == self.enabled
577 && at.elapsed() < Duration::from_secs(60)
578 {
579 return value.clone();
580 }
581 let value = self.compute_preview();
582 *self.preview_cache.borrow_mut() =
583 Some((rule, self.enabled, Instant::now(), value.clone()));
584 value
585 }
586
587 fn compute_preview(&self) -> String {
588 if !self.enabled {
589 return tr(self.locale, MessageId::AutomationEditorPausedPreview).into_owned();
590 }
591 if !self.schedule_changed
592 && let Some(original) = &self.original
593 && original.status == AutomationStatus::Active
594 && let Some(next) = original.next_run_at
595 && next > Utc::now()
596 {
597 return local_time(next);
598 }
599 let now = Utc::now();
600 match self
601 .rule()
602 .and_then(|rule| AutomationSchedule::parse_rrule(&rule))
603 .and_then(|schedule| {
604 schedule.next_after_with_anchor(
605 now,
606 self.original.as_ref().map_or(now, |r| r.created_at),
607 )
608 }) {
609 Ok(next) => local_time(next),
610 Err(_) if self.original.is_some() && !self.schedule_changed => {
611 tr(self.locale, MessageId::AutomationEditorPreviewUnavailable).into_owned()
612 }
613 Err(error) => tr(self.locale, MessageId::AutomationEditorInvalidSchedule)
614 .replace("{error}", &error.to_string()),
615 }
616 }
617
618 pub(super) fn save(&self, manager: &AutomationManager) -> anyhow::Result<AutomationRecord> {
619 let rrule = self.rule()?;
620 let status = if self.enabled {
621 AutomationStatus::Active
622 } else {
623 AutomationStatus::Paused
624 };
625 let choice = self.models[self.model].clone();
626 let old_workspace = self.original.as_ref().map(|r| {
627 r.cwds
628 .first()
629 .unwrap_or(&self.workspace_root)
630 .display()
631 .to_string()
632 });
633 let workspace_changed = old_workspace.as_deref() != Some(self.workspace.value.as_str());
634 let mut cwds = self
635 .original
636 .as_ref()
637 .map(|r| r.cwds.clone())
638 .unwrap_or_default();
639 // Leave old multi-workspace definitions intact; the existing scheduler
640 // executes their first workspace. Editing that field keeps the tail.
641 if workspace_changed {
642 let path = PathBuf::from(self.workspace.value.trim());
643 let path = if path.is_absolute() {
644 path
645 } else {
646 self.workspace_root.join(path)
647 };
648 if self.workspace.value.trim().is_empty() || !path.is_dir() {
649 anyhow::bail!(
650 "{}",
651 tr(self.locale, MessageId::AutomationEditorInvalidWorkspace)
652 );
653 }
654 let path = path.canonicalize()?;
655 if cwds.is_empty() {
656 cwds.push(path);
657 } else {
658 cwds[0] = path;
659 }
660 }
661 if let Some(original) = &self.original {
662 let latest = manager.get_automation(&original.id)?;
663 let request = UpdateAutomationRequest {
664 name: (self.name.value != original.name).then(|| self.name.value.clone()),
665 prompt: (self.prompt.value != original.prompt).then(|| self.prompt.value.clone()),
666 rrule: self.schedule_changed.then_some(rrule),
667 cwds: workspace_changed.then_some(cwds),
668 model: (choice != ModelChoice::from_record(original))
669 .then(|| choice.model.clone().unwrap_or_default()),
670 model_provider: (choice != ModelChoice::from_record(original))
671 .then(|| choice.provider.clone().unwrap_or_default()),
672 model_provider_id: (choice != ModelChoice::from_record(original))
673 .then(|| choice.provider_id.clone().unwrap_or_default()),
674 status: (status != original.status).then_some(status),
675 ..Default::default()
676 };
677 if (request.name.is_some() && latest.name != original.name)
678 || (request.prompt.is_some() && latest.prompt != original.prompt)
679 || (request.rrule.is_some() && latest.rrule != original.rrule)
680 || (request.cwds.is_some() && latest.cwds != original.cwds)
681 || (request.model.is_some()
682 && ModelChoice::from_record(&latest) != ModelChoice::from_record(original))
683 || (request.status.is_some() && latest.status != original.status)
684 {
685 anyhow::bail!("{}", tr(self.locale, MessageId::AutomationEditorConflict));
686 }
687 manager.update_automation(&original.id, request)
688 } else {
689 manager.create_automation(CreateAutomationRequest {
690 name: self.name.value.clone(),
691 prompt: self.prompt.value.clone(),
692 rrule,
693 cwds,
694 model: choice.model,
695 model_provider: choice.provider,
696 model_provider_id: choice.provider_id,
697 status: Some(status),
698 mode: None,
699 allow_shell: None,
700 trust_mode: None,
701 auto_approve: None,
702 delivery_mode: None,
703 })
704 }
705 }
706
707 fn cycle_preset(&mut self, delta: isize) {
708 // Keep a custom expression available if the user cycles away and back.
709 if self.preset != Preset::Custom
710 && let Ok(rule) = self.rule()
711 {
712 self.rrule = TextField::new(rule);
713 }
714 let index = Preset::ALL
715 .iter()
716 .position(|preset| *preset == self.preset)
717 .unwrap();
718 self.preset =
719 Preset::ALL[crate::tui::list_nav::wrap_index(index, Preset::ALL.len(), delta)];
720 self.schedule_changed = true;
721 }
722
723 fn adjust_time(&mut self, delta: i32) {
724 if let Ok(time) = NaiveTime::parse_from_str(self.time.value.trim(), "%H:%M") {
725 self.time = TextField::new(
726 (time + chrono::Duration::minutes(i64::from(delta)))
727 .format("%H:%M")
728 .to_string(),
729 );
730 self.schedule_changed = true;
731 }
732 }
733
734 fn model_label(&self, index: usize) -> String {
735 let choice = &self.models[index];
736 let model = choice.model.clone().unwrap_or_else(|| {
737 tr(self.locale, MessageId::AutomationEditorDefaultModel).into_owned()
738 });
739 match choice.provider_id.as_ref().or(choice.provider.as_ref()) {
740 Some(provider) => format!("{provider} / {model}"),
741 None => format!(
742 "{model} · {}",
743 tr(self.locale, MessageId::AutomationEditorInheritedProvider)
744 ),
745 }
746 }
747
748 fn filtered_models(&self) -> Vec<usize> {
749 let query = self.model_query.value.to_lowercase();
750 (0..self.models.len())
751 .filter(|index| self.model_label(*index).to_lowercase().contains(&query))
752 .collect()
753 }
754
755 fn open_models(&mut self) {
756 self.picking_model = true;
757 self.model_query = TextField::new(String::new());
758 self.model_row = self.model;
759 }
760
761 pub(super) fn key(&mut self, key: KeyEvent) -> EditorAction {
762 // AltGr emits printable Ctrl+Alt chords on Windows. It can type into
763 // a field, but can never save, cancel, select all, or toggle a control.
764 if crate::tui::widgets::key_hint::is_altgr(key.modifiers) {
765 if self.picking_model {
766 self.model_query.key(key, false);
767 self.model_row = 0;
768 } else {
769 let multiline = self.focus == Field::Prompt;
770 let schedule = matches!(self.focus, Field::Time | Field::Date | Field::Rrule);
771 if let Some(text) = self.text() {
772 let before = text.value.clone();
773 text.key(key, multiline);
774 if schedule && before != text.value {
775 self.schedule_changed = true;
776 }
777 }
778 }
779 return EditorAction::None;
780 }
781 if key.modifiers.intersects(
782 KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META,
783 ) {
784 return EditorAction::None;
785 }
786 if self.picking_model {
787 let models = self.filtered_models();
788 match key.code {
789 KeyCode::Esc => self.picking_model = false,
790 KeyCode::Enter => {
791 if let Some(index) = models.get(self.model_row) {
792 self.model = *index;
793 self.picking_model = false;
794 }
795 }
796 KeyCode::Up | KeyCode::Down if !models.is_empty() => {
797 self.model_row = crate::tui::list_nav::wrap_index(
798 self.model_row,
799 models.len(),
800 if key.code == KeyCode::Up { -1 } else { 1 },
801 )
802 }
803 _ => {
804 self.model_query.key(key, false);
805 self.model_row = 0;
806 }
807 }
808 return EditorAction::None;
809 }
810 if key.code == KeyCode::Esc {
811 return EditorAction::Cancel;
812 }
813 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('s') {
814 return EditorAction::Save;
815 }
816 self.problem = None;
817 match key.code {
818 KeyCode::Tab => self.move_focus(1),
819 KeyCode::BackTab => self.move_focus(-1),
820 KeyCode::Enter if self.focus == Field::Save => return EditorAction::Save,
821 KeyCode::Enter if self.focus == Field::Cancel => return EditorAction::Cancel,
822 KeyCode::Left | KeyCode::Right | KeyCode::Enter if self.focus == Field::Schedule => {
823 self.cycle_preset(if key.code == KeyCode::Left { -1 } else { 1 })
824 }
825 KeyCode::Up | KeyCode::Down if self.focus == Field::Time => {
826 self.adjust_time(if key.code == KeyCode::Up { 15 } else { -15 })
827 }
828 KeyCode::Up | KeyCode::Down if self.focus == Field::Date => {
829 if let Ok(date) = NaiveDate::parse_from_str(&self.date.value, "%Y-%m-%d")
830 && let Some(next) = date.checked_add_signed(chrono::Duration::days(
831 if key.code == KeyCode::Up { 1 } else { -1 },
832 ))
833 {
834 self.date = TextField::new(next.to_string());
835 self.schedule_changed = true;
836 }
837 }
838 KeyCode::Left | KeyCode::Right if self.focus == Field::Days => {
839 self.day_cursor = crate::tui::list_nav::wrap_index(
840 self.day_cursor,
841 7,
842 if key.code == KeyCode::Left { -1 } else { 1 },
843 )
844 }
845 KeyCode::Enter | KeyCode::Char(' ') if self.focus == Field::Days => {
846 self.days[self.day_cursor] = !self.days[self.day_cursor];
847 self.schedule_changed = true;
848 }
849 KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right
850 if self.focus == Field::Enabled =>
851 {
852 self.enabled = !self.enabled
853 }
854 KeyCode::Enter | KeyCode::Char(' ') if self.focus == Field::Model => self.open_models(),
855 KeyCode::Enter if self.focus != Field::Prompt => self.move_focus(1),
856 _ => {
857 let schedule = matches!(self.focus, Field::Time | Field::Date | Field::Rrule);
858 let multiline = self.focus == Field::Prompt;
859 if let Some(text) = self.text() {
860 let before = text.value.clone();
861 text.key(key, multiline);
862 if schedule && before != text.value {
863 self.schedule_changed = true;
864 }
865 }
866 }
867 }
868 EditorAction::None
869 }
870
871 pub(super) fn paste(&mut self, value: &str) {
872 if self.picking_model {
873 self.model_query.insert(value, false);
874 self.model_row = 0;
875 return;
876 }
877 let schedule = matches!(self.focus, Field::Time | Field::Date | Field::Rrule);
878 let multiline = self.focus == Field::Prompt;
879 if let Some(text) = self.text() {
880 let before = text.value.clone();
881 text.insert(value, multiline);
882 if schedule && before != text.value {
883 self.schedule_changed = true;
884 }
885 }
886 }
887
888 pub(super) fn mouse(&mut self, mouse: MouseEvent) -> EditorAction {
889 match mouse.kind {
890 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
891 let delta = if mouse.kind == MouseEventKind::ScrollUp {
892 -1
893 } else {
894 1
895 };
896 if self.picking_model {
897 let models = self.filtered_models();
898 if !models.is_empty() {
899 self.model_row =
900 crate::tui::list_nav::wrap_index(self.model_row, models.len(), delta);
901 }
902 } else {
903 self.move_focus(delta);
904 }
905 }
906 MouseEventKind::Down(MouseButton::Left) => {
907 let hit = self
908 .hits
909 .borrow()
910 .iter()
911 .rev()
912 .find(|(rect, _)| rect.contains((mouse.column, mouse.row).into()))
913 .map(|(_, hit)| *hit);
914 match hit {
915 Some(Hit::Field(Field::Save)) => return EditorAction::Save,
916 Some(Hit::Field(Field::Cancel)) if self.picking_model => {
917 self.picking_model = false
918 }
919 Some(Hit::Field(Field::Cancel)) => return EditorAction::Cancel,
920 Some(Hit::Field(field)) => {
921 self.focus = field;
922 match field {
923 Field::Model => self.open_models(),
924 Field::Schedule => self.cycle_preset(1),
925 Field::Enabled => self.enabled = !self.enabled,
926 _ => {}
927 }
928 }
929 Some(Hit::Day(day)) => {
930 self.focus = Field::Days;
931 self.day_cursor = day;
932 self.days[day] = !self.days[day];
933 self.schedule_changed = true;
934 }
935 Some(Hit::Time(delta)) => {
936 self.focus = Field::Time;
937 self.adjust_time(delta);
938 }
939 Some(Hit::Model(index)) => {
940 self.model = index;
941 self.picking_model = false;
942 }
943 None => {}
944 }
945 }
946 _ => {}
947 }
948 EditorAction::None
949 }
950
951 pub(super) fn render(&self, area: Rect, buf: &mut Buffer) {
952 self.hits.borrow_mut().clear();
953 if area.height < 6 || area.width < 8 {
954 return;
955 }
956 let title = if self.original.is_some() {
957 MessageId::AutomationEditorEdit
958 } else {
959 MessageId::AutomationEditorNew
960 };
961 Paragraph::new(tr(self.locale, title).into_owned())
962 .style(Style::default().fg(palette::WHALE_ACTION).bold())
963 .render(Rect::new(area.x, area.y, area.width, 1), buf);
964 let subtitle = if self.picking_model {
965 tr(self.locale, MessageId::AutomationEditorProvider)
966 .replace("{provider}", &self.provider)
967 } else {
968 tr(self.locale, MessageId::AutomationEditorLocalTime)
969 .replace("{zone}", &Local::now().format("%:z").to_string())
970 };
971 Paragraph::new(subtitle)
972 .style(Style::default().fg(palette::TEXT_MUTED))
973 .render(Rect::new(area.x, area.y + 1, area.width, 1), buf);
974 let content = Rect::new(area.x, area.y + 2, area.width, area.height - 5);
975 if self.picking_model {
976 self.render_models(content, buf);
977 } else {
978 self.render_fields(content, buf);
979 }
980 let next = self.problem.clone().unwrap_or_else(|| {
981 format!(
982 "{} {}",
983 tr(self.locale, MessageId::AutomationNextLabel),
984 self.preview()
985 )
986 });
987 Paragraph::new(super::display_text(&next))
988 .style(Style::default().fg(if self.problem.is_some() {
989 palette::STATUS_ERROR
990 } else {
991 palette::TEXT_MUTED
992 }))
993 .render(Rect::new(area.x, area.bottom() - 3, area.width, 1), buf);
994 let controls = if self.picking_model {
995 MessageId::AutomationEditorModelControls
996 } else if self.focus == Field::Prompt {
997 MessageId::AutomationEditorPromptControls
998 } else {
999 MessageId::AutomationEditorControls
1000 };
1001 Paragraph::new(tr(self.locale, controls).into_owned())
1002 .style(Style::default().fg(palette::TEXT_DIM))
1003 .render(Rect::new(area.x, area.bottom() - 2, area.width, 1), buf);
1004 {
1005 let mut x = area.x;
1006 for (field, key, label) in [
1007 (Field::Save, "Ctrl+S", MessageId::StatusPickerActionSave),
1008 (Field::Cancel, "Esc", MessageId::StatusPickerActionCancel),
1009 ] {
1010 if self.picking_model && field == Field::Save {
1011 continue;
1012 }
1013 let text = format!("[{} {key}] ", tr(self.locale, label).trim());
1014 let width = u16::try_from(text_display_width(&text))
1015 .unwrap_or(u16::MAX)
1016 .min(area.right().saturating_sub(x));
1017 let rect = Rect::new(x, area.bottom() - 1, width, 1);
1018 Paragraph::new(text)
1019 .style(self.style(self.focus == field))
1020 .render(rect, buf);
1021 self.hits.borrow_mut().push((rect, Hit::Field(field)));
1022 x += width;
1023 }
1024 }
1025 }
1026
1027 fn style(&self, focused: bool) -> Style {
1028 if focused {
1029 Style::default()
1030 .fg(palette::WHALE_ACTION)
1031 .add_modifier(Modifier::BOLD)
1032 } else {
1033 Style::default().fg(palette::TEXT_SECONDARY)
1034 }
1035 }
1036
1037 fn render_fields(&self, area: Rect, buf: &mut Buffer) {
1038 let fields: Vec<_> = self
1039 .fields()
1040 .into_iter()
1041 .filter(|f| !matches!(f, Field::Save | Field::Cancel))
1042 .collect();
1043 let height = |field| {
1044 if field == Field::Prompt {
1045 5
1046 } else if field == Field::Days {
1047 let columns = (area.width.saturating_sub(2) / self.day_width()).max(1);
1048 2 + 7u16.div_ceil(columns)
1049 } else {
1050 3
1051 }
1052 };
1053 let focus = fields
1054 .iter()
1055 .position(|field| *field == self.focus)
1056 .unwrap_or(fields.len() - 1);
1057 let mut start = self.scroll.get().min(focus);
1058 while start < focus
1059 && fields[start..=focus]
1060 .iter()
1061 .map(|field| height(*field))
1062 .sum::<u16>()
1063 > area.height
1064 {
1065 start += 1;
1066 }
1067 self.scroll.set(start);
1068 let mut y = area.y;
1069 for field in fields.into_iter().skip(start) {
1070 if y >= area.bottom() {
1071 break;
1072 }
1073 let rect = Rect::new(area.x, y, area.width, height(field).min(area.bottom() - y));
1074 self.render_field(field, rect, buf);
1075 y += rect.height;
1076 }
1077 }
1078
1079 fn render_field(&self, field: Field, area: Rect, buf: &mut Buffer) {
1080 let label = match field {
1081 Field::Name => MessageId::AutomationNameLabel,
1082 Field::Prompt => MessageId::AutomationPromptLabel,
1083 Field::Schedule => MessageId::AutomationEditorSchedule,
1084 Field::Time => MessageId::AutomationEditorTime,
1085 Field::Days => MessageId::AutomationEditorDays,
1086 Field::Date => MessageId::AutomationEditorDate,
1087 Field::Rrule => MessageId::AutomationRruleLabel,
1088 Field::Model => MessageId::SetupCardModelLabel,
1089 Field::Workspace => MessageId::AutomationCwdLabel,
1090 Field::Enabled => MessageId::AutomationEditorEnabled,
1091 _ => return,
1092 };
1093 let focused = field == self.focus;
1094 let title = tr(self.locale, label).into_owned();
1095 let block = Block::default()
1096 .borders(Borders::ALL)
1097 .title(title)
1098 .border_style(self.style(focused));
1099 let inner = block.inner(area);
1100 block.render(area, buf);
1101 self.hits.borrow_mut().push((area, Hit::Field(field)));
1102 let text = match field {
1103 Field::Name => Some(&self.name),
1104 Field::Prompt => Some(&self.prompt),
1105 Field::Time => Some(&self.time),
1106 Field::Date => Some(&self.date),
1107 Field::Rrule => Some(&self.rrule),
1108 Field::Workspace => Some(&self.workspace),
1109 _ => None,
1110 };
1111 if let Some(text) = text {
1112 let input = if field == Field::Time && inner.width >= 16 {
1113 Rect::new(inner.x, inner.y, inner.width - 10, inner.height)
1114 } else {
1115 inner
1116 };
1117 text.render(input, buf, focused);
1118 if field == Field::Time && inner.width >= 16 {
1119 for (offset, value, delta) in [(10, "[−]", -15), (5, "[+]", 15)] {
1120 let rect = Rect::new(inner.right() - offset, inner.y, 3, inner.height);
1121 Paragraph::new(value)
1122 .style(self.style(focused))
1123 .render(rect, buf);
1124 self.hits.borrow_mut().push((rect, Hit::Time(delta)));
1125 }
1126 }
1127 return;
1128 }
1129 match field {
1130 Field::Schedule => {
1131 Paragraph::new(format!("‹ {} ›", tr(self.locale, self.preset.label())))
1132 .style(self.style(focused))
1133 .render(inner, buf);
1134 }
1135 Field::Model => {
1136 Paragraph::new(super::display_text(&self.model_label(self.model)))
1137 .style(self.style(focused))
1138 .render(inner, buf);
1139 }
1140 Field::Enabled => {
1141 Paragraph::new(format!(
1142 "{} {}",
1143 if self.enabled { "[x]" } else { "[ ]" },
1144 tr(
1145 self.locale,
1146 if self.enabled {
1147 MessageId::AutomationStatusActive
1148 } else {
1149 MessageId::AutomationStatusPaused
1150 }
1151 )
1152 ))
1153 .style(self.style(focused))
1154 .render(inner, buf);
1155 }
1156 Field::Days => {
1157 let cell_width = self.day_width().min(inner.width).max(1);
1158 let columns = (inner.width / cell_width).max(1);
1159 for (day, label) in DAY_LABELS.iter().enumerate() {
1160 let text = format!(
1161 "{}{} ",
1162 if self.days[day] { "✓" } else { "·" },
1163 tr(self.locale, *label)
1164 );
1165 let column = day as u16 % columns;
1166 let row = day as u16 / columns;
1167 if row >= inner.height {
1168 break;
1169 }
1170 let rect =
1171 Rect::new(inner.x + column * cell_width, inner.y + row, cell_width, 1);
1172 let style = self.style(focused && self.day_cursor == day);
1173 Paragraph::new(text)
1174 .style(if focused && self.day_cursor == day {
1175 style.add_modifier(Modifier::REVERSED)
1176 } else {
1177 style
1178 })
1179 .render(rect, buf);
1180 self.hits.borrow_mut().push((rect, Hit::Day(day)));
1181 }
1182 }
1183 _ => {}
1184 }
1185 }
1186
1187 fn day_width(&self) -> u16 {
1188 DAY_LABELS
1189 .iter()
1190 .map(|label| text_display_width(&format!("✓{} ", tr(self.locale, *label))) as u16)
1191 .max()
1192 .unwrap_or(1)
1193 .max(1)
1194 }
1195
1196 fn render_models(&self, area: Rect, buf: &mut Buffer) {
1197 let search = Rect::new(area.x, area.y, area.width, area.height.min(3));
1198 let block = Block::default()
1199 .borders(Borders::ALL)
1200 .title(tr(self.locale, MessageId::ConfigSearchLabel).into_owned())
1201 .border_style(self.style(true));
1202 let inner = block.inner(search);
1203 block.render(search, buf);
1204 self.model_query.render(inner, buf, true);
1205 let rows = usize::from(area.height.saturating_sub(3));
1206 let models = self.filtered_models();
1207 if models.is_empty() {
1208 Paragraph::new(tr(self.locale, MessageId::HistoryNoMatches).into_owned()).render(
1209 Rect::new(
1210 area.x,
1211 search.bottom(),
1212 area.width,
1213 area.height.saturating_sub(3),
1214 ),
1215 buf,
1216 );
1217 }
1218 let start = self.model_row.saturating_sub(rows.saturating_sub(1));
1219 for (row, index) in models.into_iter().enumerate().skip(start).take(rows) {
1220 let rect = Rect::new(
1221 area.x,
1222 search.bottom() + u16::try_from(row - start).unwrap_or(0),
1223 area.width,
1224 1,
1225 );
1226 Paragraph::new(format!(
1227 "{} {}",
1228 if row == self.model_row { "▸" } else { " " },
1229 super::display_text(&self.model_label(index))
1230 ))
1231 .style(self.style(row == self.model_row))
1232 .render(rect, buf);
1233 self.hits.borrow_mut().push((rect, Hit::Model(index)));
1234 }
1235 }
1236 }
1237
1238 fn local_time(at: DateTime<Utc>) -> String {
1239 at.with_timezone(&Local)
1240 .format("%Y-%m-%d %H:%M %:z")
1241 .to_string()
1242 }
1243
1244 #[cfg(test)]
1245 mod tests {
1246 use super::*;
1247 use crate::automation_manager::AutomationDeliveryMode;
1248
1249 fn editor(root: &Path) -> AutomationEditor {
1250 AutomationEditor::new(&Config::default(), root, Locale::En, None)
1251 }
1252
1253 fn key(code: KeyCode) -> KeyEvent {
1254 KeyEvent::new(code, KeyModifiers::NONE)
1255 }
1256
1257 #[test]
1258 fn editor_preserves_unknown_schedule_permissions_models_and_unedited_workspace() {
1259 let _env = crate::test_support::lock_test_env();
1260 let root = tempfile::tempdir().unwrap();
1261 let manager = AutomationManager::open_for_test(root.path().join("store")).unwrap();
1262 let mut draft = editor(root.path());
1263 draft.name.insert("original", false);
1264 draft.prompt.insert("line one\nline two", true);
1265 draft.enabled = false;
1266 let mut original = draft.save(&manager).unwrap();
1267 original.rrule = "FREQ=FUTURE;PRESERVE=EXACT".to_string();
1268 original.cwds.clear();
1269 original.allow_shell = Some(true);
1270 original.trust_mode = Some(true);
1271 original.auto_approve = Some(false);
1272 original.mode = Some("plan".to_string());
1273 original.delivery_mode = Some(AutomationDeliveryMode::Task);
1274 original.model = Some("private/retired-model".to_string());
1275 original.model_provider = Some("custom".to_string());
1276 original.model_provider_id = Some("retired-route".to_string());
1277 manager.save_automation(&original).unwrap();
1278 let mut edit = AutomationEditor::new(
1279 &Config::default(),
1280 root.path(),
1281 Locale::En,
1282 Some(original.clone()),
1283 );
1284 edit.name = TextField::new("renamed".to_string());
1285 let saved = edit.save(&manager).unwrap();
1286 assert_eq!(saved.name, "renamed");
1287 let mut before = serde_json::to_value(original).unwrap();
1288 let mut after = serde_json::to_value(saved).unwrap();
1289 for key in ["name", "updated_at"] {
1290 before.as_object_mut().unwrap().remove(key);
1291 after.as_object_mut().unwrap().remove(key);
1292 }
1293 assert_eq!(before, after, "only explicitly edited fields may change");
1294 }
1295
1296 #[test]
1297 fn editor_rejects_invalid_and_conflicting_saves_without_mutation() {
1298 let _env = crate::test_support::lock_test_env();
1299 let root = tempfile::tempdir().unwrap();
1300 let manager = AutomationManager::open_for_test(root.path().join("store")).unwrap();
1301 let mut draft = editor(root.path());
1302 assert!(draft.save(&manager).is_err());
1303 assert!(manager.list_automations().unwrap().is_empty());
1304 draft.name.insert("name", false);
1305 draft.prompt.insert("prompt", true);
1306 let saved = draft.save(&manager).unwrap();
1307 let mut edit = AutomationEditor::new(
1308 &Config::default(),
1309 root.path(),
1310 Locale::En,
1311 Some(saved.clone()),
1312 );
1313 edit.time = TextField::new("25:90".to_string());
1314 edit.schedule_changed = true;
1315 assert!(edit.save(&manager).is_err());
1316 assert_eq!(
1317 manager.get_automation(&saved.id).unwrap().updated_at,
1318 saved.updated_at
1319 );
1320 edit.schedule_changed = false;
1321 edit.name = TextField::new("my edit".to_string());
1322 manager
1323 .update_automation(
1324 &saved.id,
1325 UpdateAutomationRequest {
1326 name: Some("other editor".to_string()),
1327 ..Default::default()
1328 },
1329 )
1330 .unwrap();
1331 assert!(
1332 edit.save(&manager)
1333 .unwrap_err()
1334 .to_string()
1335 .contains("changed since opening")
1336 );
1337 assert_eq!(
1338 manager.get_automation(&saved.id).unwrap().name,
1339 "other editor"
1340 );
1341 }
1342
1343 #[test]
1344 fn editor_presets_use_canonical_schedules_and_atomic_pause() {
1345 let _env = crate::test_support::lock_test_env();
1346 let root = tempfile::tempdir().unwrap();
1347 let manager = AutomationManager::open_for_test(root.path().join("store")).unwrap();
1348 let mut draft = editor(root.path());
1349 draft.name.insert("scheduled", false);
1350 draft.prompt.insert("prompt", true);
1351 for preset in Preset::ALL {
1352 draft.preset = preset;
1353 draft.schedule_changed = true;
1354 draft.rrule = TextField::new("FREQ=CRON;EXPR=30 9 * * 1-5".to_string());
1355 let rule = draft.rule().unwrap();
1356 AutomationSchedule::parse_rrule(&rule).unwrap();
1357 let saved = draft.save(&manager).unwrap();
1358 assert!(saved.next_run_at.unwrap() > Utc::now());
1359 }
1360 let saved = draft.save(&manager).unwrap();
1361 let mut edit =
1362 AutomationEditor::new(&Config::default(), root.path(), Locale::En, Some(saved));
1363 edit.preset = Preset::Once;
1364 edit.date = TextField::new("2020-01-01".to_string());
1365 edit.schedule_changed = true;
1366 edit.enabled = false;
1367 let saved = edit
1368 .save(&manager)
1369 .expect("paused one-shot need not have a future run");
1370 assert_eq!(saved.status, AutomationStatus::Paused);
1371 assert_eq!(saved.next_run_at, None);
1372 }
1373
1374 #[test]
1375 fn editor_model_choices_pin_exact_custom_routes_and_clear_atomically() {
1376 let _env = crate::test_support::lock_test_env();
1377 let root = tempfile::tempdir().unwrap();
1378 let config: Config = toml::from_str(
1379 r#"
1380 provider = "first"
1381 [providers.first]
1382 kind = "openai-compatible"
1383 base_url = "http://127.0.0.1:9/first/v1"
1384 api_key = "fixture-first"
1385 model = "same-model"
1386 [providers.second]
1387 kind = "openai-compatible"
1388 base_url = "http://127.0.0.1:9/second/v1"
1389 api_key = "fixture-second"
1390 model = "same-model"
1391 "#,
1392 )
1393 .unwrap();
1394 let manager = AutomationManager::open_for_test(root.path().join("store")).unwrap();
1395 let mut draft = AutomationEditor::new(&config, root.path(), Locale::En, None);
1396 draft.name.insert("name", false);
1397 draft.prompt.insert("prompt", true);
1398 draft.enabled = false;
1399 draft.model = draft
1400 .models
1401 .iter()
1402 .position(|m| {
1403 m.provider_id.as_deref() == Some("second")
1404 && m.model.as_deref() == Some("same-model")
1405 })
1406 .unwrap();
1407 let saved = draft.save(&manager).unwrap();
1408 assert_eq!(saved.model.as_deref(), Some("same-model"));
1409 assert_eq!(saved.model_provider.as_deref(), Some("custom"));
1410 assert_eq!(saved.model_provider_id.as_deref(), Some("second"));
1411 let mut edit = AutomationEditor::new(&config, root.path(), Locale::En, Some(saved));
1412 edit.model = 0;
1413 let saved = edit.save(&manager).unwrap();
1414 assert_eq!(
1415 (saved.model, saved.model_provider, saved.model_provider_id),
1416 (None, None, None)
1417 );
1418 }
1419
1420 #[test]
1421 fn editor_multiline_unicode_input_does_not_execute_commands() {
1422 let mut input = TextField::new(String::new());
1423 input.insert("hello\r\n👨‍👩‍👧‍👦\u{1b}", true);
1424 assert_eq!(input.value, "hello\n👨‍👩‍👧‍👦");
1425 input.key(key(KeyCode::Backspace), true);
1426 assert_eq!(input.value, "hello\n");
1427 input.key(
1428 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
1429 true,
1430 );
1431 input.insert("replacement\n/automation delete foo", true);
1432 assert_eq!(input.value, "replacement\n/automation delete foo");
1433 }
1434
1435 #[test]
1436 fn editor_altgr_cannot_save_cancel_or_erase_the_draft() {
1437 let _env = crate::test_support::lock_test_env();
1438 let root = tempfile::tempdir().unwrap();
1439 let mut draft = editor(root.path());
1440 draft.name.insert("keep-", false);
1441 for ch in ['s', 'a', 'u', 'q'] {
1442 assert!(matches!(
1443 draft.key(KeyEvent::new(
1444 KeyCode::Char(ch),
1445 KeyModifiers::CONTROL | KeyModifiers::ALT
1446 )),
1447 EditorAction::None
1448 ));
1449 assert!(draft.name.value.starts_with("keep-"));
1450 assert!(!draft.name.selected);
1451 }
1452 #[cfg(windows)]
1453 assert_eq!(draft.name.value, "keep-sauq");
1454 #[cfg(not(windows))]
1455 assert_eq!(draft.name.value, "keep-");
1456 assert!(matches!(
1457 draft.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)),
1458 EditorAction::Save
1459 ));
1460 }
1461 }
1462
1462 lines RUST