diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 37dcfb97..aa91fb01 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -52,7 +52,8 @@ pub use configure::{ ConfigurePopup, ConfigureTab, CONFIGURE_TABS, }; pub use operator_dialog::{ - OperatorChannelAction, OperatorChannelActionAddress, OperatorChannelActions, OperatorChannelDialog, + channel_field_is_text, operator_field_is_text, OperatorChannelAction, + OperatorChannelActionAddress, OperatorChannelActions, OperatorChannelDialog, OperatorChannelDialogMode, OperatorDialog, OperatorDialogFocus, OperatorDialogMode, OperatorDialogPickerKind, OPERATOR_FIELD_COUNT, OPERATOR_PICKER_VISIBLE_ROWS, }; @@ -42990,13 +42991,22 @@ mod tests { app.operator_dialog.as_ref().map(|dialog| dialog.mode), Some(OperatorDialogMode::Create) )); - assert_eq!(app.selection, Selection::Operator("operator".into())); + // The draft's name starts empty (its suggestion is a placeholder), + // and the pane binds to the draft by that empty name until typing + // renames it keystroke by keystroke. + assert_eq!(app.selection, Selection::Operator(String::new())); + assert_eq!( + app.operator_dialog + .as_ref() + .map(|dialog| dialog.name_placeholder.as_str()), + Some("operator") + ); assert_eq!(app.focus, PaneFocus::View); server.abort(); } #[tokio::test] - async fn new_picker_operator_action_asks_for_name_then_opens_a_focused_draft() { + async fn new_picker_operator_action_opens_a_draft_with_a_name_placeholder() { let (mut app, _dir, server) = captured_app().await; app.run_slash_command("serve demo").await; assert_eq!( @@ -43010,52 +43020,92 @@ mod tests { app.handle_prompt_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) .await; - let name_prompt = app.prompt.as_mut().expect("operator name prompt"); - assert_eq!(name_prompt.prompt, "Operator name: "); - assert!(matches!(name_prompt.intent, PromptIntent::NewOperatorName)); - name_prompt.input = "triage".into(); - name_prompt.cursor = name_prompt.input.len(); - app.handle_prompt_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) - .await; - - assert!(app.prompt.is_none(), "submitting the name closes the prompt"); - assert_eq!(app.selection, Selection::Operator("triage".into())); + // No name prompt: the draft editor opens straight away, the Name + // field is empty, and its suggestion is a placeholder — typed content + // and suggested content are visually and semantically distinct. + assert!(app.prompt.is_none(), "the picker action opens the editor directly"); + assert_eq!(app.selection, Selection::Operator(String::new())); assert_eq!(app.focus, PaneFocus::View); - assert!(matches!( - app.operator_dialog.as_ref().map(|dialog| dialog.mode), - Some(OperatorDialogMode::Create) - )); - - // Creation names cannot silently replace an existing operator. - app.operators.push(operator_summary_for_test("existing")); - app.run_prompt_submit(PromptIntent::NewOperatorName, "existing".into()) - .await; - assert_eq!( - app.status.as_ref().map(|(status, _)| status.as_str()), - Some("operator 'existing' already exists") + let dialog = app.operator_dialog.as_ref().expect("draft editor"); + assert!(matches!(dialog.mode, OperatorDialogMode::Create)); + assert_eq!(dialog.operator.name, ""); + assert_eq!(dialog.name_placeholder, "operator"); + assert_eq!(dialog.focus, OperatorDialogFocus::Field(0)); + let backend = ratatui::backend::TestBackend::new(120, 40); + let mut term = ratatui::Terminal::new(backend).expect("terminal"); + app.session_transitions.clear(); + term.draw(|f| crate::ui::render(f, &mut app)).expect("draw"); + let text = rendered_text(term.backend().buffer()); + assert!( + text.contains("operator: operator*"), + "the title borrows the placeholder while the name is empty: {text}" ); // Typing lands in the editor's Name field, not anywhere else, and the // pane keeps following the name it is being given. - app.on_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)) - .await; + for ch in "triage".chars() { + app.on_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)) + .await; + } assert_eq!( app.operator_dialog .as_ref() .map(|dialog| dialog.operator.name.as_str()), - Some("triage2") + Some("triage") ); - assert_eq!(app.selection, Selection::Operator("triage2".into())); - let backend = ratatui::backend::TestBackend::new(120, 40); - let mut term = ratatui::Terminal::new(backend).expect("terminal"); + assert_eq!(app.selection, Selection::Operator("triage".into())); app.session_transitions.clear(); term.draw(|f| crate::ui::render(f, &mut app)).expect("draw"); let text = rendered_text(term.backend().buffer()); - assert!(text.contains("operator: triage2*"), "{text}"); + assert!(text.contains("operator: triage*"), "{text}"); assert!( !text.contains("no longer available"), "renaming a draft must not orphan its pane: {text}" ); + + // Creation names cannot silently replace an existing operator — the + // check the retired name prompt used to make now happens at save. + app.operators.push(operator_summary_for_test("triage")); + app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .await; + assert_eq!( + app.operator_dialog + .as_ref() + .and_then(|dialog| dialog.note.as_deref()), + Some("operator 'triage' already exists") + ); + server.abort(); + } + + #[tokio::test] + async fn a_draft_saved_without_typing_adopts_its_name_placeholder() { + let (mut app, _dir, server) = captured_app().await; + app.open_new_operator_view("operator"); + assert_eq!( + app.operator_dialog.as_ref().map(|d| d.operator.name.as_str()), + Some("") + ); + app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .await; + // Whatever the save round-trip did, the untyped name became the + // suggested one before validation ran — never an empty-name error — + // and the pane's binding followed it. + let dialog = app.operator_dialog.as_ref().expect("editor stays open"); + assert_eq!(dialog.operator.name, "operator"); + assert_ne!( + dialog.note.as_deref(), + Some("Name must be 1–32 lowercase letters, digits, or interior hyphens."), + ); + assert_eq!(app.selection, Selection::Operator("operator".into())); + server.abort(); + } + + #[tokio::test] + async fn a_taken_suggestion_moves_to_the_next_free_name() { + let (mut app, _dir, server) = captured_app().await; + assert_eq!(app.suggested_operator_name(), "operator"); + app.operators.push(operator_summary_for_test("operator")); + assert_eq!(app.suggested_operator_name(), "operator-2"); server.abort(); } @@ -44273,12 +44323,76 @@ mod tests { .as_ref() .unwrap(); assert_eq!(editor.mode, OperatorChannelDialogMode::Create); - assert_eq!(editor.channel.id, "http-2"); + // The ID starts empty; the suggested next free ID is its placeholder. + assert_eq!(editor.channel.id, ""); + assert_eq!(editor.id_placeholder, "http-2"); assert_eq!(editor.channel.port, Some(8788)); server.abort(); } + #[tokio::test] + async fn a_channel_saved_without_typing_adopts_its_id_placeholder() { + let (mut app, _dir, server) = captured_app().await; + app.operators.push(operator_summary_for_test("assistant")); + app.operator_channel_catalog = app.operators[0].channels.clone(); + app.open_edit_operator_view("assistant"); + app.open_new_operator_channel(); + assert_eq!(channel_editor(&app).channel.id, ""); + app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .await; + // Whatever the save round-trip did, the untyped ID became the + // suggested one before validation ran — never an empty-ID error. + let editor = channel_editor(&app); + assert_eq!(editor.channel.id, editor.id_placeholder); + assert_ne!( + editor.note.as_deref(), + Some("Channel ID must be 1–32 lowercase letters, digits, or interior hyphens."), + ); + server.abort(); + } + + #[tokio::test] + async fn channel_editor_shows_the_id_placeholder_dimmed_and_underlines_the_input() { + let (mut app, _dir, server) = captured_app().await; + app.operators.push(operator_summary_for_test("assistant")); + app.operator_channel_catalog = app.operators[0].channels.clone(); + app.open_edit_operator_view("assistant"); + app.open_new_operator_channel(); + let placeholder = channel_editor(&app).id_placeholder.clone(); + assert!(!placeholder.is_empty()); + app.session_transitions.clear(); + let backend = ratatui::backend::TestBackend::new(120, 40); + let mut term = ratatui::Terminal::new(backend).expect("terminal"); + term.draw(|f| crate::ui::render(f, &mut app)).expect("draw"); + let buffer = term.backend().buffer(); + let text = rendered_text(buffer); + assert!( + text.contains(&placeholder), + "the empty ID shows its suggestion: {text}" + ); + // The selected ID row is a free-text input, so its value area is + // underlined — that is what marks "type here" among rows that cycle. + let area = *buffer.area(); + let mut underlined_dim_cells = 0usize; + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + let cell = &buffer[(x, y)]; + if cell + .modifier + .contains(ratatui::style::Modifier::UNDERLINED) + { + underlined_dim_cells += 1; + } + } + } + assert!( + underlined_dim_cells >= placeholder.len(), + "the selected text field renders an underlined input area" + ); + server.abort(); + } + #[tokio::test] async fn operator_channel_editor_shows_clickable_back_affordance() { use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; diff --git a/crates/cli/src/app/operator_dialog.rs b/crates/cli/src/app/operator_dialog.rs index 90ebcdd4..1a093275 100644 --- a/crates/cli/src/app/operator_dialog.rs +++ b/crates/cli/src/app/operator_dialog.rs @@ -165,6 +165,10 @@ pub struct OperatorDialog { pub picker_selected: usize, pub picker_scroll: usize, pub channel_editor: Option, + /// Suggested name shown dimmed while a created operator's name is still + /// empty. It is a suggestion, not typed content — but saving without + /// typing adopts it, so the one-keystroke create flow keeps working. + pub name_placeholder: String, } #[derive(Debug, Clone)] @@ -178,6 +182,9 @@ pub struct OperatorChannelDialog { pub confirm_delete: bool, pub app_token: String, pub bot_token: String, + /// Suggested channel ID, shown dimmed while the created channel's ID is + /// still empty and adopted by a save that never typed one. + pub id_placeholder: String, } /// Address-level actions exposed by a channel publication. Keeping this typed @@ -252,6 +259,30 @@ fn channel_field_count(editor: &OperatorChannelDialog) -> usize { channel_state_field(&editor.channel.kind) + 1 } +/// Whether an operator-dialog field is edited as free text. Harness, model, +/// and session mode are picker-backed; routing cycles; state toggles. +pub fn operator_field_is_text(field: usize) -> bool { + matches!(field, 0 | 1 | 5) +} + +/// Whether a channel-editor field is edited as free text for this kind +/// (ID's create-only lock is the caller's to apply). The renderer uses this +/// to underline exactly the fields that accept typing, so it must agree with +/// the key handling above. +pub fn channel_field_is_text(kind: &str, field: usize) -> bool { + if field == 0 { + return true; + } + match kind { + "slack" => matches!(field, 2..=5 | SLACK_FIELD_THREAD_CONTEXT), + "slack-personal" => matches!( + field, + 2..=4 | PERSONAL_FIELD_POLL | PERSONAL_FIELD_THREAD_CONTEXT + ), + _ => field == 2, + } +} + /// The two Slack kinds share the allowlist fields but at different indexes, /// because the bot kind spends 2–3 on its tokens and slack-personal spends 2 /// on its MCP command. @@ -339,6 +370,7 @@ impl OperatorDialog { picker_selected: 0, picker_scroll: 0, channel_editor: None, + name_placeholder: String::new(), } } @@ -651,11 +683,27 @@ impl App { self.show_terminal_scrollbar(); } + /// The name a fresh operator draft suggests as its placeholder: the first + /// of `operator`, `operator-2`, … no saved definition uses. + pub fn suggested_operator_name(&self) -> String { + if !self.operators.iter().any(|op| op.name == "operator") { + return "operator".to_string(); + } + (2..) + .map(|index| format!("operator-{index}")) + .find(|candidate| !self.operators.iter().any(|op| &op.name == candidate)) + .expect("some suffix is free") + } + pub fn open_new_operator_view(&mut self, suggested: impl Into) { let suggested = suggested.into(); self.dismiss_surfaces_over_operator_view(); - self.select_operator(suggested.clone()); - let operator = default_operator(self, suggested); + // The name starts empty and shows the suggestion as a placeholder: + // prefilling it would make the first keystroke append to a name the + // user never typed. The pane is bound to the draft by the (empty) + // name in the editor, exactly as it follows every later keystroke. + self.select_operator(String::new()); + let operator = default_operator(self, String::new()); self.operator_dialog = Some(OperatorDialog { mode: OperatorDialogMode::Create, saved: operator.clone(), @@ -666,6 +714,7 @@ impl App { picker_selected: 0, picker_scroll: 0, channel_editor: None, + name_placeholder: suggested, }); } @@ -774,7 +823,9 @@ impl App { mode: OperatorChannelDialogMode::Create, operator_name: dialog.operator.name.clone(), channel: OperatorChannelSummary { - id, + // Empty until typed; `id` becomes the dimmed placeholder and + // is adopted by a save that never names the channel. + id: String::new(), kind: "http".to_string(), enabled: true, port: Some(port), @@ -804,6 +855,7 @@ impl App { confirm_delete: false, app_token: String::new(), bot_token: String::new(), + id_placeholder: id, }); true } @@ -841,6 +893,7 @@ impl App { confirm_delete: false, app_token: String::new(), bot_token: String::new(), + id_placeholder: String::new(), }); true } @@ -1225,6 +1278,19 @@ impl App { } async fn save_operator_channel(&mut self, rotate_secret: bool) { + // Like the operator name: an untyped ID adopts its placeholder. + if let Some(editor) = self + .operator_dialog + .as_mut() + .and_then(|dialog| dialog.channel_editor.as_mut()) + { + if editor.mode == OperatorChannelDialogMode::Create + && editor.channel.id.is_empty() + && !editor.id_placeholder.is_empty() + { + editor.channel.id = editor.id_placeholder.clone(); + } + } let Some(parent) = self.operator_dialog.as_ref() else { return; }; @@ -1616,12 +1682,42 @@ impl App { } async fn save_operator_dialog(&mut self) { + // Saving a create whose name was never typed adopts the placeholder, + // keeping the one-keystroke flow the prefilled default used to give. + // Adopted before the snapshot below so the editor, the selection that + // binds this pane to the draft, and the save all agree on the name. + if self.operator_dialog.as_ref().is_some_and(|dialog| { + dialog.mode == OperatorDialogMode::Create + && dialog.operator.name.is_empty() + && !dialog.name_placeholder.is_empty() + }) { + let adopted = self + .operator_dialog + .as_ref() + .map(|dialog| dialog.name_placeholder.clone()) + .unwrap_or_default(); + if let Some(dialog) = self.operator_dialog.as_mut() { + dialog.operator.name = adopted.clone(); + } + if self.selection.operator_name() == Some("") { + self.selection = Selection::Operator(adopted); + self.sync_active_window_selection(); + } + } let Some(dialog) = self.operator_dialog.clone() else { return; }; let operator = dialog.operator; + // Uniqueness used to be enforced by the name prompt this editor + // replaced; without it here, saving a create would silently overwrite + // the existing definition of the same name. + let name_taken = dialog.mode == OperatorDialogMode::Create + && self.operators.iter().any(|op| op.name == operator.name); + let duplicate_note = format!("operator '{}' already exists", operator.name); let validation_error = if !valid_operator_name(&operator.name) { Some("Name must be 1–32 lowercase letters, digits, or interior hyphens.") + } else if name_taken { + Some(duplicate_note.as_str()) } else if operator.harness.trim().is_empty() { Some("Harness cannot be empty.") } else if operator.cwd.trim().is_empty() { diff --git a/crates/cli/src/app/prompt.rs b/crates/cli/src/app/prompt.rs index 9bea793e..eb0e6d0c 100644 --- a/crates/cli/src/app/prompt.rs +++ b/crates/cli/src/app/prompt.rs @@ -456,13 +456,11 @@ impl App { return; } if harness == "operator" { - self.prompt = Some(Prompt { - prompt: "Operator name: ".to_string(), - input: String::new(), - cursor: 0, - intent: PromptIntent::NewOperatorName, - error: None, - }); + // Straight into the draft editor: the Name field with its + // dimmed suggested placeholder is where the name is typed, + // the same shape as naming a new channel. + let suggested = self.suggested_operator_name(); + self.open_new_operator_view(suggested); return; } let cwd = std::env::current_dir() diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 8ad810fe..9e09eaef 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -8599,6 +8599,13 @@ fn render_operator_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, fo // A trailing `*` is the whole unsaved-state indicator: the definition on // screen differs from the one the daemon has (spec 0175). let unsaved = if editing && dialog.is_dirty() { "*" } else { "" }; + // While a create's name is still empty the title borrows the placeholder, + // so the pane is never captioned "operator: ". + let display_name = if summary.name.is_empty() && !dialog.name_placeholder.is_empty() { + dialog.name_placeholder.clone() + } else { + summary.name.clone() + }; let border_style = pane_border_style(&app.theme, focused); let title = if dialog.channel_editor.is_some() { const BACK_LABEL: &str = "< back"; @@ -8624,12 +8631,12 @@ fn render_operator_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, fo .add_modifier(Modifier::BOLD), ), Span::styled( - format!(" ⛓︎ operator: {}{unsaved} ", summary.name), + format!(" ⛓︎ operator: {}{unsaved} ", display_name), border_style, ), ]) } else { - Line::from(format!(" ⛓︎ operator: {}{unsaved} ", summary.name)) + Line::from(format!(" ⛓︎ operator: {}{unsaved} ", display_name)) }; let block = Block::default() .borders(Borders::ALL) @@ -8699,9 +8706,20 @@ fn render_operator_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, fo } else { style }; + // The name of a create starts empty and shows its suggestion dimmed. + let placeholder = (index == 0 && dialog.mode == crate::app::OperatorDialogMode::Create) + .then_some(dialog.name_placeholder.as_str()); + let value_span = editor_value_span( + app.theme.dim, + &value, + placeholder, + selected, + crate::app::operator_field_is_text(index) && !locked, + value_style, + ); field_lines.push(Line::from(vec![ Span::styled(format!("{marker} {label:<12} "), style), - Span::styled(value, value_style), + value_span, ])); } @@ -9097,6 +9115,35 @@ fn render_operator_view(f: &mut Frame, area: Rect, app: &mut App, name: &str, fo } } +/// The value cell of one editor form row. +/// +/// A field that is empty but has a placeholder shows the placeholder dimmed — +/// a suggestion on display, not typed content. While a free-text field is +/// selected its value is underlined and padded to a minimum input width, so +/// the rows that accept typing are visibly inputs next to the rows that +/// cycle or toggle. +fn editor_value_span( + dim: Color, + value: &str, + placeholder: Option<&str>, + selected: bool, + free_text: bool, + style: Style, +) -> Span<'static> { + const INPUT_MIN_WIDTH: usize = 24; + let placeholder = placeholder.filter(|text| value.is_empty() && !text.is_empty()); + let text = placeholder.unwrap_or(value).to_string(); + let mut style = style; + if placeholder.is_some() { + style = style.fg(dim); + } + if selected && free_text { + style = style.add_modifier(Modifier::UNDERLINED); + return Span::styled(format!("{text: