diff --git a/crates/seal-tui/src/app.rs b/crates/seal-tui/src/app.rs index 79d9162d..db4c58d1 100644 --- a/crates/seal-tui/src/app.rs +++ b/crates/seal-tui/src/app.rs @@ -210,6 +210,8 @@ pub struct ChatState { pub composer: Composer, pub path_mention_index: crate::path_mentions::PathMentionIndex, pub path_mention_popup: crate::path_mentions::PathMentionPopupState, + pub slash_command_selected: usize, + slash_command_selection_key: Option, /// Per-turn timing + token state. See [`crate::turn::TurnTracker`] /// for the field-level breakdown. `busy`, `tokens_used`, /// `discard_next_response`, and the start-instant timers all @@ -347,6 +349,8 @@ impl ChatState { composer: Composer::new(), path_mention_index: crate::path_mentions::PathMentionIndex::default(), path_mention_popup: crate::path_mentions::PathMentionPopupState::default(), + slash_command_selected: 0, + slash_command_selection_key: None, turn: TurnTracker::new(), queued_messages: Vec::new(), session_id: None, @@ -380,6 +384,7 @@ impl ChatState { self.connection.dismiss_connected_toast(); self.composer.handle_paste(text); self.sync_path_mention_popup(); + self.sync_slash_command_selection(); } /// Top-level keystroke entry point. @@ -445,20 +450,28 @@ impl ChatState { return action; } + if let Some(action) = self.handle_slash_command_popup_keys(key) { + return action; + } + if let Some(action) = self.handle_cancel_keys(key) { self.sync_path_mention_popup(); + self.sync_slash_command_selection(); return action; } if let Some(action) = self.handle_reconnect(key) { self.sync_path_mention_popup(); + self.sync_slash_command_selection(); return action; } if let Some(action) = self.handle_tool_toggle(key) { self.sync_path_mention_popup(); + self.sync_slash_command_selection(); return action; } let action = self.handle_composer_keys(key); self.sync_path_mention_popup(); + self.sync_slash_command_selection(); action } @@ -476,6 +489,88 @@ impl ChatState { ); } + fn sync_slash_command_selection(&mut self) { + let key = crate::slash_commands::slash_command_selection_key( + &self.composer.text, + self.composer.cursor, + ); + if self.slash_command_selection_key != key { + self.slash_command_selected = 0; + self.slash_command_selection_key = key; + } + let suggestions = crate::slash_commands::slash_command_suggestions( + &self.composer.text, + self.composer.cursor, + ); + if suggestions.is_empty() { + self.slash_command_selected = 0; + } else { + self.slash_command_selected = self.slash_command_selected.min(suggestions.len() - 1); + } + } + + fn handle_slash_command_popup_keys(&mut self, key: KeyEvent) -> Option { + if self.path_mention_popup.is_visible() { + return None; + } + let suggestions = crate::slash_commands::slash_command_suggestions( + &self.composer.text, + self.composer.cursor, + ); + if suggestions.is_empty() { + return None; + } + self.sync_slash_command_selection(); + if !crate::slash_commands::slash_command_completion_context_open( + &self.composer.text, + self.composer.cursor, + ) { + return None; + } + match key.code { + KeyCode::Up => { + self.slash_command_selected = self.slash_command_selected.saturating_sub(1); + self.dirty = true; + Some(Action::None) + } + KeyCode::Down => { + self.slash_command_selected = + (self.slash_command_selected + 1).min(suggestions.len() - 1); + self.dirty = true; + Some(Action::None) + } + KeyCode::Enter if Self::is_plain_enter(key) => { + self.insert_selected_slash_command_or_newline() + } + KeyCode::Tab => self.insert_selected_slash_command().or(Some(Action::None)), + _ => None, + } + } + + fn insert_selected_slash_command_or_newline(&mut self) -> Option { + self.composer.tick_paste_fsm(); + if self.composer.paste_mode { + self.composer.insert_char('\n'); + self.sync_path_mention_popup(); + self.sync_slash_command_selection(); + return Some(Action::None); + } + self.insert_selected_slash_command() + } + + fn insert_selected_slash_command(&mut self) -> Option { + let completion = crate::slash_commands::slash_command_completion( + &self.composer.text, + self.composer.cursor, + self.slash_command_selected, + )?; + self.composer + .replace_range(completion.range, &completion.replacement); + self.sync_slash_command_selection(); + self.dirty = true; + Some(Action::None) + } + fn handle_path_mention_paste_enter(&mut self, key: KeyEvent) -> Option { if !self.path_mention_popup.is_visible() || !Self::is_plain_enter(key) { return None; @@ -1616,6 +1711,89 @@ mod tests { assert!(!app.path_mention_popup.is_visible()); } + #[test] + fn slash_command_down_and_tab_accept_selected_completion() { + let mut app = ready_app(); + for c in "/re".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + + app.handle_key(key(KeyCode::Down)); + app.handle_key(key(KeyCode::Tab)); + + assert_eq!(app.composer.text, "/reload-shell"); + assert_eq!(app.composer.cursor, "/reload-shell".len()); + } + + #[test] + fn slash_command_down_from_exact_match_selects_next_completion() { + let mut app = ready_app(); + for c in "/reload".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + + app.handle_key(key(KeyCode::Down)); + app.handle_key(key(KeyCode::Tab)); + + assert_eq!(app.composer.text, "/reload-shell"); + assert_eq!(app.composer.cursor, "/reload-shell".len()); + } + + #[test] + fn slash_command_enter_accepts_selected_completion() { + let mut app = ready_app(); + for c in "/co".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + + app.handle_key(key(KeyCode::Down)); + let action = app.handle_key(key(KeyCode::Enter)); + + assert_eq!(action, Action::None); + assert_eq!(app.composer.text, "/compact"); + } + + #[test] + fn slash_command_enter_in_paste_mode_inserts_newline() { + let mut app = ready_app(); + for c in "/re".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.composer.paste_mode = true; + + let action = app.handle_key(key(KeyCode::Enter)); + + assert_eq!(action, Action::None); + assert_eq!(app.composer.text, "/re\n"); + } + + #[test] + fn slash_command_up_after_command_args_moves_composer_cursor() { + let mut app = ready_app(); + for c in "/re arg".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + + app.handle_key(key(KeyCode::Up)); + + assert_eq!(app.composer.cursor, 0); + } + + #[test] + fn slash_command_enter_on_exact_match_still_dispatches() { + let mut app = ready_app(); + for c in "/help".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + + let action = app.handle_key(key(KeyCode::Enter)); + + assert_eq!( + action, + Action::SlashCommand(crate::slash_commands::SlashCommand::Help) + ); + } + #[test] fn typing_characters() { let mut app = ChatState::new(); @@ -4681,16 +4859,17 @@ mod tests { } #[test] - fn bare_slash_surfaces_empty_command_error() { + fn bare_slash_enter_accepts_first_suggestion() { let mut app = ready_app(); - let action = submit(&mut app, "/"); + for c in "/".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + + let action = app.handle_key(key(KeyCode::Enter)); + assert_eq!(action, Action::None); - assert!( - app.transcript - .events - .iter() - .any(|e| matches!(e, DisplayEvent::Error(_))) - ); + assert_eq!(app.composer.text, "/clear"); + assert_eq!(app.composer.cursor, "/clear".len()); } #[test] diff --git a/crates/seal-tui/src/composer.rs b/crates/seal-tui/src/composer.rs index 4ff6b763..4579b5ce 100644 --- a/crates/seal-tui/src/composer.rs +++ b/crates/seal-tui/src/composer.rs @@ -803,6 +803,16 @@ impl Composer { text_width: u16, prompt_width: u16, line_count_prefix: Option, + ) -> ComposerLayout { + self.layout_with_ghost(text_width, prompt_width, line_count_prefix, None) + } + + pub fn layout_with_ghost( + &self, + text_width: u16, + prompt_width: u16, + line_count_prefix: Option, + ghost_text: Option<&str>, ) -> ComposerLayout { let text_width = text_width as usize; @@ -835,7 +845,16 @@ impl Composer { if text_width == 0 || logical_line.is_empty() { let mut spans: Vec> = vec![Span::styled(prefix, Style::default().fg(colors::AMBER))]; - extend_with_paste_placeholders(logical_line, &mut spans); + if logical_idx == cursor_logical_line { + push_text_with_inline_ghost( + logical_line, + cursor_col_in_logical, + ghost_text, + &mut spans, + ); + } else { + extend_with_paste_placeholders(logical_line, &mut spans); + } lines.push(Line::from(spans)); if logical_idx == cursor_logical_line { @@ -865,7 +884,20 @@ impl Composer { // emits raw text. Real placeholders are ~30 // chars and rarely straddle. let mut chunk_spans: Vec> = vec![line_prefix]; - extend_with_paste_placeholders(&chunk_text, &mut chunk_spans); + let ghost_cursor_in_chunk = logical_idx == cursor_logical_line + && cursor_col_in_logical >= start + && cursor_col_in_logical <= end + && !(chunk_idx > 0 && cursor_col_in_logical == start); + if ghost_cursor_in_chunk { + push_text_with_inline_ghost( + &chunk_text, + cursor_col_in_logical - start, + ghost_text, + &mut chunk_spans, + ); + } else { + extend_with_paste_placeholders(&chunk_text, &mut chunk_spans); + } lines.push(Line::from(chunk_spans)); if logical_idx == cursor_logical_line { @@ -994,6 +1026,32 @@ fn next_char(text: &str, pos: usize) -> Option<(usize, char)> { Some((pos + c.len_utf8(), c)) } +fn push_text_with_inline_ghost( + text: &str, + cursor_col: usize, + ghost_text: Option<&str>, + spans: &mut Vec>, +) { + let Some(ghost_text) = ghost_text else { + extend_with_paste_placeholders(text, spans); + return; + }; + let split = byte_index_for_char_col(text, cursor_col); + extend_with_paste_placeholders(&text[..split], spans); + spans.push(Span::styled( + ghost_text.to_string(), + Style::default().fg(colors::EXTRA_DIM), + )); + extend_with_paste_placeholders(&text[split..], spans); +} + +fn byte_index_for_char_col(text: &str, col: usize) -> usize { + text.char_indices() + .nth(col) + .map(|(idx, _)| idx) + .unwrap_or(text.len()) +} + /// a glance which parts of their composer are pasted vs typed. /// Module-private free function — operates on a `&str` chunk and /// a span vec, no composer state needed. @@ -1063,6 +1121,16 @@ mod tests { assert_eq!(line_text(&layout.lines[0]), "❯ foo"); } + #[test] + fn layout_with_ghost_inserts_text_after_cursor() { + let layout = at("/re", 3).layout_with_ghost(W, 2, None, Some("load [hit `tab` to accept]")); + assert_eq!(layout.cursor_col, 5); + assert_eq!( + line_text(&layout.lines[0]), + "❯ /reload [hit `tab` to accept]" + ); + } + /// SEA-106 regression guard at the unit layer. Trailing `\n` /// must produce exactly two visual lines. #[test] diff --git a/crates/seal-tui/src/renderer.rs b/crates/seal-tui/src/renderer.rs index 2572aaa0..e0030b3d 100644 --- a/crates/seal-tui/src/renderer.rs +++ b/crates/seal-tui/src/renderer.rs @@ -106,6 +106,7 @@ fn render_slash_command_suggestions( frame: &mut Frame, area: Rect, suggestions: &[crate::slash_commands::SlashCommandSpec], + selected: usize, ) { let display_commands = suggestions .iter() @@ -117,28 +118,27 @@ fn render_slash_command_suggestions( "─".repeat(area.width as usize), Style::default().fg(colors::DIM), )])); - lines.extend( - suggestions - .iter() - .zip(display_commands) - .map(|(spec, command)| { - let padding = " ".repeat(name_width.saturating_sub(command.len()) + 2); - Line::from(vec![ - Span::raw(" "), - Span::styled( - command, - Style::default() - .fg(colors::GOLD) - .add_modifier(Modifier::BOLD), - ), - Span::raw(padding), - Span::styled( - spec.autocomplete_description, - Style::default().fg(colors::EXTRA_DIM), - ), - ]) - }), - ); + lines.extend(suggestions.iter().zip(display_commands).enumerate().map( + |(idx, (spec, command))| { + let padding = " ".repeat(name_width.saturating_sub(command.len()) + 2); + let style = if idx == selected { + Style::default() + .fg(colors::GOLD) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(colors::GOLD) + }; + Line::from(vec![ + Span::raw(" "), + Span::styled(command, style), + Span::raw(padding), + Span::styled( + spec.autocomplete_description, + Style::default().fg(colors::EXTRA_DIM), + ), + ]) + }, + )); while lines.len() < area.height as usize { lines.push(Line::default()); } @@ -222,6 +222,18 @@ pub fn draw_with_context(frame: &mut Frame, app: &mut ChatState, context: ChatCo // Tell app state the usable input width so Up/Down can handle visual-line wrapping. app.composer.width = text_width as u16; + let show_path_mention_suggestions = app.path_mention_popup.is_visible(); + let slash_completion_ghost = if show_path_mention_suggestions { + None + } else { + crate::slash_commands::slash_command_completion( + &app.composer.text, + app.composer.cursor, + app.slash_command_selected, + ) + .map(|completion| completion.ghost_text) + }; + // Single composer layout pass — both the height-sizing decision // and the render decision are derived from this. Walking the // composer text once eliminates the SEA-106 class of bugs where @@ -235,9 +247,12 @@ pub fn draw_with_context(frame: &mut Frame, app: &mut ChatState, context: ChatCo // path is rare (only fires on long multi-line composers) and // still cheaper than the old two-walk approach. let logical_line_count = app.composer.text.split('\n').count(); - let initial_layout = app - .composer - .layout(text_width as u16, prompt_width as u16, None); + let initial_layout = app.composer.layout_with_ghost( + text_width as u16, + prompt_width as u16, + None, + slash_completion_ghost.as_deref(), + ); let max_input_lines = (frame_area.height as usize * 40 / 100).max(3); let input_visual_lines = initial_layout.visual_line_count.max(1) as usize; let input_content_lines = input_visual_lines.min(max_input_lines); @@ -247,10 +262,11 @@ pub fn draw_with_context(frame: &mut Frame, app: &mut ChatState, context: ChatCo // with the "(N lines)" prefix so the user sees the hidden-row // count without having to scroll. let composer_layout = if logical_line_count > max_input_lines { - app.composer.layout( + app.composer.layout_with_ghost( text_width as u16, prompt_width as u16, Some(logical_line_count), + slash_completion_ghost.as_deref(), ) } else { initial_layout @@ -277,7 +293,6 @@ pub fn draw_with_context(frame: &mut Frame, app: &mut ChatState, context: ChatCo // the renderer publishes; layout reserves a single row only when // there's something to surface. let show_unread_banner = render::unread_banner::is_visible(app.view.unread_rows_below); - let show_path_mention_suggestions = app.path_mention_popup.is_visible(); let slash_suggestions = if show_path_mention_suggestions { Vec::new() } else { @@ -1009,7 +1024,12 @@ pub fn draw_with_context(frame: &mut Frame, app: &mut ChatState, context: ChatCo if show_path_mention_suggestions { render_path_mention_suggestions(frame, chunks[idx], &app.path_mention_popup); } else { - render_slash_command_suggestions(frame, chunks[idx], &slash_suggestions); + render_slash_command_suggestions( + frame, + chunks[idx], + &slash_suggestions, + app.slash_command_selected, + ); } } @@ -2896,6 +2916,20 @@ mod tests { ); } + #[test] + fn slash_completion_renders_inline_ghost_text() { + let mut app = ready_app(); + app.composer.text = "/re".to_string(); + app.composer.cursor = app.composer.text.len(); + + let out = render_to_string(&mut app, 100, 20); + + assert!( + out.contains("/reload [hit `tab` to accept]"), + "slash completion ghost text should render after typed prefix, got:\n{out}" + ); + } + #[test] fn help_ghost_text_uses_dim_style() { let mut app = ready_app(); diff --git a/crates/seal-tui/src/slash_commands.rs b/crates/seal-tui/src/slash_commands.rs index 74d199a6..f09e842e 100644 --- a/crates/seal-tui/src/slash_commands.rs +++ b/crates/seal-tui/src/slash_commands.rs @@ -16,6 +16,7 @@ //! (SEA-170) will add `/allow ` on the same shape. use std::fmt; +use std::ops::Range; /// A registered slash command. Keep the variants small and specific; /// generic "run shell" or "exec arbitrary" belongs in the agent-tool @@ -265,6 +266,13 @@ pub fn render_help() -> String { out } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlashCommandCompletion { + pub range: Range, + pub replacement: String, + pub ghost_text: String, +} + pub fn slash_command_suggestions(input: &str, cursor: usize) -> Vec { let Some(command_prefix) = slash_command_prefix(input, cursor) else { return Vec::new(); @@ -283,6 +291,62 @@ pub fn slash_command_suggestions_open(input: &str, cursor: usize) -> bool { slash_command_prefix(input, cursor).is_some() } +pub fn slash_command_selection_key(input: &str, cursor: usize) -> Option { + slash_command_prefix(input, cursor).map(str::to_string) +} + +pub fn slash_command_completion_context_open(input: &str, cursor: usize) -> bool { + active_slash_command(input, cursor).is_some() +} + +pub fn slash_command_completion( + input: &str, + cursor: usize, + selected: usize, +) -> Option { + let active = active_slash_command(input, cursor)?; + let suggestions = slash_command_suggestions(input, cursor); + let spec = suggestions.get(selected.min(suggestions.len().saturating_sub(1)))?; + let replacement = format!("/{}", spec.name); + let cursor = cursor.min(input.len()); + let typed_before_cursor = &input[..cursor]; + let ghost_suffix = replacement.strip_prefix(typed_before_cursor)?.to_string(); + if ghost_suffix.is_empty() { + return None; + } + Some(SlashCommandCompletion { + range: active.range, + replacement, + ghost_text: format!("{ghost_suffix} [hit `tab` to accept]"), + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ActiveSlashCommand { + range: Range, +} + +fn active_slash_command(input: &str, cursor: usize) -> Option { + if !input.starts_with('/') { + return None; + } + let cursor = cursor.min(input.len()); + if !input.is_char_boundary(cursor) { + return None; + } + let command_end = input + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(input.len()); + if cursor != command_end { + return None; + } + Some(ActiveSlashCommand { + range: 0..command_end, + }) +} + fn slash_command_prefix(input: &str, cursor: usize) -> Option<&str> { if !input.starts_with('/') { return None; @@ -374,6 +438,39 @@ mod tests { ); } + #[test] + fn completion_returns_replacement_and_ghost_text() { + let completion = slash_command_completion("/re", 3, 0).unwrap(); + assert_eq!(completion.range, 0..3); + assert_eq!(completion.replacement, "/reload"); + assert_eq!(completion.ghost_text, "load [hit `tab` to accept]"); + } + + #[test] + fn completion_is_none_after_command_token() { + assert_eq!(slash_command_completion("/re arg", 7, 0), None); + } + + #[test] + fn completion_is_none_inside_command_token() { + assert_eq!(slash_command_completion("/reload", 3, 0), None); + } + + #[test] + fn completion_context_stays_open_for_exact_match() { + assert!(slash_command_completion_context_open("/reload", 7)); + } + + #[test] + fn completion_context_is_closed_after_command_token() { + assert!(!slash_command_completion_context_open("/re arg", 7)); + } + + #[test] + fn completion_is_none_for_exact_match() { + assert_eq!(slash_command_completion("/reload", 7, 0), None); + } + #[test] fn parse_known_command() { assert_eq!(