Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 187 additions & 8 deletions crates/seal-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand All @@ -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<Action> {
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(
Comment thread
fluxdiv marked this conversation as resolved.
&self.composer.text,
self.composer.cursor,
) {
return None;
}
match key.code {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<Action> {
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()
Comment thread
fluxdiv marked this conversation as resolved.
}

fn insert_selected_slash_command(&mut self) -> Option<Action> {
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<Action> {
if !self.path_mention_popup.is_visible() || !Self::is_plain_enter(key) {
return None;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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]
Expand Down
72 changes: 70 additions & 2 deletions crates/seal-tui/src/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,16 @@ impl Composer {
text_width: u16,
prompt_width: u16,
line_count_prefix: Option<usize>,
) -> 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<usize>,
ghost_text: Option<&str>,
) -> ComposerLayout {
let text_width = text_width as usize;

Expand Down Expand Up @@ -835,7 +845,16 @@ impl Composer {
if text_width == 0 || logical_line.is_empty() {
let mut spans: Vec<Span<'static>> =
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 {
Expand Down Expand Up @@ -865,7 +884,20 @@ impl Composer {
// emits raw text. Real placeholders are ~30
// chars and rarely straddle.
let mut chunk_spans: Vec<Span<'static>> = 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 {
Expand Down Expand Up @@ -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<Span<'static>>,
) {
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.
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading