From 1c4e5cadf23d38350fd281bac85a6908c2414ce2 Mon Sep 17 00:00:00 2001
From: Johannes Altmanninger <aclopte@gmail.com>
Date: Sun, 15 Dec 2024 17:27:00 +0100
Subject: [PATCH] Autosuggestions in multi-line command lines

If I run

	$ command A
	$ command B
	$ command C

and find myself wanting to re-run the same sequence of commands
multiple times, I like to join them into a single command:

	$ command A &&
	    command B &&
	    command C

When composing this mega-commandline, history search can recall the
first one; the others I usually inserted with a combination of ctrl-k,
ctrl-x or the ctrl-r (since 232483d89a (History pager to only operate
on the line at cursor, 2024-03-22), which is motivated by exactly
this use case).

It's irritating that autosuggestions are missing, so try adding them.

Today, only single-line commands from history are suggested. In
future, we should perhaps also suggest any line from a multi-line
command from history.
---
 CHANGELOG.rst        |   1 +
 src/editable_line.rs |   2 +-
 src/reader.rs        | 383 +++++++++++++++++++++++++++++--------------
 src/screen.rs        | 109 +++++++-----
 src/tests/reader.rs  |  25 ++-
 src/tests/screen.rs  | 113 ++++++++++++-
 6 files changed, 460 insertions(+), 173 deletions(-)

diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index baef63c5d..7323361db 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,6 +12,7 @@ Scripting improvements
 
 Interactive improvements
 ------------------------
+- Autosuggestions are now also provided in  multi-line command lines. Like `ctrl-r`, autosuggestions operate only on the current line.
 
 New or improved bindings
 ^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/src/editable_line.rs b/src/editable_line.rs
index 725a26fd7..b12bc1c81 100644
--- a/src/editable_line.rs
+++ b/src/editable_line.rs
@@ -342,7 +342,7 @@ fn cursor_position_after_edit(edit: &Edit) -> usize {
     cursor.saturating_sub(removed)
 }
 
-fn range_of_line_at_cursor(buffer: &wstr, cursor: usize) -> Range<usize> {
+pub fn range_of_line_at_cursor(buffer: &wstr, cursor: usize) -> Range<usize> {
     let start = buffer[0..cursor]
         .as_char_slice()
         .iter()
diff --git a/src/reader.rs b/src/reader.rs
index 6355ce912..3ac74c970 100644
--- a/src/reader.rs
+++ b/src/reader.rs
@@ -58,8 +58,7 @@ use crate::complete::{
     complete, complete_load, sort_and_prioritize, CompleteFlags, Completion, CompletionList,
     CompletionRequestOptions,
 };
-use crate::editable_line::line_at_cursor;
-use crate::editable_line::{Edit, EditableLine};
+use crate::editable_line::{line_at_cursor, range_of_line_at_cursor, Edit, EditableLine};
 use crate::env::{EnvMode, Environment, Statuses};
 use crate::exec::exec_subshell;
 use crate::expand::{expand_string, expand_tilde, ExpandFlags, ExpandResultCode};
@@ -100,6 +99,7 @@ use crate::panic::AT_EXIT;
 use crate::parse_constants::SourceRange;
 use crate::parse_constants::{ParseTreeFlags, ParserTestErrorBits};
 use crate::parse_tree::ParsedSource;
+use crate::parse_util::parse_util_process_extent;
 use crate::parse_util::MaybeParentheses;
 use crate::parse_util::SPACES_PER_INDENT;
 use crate::parse_util::{
@@ -133,7 +133,8 @@ use crate::tokenizer::{
 use crate::wchar::prelude::*;
 use crate::wcstringutil::string_prefixes_string_maybe_case_insensitive;
 use crate::wcstringutil::{
-    count_preceding_backslashes, join_strings, string_prefixes_string, StringFuzzyMatch,
+    count_preceding_backslashes, join_strings, string_prefixes_string,
+    string_prefixes_string_case_insensitive, StringFuzzyMatch,
 };
 use crate::wildcard::wildcard_has;
 use crate::wutil::{fstat, perror};
@@ -983,7 +984,8 @@ pub fn reader_showing_suggestion(parser: &Parser) -> bool {
         let reader = Reader { parser, data };
         let suggestion = &reader.autosuggestion.text;
         let is_single_space = suggestion.ends_with(L!(" "))
-            && reader.command_line.text() == suggestion[..suggestion.len() - 1];
+            && line_at_cursor(reader.command_line.text(), reader.command_line.position())
+                == suggestion[..suggestion.len() - 1];
         !suggestion.is_empty() && !is_single_space
     } else {
         false
@@ -1322,8 +1324,8 @@ impl ReaderData {
 
     /// Update the cursor position.
     fn update_buff_pos(&mut self, elt: EditableLineTag, mut new_pos: Option<usize>) -> bool {
+        let el = self.edit_line(elt);
         if self.cursor_end_mode == CursorEndMode::Inclusive {
-            let el = self.edit_line(elt);
             let mut pos = new_pos.unwrap_or(el.position());
             if !el.is_empty() && pos == el.len() {
                 pos = el.len() - 1;
@@ -1333,6 +1335,7 @@ impl ReaderData {
                 new_pos = Some(pos);
             }
         }
+        let old_pos = el.position();
         if let Some(pos) = new_pos {
             self.edit_line_mut(elt).set_position(pos);
         }
@@ -1340,6 +1343,17 @@ impl ReaderData {
         if elt != EditableLineTag::Commandline {
             return true;
         }
+        // When moving across lines, hold off on autosuggestions until the next insertion.
+        if let Some(new_pos) = new_pos {
+            let range = if new_pos <= old_pos {
+                new_pos..old_pos
+            } else {
+                old_pos..new_pos
+            };
+            if self.command_line.text()[range].contains('\n') {
+                self.suppress_autosuggestion = true;
+            }
+        }
         let buff_pos = self.command_line.position();
         let target_char = if self.cursor_selection_mode == CursorSelectionMode::Inclusive {
             1
@@ -1386,36 +1400,50 @@ impl ReaderData {
 
 /// Given a command line and an autosuggestion, return the string that gets shown to the user.
 /// Exposed for testing purposes only.
-pub fn combine_command_and_autosuggestion(cmdline: &wstr, autosuggestion: &wstr) -> WString {
+pub fn combine_command_and_autosuggestion(
+    cmdline: &wstr,
+    line_range: Range<usize>,
+    autosuggestion: &wstr,
+) -> WString {
     // We want to compute the full line, containing the command line and the autosuggestion They may
-    // disagree on whether characters are uppercase or lowercase Here we do something funny: if the
-    // last token of the command line contains any uppercase characters, we use its case. Otherwise
-    // we use the case of the autosuggestion. This is an idea from issue #335.
-    let mut full_line;
-    if autosuggestion.len() <= cmdline.len() || cmdline.is_empty() {
-        // No or useless autosuggestion, or no command line.
-        full_line = cmdline.to_owned();
-    } else if string_prefixes_string(cmdline, autosuggestion) {
-        // No case disagreements, or no extra characters in the autosuggestion.
-        full_line = autosuggestion.to_owned();
-    } else {
+    // disagree on whether characters are uppercase or lowercase.
+    let pos = line_range.end;
+    let full_line;
+    assert!(!autosuggestion.is_empty());
+    assert!(autosuggestion.len() >= line_range.len());
+    let available = autosuggestion.len() - line_range.len();
+    let line = &cmdline[line_range.clone()];
+
+    if !string_prefixes_string(line, autosuggestion) {
         // We have an autosuggestion which is not a prefix of the command line, i.e. a case
         // disagreement. Decide whose case we want to use.
+        assert!(string_prefixes_string_case_insensitive(
+            line,
+            autosuggestion
+        ));
+        // Here we do something funny: if the last token of the command line contains any uppercase
+        // characters, we use its case. Otherwise we use the case of the autosuggestion. This
+        // is an idea from issue #335.
         let mut tok = 0..0;
         parse_util_token_extent(cmdline, cmdline.len() - 1, &mut tok, None);
         let last_token_contains_uppercase = cmdline[tok].chars().any(|c| c.is_uppercase());
         if !last_token_contains_uppercase {
             // Use the autosuggestion's case.
-            full_line = autosuggestion.to_owned();
-        } else {
-            // Use the command line case for its characters, then append the remaining characters in
-            // the autosuggestion. Note that we know that autosuggestion.size() > cmdline.size() due
-            // to the first test above.
-            full_line = cmdline.to_owned();
-            full_line.push_utfstr(&autosuggestion[cmdline.len()..]);
+            let start: usize = unsafe {
+                (line.as_char_slice().first().unwrap() as *const char)
+                    .offset_from(&cmdline.as_char_slice()[0])
+            }
+            .try_into()
+            .unwrap();
+            full_line = cmdline[..start].to_owned() + autosuggestion + &cmdline[pos..];
+            return full_line;
         }
     }
-    full_line
+    // Use the command line case for its characters, then append the remaining characters in
+    // the autosuggestion.
+    cmdline[..pos].to_owned()
+        + &autosuggestion[autosuggestion.len() - available..]
+        + &cmdline[pos..]
 }
 
 impl<'a> Reader<'a> {
@@ -1510,17 +1538,35 @@ impl<'a> Reader<'a> {
     /// `reason` is used in FLOG to explain why.
     fn paint_layout(&mut self, reason: &wstr, is_final_rendering: bool) {
         FLOGF!(reader_render, "Repainting from %ls", reason);
-        let data = &self.data.rendered_layout;
         let cmd_line = &self.data.command_line;
 
-        let full_line = if self.conf.in_silent_mode {
-            wstr::from_char_slice(&[get_obfuscation_read_char()]).repeat(cmd_line.len())
-        } else {
+        let (full_line, autosuggested_range) = if self.conf.in_silent_mode {
+            (
+                Cow::Owned(
+                    wstr::from_char_slice(&[get_obfuscation_read_char()]).repeat(cmd_line.len()),
+                ),
+                0..0,
+            )
+        } else if self.is_at_line_with_autosuggestion() {
             // Combine the command and autosuggestion into one string.
-            combine_command_and_autosuggestion(cmd_line.text(), &self.autosuggestion.text)
+            let autosuggestion = &self.autosuggestion;
+            let search_string_range = &autosuggestion.search_string_range;
+            let autosuggested_start = search_string_range.end;
+            let autosuggested_end = search_string_range.start + autosuggestion.text.len();
+            (
+                Cow::Owned(combine_command_and_autosuggestion(
+                    cmd_line.text(),
+                    autosuggestion.search_string_range.clone(),
+                    &autosuggestion.text,
+                )),
+                autosuggested_start..autosuggested_end,
+            )
+        } else {
+            (Cow::Borrowed(cmd_line.text()), 0..0)
         };
 
-        // Copy the colors and extend them with autosuggestion color.
+        // Copy the colors and insert the autosuggestion color.
+        let data = &self.data.rendered_layout;
         let mut colors = data.colors.clone();
 
         // Highlight any history search.
@@ -1546,16 +1592,23 @@ impl<'a> Reader<'a> {
             }
         }
 
-        // Extend our colors with the autosuggestion.
-        colors.resize(
-            full_line.len(),
-            HighlightSpec::with_fg(HighlightRole::autosuggestion),
-        );
+        let mut indents;
+        {
+            // Extend our colors with the autosuggestion.
+            let pos = autosuggested_range.start;
+            colors.splice(
+                pos..pos,
+                vec![
+                    HighlightSpec::with_fg(HighlightRole::autosuggestion);
+                    autosuggested_range.len()
+                ],
+            );
 
-        // Compute the indentation, then extend it with 0s for the autosuggestion. The autosuggestion
-        // always conceptually has an indent of 0.
-        let mut indents = parse_util_compute_indents(cmd_line.text());
-        indents.resize(full_line.len(), 0);
+            // Compute the indentation, then extend it with 0s for the autosuggestion. The autosuggestion
+            // always conceptually has an indent of 0.
+            indents = parse_util_compute_indents(cmd_line.text());
+            indents.splice(pos..pos, vec![0; autosuggested_range.len()]);
+        }
 
         let screen = &mut self.data.screen;
         let pager = &mut self.data.pager;
@@ -1565,9 +1618,9 @@ impl<'a> Reader<'a> {
             &(self.data.mode_prompt_buff.clone() + &self.data.left_prompt_buff[..]),
             &self.data.right_prompt_buff,
             &full_line,
-            cmd_line.len(),
-            &colors,
-            &indents,
+            autosuggested_range,
+            colors,
+            indents,
             data.position,
             data.pager_search_field_position,
             self.parser.vars(),
@@ -1682,15 +1735,19 @@ impl ReaderData {
         // text avoid recomputing the autosuggestion.
         assert!(string_prefixes_string_maybe_case_insensitive(
             autosuggestion.icase,
-            &self.command_line.text(),
+            &self.command_line.text()[autosuggestion.search_string_range.clone()],
             &autosuggestion.text
         ));
+        let search_string_range = autosuggestion.search_string_range.clone();
 
         // This is a heuristic with false negatives but that seems fine.
-        let Some(remaining) = autosuggestion.text.get(edit.range.start..) else {
+        let Some(offset) = edit.range.start.checked_sub(search_string_range.start) else {
             return false;
         };
-        if edit.range.end != self.command_line.len()
+        let Some(remaining) = autosuggestion.text.get(offset..) else {
+            return false;
+        };
+        if edit.range.end != search_string_range.end
             || !string_prefixes_string_maybe_case_insensitive(
                 autosuggestion.icase,
                 &edit.replacement,
@@ -1700,6 +1757,9 @@ impl ReaderData {
         {
             return false;
         }
+        self.autosuggestion.search_string_range.end = search_string_range.end
+            - edit.range.len().min(search_string_range.end)
+            + edit.replacement.len();
         true
     }
 
@@ -2379,10 +2439,9 @@ impl<'a> Reader<'a> {
                 }
             }
             rl::EndOfLine => {
-                let (_elt, el) = self.active_edit_line();
-                if self.is_at_end(el) {
+                if self.is_at_autosuggestion() {
                     self.accept_autosuggestion(AutosuggestionPortion::Count(usize::MAX));
-                } else {
+                } else if !self.is_at_end() {
                     loop {
                         let position = {
                             let (_elt, el) = self.active_edit_line();
@@ -2860,7 +2919,7 @@ impl<'a> Reader<'a> {
             rl::HistoryPagerDelete => {
                 // Also applies to ordinary history search.
                 let is_history_search = !self.history_search.is_at_end();
-                if is_history_search || !self.autosuggestion.is_empty() {
+                if is_history_search || self.is_at_line_with_autosuggestion() {
                     self.history.remove(if is_history_search {
                         self.history_search.current_result()
                     } else {
@@ -2909,10 +2968,9 @@ impl<'a> Reader<'a> {
                 }
             }
             rl::ForwardChar | rl::ForwardSingleChar => {
-                let (elt, el) = self.active_edit_line();
                 if self.is_navigating_pager_contents() {
                     self.select_completion_in_direction(SelectionMotion::East, false);
-                } else if self.is_at_end(el) {
+                } else if self.is_at_autosuggestion() {
                     self.accept_autosuggestion(AutosuggestionPortion::Count(
                         if c == rl::ForwardSingleChar {
                             1
@@ -2920,13 +2978,14 @@ impl<'a> Reader<'a> {
                             usize::MAX
                         },
                     ));
-                } else {
+                } else if !self.is_at_end() {
+                    let (elt, el) = self.active_edit_line();
                     self.update_buff_pos(elt, Some(el.position() + 1));
                 }
             }
             rl::ForwardCharPassive => {
-                let (elt, el) = self.active_edit_line();
-                if !self.is_at_end(el) {
+                if !self.is_at_end() {
+                    let (elt, el) = self.active_edit_line();
                     if elt == EditableLineTag::SearchField || !self.is_navigating_pager_contents() {
                         self.update_buff_pos(elt, Some(el.position() + 1));
                     }
@@ -3016,15 +3075,16 @@ impl<'a> Reader<'a> {
                 );
             }
             rl::ForwardToken => {
-                let (_elt, el) = self.active_edit_line();
-                if self.is_at_end(el) {
+                if self.is_at_autosuggestion() {
                     let Some(new_position) = self.forward_token(true) else {
                         return;
                     };
+                    let (_elt, el) = self.active_edit_line();
+                    let search_string_range = range_of_line_at_cursor(el.text(), el.position());
                     self.accept_autosuggestion(AutosuggestionPortion::Count(
-                        new_position - el.len(),
+                        new_position - search_string_range.end,
                     ));
-                } else {
+                } else if !self.is_at_end() {
                     let Some(new_position) = self.forward_token(false) else {
                         return;
                     };
@@ -3068,10 +3128,10 @@ impl<'a> Reader<'a> {
                 } else {
                     MoveWordStyle::Whitespace
                 };
-                let (elt, el) = self.active_edit_line();
-                if self.is_at_end(el) {
+                if self.is_at_autosuggestion() {
                     self.accept_autosuggestion(AutosuggestionPortion::PerMoveWordStyle(style));
-                } else {
+                } else if !self.is_at_end() {
+                    let (elt, _el) = self.active_edit_line();
                     self.move_word(elt, MoveWordDir::Right, /*erase=*/ false, style, false);
                 }
             }
@@ -3165,14 +3225,16 @@ impl<'a> Reader<'a> {
             }
             rl::SuppressAutosuggestion => {
                 self.suppress_autosuggestion = true;
-                let success = !self.autosuggestion.is_empty();
+                let success = self.is_at_line_with_autosuggestion();
                 self.autosuggestion.clear();
                 // Return true if we had a suggestion to clear.
                 self.input_data.function_set_status(success);
             }
             rl::AcceptAutosuggestion => {
-                let success = !self.autosuggestion.is_empty();
-                self.accept_autosuggestion(AutosuggestionPortion::Count(usize::MAX));
+                let success = self.is_at_line_with_autosuggestion();
+                if success {
+                    self.accept_autosuggestion(AutosuggestionPortion::Count(usize::MAX));
+                }
                 self.input_data.function_set_status(success);
             }
             rl::TransposeChars => {
@@ -3312,7 +3374,7 @@ impl<'a> Reader<'a> {
                 let mut replacement = WString::new();
                 while pos
                     < if self.cursor_selection_mode == CursorSelectionMode::Inclusive
-                        && self.is_at_end(el)
+                        && self.is_at_end()
                     {
                         el.len()
                     } else {
@@ -3606,15 +3668,19 @@ impl<'a> Reader<'a> {
     }
 
     fn forward_token(&self, autosuggest: bool) -> Option<usize> {
-        let (_elt, el) = self.active_edit_line();
+        let (elt, el) = self.active_edit_line();
         let pos = el.position();
         let buffer = if autosuggest {
-            if pos > self.autosuggestion.text.len() {
-                return None;
-            }
-            &self.autosuggestion.text
+            assert!(elt == EditableLineTag::Commandline);
+            assert!(self.is_at_line_with_autosuggestion());
+            let autosuggestion = &self.autosuggestion;
+            Cow::Owned(combine_command_and_autosuggestion(
+                el.text(),
+                autosuggestion.search_string_range.clone(),
+                &autosuggestion.text,
+            ))
         } else {
-            el.text()
+            Cow::Borrowed(el.text())
         };
         if pos == buffer.len() {
             return None;
@@ -3628,7 +3694,7 @@ impl<'a> Reader<'a> {
                 .count();
 
         let mut tok = 0..0;
-        parse_util_token_extent(buffer, buff_pos, &mut tok, None);
+        parse_util_token_extent(&buffer, buff_pos, &mut tok, None);
 
         let new_position = if tok.end == pos { pos + 1 } else { tok.end };
 
@@ -4364,9 +4430,12 @@ impl<'a> Reader<'a> {
 
 #[derive(Default)]
 struct Autosuggestion {
-    // The text to use, as an extension/replacement of the command line.
+    // The text to use, as an extension/replacement of the current line.
     text: WString,
 
+    // The range within the commandline that was searched. Always a whole line.
+    search_string_range: Range<usize>,
+
     // Whether the autosuggestion should be case insensitive.
     // This is true for file-generated autosuggestions, but not for history.
     icase: bool,
@@ -4387,10 +4456,11 @@ impl Autosuggestion {
 /// The result of an autosuggestion computation.
 #[derive(Default)]
 struct AutosuggestionResult {
+    // The autosuggestion.
     autosuggestion: Autosuggestion,
 
-    // The string which was searched for.
-    search_string: WString,
+    // The commandline this result is based off.
+    command_line: WString,
 
     // The list of completions which may need loading.
     needs_load: Vec<WString>,
@@ -4404,20 +4474,34 @@ impl std::ops::Deref for AutosuggestionResult {
 }
 
 impl AutosuggestionResult {
-    fn new(text: WString, search_string: WString, icase: bool) -> Self {
+    fn new(
+        command_line: WString,
+        search_string_range: Range<usize>,
+        text: WString,
+        icase: bool,
+    ) -> Self {
         Self {
-            autosuggestion: Autosuggestion { text, icase },
-            search_string,
+            autosuggestion: Autosuggestion {
+                text,
+                search_string_range,
+                icase,
+            },
+            command_line,
             needs_load: vec![],
         }
     }
+
+    /// The line which was searched for.
+    fn search_string(&self) -> &wstr {
+        &self.command_line[self.search_string_range.clone()]
+    }
 }
 
 // Returns a function that can be invoked (potentially
 // on a background thread) to determine the autosuggestion
 fn get_autosuggestion_performer(
     parser: &Parser,
-    search_string: WString,
+    command_line: WString,
     cursor_pos: usize,
     history: Arc<History>,
 ) -> impl FnOnce() -> AutosuggestionResult {
@@ -4433,29 +4517,38 @@ fn get_autosuggestion_performer(
         }
 
         // Let's make sure we aren't using the empty string.
-        if search_string.is_empty() {
+        let search_string_range = range_of_line_at_cursor(&command_line, cursor_pos);
+        let search_string = &command_line[search_string_range.clone()];
+        let Some(last_char) = search_string.chars().next_back() else {
             return nothing;
-        }
+        };
 
-        // Search history for a matching item.
-        let mut searcher =
-            HistorySearch::new_with_type(history, search_string.to_owned(), SearchType::Prefix);
-        while !ctx.check_cancel() && searcher.go_to_next_match(SearchDirection::Backward) {
-            let item = searcher.current_item();
+        // Search history for a matching item unless this line is not a continuation line or quoted.
+        if range_of_line_at_cursor(
+            &command_line,
+            parse_util_process_extent(&command_line, cursor_pos, None).start,
+        ) == search_string_range
+        {
+            let mut searcher =
+                HistorySearch::new_with_type(history, search_string.to_owned(), SearchType::Prefix);
+            while !ctx.check_cancel() && searcher.go_to_next_match(SearchDirection::Backward) {
+                let item = searcher.current_item();
 
-            // Skip items with newlines because they make terrible autosuggestions.
-            if item.str().contains('\n') {
-                continue;
-            }
+                // Skip items with newlines because they make terrible autosuggestions.
+                if item.str().contains('\n') {
+                    continue;
+                }
 
-            if autosuggest_validate_from_history(item, &working_directory, &ctx) {
-                // The command autosuggestion was handled specially, so we're done.
-                // History items are case-sensitive, see #3978.
-                return AutosuggestionResult::new(
-                    searcher.current_string().to_owned(),
-                    search_string.to_owned(),
-                    /*icase=*/ false,
-                );
+                if autosuggest_validate_from_history(item, &working_directory, &ctx) {
+                    // The command autosuggestion was handled specially, so we're done.
+                    // History items are case-sensitive, see #3978.
+                    return AutosuggestionResult::new(
+                        command_line,
+                        search_string_range,
+                        searcher.current_string().to_owned(),
+                        /*icase=*/ false,
+                    );
+                }
             }
         }
 
@@ -4467,8 +4560,8 @@ fn get_autosuggestion_performer(
         // Here we do something a little funny. If the line ends with a space, and the cursor is not
         // at the end, don't use completion autosuggestions. It ends up being pretty weird seeing
         // stuff get spammed on the right while you go back to edit a line
-        let last_char = search_string.chars().next_back().unwrap();
-        let cursor_at_end = cursor_pos == search_string.len();
+        let cursor_at_end =
+            cursor_pos == command_line.len() || command_line.as_char_slice()[cursor_pos] == '\n';
         if !cursor_at_end && last_char.is_whitespace() {
             return nothing;
         }
@@ -4480,25 +4573,28 @@ fn get_autosuggestion_performer(
 
         // Try normal completions.
         let complete_flags = CompletionRequestOptions::autosuggest();
-        let (mut completions, needs_load) = complete(&search_string, complete_flags, &ctx);
+        let (mut completions, needs_load) =
+            complete(&command_line[..cursor_pos], complete_flags, &ctx);
 
-        let full_line = if completions.is_empty() {
+        let suggestion = if completions.is_empty() {
             WString::new()
         } else {
             sort_and_prioritize(&mut completions, complete_flags);
             let comp = &completions[0];
             let mut cursor = cursor_pos;
-            completion_apply_to_command_line(
+            let full_line = completion_apply_to_command_line(
                 &comp.completion,
                 comp.flags,
-                &search_string,
+                &command_line,
                 &mut cursor,
                 /*append_only=*/ true,
-            )
+            );
+            line_at_cursor(&full_line, search_string_range.end).to_owned()
         };
         let mut result = AutosuggestionResult::new(
-            full_line,
-            search_string.to_owned(),
+            command_line,
+            search_string_range.clone(),
+            suggestion,
             true, // normal completions are case-insensitive
         );
         result.needs_load = needs_load;
@@ -4529,10 +4625,10 @@ impl<'a> Reader<'a> {
     // Called after an autosuggestion has been computed on a background thread.
     fn autosuggest_completed(&mut self, result: AutosuggestionResult) {
         assert_is_main_thread();
-        if result.search_string == self.data.in_flight_autosuggest_request {
+        if result.command_line == self.data.in_flight_autosuggest_request {
             self.data.in_flight_autosuggest_request.clear();
         }
-        if result.search_string != self.command_line.text() {
+        if result.command_line != self.command_line.text() {
             // This autosuggestion is stale.
             return;
         }
@@ -4556,7 +4652,7 @@ impl<'a> Reader<'a> {
             && self.can_autosuggest()
             && string_prefixes_string_maybe_case_insensitive(
                 result.icase,
-                &result.search_string,
+                result.search_string(),
                 &result.text,
             )
         {
@@ -4578,10 +4674,10 @@ impl<'a> Reader<'a> {
 
         let el = &self.data.command_line;
         let autosuggestion = &self.autosuggestion;
-        if !self.autosuggestion.is_empty() {
+        if self.is_at_line_with_autosuggestion() {
             assert!(string_prefixes_string_maybe_case_insensitive(
                 autosuggestion.icase,
-                &el.text(),
+                &el.text()[autosuggestion.search_string_range.clone()],
                 &autosuggestion.text
             ));
             return;
@@ -4612,52 +4708,87 @@ impl<'a> Reader<'a> {
         debounce_autosuggestions().perform_with_completion(performer, completion);
     }
 
-    fn is_at_end(&self, el: &EditableLine) -> bool {
+    fn is_at_end(&self) -> bool {
+        let (_elt, el) = self.active_edit_line();
         match self.cursor_end_mode {
             CursorEndMode::Exclusive => el.position() == el.len(),
             CursorEndMode::Inclusive => el.position() + 1 >= el.len(),
         }
     }
 
+    fn is_at_autosuggestion(&self) -> bool {
+        if self.active_edit_line_tag() != EditableLineTag::Commandline {
+            return false;
+        }
+        let autosuggestion = &self.autosuggestion;
+        if autosuggestion.is_empty() {
+            return false;
+        }
+        let el = &self.command_line;
+        (match self.cursor_end_mode {
+            CursorEndMode::Exclusive => el.position(),
+            CursorEndMode::Inclusive => el.position() + 1,
+        }) == autosuggestion.search_string_range.end
+    }
+
+    fn is_at_line_with_autosuggestion(&self) -> bool {
+        if self.active_edit_line_tag() != EditableLineTag::Commandline {
+            return false;
+        }
+        let autosuggestion = &self.autosuggestion;
+        if autosuggestion.is_empty() {
+            return false;
+        }
+        let el = &self.command_line;
+        range_of_line_at_cursor(el.text(), el.position()) == autosuggestion.search_string_range
+    }
+
     // Accept any autosuggestion by replacing the command line with it. If full is true, take the whole
     // thing; if it's false, then respect the passed in style.
     fn accept_autosuggestion(&mut self, amount: AutosuggestionPortion) {
-        if self.autosuggestion.is_empty() {
-            return;
-        }
+        assert!(self.is_at_line_with_autosuggestion());
+
         // Accepting an autosuggestion clears the pager.
         self.clear_pager();
 
+        let autosuggestion = &self.autosuggestion;
+        let autosuggestion_text = &autosuggestion.text;
+        let search_string_range = autosuggestion.search_string_range.clone();
         // Accept the autosuggestion.
         let (range, replacement) = match amount {
             AutosuggestionPortion::Count(count) => {
-                let pos = self.command_line.len();
                 if count == usize::MAX {
-                    (0..self.command_line.len(), self.autosuggestion.text.clone())
+                    (search_string_range, autosuggestion_text.clone())
                 } else {
-                    let count = count.min(self.autosuggestion.text.len() - pos);
+                    let pos = search_string_range.end;
+                    let available = autosuggestion_text.len() - search_string_range.len();
+                    let count = count.min(available);
                     if count == 0 {
                         return;
                     }
+                    let start = autosuggestion_text.len() - available;
                     (
                         pos..pos,
-                        self.autosuggestion.text[pos..pos + count].to_owned(),
+                        autosuggestion_text[start..start + count].to_owned(),
                     )
                 }
             }
             AutosuggestionPortion::PerMoveWordStyle(style) => {
                 // Accept characters according to the specified style.
                 let mut state = MoveWordStateMachine::new(style);
-                let mut want = self.command_line.len();
-                while want < self.autosuggestion.text.len() {
-                    let wc = self.autosuggestion.text.as_char_slice()[want];
+                let have = search_string_range.len();
+                let mut want = have;
+                while want < autosuggestion_text.len() {
+                    let wc = autosuggestion_text.as_char_slice()[want];
                     if !state.consume_char(wc) {
                         break;
                     }
                     want += 1;
                 }
-                let have = self.command_line.len();
-                (have..have, self.autosuggestion.text[have..want].to_owned())
+                (
+                    search_string_range.end..search_string_range.end,
+                    autosuggestion_text[have..want].to_owned(),
+                )
             }
         };
         self.data
diff --git a/src/screen.rs b/src/screen.rs
index 17301e37b..638b2949f 100644
--- a/src/screen.rs
+++ b/src/screen.rs
@@ -15,6 +15,7 @@ use std::cell::RefCell;
 use std::collections::LinkedList;
 use std::ffi::{CStr, CString};
 use std::io::Write;
+use std::ops::Range;
 use std::sync::atomic::{AtomicU32, Ordering};
 use std::sync::Mutex;
 use std::time::SystemTime;
@@ -255,9 +256,9 @@ impl Screen {
         left_prompt: &wstr,
         right_prompt: &wstr,
         commandline: &wstr,
-        explicit_len: usize,
-        colors: &[HighlightSpec],
-        indent: &[i32],
+        autosuggested_range: Range<usize>,
+        mut colors: Vec<HighlightSpec>,
+        mut indent: Vec<i32>,
         cursor_pos: usize,
         pager_search_field_position: Option<usize>,
         vars: &dyn Environment,
@@ -282,17 +283,19 @@ impl Screen {
         let mut scrolled_cursor: Option<ScrolledCursor> = None;
 
         // Turn the command line into the explicit portion and the autosuggestion.
-        let (explicit_command_line, autosuggestion) = commandline.split_at(explicit_len);
+        let explicit_before_suggestion = &commandline[..autosuggested_range.start];
+        let autosuggestion = &commandline[autosuggested_range.clone()];
+        let explicit_after_suggestion = &commandline[autosuggested_range.end..];
 
         // If we are using a dumb terminal, don't try any fancy stuff, just print out the text.
         // right_prompt not supported.
         if is_dumb() {
             let prompt_narrow = wcs2string(left_prompt);
-            let command_line_narrow = wcs2string(explicit_command_line);
 
             let _ = write_loop(&STDOUT_FILENO, b"\r");
             let _ = write_loop(&STDOUT_FILENO, &prompt_narrow);
-            let _ = write_loop(&STDOUT_FILENO, &command_line_narrow);
+            let _ = write_loop(&STDOUT_FILENO, &wcs2string(explicit_before_suggestion));
+            let _ = write_loop(&STDOUT_FILENO, &wcs2string(explicit_after_suggestion));
 
             return;
         }
@@ -311,10 +314,13 @@ impl Screen {
 
         // Compute a layout.
         let layout = compute_layout(
+            get_ellipsis_char(),
             screen_width,
             left_prompt,
             right_prompt,
-            explicit_command_line,
+            explicit_before_suggestion,
+            &mut colors,
+            &mut indent,
             autosuggestion,
         );
 
@@ -345,7 +351,9 @@ impl Screen {
         let first_line_prompt_space = layout.left_prompt_space;
 
         // Reconstruct the command line.
-        let effective_commandline = explicit_command_line.to_owned() + &layout.autosuggestion[..];
+        let effective_commandline = explicit_before_suggestion.to_owned()
+            + &layout.autosuggestion[..]
+            + explicit_after_suggestion;
 
         // Output the command line.
         let mut i = 0;
@@ -375,7 +383,12 @@ impl Screen {
                 break scrolled_cursor.unwrap();
             }
             if !self.desired_append_char(
-                /*offset_in_cmdline=*/ i,
+                /*offset_in_cmdline=*/
+                if i <= explicit_before_suggestion.len() + layout.autosuggestion.len() {
+                    i.min(explicit_before_suggestion.len())
+                } else {
+                    i - layout.autosuggestion.len()
+                },
                 if is_final_rendering {
                     usize::MAX
                 } else {
@@ -1835,16 +1848,17 @@ fn is_dumb() -> bool {
     })
 }
 
-#[derive(Default)]
-struct ScreenLayout {
+// Exposed for testing.
+#[derive(Debug, Default, Eq, PartialEq)]
+pub(crate) struct ScreenLayout {
     // The left prompt that we're going to use.
-    left_prompt: WString,
+    pub(crate) left_prompt: WString,
     // How much space to leave for it.
-    left_prompt_space: usize,
+    pub(crate) left_prompt_space: usize,
     // The right prompt.
-    right_prompt: WString,
+    pub(crate) right_prompt: WString,
     // The autosuggestion.
-    autosuggestion: WString,
+    pub(crate) autosuggestion: WString,
 }
 
 // Given a vector whose indexes are offsets and whose values are the widths of the string if
@@ -1864,11 +1878,15 @@ fn truncation_offset_for_width(width_by_offset: &[usize], max_width: usize) -> u
     i - 1
 }
 
-fn compute_layout(
+// Exposed for testing.
+pub(crate) fn compute_layout(
+    ellipsis_char: char,
     screen_width: usize,
     left_untrunc_prompt: &wstr,
     right_untrunc_prompt: &wstr,
-    commandline: &wstr,
+    commandline_before_suggestion: &wstr,
+    colors: &mut Vec<HighlightSpec>,
+    indent: &mut Vec<i32>,
     autosuggestion_str: &wstr,
 ) -> ScreenLayout {
     let mut result = ScreenLayout::default();
@@ -1901,24 +1919,23 @@ fn compute_layout(
     assert!(left_prompt_width + right_prompt_width <= screen_width);
 
     // Get the width of the first line, and if there is more than one line.
-    let multiline = commandline.contains('\n');
-    let first_command_line_width: usize = line_at_cursor(commandline, 0)
+    let first_command_line_width: usize = line_at_cursor(commandline_before_suggestion, 0)
         .chars()
         .map(wcwidth_rendered_min_0)
         .sum();
+    let autosuggestion_line_explicit_width: usize = line_at_cursor(
+        commandline_before_suggestion,
+        commandline_before_suggestion.len(),
+    )
+    .chars()
+    .map(wcwidth_rendered_min_0)
+    .sum();
 
-    // If we have more than one line, ensure we have no autosuggestion.
-    let mut autosuggestion = autosuggestion_str;
     let mut autosuggest_total_width = 0;
-    let mut autosuggest_truncated_widths = vec![];
-    if multiline {
-        autosuggestion = L!("");
-    } else {
-        autosuggest_truncated_widths.reserve(1 + autosuggestion_str.len());
-        for c in autosuggestion_str.chars() {
-            autosuggest_truncated_widths.push(autosuggest_total_width);
-            autosuggest_total_width += wcwidth_rendered_min_0(c);
-        }
+    let mut autosuggest_truncated_widths = Vec::with_capacity(autosuggestion_str.len());
+    for c in autosuggestion_str.chars() {
+        autosuggest_truncated_widths.push(autosuggest_total_width);
+        autosuggest_total_width += wcwidth_rendered_min_0(c);
     }
 
     // Here are the layouts we try in turn:
@@ -1940,21 +1957,35 @@ fn compute_layout(
     // prompt will wrap to the next line. This means that we can't go back to the line that we were
     // on, and things turn to chaos very quickly.
 
-    let truncated_autosuggestion = |right_prompt_width: usize| {
-        let width = left_prompt_width + right_prompt_width + first_command_line_width;
+    let mut truncated_autosuggestion = |indent: &mut Vec<i32>, right_prompt_width: usize| {
+        let width = if let Some(pos) = commandline_before_suggestion
+            .chars()
+            .rposition(|c| c == '\n')
+        {
+            left_prompt_width
+                + usize::try_from(indent[pos]).unwrap() * INDENT_STEP
+                + autosuggestion_line_explicit_width
+        } else {
+            left_prompt_width + right_prompt_width + first_command_line_width
+        };
         // Need at least two characters to show an autosuggestion.
-        let available_autosuggest_space = screen_width - width;
+        let available_autosuggest_space = screen_width.saturating_sub(width);
         let mut result = WString::new();
         if available_autosuggest_space > autosuggest_total_width {
-            result = autosuggestion.to_owned();
+            result = autosuggestion_str.to_owned();
         } else if autosuggest_total_width > 0 && available_autosuggest_space > 2 {
             let truncation_offset = truncation_offset_for_width(
                 &autosuggest_truncated_widths,
                 available_autosuggest_space - 2,
             );
-            result = autosuggestion[..truncation_offset].to_owned();
-            result.push(get_ellipsis_char());
+            result = autosuggestion_str[..truncation_offset].to_owned();
+            result.push(ellipsis_char);
         }
+        let suggestion_start = commandline_before_suggestion.len();
+        let truncation_range =
+            suggestion_start + result.len()..suggestion_start + autosuggestion_str.len();
+        colors.drain(truncation_range.clone());
+        indent.drain(truncation_range);
         result
     };
 
@@ -1965,7 +1996,7 @@ fn compute_layout(
         result.left_prompt = left_prompt;
         result.left_prompt_space = left_prompt_width;
         result.right_prompt = right_prompt;
-        result.autosuggestion = truncated_autosuggestion(right_prompt_width);
+        result.autosuggestion = truncated_autosuggestion(indent, right_prompt_width);
         return result;
     }
 
@@ -1974,14 +2005,14 @@ fn compute_layout(
     if calculated_width <= screen_width {
         result.left_prompt = left_prompt;
         result.left_prompt_space = left_prompt_width;
-        result.autosuggestion = truncated_autosuggestion(0);
+        result.autosuggestion = truncated_autosuggestion(indent, 0);
         return result;
     }
 
     // Case 5
     result.left_prompt = left_prompt;
     result.left_prompt_space = left_prompt_width;
-    result.autosuggestion = autosuggestion.to_owned();
+    result.autosuggestion = autosuggestion_str.to_owned();
     result
 }
 
diff --git a/src/tests/reader.rs b/src/tests/reader.rs
index 2b0450a91..ffd3a7a4c 100644
--- a/src/tests/reader.rs
+++ b/src/tests/reader.rs
@@ -5,30 +5,43 @@ use crate::wchar::prelude::*;
 #[test]
 fn test_autosuggestion_combining() {
     assert_eq!(
-        combine_command_and_autosuggestion(L!("alpha"), L!("alphabeta")),
+        combine_command_and_autosuggestion(L!("alpha"), 0..5, L!("alphabeta")),
         L!("alphabeta")
     );
 
     // When the last token contains no capital letters, we use the case of the autosuggestion.
     assert_eq!(
-        combine_command_and_autosuggestion(L!("alpha"), L!("ALPHABETA")),
+        combine_command_and_autosuggestion(L!("alpha"), 0..5, L!("ALPHABETA")),
         L!("ALPHABETA")
     );
 
     // When the last token contains capital letters, we use its case.
     assert_eq!(
-        combine_command_and_autosuggestion(L!("alPha"), L!("alphabeTa")),
+        combine_command_and_autosuggestion(L!("alPha"), 0..5, L!("alphabeTa")),
         L!("alPhabeTa")
     );
 
     // If autosuggestion is not longer than input, use the input's case.
     assert_eq!(
-        combine_command_and_autosuggestion(L!("alpha"), L!("ALPHAA")),
+        combine_command_and_autosuggestion(L!("alpha"), 0..5, L!("ALPHAA")),
         L!("ALPHAA")
     );
     assert_eq!(
-        combine_command_and_autosuggestion(L!("alpha"), L!("ALPHA")),
-        L!("alpha")
+        combine_command_and_autosuggestion(L!("alpha"), 0..5, L!("ALPHA")),
+        L!("ALPHA")
+    );
+
+    assert_eq!(
+        combine_command_and_autosuggestion(L!("al\nbeta"), 0..2, L!("alpha")),
+        L!("alpha\nbeta").to_owned()
+    );
+    assert_eq!(
+        combine_command_and_autosuggestion(L!("alpha\nbe"), 6..8, L!("beta")),
+        L!("alpha\nbeta").to_owned()
+    );
+    assert_eq!(
+        combine_command_and_autosuggestion(L!("alpha\nbe\ngamma"), 6..8, L!("beta")),
+        L!("alpha\nbeta\ngamma").to_owned()
     );
 }
 
diff --git a/src/tests/screen.rs b/src/tests/screen.rs
index 5866180d8..5ed37e3bd 100644
--- a/src/tests/screen.rs
+++ b/src/tests/screen.rs
@@ -1,5 +1,7 @@
 use crate::common::get_ellipsis_char;
-use crate::screen::{LayoutCache, PromptCacheEntry, PromptLayout};
+use crate::highlight::HighlightSpec;
+use crate::parse_util::parse_util_compute_indents;
+use crate::screen::{compute_layout, LayoutCache, PromptCacheEntry, PromptLayout, ScreenLayout};
 use crate::tests::prelude::*;
 use crate::wchar::prelude::*;
 use crate::wcstringutil::join_strings;
@@ -245,3 +247,112 @@ fn test_prompt_truncation() {
     );
     assert_eq!(trunc, ellipsis());
 }
+
+#[test]
+fn test_compute_layout() {
+    macro_rules! validate {
+        (
+            (
+                $screen_width:expr,
+                $left_untrunc_prompt:literal,
+                $right_untrunc_prompt:literal,
+                $commandline_before_suggestion:literal,
+                $autosuggestion_str:literal,
+                $commandline_after_suggestion:literal
+            )
+            -> (
+                $left_prompt:literal,
+                $left_prompt_space:expr,
+                $right_prompt:literal,
+                $autosuggestion:literal $(,)?
+            )
+        ) => {{
+            let full_commandline = L!($commandline_before_suggestion).to_owned()
+                + L!($autosuggestion_str)
+                + L!($commandline_after_suggestion);
+            let mut colors = vec![HighlightSpec::default(); full_commandline.len()];
+            let mut indent = parse_util_compute_indents(&full_commandline);
+            assert_eq!(
+                compute_layout(
+                    '…',
+                    $screen_width,
+                    L!($left_untrunc_prompt),
+                    L!($right_untrunc_prompt),
+                    L!($commandline_before_suggestion),
+                    &mut colors,
+                    &mut indent,
+                    L!($autosuggestion_str),
+                ),
+                ScreenLayout {
+                    left_prompt: L!($left_prompt).to_owned(),
+                    left_prompt_space: $left_prompt_space,
+                    right_prompt: L!($right_prompt).to_owned(),
+                    autosuggestion: L!($autosuggestion).to_owned(),
+                }
+            );
+            indent
+        }};
+    }
+
+    validate!(
+        (
+            80, "left>", "<right", "command", " autosuggestion", ""
+        ) -> (
+            "left>",
+            5,
+            "<right",
+            " autosuggestion",
+        )
+    );
+    validate!(
+        (
+            30, "left>", "<right", "command", " autosuggestion", ""
+        ) -> (
+            "left>",
+            5,
+            "<right",
+            " autosugge…",
+        )
+    );
+    validate!(
+        (
+            30, "left>", "<right", "foo\ncommand", " autosuggestion", ""
+        ) -> (
+            "left>",
+            5,
+            "<right",
+            " autosuggestion",
+        )
+    );
+    validate!(
+        (
+            30, "left>", "<right", "foo\ncommand", " autosuggestion TRUNCATED", ""
+        ) -> (
+            "left>",
+            5,
+            "<right",
+            " autosuggestion …",
+        )
+    );
+    validate!(
+        (
+            30, "left>", "<right", "if :\ncommand", " autosuggestion TRUNCATED", ""
+        ) -> (
+            "left>",
+            5,
+            "<right",
+            " autosuggest…",
+        )
+    );
+    let indent = validate!(
+        (
+            30, "left>", "<right", "if :\ncommand", " autosuggestion TRUNCATED", "\nfoo"
+        ) -> (
+            "left>",
+            5,
+            "<right",
+            " autosuggest…",
+        )
+    );
+    assert_eq!(indent["if :\ncommand autosuggest…\n".len()], 1);
+}