diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index 43cbb34..33b514a 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -92,7 +92,7 @@ sidebar, insets + rounds the content area, and re-homes the toolbar off-screen. |---|---------|--------------------|------|----------------------| | ☐ | Cmd+T in sidebar window shows command bar instead of blank tab | `chrome/browser/ui/browser_commands.cc.patch` (`NewTab()` hook) | 🔴 | Cmd+T shows command bar; programmatic/restore new-tabs still create real tabs | | ☐ | Cmd+L pre-fills current URL | `views/frame/browser_view.cc.patch` (`SetFocusToLocationBar` redirect) | 🔴 | Cmd+L opens command bar with URL | -| ☐ | Command bar UI + suggestions + Ask AI | `src/dao/.../dao_command_bar_view.*`, `dao_suggestion_item_view.*` | `DaoCommandBarBrowserTest.RightArrowFillsExplicitlySelectedSuggestion`, `DaoCommandBarBrowserTest.ReservesExactSearchWhenNonSearchMatchesFillVisibleSlots`, `DaoCommandBarBrowserTest.ExactSearchTabMatchDoesNotReplaceReservedSearchAction`, `DaoCommandBarBrowserTest.EmptyInputShowsNoSuggestionsInBothModes`, `DaoCommandBarBrowserTest.WhitespaceOnlyInputShowsNoSuggestions` | Arrow-key select; Right Arrow fills the explicitly selected suggestion without navigating; Tab-complete; Esc dismiss; Ask AI routes to agent and remains second when eligible. Fill all competing suggestion slots and verify one exact-input Search action remains visible, including for URL-like input, and submits through the default search provider. Empty and whitespace-only input must show zero suggestions in both modes. | +| ☐ | Command bar UI + suggestions + Ask AI | `src/dao/.../dao_command_bar_view.*`, `dao_suggestion_item_view.*` | `DaoCommandBarBrowserTest.*Preview*`, `DaoCommandBarBrowserTest.*AutoHighlighted*`, `DaoCommandBarBrowserTest.InlineCompletion*`, `DaoCommandBarBrowserTest.TypingReplacesSelectedInlineCompletionSuffix`, `DaoCommandBarBrowserTest.ReservesExactSearchWhenNonSearchMatchesFillVisibleSlots`, `DaoCommandBarBrowserTest.ExactSearchTabMatchDoesNotReplaceReservedSearchAction`, `DaoCommandBarBrowserTest.EmptyInputShowsNoSuggestionsInBothModes`, `DaoCommandBarBrowserTest.WhitespaceOnlyInputShowsNoSuggestions` | The first row is highlighted automatically without writing its `fill_into_edit` value into the native textfield, including after deleting text and continuing to type. A provider-owned inline completion is different: render the typed prefix plus its selected suffix in the textfield, keep the logical query separate, keep the typed prefix and caret boundary visible when the suffix overflows, and verify Select All, copy, and replacement edits include the completed text. Arrow keys explicitly browse and preview without restarting autocomplete; typing replaces a selected suffix; Backspace rejects an active preview once without deleting or immediately restoring it; the rejected suggestion remains suppressed during subsequent typing while other results remain highlight-only until the user acts. Right Arrow and Tab accept the highlighted or previewed row without navigating; Enter submits the visible selected action; Esc dismisses. Exact-input Search and Ask AI keep the original query visible, async same-row replacements refresh an explicit preview, and inline completion stays hidden while a preview is active. Ask AI remains second when eligible. Fill all competing suggestion slots and verify one exact-input Search action remains visible, including for URL-like input, and submits through the default search provider. Empty and whitespace-only input must show zero suggestions in both modes. | ## 3. AI Agent System diff --git a/docs/features.md b/docs/features.md index 528ffb9..6a0b1e6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -76,12 +76,25 @@ An Arc-inspired vertical sidebar replaces Chromium's top tab strip — the singl - Cmd+L → `Show()` pre-fills current URL (`SetFocusToLocationBar(is_user_initiated=true)`) - Cmd+T → `ShowForNewTab(prev)` opens blank tab, remembers previous tab; Esc / click-outside calls `CancelNewTab()` to close the blank and return - **Ask AI** — Submits prompt directly to the Agent - - URL-vs-query detection heuristics + ghost-text completion + - URL-vs-query detection heuristics + provider inline completion when no + selection preview is active; the native textfield contains the typed prefix + plus a selected completion suffix, so Select All, copy, and replacement + edits include the completed text, while long suffixes keep the typed prefix + and caret boundary visible - Every non-blank query reserves one exact-input Search action within the five-row suggestion limit, even when history, tabs, bookmarks, or URL matches rank above it; empty and whitespace-only input shows no suggestions - - Keyboard-first: arrow keys to select, Right Arrow to fill the selected - suggestion into the input, Tab to complete, Esc to dismiss + - The first result is highlighted automatically without writing that row's + `fill_into_edit` value into the native textfield; provider-owned inline + completion remains a distinct selected suffix, and only explicit arrow-key + browsing previews a row while the original query remains separate and + autocomplete is not restarted + - Keyboard-first: Backspace rejects a non-identical preview without deleting + the query; that exact rejected suggestion stays suppressed while the user + continues typing, and later provider results remain highlight-only until the + user browses or accepts them; Right Arrow or Tab accepts the highlighted or + previewed row without navigating, Enter submits the selected result, and + Esc dismisses - **DaoSuggestionItemView** (`dao_suggestion_item_view.{h,cc}`) — Suggestion row - **DaoNewTabButton** also routes through `ShowForNewTab()` with the recorded previous index diff --git a/src/dao/browser/ui/views/dao_browser_browsertest.cc b/src/dao/browser/ui/views/dao_browser_browsertest.cc index 4bc0716..caad916 100644 --- a/src/dao/browser/ui/views/dao_browser_browsertest.cc +++ b/src/dao/browser/ui/views/dao_browser_browsertest.cc @@ -188,6 +188,8 @@ #include "ui/events/test/event_generator.h" #include "ui/gfx/image/image.h" #include "ui/gfx/image/image_skia_rep.h" +#include "ui/gfx/range/range.h" +#include "ui/gfx/text_utils.h" #include "ui/native_theme/native_theme.h" #include "ui/views/controls/button/checkbox.h" #include "ui/views/controls/button/image_button.h" @@ -2986,7 +2988,93 @@ IN_PROC_BROWSER_TEST_F(DaoAddressBarBrowserTest, // DaoCommandBarBrowserTest // ============================================================================= -class DaoCommandBarBrowserTest : public InProcessBrowserTest {}; +class DaoCommandBarBrowserTest : public InProcessBrowserTest { + protected: + bool LoadAgentWebUI() { + agent_sidebar_ = GetBrowserView(browser())->dao_agent_sidebar(); + if (!agent_sidebar_) { + ADD_FAILURE() << "Agent sidebar is unavailable"; + return false; + } + + auto* web_view = + FindDescendantViewOfClass(agent_sidebar_); + if (!web_view) { + ADD_FAILURE() << "Agent WebView is unavailable"; + return false; + } + agent_contents_ = web_view->GetWebContents(); + if (!agent_contents_) { + ADD_FAILURE() << "Agent WebContents is unavailable"; + return false; + } + + const GURL agent_url("chrome://agent/"); + if (agent_contents_->GetLastCommittedURL() != agent_url) { + content::TestNavigationObserver observer(agent_contents_); + agent_sidebar_->Toggle(); + observer.Wait(); + } else if (!agent_sidebar_->is_expanded()) { + agent_sidebar_->Toggle(); + } + if (!content::WaitForLoadStop(agent_contents_)) { + ADD_FAILURE() << "Agent WebUI did not finish loading"; + return false; + } + + return content::EvalJs(agent_contents_, R"( + (async () => { + await customElements.whenDefined('dao-agent-app'); + return true; + })() + )").ExtractBool(); + } + + bool InstallExternalSubmitRecorder() { + if (!agent_contents_) { + return false; + } + return content::ExecJs(agent_contents_, R"( + (() => { + window.__daoExternalActions = []; + window.__daoExternalSubmit = (value, options) => { + window.__daoExternalActions.push({ + kind: 'submit', + value, + includePageContext: options?.includePageContext, + }); + }; + window.__daoWaitForExternalAction = () => new Promise(resolve => { + const deadline = Date.now() + 7000; + const poll = () => { + if (window.__daoExternalActions.length > 0) { + resolve(true); + return; + } + if (Date.now() >= deadline) { + resolve(false); + return; + } + setTimeout(poll, 10); + }; + poll(); + }); + })(); + )"); + } + + bool WaitForExternalAction() { + if (!agent_contents_) { + return false; + } + return content::EvalJs(agent_contents_, + "window.__daoWaitForExternalAction()") + .ExtractBool(); + } + + raw_ptr agent_sidebar_ = nullptr; + raw_ptr agent_contents_ = nullptr; +}; IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, CommandBarInitiallyHidden) { DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); @@ -3006,7 +3094,7 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, CommandBarShowAndHide) { } IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, - InputAndInlineCompletionUseSeventeenPointText) { + InlineCompletionUsesNativeTextfieldSelection) { DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); ASSERT_NE(nullptr, command_bar); @@ -3016,10 +3104,96 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, auto* textfield = FindDescendantViewOfClass(command_bar); ASSERT_NE(nullptr, textfield); EXPECT_EQ(17, textfield->GetFontList().GetFontSize()); + EXPECT_EQ(u"dao.com", textfield->GetText()); + EXPECT_EQ(gfx::Range(7, 3), textfield->GetSelectedRange()); + EXPECT_EQ(u".com", textfield->GetSelectedText()); +} + +IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, + InlineCompletionSelectAllIncludesCompletedText) { + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"dao", u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.inline_autocompletion = u".com"; + match.fill_into_edit = u"dao.com"; + match.contents = u"dao.com"; + match.destination_url = GURL("https://dao.com/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}, true); - auto* ghost_label = FindDescendantLabelWithText(command_bar, u".com"); - ASSERT_NE(nullptr, ghost_label); - EXPECT_EQ(17, ghost_label->font_list().GetFontSize()); + auto* textfield = FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + textfield->SelectAll(false); + + // A repeated async provider result must not reset the user's full selection + // back to only the inline suffix. + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}, true); + EXPECT_EQ(u"dao.com", textfield->GetSelectedText()); + textfield->InsertOrReplaceText(u"replacement"); + EXPECT_EQ(u"replacement", textfield->GetText()); + EXPECT_EQ(u"replacement", command_bar->GetUserInputTextForTesting()); + EXPECT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); +} + +IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, + InlineCompletionKeepsTypedPrefixVisible) { + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + + command_bar->ShowForNewTab(); + const std::u16string user_input = u"githu"; + command_bar->SetUserInputAndInlineAutocompletionForTesting(user_input, u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.inline_autocompletion = + u"b.com/chengzeli7/Bartender/tree/master/app/src/main/java/com/example/" + u"commandbar/VeryLongCompletion.java"; + match.fill_into_edit = user_input + match.inline_autocompletion; + match.contents = match.fill_into_edit; + match.destination_url = GURL("https://github.com/chengzeli7/Bartender/tree/" + "master/app/src/main/java/com/example/" + "commandbar/VeryLongCompletion.java"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}, true); + + auto* textfield = FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + ASSERT_LT(textfield->width(), + gfx::GetStringWidth(textfield->GetText(), + textfield->GetFontList())); + + const int display_left = + textfield->GetBoundsInScreen().x() + textfield->GetInsets().left(); + const int typed_prefix_width = + gfx::GetStringWidth(user_input, textfield->GetFontList()); + EXPECT_GE(textfield->GetCaretBounds().x(), + display_left + typed_prefix_width - 1); + EXPECT_EQ(gfx::Range(textfield->GetText().length(), user_input.length()), + textfield->GetSelectedRange()); +} + +IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, + TypingReplacesSelectedInlineCompletionSuffix) { + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"go", + u"ogle.com"); + + auto* textfield = FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + textfield->InsertOrReplaceText(u"x"); + + EXPECT_EQ(u"gox", textfield->GetText()); + EXPECT_EQ(u"gox", command_bar->GetUserInputTextForTesting()); + EXPECT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); } IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, ShowIsIdempotent) { @@ -3272,12 +3446,15 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, command_bar->SetUserInputAndInlineAutocompletionForTesting(u"git", u""); command_bar->SetAutocompleteMatchesForTesting(ACMatches{history_match}); ASSERT_GT(command_bar->GetVisibleSuggestionCountForTesting(), 0); + ASSERT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); command_bar->ContentsChanged(nullptr, u""); EXPECT_EQ(0, command_bar->GetVisibleSuggestionCountForTesting()); EXPECT_EQ(-1, command_bar->GetSelectedIndexForTesting()); EXPECT_EQ(-1, command_bar->GetAskAiRowIndexForTesting()); + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_TRUE(command_bar->GetSelectionPreviewTextForTesting().empty()); EXPECT_FALSE( HasVisibleDescendantLabelText(command_bar, u"github.com")); command_bar->Hide(); @@ -3585,8 +3762,9 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, EXPECT_EQ(target_url, contents->GetLastCommittedURL()); } -IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, - RightArrowFillsExplicitlySelectedSuggestion) { +IN_PROC_BROWSER_TEST_F( + DaoCommandBarBrowserTest, + SelectionPreviewArrowsAndRightAcceptanceDoNotRestartOrNavigate) { browser()->profile()->GetPrefs()->SetBoolean(dao::prefs::kDaoAskAiEnabled, false); @@ -3623,20 +3801,469 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, views::Textfield* textfield = FindDescendantViewOfClass(command_bar); ASSERT_NE(nullptr, textfield); + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_EQ(u"git", command_bar->GetUserInputTextForTesting()); + EXPECT_TRUE(command_bar->GetSelectionPreviewTextForTesting().empty()); + EXPECT_EQ(u"github.com", textfield->GetText()); + EXPECT_EQ(gfx::Range(10, 3), textfield->GetSelectedRange()); + EXPECT_EQ(u"hub.com", + command_bar->GetInlineAutocompletionForTesting()); + + const int autocomplete_start_count = + command_bar->GetAutocompleteStartCountForTesting(); SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); ASSERT_EQ(1, command_bar->GetSelectedIndexForTesting()); + EXPECT_EQ(u"github.com/settings/profile", textfield->GetText()); + EXPECT_EQ(autocomplete_start_count, + command_bar->GetAutocompleteStartCountForTesting()); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + ASSERT_EQ(0, command_bar->GetSelectedIndexForTesting()); + EXPECT_EQ(u"github.com", textfield->GetText()); + EXPECT_EQ(autocomplete_start_count, + command_bar->GetAutocompleteStartCountForTesting()); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + ASSERT_EQ(1, command_bar->GetSelectedIndexForTesting()); + EXPECT_EQ(u"github.com/settings/profile", textfield->GetText()); + EXPECT_EQ(autocomplete_start_count, + command_bar->GetAutocompleteStartCountForTesting()); const int tab_count = browser()->tab_strip_model()->count(); SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_RIGHT); EXPECT_EQ(u"github.com/settings/profile", textfield->GetText()); + EXPECT_EQ(u"github.com/settings/profile", + command_bar->GetUserInputTextForTesting()); + EXPECT_EQ(autocomplete_start_count + 1, + command_bar->GetAutocompleteStartCountForTesting()); EXPECT_EQ(tab_count, browser()->tab_strip_model()->count()); EXPECT_TRUE(command_bar->GetVisible()); } +IN_PROC_BROWSER_TEST_F( + DaoCommandBarBrowserTest, + SelectionPreviewUpArrowWrapsAndUpdatesSuggestionPreview) { + browser()->profile()->GetPrefs()->SetBoolean(dao::prefs::kDaoAskAiEnabled, + false); + + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"git", u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.fill_into_edit = u"github.com"; + match.contents = u"github.com"; + match.destination_url = GURL("https://github.com/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + ASSERT_EQ(0, command_bar->GetSelectedIndexForTesting()); + ASSERT_EQ(2, command_bar->GetVisibleSuggestionCountForTesting()); + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_EQ(u"git", textfield->GetText()); + + const int autocomplete_start_count = + command_bar->GetAutocompleteStartCountForTesting(); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + + EXPECT_EQ(1, command_bar->GetSelectedIndexForTesting()); + EXPECT_EQ(u"git", textfield->GetText()); + EXPECT_EQ(u"git", command_bar->GetSelectionPreviewTextForTesting()); + EXPECT_EQ(autocomplete_start_count, + command_bar->GetAutocompleteStartCountForTesting()); +} + IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, - EnterSubmitsTypedInputWhenSelectionIsAutomatic) { + SelectionPreviewTabAcceptsWithoutMovingFocus) { + browser()->profile()->GetPrefs()->SetBoolean(dao::prefs::kDaoAskAiEnabled, + false); + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"git", u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.fill_into_edit = u"github.com"; + match.contents = u"github.com"; + match.destination_url = GURL("https://github.com/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + ASSERT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + ASSERT_EQ(u"git", textfield->GetText()); + ASSERT_TRUE(textfield->HasFocus()); + const int autocomplete_start_count = + command_bar->GetAutocompleteStartCountForTesting(); + const int tab_count = browser()->tab_strip_model()->count(); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_TAB); + + EXPECT_EQ(u"github.com", command_bar->GetUserInputTextForTesting()); + EXPECT_EQ(u"github.com", textfield->GetText()); + EXPECT_EQ(autocomplete_start_count + 1, + command_bar->GetAutocompleteStartCountForTesting()); + EXPECT_EQ(tab_count, browser()->tab_strip_model()->count()); + EXPECT_TRUE(command_bar->GetVisible()); + EXPECT_TRUE(textfield->HasFocus()); +} + +IN_PROC_BROWSER_TEST_F( + DaoCommandBarBrowserTest, + AutoHighlightedSuggestionDoesNotReplaceEditedInputUntilAccepted) { + browser()->profile()->GetPrefs()->SetBoolean(dao::prefs::kDaoAskAiEnabled, + false); + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"github", u""); + + AutocompleteMatch initial_match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + initial_match.allowed_to_be_default_match = true; + initial_match.fill_into_edit = u"github.com"; + initial_match.contents = u"github.com"; + initial_match.destination_url = GURL("https://github.com/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{initial_match}); + + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + ASSERT_EQ(0, command_bar->GetSelectedIndexForTesting()); + ASSERT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + ASSERT_EQ(u"github", textfield->GetText()); + + textfield->SetSelectedRange(gfx::Range(3, 6)); + textfield->InsertOrReplaceText(u""); + textfield->InsertOrReplaceText(u"x"); + ASSERT_EQ(u"gitx", command_bar->GetUserInputTextForTesting()); + ASSERT_EQ(u"gitx", textfield->GetText()); + + AutocompleteMatch fresh_match(nullptr, 1100, false, + AutocompleteMatchType::HISTORY_URL); + fresh_match.allowed_to_be_default_match = true; + fresh_match.fill_into_edit = u"gitx.dev"; + fresh_match.contents = u"gitx.dev"; + fresh_match.destination_url = GURL("https://gitx.dev/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{fresh_match}); + + EXPECT_EQ(0, command_bar->GetSelectedIndexForTesting()); + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_EQ(u"gitx", command_bar->GetUserInputTextForTesting()); + EXPECT_EQ(u"gitx", textfield->GetText()); + EXPECT_EQ(gfx::Range(4), textfield->GetSelectedRange()); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_RIGHT); + + EXPECT_EQ(u"gitx.dev", command_bar->GetUserInputTextForTesting()); + EXPECT_EQ(u"gitx.dev", textfield->GetText()); +} + +IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, + SelectionPreviewTypingReplacesSelectedSuffix) { + browser()->profile()->GetPrefs()->SetBoolean(dao::prefs::kDaoAskAiEnabled, + false); + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"git", u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.fill_into_edit = u"github.com"; + match.contents = u"github.com"; + match.destination_url = GURL("https://github.com/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + ASSERT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); + ASSERT_EQ(gfx::Range(3, 10), textfield->GetSelectedRange()); + const int autocomplete_start_count = + command_bar->GetAutocompleteStartCountForTesting(); + + textfield->InsertOrReplaceText(u"x"); + + EXPECT_EQ(u"gitx", command_bar->GetUserInputTextForTesting()); + EXPECT_EQ(u"gitx", textfield->GetText()); + EXPECT_EQ(autocomplete_start_count + 1, + command_bar->GetAutocompleteStartCountForTesting()); +} + +IN_PROC_BROWSER_TEST_F( + DaoCommandBarBrowserTest, + SelectionPreviewBackspaceRejectsAndAsyncTickDoesNotRestore) { + browser()->profile()->GetPrefs()->SetBoolean(dao::prefs::kDaoAskAiEnabled, + false); + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"git", u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.fill_into_edit = u"github.com"; + match.contents = u"github.com"; + match.destination_url = GURL("https://github.com/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + ASSERT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_BACK); + + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_EQ(u"git", command_bar->GetUserInputTextForTesting()); + EXPECT_EQ(u"git", textfield->GetText()); + EXPECT_EQ(gfx::Range(3), textfield->GetSelectedRange()); + + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_EQ(u"git", textfield->GetText()); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + EXPECT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_EQ(u"github.com", textfield->GetText()); +} + +IN_PROC_BROWSER_TEST_F( + DaoCommandBarBrowserTest, + SelectionPreviewRejectionSuppressionSurvivesSubsequentTyping) { + browser()->profile()->GetPrefs()->SetBoolean(dao::prefs::kDaoAskAiEnabled, + false); + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"git", u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.fill_into_edit = u"github.com"; + match.contents = u"github.com"; + match.destination_url = GURL("https://github.com/"); + match.inline_autocompletion = u"hub.com"; + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + ASSERT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_BACK); + ASSERT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + ASSERT_EQ(u"git", textfield->GetText()); + + textfield->InsertOrReplaceText(u"x"); + ASSERT_EQ(u"gitx", command_bar->GetUserInputTextForTesting()); + ASSERT_EQ(u"gitx", textfield->GetText()); + + // A stale provider result must not restore the rejected text or become the + // implicit Enter action after the user continues typing. + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); + EXPECT_EQ(u"gitx", command_bar->GetUserInputTextForTesting()); + EXPECT_EQ(u"gitx", textfield->GetText()); + EXPECT_EQ(gfx::Range(4), textfield->GetSelectedRange()); + + AutocompleteMatch fresh_match(nullptr, 1100, false, + AutocompleteMatchType::HISTORY_URL); + fresh_match.allowed_to_be_default_match = true; + fresh_match.fill_into_edit = u"gitx.dev"; + fresh_match.contents = u"gitx.dev"; + fresh_match.destination_url = GURL("https://gitx.dev/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{fresh_match}); + + // A different result may be highlighted automatically, but it must not + // enter the textfield until the user browses or accepts it. + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_EQ(u"gitx", textfield->GetText()); + EXPECT_EQ(gfx::Range(4), textfield->GetSelectedRange()); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_RIGHT); + EXPECT_EQ(u"gitx.dev", command_bar->GetUserInputTextForTesting()); + EXPECT_EQ(u"gitx.dev", textfield->GetText()); +} + +IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, + SelectionPreviewRecomputesWhenSameRowChanges) { + browser()->profile()->GetPrefs()->SetBoolean(dao::prefs::kDaoAskAiEnabled, + false); + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"git", u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.fill_into_edit = u"github.com"; + match.contents = u"github.com"; + match.destination_url = GURL("https://github.com/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + ASSERT_EQ(0, command_bar->GetSelectedIndexForTesting()); + EXPECT_EQ(u"git", textfield->GetText()); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + ASSERT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_EQ(gfx::Range(3, 10), textfield->GetSelectedRange()); + + match.fill_into_edit = u"https://github.com"; + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + EXPECT_EQ(0, command_bar->GetSelectedIndexForTesting()); + EXPECT_EQ(u"https://github.com", textfield->GetText()); + EXPECT_EQ(gfx::Range(18), textfield->GetSelectedRange()); + + match.fill_into_edit.clear(); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + EXPECT_EQ(0, command_bar->GetSelectedIndexForTesting()); + EXPECT_EQ(u"git", textfield->GetText()); + EXPECT_EQ(gfx::Range(3), textfield->GetSelectedRange()); +} + +IN_PROC_BROWSER_TEST_F( + DaoCommandBarBrowserTest, + SelectionPreviewPreservesOriginalInputForExactSearchAndAskAi) { + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"new york", u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.fill_into_edit = u"newyork.com"; + match.contents = u"newyork.com"; + match.destination_url = GURL("https://newyork.com/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + ASSERT_EQ(1, command_bar->GetAskAiRowIndexForTesting()); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + ASSERT_EQ(1, command_bar->GetSelectedIndexForTesting()); + EXPECT_EQ(u"new york", textfield->GetText()); + EXPECT_EQ(u"new york", command_bar->GetSelectionPreviewTextForTesting()); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + ASSERT_EQ(2, command_bar->GetSelectedIndexForTesting()); + EXPECT_EQ(u"new york", textfield->GetText()); + EXPECT_EQ(u"new york", command_bar->GetSelectionPreviewTextForTesting()); +} + +IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, + SelectionPreviewEnterOnAskAiUsesOriginalQuery) { + ASSERT_TRUE(LoadAgentWebUI()); + ASSERT_TRUE(InstallExternalSubmitRecorder()); + + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"new york", u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.fill_into_edit = u"newyork.com"; + match.contents = u"newyork.com"; + match.destination_url = GURL("https://newyork.com/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + + ASSERT_EQ(1, command_bar->GetAskAiRowIndexForTesting()); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + ASSERT_EQ(1, command_bar->GetSelectedIndexForTesting()); + ASSERT_EQ(u"new york", command_bar->GetSelectionPreviewTextForTesting()); + + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_RETURN); + + ASSERT_TRUE(WaitForExternalAction()); + EXPECT_EQ("submit", + content::EvalJs(agent_contents_, + "window.__daoExternalActions[0].kind") + .ExtractString()); + EXPECT_EQ("new york", + content::EvalJs(agent_contents_, + "window.__daoExternalActions[0].value") + .ExtractString()); + EXPECT_FALSE( + content::EvalJs( + agent_contents_, + "window.__daoExternalActions[0].includePageContext") + .ExtractBool()); + EXPECT_FALSE(command_bar->GetVisible()); +} + +IN_PROC_BROWSER_TEST_F( + DaoCommandBarBrowserTest, + SelectionPreviewClearsOnHideAndNewTabInitialization) { + browser()->profile()->GetPrefs()->SetBoolean(dao::prefs::kDaoAskAiEnabled, + false); + DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); + ASSERT_NE(nullptr, command_bar); + command_bar->ShowForNewTab(); + command_bar->SetUserInputAndInlineAutocompletionForTesting(u"git", u""); + + AutocompleteMatch match(nullptr, 1000, false, + AutocompleteMatchType::HISTORY_URL); + match.allowed_to_be_default_match = true; + match.fill_into_edit = u"github.com"; + match.contents = u"github.com"; + match.destination_url = GURL("https://github.com/"); + command_bar->SetAutocompleteMatchesForTesting(ACMatches{match}); + + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + ASSERT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + ASSERT_EQ(u"git", textfield->GetText()); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + ASSERT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); + ASSERT_EQ(u"github.com", textfield->GetText()); + + command_bar->Hide(); + + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_TRUE(command_bar->GetSelectionPreviewTextForTesting().empty()); + EXPECT_EQ(u"git", textfield->GetText()); + + command_bar->ShowForNewTab(); + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_TRUE(command_bar->GetSelectionPreviewTextForTesting().empty()); + EXPECT_TRUE(textfield->GetText().empty()); +} + +IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, + SelectionPreviewEnterSubmitsAutoSelectedMatch) { ASSERT_TRUE(embedded_test_server()->Start()); const GURL typed_url = embedded_test_server()->GetURL("/title1.html"); @@ -3661,6 +4288,11 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, command_bar->SetAutocompleteMatchesForTesting(ACMatches{default_match}); ASSERT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); + ASSERT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + ASSERT_EQ(base::UTF8ToUTF16(typed_url.spec()), textfield->GetText()); ui_test_utils::TabAddedWaiter tab_waiter(browser()); SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_RETURN); @@ -3668,7 +4300,7 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, ASSERT_NE(nullptr, contents); ASSERT_TRUE(content::WaitForLoadStop(contents)); - EXPECT_EQ(typed_url, contents->GetLastCommittedURL()); + EXPECT_EQ(suggestion_url, contents->GetLastCommittedURL()); } IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, @@ -3700,7 +4332,7 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, } IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, - EnhancedSuggestionsEnterSubmitsAutoSelectedMatch) { + EnhancedSelectionPreviewEnterSubmitsAutoSelectedMatch) { ASSERT_TRUE(embedded_test_server()->Start()); const GURL typed_url = embedded_test_server()->GetURL("/title1.html"); @@ -3727,6 +4359,11 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, command_bar->SetAutocompleteMatchesForTesting(ACMatches{default_match}); ASSERT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); + ASSERT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + ASSERT_EQ(base::UTF8ToUTF16(typed_url.spec()), textfield->GetText()); ui_test_utils::TabAddedWaiter tab_waiter(browser()); SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_RETURN); @@ -3774,7 +4411,7 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, IN_PROC_BROWSER_TEST_F( DaoCommandBarBrowserTest, - EnhancedSuggestionsEnterAfterGhostRejectionUsesTypedInput) { + SelectionPreviewEnterAfterBackspaceRejectionUsesTypedInput) { ASSERT_TRUE(embedded_test_server()->Start()); const GURL typed_url = embedded_test_server()->GetURL("/title1.html"); @@ -3805,13 +4442,31 @@ IN_PROC_BROWSER_TEST_F( command_bar->SetAutocompleteMatchesForTesting(ACMatches{default_match}, /*autocomplete_done=*/true); ASSERT_EQ(0, command_bar->GetSelectedIndexForTesting()); - ASSERT_EQ(std::u16string(kGhost), - command_bar->GetInlineAutocompletionForTesting()); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + ASSERT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); + ASSERT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); - // First Backspace absorbs the visible ghost text and marks the inline - // suggestion as rejected for this query; the typed text stays intact. + // First Backspace rejects the visible selection preview and leaves the + // original query intact. SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_BACK); - ASSERT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); + ASSERT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + ASSERT_EQ(typed_text, command_bar->GetUserInputTextForTesting()); + + views::Textfield* textfield = + FindDescendantViewOfClass(command_bar); + ASSERT_NE(nullptr, textfield); + textfield->InsertOrReplaceText(u"#typed"); + const GURL edited_url(typed_url.spec() + "#typed"); + ASSERT_EQ(base::UTF8ToUTF16(edited_url.spec()), + command_bar->GetUserInputTextForTesting()); + + // Simulate the rejected result arriving again after the next edit. It must + // remain neither visible as a preview nor eligible as the implicit action. + command_bar->SetAutocompleteMatchesForTesting(ACMatches{default_match}, + /*autocomplete_done=*/true); + ASSERT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + ASSERT_EQ(base::UTF8ToUTF16(edited_url.spec()), textfield->GetText()); ui_test_utils::TabAddedWaiter tab_waiter(browser()); SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_RETURN); @@ -3819,9 +4474,9 @@ IN_PROC_BROWSER_TEST_F( ASSERT_NE(nullptr, contents); ASSERT_TRUE(content::WaitForLoadStop(contents)); - // Enter must honor the rejection and navigate by the typed text, not the - // auto-selected match behind the rejected ghost text. - EXPECT_EQ(typed_url, contents->GetLastCommittedURL()); + // Enter must honor the rejection and subsequent edit, not the auto-selected + // match behind the rejected ghost text. + EXPECT_EQ(edited_url, contents->GetLastCommittedURL()); } IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, @@ -4086,7 +4741,7 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, } IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, - InlineAutocompletionClearsForNewQuery) { + SelectionPreviewSuppressesGhostForNewQuery) { DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); ASSERT_NE(nullptr, command_bar); @@ -4101,15 +4756,20 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, default_match.contents = u"google.com"; default_match.destination_url = GURL("https://google.com/"); command_bar->SetAutocompleteMatchesForTesting(ACMatches{default_match}); - ASSERT_EQ(u"ogle.com", command_bar->GetInlineAutocompletionForTesting()); + ASSERT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + ASSERT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); + ASSERT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); command_bar->ContentsChanged(nullptr, u"goo"); EXPECT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); } -IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, - InlineAutocompletionWaitsForStableResult) { +IN_PROC_BROWSER_TEST_F( + DaoCommandBarBrowserTest, + SelectionPreviewSuppressesGhostForTransientAndStableResults) { DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); ASSERT_NE(nullptr, command_bar); @@ -4126,14 +4786,19 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, command_bar->SetAutocompleteMatchesForTesting(ACMatches{default_match}, false); + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + EXPECT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); EXPECT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); command_bar->SetAutocompleteMatchesForTesting(ACMatches{default_match}, true); - EXPECT_EQ(u"ogle.com", command_bar->GetInlineAutocompletionForTesting()); + EXPECT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); } IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, - InlineAutocompletionAllowsSearchLikeInput) { + SelectionPreviewSuppressesGhostForSearchLikeInput) { DaoCommandBarView* command_bar = GetBrowserView(browser())->dao_command_bar(); ASSERT_NE(nullptr, command_bar); @@ -4150,9 +4815,11 @@ IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, command_bar->SetAutocompleteMatchesForTesting(ACMatches{default_match}, true); - // Search-like inputs (no dot, >2 chars) used to be suppressed; ghost - // text now follows the default match like the browser omnibox does. - EXPECT_EQ(u"gle.com", command_bar->GetInlineAutocompletionForTesting()); + EXPECT_FALSE(command_bar->IsSelectionPreviewActiveForTesting()); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_DOWN); + SendDialogKey(GetBrowserView(browser())->GetWidget(), ui::VKEY_UP); + EXPECT_TRUE(command_bar->IsSelectionPreviewActiveForTesting()); + EXPECT_TRUE(command_bar->GetInlineAutocompletionForTesting().empty()); } IN_PROC_BROWSER_TEST_F(DaoCommandBarBrowserTest, diff --git a/src/dao/browser/ui/views/dao_command_bar_view.cc b/src/dao/browser/ui/views/dao_command_bar_view.cc index 4908ec8..743ead0 100644 --- a/src/dao/browser/ui/views/dao_command_bar_view.cc +++ b/src/dao/browser/ui/views/dao_command_bar_view.cc @@ -5,6 +5,7 @@ #include "dao/browser/ui/views/dao_command_bar_view.h" #include +#include #include "base/strings/utf_string_conversions.h" #include "base/strings/escape.h" @@ -66,10 +67,8 @@ #include "ui/gfx/image/image_skia_operations.h" #include "ui/gfx/paint_vector_icon.h" #include "ui/gfx/range/range.h" -#include "ui/gfx/text_utils.h" #include "ui/views/background.h" #include "ui/views/border.h" -#include "ui/views/controls/label.h" #include "ui/views/controls/textfield/textfield.h" #include "ui/views/layout/box_layout.h" @@ -79,6 +78,25 @@ namespace { constexpr int kCommandBarTextFontSize = 17; +void SetTextAndSelectedRangeKeepingCaretVisible( + views::Textfield* textfield, + const std::u16string& text, + const gfx::Range& selection) { + // Keep useful context on both sides of the caret when a completion is wider + // than the textfield. The positions are applied in priority order, matching + // Chromium's Omnibox behavior: leading context wins, then trailing context. + static constexpr size_t kTrailingContextLength = 30; + static constexpr size_t kLeadingContextLength = 10; + + textfield->SetTextWithoutCaretBoundsChangeNotification(text, + selection.end()); + textfield->Scroll( + {0, std::min(selection.end() + kTrailingContextLength, text.size()), + selection.end() - + std::min(kLeadingContextLength, selection.end())}); + textfield->SetSelectedRange(selection); +} + bool LooksLikeLocalFilePath(const std::string& text) { return !text.empty() && (text[0] == '/' || text[0] == '~'); } @@ -129,37 +147,6 @@ class FrostedGlassBackground : public views::Background { SkColor paint_color_; }; -// Background for the ghost-text label. The label itself takes the full -// textfield height so its RenderText baseline aligns with the textfield's -// (otherwise the user sees the text "drop down" when Right Arrow accepts -// the completion). This background, however, only paints a rounded pill -// at the font height vertically centered in the bounds, so the accent -// fill hugs the glyphs as the PRD requires. -class GhostTextPillBackground : public views::Background { - public: - GhostTextPillBackground(SkColor paint_color, int pill_height, float radius) - : paint_color_(paint_color), pill_height_(pill_height), radius_(radius) { - SetColor(SK_ColorWHITE); - } - - void Paint(gfx::Canvas* canvas, views::View* view) const override { - cc::PaintFlags flags; - flags.setAntiAlias(true); - flags.setStyle(cc::PaintFlags::kFill_Style); - flags.setColor(paint_color_); - - const float top = std::max(0.f, (view->height() - pill_height_) / 2.0f); - gfx::RectF pill_rect(0.0f, top, view->width(), - std::min(pill_height_, view->height())); - canvas->DrawRoundRect(pill_rect, radius_, flags); - } - - private: - SkColor paint_color_; - int pill_height_; - float radius_; -}; - // A view whose sole job is to paint a DrawLooper shadow. // It has its own layer so it can render outside its clip without // requiring the parent DaoCommandBarView to have a layer (which @@ -254,24 +241,6 @@ DaoCommandBarView::DaoCommandBarView(Browser* browser) : browser_(browser) { // Make the textfield fill the card width card_layout->SetFlexForView(textfield_, 1); - // Ghost text label: overlaid on the textfield area, shows inline - // autocompletion as a "selected" pill (accent-blue translucent fill + - // primary text color). Added as a direct child of |this| (not - // card_container_) so it is not managed by the card's BoxLayout, and - // promoted to its own layer so it paints above glass_container_. - auto ghost_label = std::make_unique(); - ghost_label->SetFontList(gfx::FontList({"system-ui"}, gfx::Font::NORMAL, - kCommandBarTextFontSize, - gfx::Font::Weight::SEMIBOLD)); - ghost_label->SetHorizontalAlignment(gfx::ALIGN_LEFT); - ghost_label->SetSubpixelRenderingEnabled(false); - ghost_label->SetVisible(false); - ghost_label->SetCanProcessEventsWithinSubtree(false); - ghost_label->SetBackgroundColor(SK_ColorTRANSPARENT); - ghost_label->SetPaintToLayer(); - ghost_label->layer()->SetFillsBoundsOpaquely(false); - ghost_text_label_ = AddChildView(std::move(ghost_label)); - // Dropdown container: inside the glass container (no own layer) dropdown_container_ = glass_container_->AddChildView( std::make_unique()); @@ -307,19 +276,6 @@ void DaoCommandBarView::ApplyTheme() { // so it reads as a hint rather than content. textfield_->set_placeholder_text_color(TextMuted()); } - if (ghost_text_label_) { - // Mirror the selected suggestion row tokens so the inline completion - // visually belongs to the same default action. - // The label's bounds match the textfield height (for baseline parity); - // the pill background only paints at font height so the fill hugs the - // glyphs. - ghost_text_label_->SetEnabledColor(SuggestionTitleColor()); - const int pill_height = - ghost_text_label_->GetPreferredSize().height() + 2; - ghost_text_label_->SetBackground( - std::make_unique( - GhostTextSelectedBackground(), pill_height, /*radius=*/4.0f)); - } // Suggestion rows cache their text color in views::Label and rasterize // their vector icons at SetMatch time, so they need an explicit refresh // when the theme changes. Favicon images are left untouched. @@ -408,11 +364,12 @@ void DaoCommandBarView::Show() { return; } + ClearSelectionPreview(false); is_new_tab_mode_ = false; selected_index_ = -1; selection_explicitly_changed_ = false; inline_autocompletion_.clear(); - ghost_text_label_->SetVisible(false); + rejected_selection_preview_text_.clear(); suppress_ghost_for_current_query_ = false; last_text_length_ = 0; @@ -442,14 +399,6 @@ void DaoCommandBarView::Show() { l->parent()->StackAtTop(l); } } - // The ghost text label has its own layer so it can paint above the - // frosted glass container. Restack it last so it is not occluded by - // the glass layer stacked above. - if (ghost_text_label_ && ghost_text_label_->layer()) { - ghost_text_label_->layer()->parent()->StackAtTop( - ghost_text_label_->layer()); - } - // Prevent web content's native view from stealing events SetWebContentEventProcessing(false); @@ -505,12 +454,13 @@ void DaoCommandBarView::ShowForNewTab() { return; } + ClearSelectionPreview(false); is_new_tab_mode_ = true; selected_index_ = -1; selection_explicitly_changed_ = false; inline_autocompletion_.clear(); - ghost_text_label_->SetVisible(false); user_input_text_.clear(); + rejected_selection_preview_text_.clear(); suppress_ghost_for_current_query_ = false; last_text_length_ = 0; @@ -539,14 +489,6 @@ void DaoCommandBarView::ShowForNewTab() { l->parent()->StackAtTop(l); } } - // The ghost text label has its own layer so it can paint above the - // frosted glass container. Restack it last so it is not occluded by - // the glass layer stacked above. - if (ghost_text_label_ && ghost_text_label_->layer()) { - ghost_text_label_->layer()->parent()->StackAtTop( - ghost_text_label_->layer()); - } - // Prevent web content's native view from stealing events SetWebContentEventProcessing(false); @@ -642,7 +584,6 @@ void DaoCommandBarView::Layout(PassKey) { dropdown_height); } - PositionGhostText(); } void DaoCommandBarView::OnPaint(gfx::Canvas* canvas) { @@ -714,6 +655,11 @@ void DaoCommandBarView::ContentsChanged(views::Textfield* sender, return; } + // A real text edit commits the textfield's current visible contents as the + // next query. When a preview suffix is selected, normal Textfield editing + // replaces that suffix before this callback runs. + ClearSelectionPreview(false); + const size_t prev_len = last_text_length_; const size_t new_len = new_contents.length(); const bool is_deletion = new_len < prev_len; @@ -723,18 +669,11 @@ void DaoCommandBarView::ContentsChanged(views::Textfield* sender, selected_index_ = -1; selection_explicitly_changed_ = false; - // Deletion queries must not show ghost text until the next non-deletion - // edit — every Backspace otherwise re-rasterizes a new ghost and the - // input feels sticky. - if (is_deletion) { - suppress_ghost_for_current_query_ = true; - inline_autocompletion_.clear(); - ghost_text_label_->SetVisible(false); - } else { - suppress_ghost_for_current_query_ = false; - inline_autocompletion_.clear(); - ghost_text_label_->SetVisible(false); - } + // Scope Chromium's provider-level inline suppression to deletion requests. + // Rejected preview text is tracked separately so a later insertion restores + // normal provider ranking without allowing that exact stale preview back. + suppress_ghost_for_current_query_ = is_deletion; + inline_autocompletion_.clear(); UpdateInputIcon(); @@ -758,16 +697,30 @@ bool DaoCommandBarView::HandleKeyEvent(views::Textfield* sender, return true; } - // First Backspace with visible ghost text: consume the keypress to clear - // only the ghost label, leaving the user's typed input intact. The - // suppression flag stops subsequent autocomplete ticks from immediately - // re-rendering ghost text for the same query. + // First Backspace rejects a non-identical preview without deleting query + // text. Suppression prevents the same async result from restoring it until + // the user explicitly selects a row again. + if (key_event.key_code() == ui::VKEY_BACK && + selection_preview_active_ && + selection_preview_text_ != user_input_text_) { + rejected_selection_preview_text_ = selection_preview_text_; + ClearSelectionPreview(true); + suppress_ghost_for_current_query_ = true; + selection_explicitly_changed_ = false; + return true; + } + + // First Backspace with the inline suffix selected rejects only that suffix, + // leaving the user's typed prefix intact. A broader selection, including + // Select All, follows normal Textfield editing semantics. + const gfx::Range selected_range = sender->GetSelectedRange(); + const size_t inline_text_end = + user_input_text_.length() + inline_autocompletion_.length(); if (key_event.key_code() == ui::VKEY_BACK && !inline_autocompletion_.empty() && - sender->GetCursorPosition() == user_input_text_.length() && - !sender->HasSelection()) { - inline_autocompletion_.clear(); - ghost_text_label_->SetVisible(false); + selected_range.GetMin() == user_input_text_.length() && + selected_range.GetMax() == inline_text_end) { + ClearInlineAutocompletion(true); suppress_ghost_for_current_query_ = true; return true; } @@ -803,10 +756,25 @@ bool DaoCommandBarView::HandleKeyEvent(views::Textfield* sender, return true; } - // Right arrow: fill the explicitly selected suggestion, or accept the - // default ghost completion, when the cursor is at the end of the input. + // Right arrow accepts either an explicit preview or the automatically + // highlighted row. Automatic highlighting alone must never write suggestion + // text into the textfield. if (key_event.key_code() == ui::VKEY_RIGHT) { + if (selection_preview_active_) { + AcceptSelectionPreview(); + return true; + } + if (sender->GetCursorPosition() == user_input_text_.length()) { + if (selected_index_ >= 0 && + selected_index_ < visible_suggestion_count_) { + SetSelectedIndex(selected_index_, true); + if (selection_preview_active_) { + AcceptSelectionPreview(); + return true; + } + } + if (selection_explicitly_changed_) { if (const AutocompleteMatch* selected_match = GetSelectedVisibleAutocompleteMatch(); @@ -824,6 +792,13 @@ bool DaoCommandBarView::HandleKeyEvent(views::Textfield* sender, } if (key_event.key_code() == ui::VKEY_TAB) { + if (!selection_preview_active_ && selected_index_ >= 0 && + selected_index_ < visible_suggestion_count_) { + SetSelectedIndex(selected_index_, true); + } + if (selection_preview_active_) { + AcceptSelectionPreview(); + } return true; } @@ -831,9 +806,9 @@ bool DaoCommandBarView::HandleKeyEvent(views::Textfield* sender, } void DaoCommandBarView::FillInput(const std::u16string& text) { + ClearSelectionPreview(false); user_input_text_ = text; inline_autocompletion_.clear(); - ghost_text_label_->SetVisible(false); updating_textfield_ = true; textfield_->SetText(user_input_text_); textfield_->SetSelectedRange(gfx::Range(user_input_text_.length())); @@ -841,6 +816,7 @@ void DaoCommandBarView::FillInput(const std::u16string& text) { last_text_length_ = user_input_text_.length(); selected_index_ = -1; selection_explicitly_changed_ = false; + rejected_selection_preview_text_.clear(); suppress_ghost_for_current_query_ = false; StartAutocomplete(user_input_text_); } @@ -848,9 +824,9 @@ void DaoCommandBarView::FillInput(const std::u16string& text) { void DaoCommandBarView::OnResultChanged(AutocompleteController* controller, bool default_match_changed) { UpdateSuggestions(); - // Ghost text must update on every tick — providers may publish a valid + // Inline text must update on every tick — providers may publish a valid // inline_autocompletion without flipping the default match. - UpdateGhostText(); + UpdateInlineAutocompletion(); // The input field's leading icon is keyed off the default match; only // refresh it when the default has actually changed so we don't fire // redundant favicon lookups for every async tick. @@ -860,6 +836,7 @@ void DaoCommandBarView::OnResultChanged(AutocompleteController* controller, } void DaoCommandBarView::StartAutocomplete(const std::u16string& text) { + ++autocomplete_start_count_for_testing_; InitAutocompleteController(); if (!autocomplete_controller_ || !scheme_classifier_) { return; @@ -885,9 +862,9 @@ void DaoCommandBarView::StartAutocomplete(const std::u16string& text) { // Deletion queries must also tell Chromium not to compute // inline_autocompletion; without this, providers can still publish - // aggressive history URL tails that Dao then has to filter out - // tick-by-tick. Note this flag also lowers history match relevance - // caps inside the providers, so it must stay scoped to deletion only. + // aggressive history URL tails that Dao then has to filter out tick-by-tick. + // Keep this scoped to deletion only because providers also use the flag when + // calculating match relevance. if (suppress_ghost_for_current_query_) { input.set_prevent_inline_autocomplete(true); } @@ -898,13 +875,16 @@ void DaoCommandBarView::StopAutocomplete() { if (autocomplete_controller_) { autocomplete_controller_->Stop(AutocompleteStopReason::kClobbered); } - inline_autocompletion_.clear(); - ghost_text_label_->SetVisible(false); + // Stop() may synchronously publish a final result tick. Clear after it so + // that tick cannot leave a preview active during dismissal or submission. + ClearSelectionPreview(true); + ClearInlineAutocompletion(true); selected_index_ = -1; selection_explicitly_changed_ = false; } void DaoCommandBarView::ClearSuggestions() { + ClearSelectionPreview(true); for (DaoSuggestionItemView* suggestion_view : suggestion_views_) { suggestion_view->SetVisible(false); suggestion_view->SetSelected(false); @@ -1014,16 +994,23 @@ void DaoCommandBarView::UpdateSuggestions() { if (visible_suggestion_count_ > 0) { dropdown_container_->SetVisible(true); - // Auto-select first item if nothing is selected - if (selected_index_ < 0) { - SetSelectedIndex(0, false); - } else if (selected_index_ >= visible_suggestion_count_) { + int next_selected_index = selected_index_; + if (next_selected_index < 0) { + next_selected_index = 0; + } else if (next_selected_index >= visible_suggestion_count_) { // Previously-selected index is no longer visible (e.g. results // shrank while the user was typing); clamp back onto the list. - SetSelectedIndex(visible_suggestion_count_ - 1, false); + next_selected_index = visible_suggestion_count_ - 1; } + + // Refresh even when the selected index is unchanged because async + // providers can replace the match that backs the same visible row. An + // automatically highlighted row does not preview into the textfield until + // the user explicitly browses or accepts it. + SetSelectedIndex(next_selected_index, false); } else { dropdown_container_->SetVisible(false); + ClearSelectionPreview(true); } InvalidateLayout(); @@ -1090,8 +1077,8 @@ bool DaoCommandBarView::IsExactSearchMatch( } std::u16string DaoCommandBarView::GetInlineAutocompletionForResult() const { - if (!autocomplete_controller_ || suppress_ghost_for_current_query_ || - user_input_text_.empty()) { + if (!autocomplete_controller_ || selection_preview_active_ || + suppress_ghost_for_current_query_ || user_input_text_.empty()) { return std::u16string(); } @@ -1102,6 +1089,11 @@ std::u16string DaoCommandBarView::GetInlineAutocompletionForResult() const { return std::u16string(); } + if (!rejected_selection_preview_text_.empty() && + default_match->fill_into_edit == rejected_selection_preview_text_) { + return std::u16string(); + } + if (!IsAutocompleteResultStableForInlineAutocompletion()) { return std::u16string(); } @@ -1134,18 +1126,14 @@ bool DaoCommandBarView::IsAutocompleteResultStableForInlineAutocompletion() } bool DaoCommandBarView::HasSubmittableInlineAutocompletion() const { - return textfield_ && !inline_autocompletion_.empty() && - std::u16string(textfield_->GetText()) == user_input_text_ && - textfield_->GetCursorPosition() == user_input_text_.length() && - !textfield_->HasSelection(); + return textfield_ && !selection_preview_active_ && + !inline_autocompletion_.empty() && + std::u16string(textfield_->GetText()) == + user_input_text_ + inline_autocompletion_; } std::u16string DaoCommandBarView::GetInlineAutocompletedInputText() const { - std::u16string text(textfield_->GetText()); - if (HasSubmittableInlineAutocompletion()) { - text += inline_autocompletion_; - } - return text; + return std::u16string(textfield_->GetText()); } const AutocompleteMatch* @@ -1187,71 +1175,151 @@ DaoCommandBarView::GetSelectedVisibleAutocompleteMatch() const { return nullptr; } -void DaoCommandBarView::UpdateGhostText() { - std::u16string ghost = GetInlineAutocompletionForResult(); +std::u16string DaoCommandBarView::GetSelectionPreviewText() const { + if (selected_index_ < 0 || + selected_index_ >= visible_suggestion_count_) { + return std::u16string(); + } - // Cheap no-op when the rendered value has not changed. This avoids - // re-rasterizing the layered label on every async result tick. - if (ghost == inline_autocompletion_) { - if (!ghost.empty()) { - // Position can shift if the textfield was relaid out; keep it in - // sync but skip the SetText work. - ghost_text_label_->SetVisible(true); - PositionGhostText(); - } else { - ghost_text_label_->SetVisible(false); - } + if (selected_index_ == ask_ai_row_index_) { + return user_input_text_; + } + + const AutocompleteMatch* selected_match = + GetSelectedVisibleAutocompleteMatch(); + if (!selected_match || + IsExactSearchMatch(*selected_match, user_input_text_) || + selected_match->fill_into_edit.empty()) { + return user_input_text_; + } + + return selected_match->fill_into_edit; +} + +void DaoCommandBarView::UpdateSelectionPreview() { + if (selected_index_ < 0 || + selected_index_ >= visible_suggestion_count_) { + ClearSelectionPreview(true); + return; + } + + std::u16string preview = GetSelectionPreviewText(); + if (preview.empty()) { + ClearSelectionPreview(true); return; } - inline_autocompletion_ = ghost; - if (ghost.empty()) { - ghost_text_label_->SetVisible(false); + const bool preview_rejected = + !selection_explicitly_changed_ && + (suppress_ghost_for_current_query_ || + preview == rejected_selection_preview_text_); + if (preview_rejected) { + ClearSelectionPreview(true); return; } - ghost_text_label_->SetText(ghost); - ghost_text_label_->SetVisible(true); - PositionGhostText(); + + selection_preview_text_ = std::move(preview); + selection_preview_active_ = true; + inline_autocompletion_.clear(); + + updating_textfield_ = true; + textfield_->SetText(selection_preview_text_); + if (base::StartsWith(selection_preview_text_, user_input_text_, + base::CompareCase::SENSITIVE)) { + textfield_->SetSelectedRange(gfx::Range( + user_input_text_.length(), selection_preview_text_.length())); + } else { + textfield_->SetSelectedRange( + gfx::Range(selection_preview_text_.length())); + } + updating_textfield_ = false; } -void DaoCommandBarView::PositionGhostText() { - if (!ghost_text_label_ || !ghost_text_label_->GetVisible() || !textfield_) { +void DaoCommandBarView::ClearSelectionPreview(bool restore_user_input) { + const bool was_active = selection_preview_active_; + selection_preview_active_ = false; + selection_preview_text_.clear(); + + if (!restore_user_input || !was_active || !textfield_) { return; } - // Measure the width of user's input text using the textfield's font. - int text_width = - gfx::GetStringWidth(user_input_text_, textfield_->GetFontList()); + updating_textfield_ = true; + textfield_->SetText(user_input_text_); + textfield_->SetSelectedRange(gfx::Range(user_input_text_.length())); + updating_textfield_ = false; +} - // Convert textfield origin to DaoCommandBarView coordinate space. - gfx::Point tf_origin; - views::View::ConvertPointToTarget(textfield_, this, &tf_origin); +void DaoCommandBarView::AcceptSelectionPreview() { + if (!selection_preview_active_) { + return; + } - int ghost_x = tf_origin.x() + text_width; - int ghost_y = tf_origin.y(); - int max_width = glass_container_->bounds().right() - ghost_x - 16; + const std::u16string accepted_text = selection_preview_text_; + FillInput(accepted_text); +} + +void DaoCommandBarView::UpdateInlineAutocompletion() { + // Replacing the Textfield contents while an IME composition is active can + // cancel or corrupt the composition. The committed edit will start another + // autocomplete request, so defer inline completion until that result tick. + if (textfield_ && textfield_->IsIMEComposing()) { + return; + } - if (max_width <= 0) { - ghost_text_label_->SetVisible(false); + std::u16string inline_autocompletion = + GetInlineAutocompletionForResult(); + if (inline_autocompletion == inline_autocompletion_) { + // Preserve a user-created selection such as Select All when an async + // provider republishes the same completion. return; } - // Tightly hug the ghost text with a 2px anti-aliasing buffer so the - // accent fill does not extend past the glyphs. Cap at the available - // width so an oversized completion stays inside the card. - constexpr int kAntiAliasBuffer = 2; - int natural_width = gfx::GetStringWidth(ghost_text_label_->GetText(), - ghost_text_label_->font_list()) + - kAntiAliasBuffer; - int label_width = std::min(natural_width, max_width); + const std::u16string previous_display_text = + user_input_text_ + inline_autocompletion_; + inline_autocompletion_ = std::move(inline_autocompletion); + if (!textfield_ || selection_preview_active_) { + return; + } - // Reuse the textfield's full y/height for the ghost label so both - // RenderText surfaces share the same vertical centering math; with a - // matching font this lines the glyph baselines up. Shrinking the label - // bounds to the font's preferred height instead would re-center inside - // a smaller box and make the accepted text appear to drop down. - ghost_text_label_->SetBounds(ghost_x, ghost_y, label_width, - textfield_->height()); + updating_textfield_ = true; + if (inline_autocompletion_.empty()) { + if (std::u16string(textfield_->GetText()) == previous_display_text) { + textfield_->SetText(user_input_text_); + textfield_->SetSelectedRange(gfx::Range(user_input_text_.length())); + } + } else { + const std::u16string display_text = + user_input_text_ + inline_autocompletion_; + // Match Chromium's Omnibox model: the full completion lives in the + // Textfield while a reversed selection keeps the caret at the end of the + // user's typed prefix. This makes Select All, copy, and replacement edits + // naturally include the completion. Pre-scrolling around that caret keeps + // a long suffix from pushing the typed prefix out of view. + SetTextAndSelectedRangeKeepingCaretVisible( + textfield_, display_text, + gfx::Range(display_text.length(), user_input_text_.length())); + } + updating_textfield_ = false; +} + +void DaoCommandBarView::ClearInlineAutocompletion(bool restore_user_input) { + if (inline_autocompletion_.empty()) { + return; + } + + const std::u16string display_text = + user_input_text_ + inline_autocompletion_; + inline_autocompletion_.clear(); + if (!restore_user_input || !textfield_ || + std::u16string(textfield_->GetText()) != display_text) { + return; + } + + updating_textfield_ = true; + textfield_->SetText(user_input_text_); + textfield_->SetSelectedRange(gfx::Range(user_input_text_.length())); + updating_textfield_ = false; } void DaoCommandBarView::UpdateInputIcon() { @@ -1345,28 +1413,31 @@ void DaoCommandBarView::OnInputFaviconFetched( void DaoCommandBarView::SetUserInputAndInlineAutocompletionForTesting( const std::u16string& user_input, const std::u16string& inline_autocompletion) { + ClearSelectionPreview(false); user_input_text_ = user_input; inline_autocompletion_ = inline_autocompletion; last_text_length_ = user_input.length(); suppress_ghost_for_current_query_ = false; + rejected_selection_preview_text_.clear(); selected_index_ = -1; selection_explicitly_changed_ = false; + autocomplete_start_count_for_testing_ = 0; ask_ai_row_index_ = -1; visible_matches_.clear(); + const std::u16string display_text = user_input + inline_autocompletion; updating_textfield_ = true; - textfield_->SetText(user_input); - textfield_->SetSelectedRange(gfx::Range(user_input.length())); - updating_textfield_ = false; - textfield_->RequestFocus(); - if (inline_autocompletion.empty()) { - ghost_text_label_->SetVisible(false); + textfield_->SetText(display_text); + textfield_->SetSelectedRange(gfx::Range(user_input.length())); } else { - ghost_text_label_->SetText(inline_autocompletion); - ghost_text_label_->SetVisible(true); - PositionGhostText(); + SetTextAndSelectedRangeKeepingCaretVisible( + textfield_, display_text, + gfx::Range(display_text.length(), user_input.length())); } + updating_textfield_ = false; + textfield_->RequestFocus(); + UpdateInputIcon(); } @@ -1379,7 +1450,7 @@ void DaoCommandBarView::SetAutocompleteMatchesForTesting( result.Reset(); result.AppendMatches(matches); UpdateSuggestions(); - UpdateGhostText(); + UpdateInlineAutocompletion(); } void DaoCommandBarView::SetAutocompleteMatchesForTesting( @@ -1401,28 +1472,37 @@ void DaoCommandBarView::SetAutocompleteMatchesForTesting( result.Reset(); result.AppendMatches(matches); UpdateSuggestions(); - UpdateGhostText(); + UpdateInlineAutocompletion(); } void DaoCommandBarView::SetSelectedIndex(int index, bool user_initiated) { if (user_initiated) { selection_explicitly_changed_ = true; + suppress_ghost_for_current_query_ = false; + rejected_selection_preview_text_.clear(); } - if (index == selected_index_) { - return; - } + if (index != selected_index_) { + // Deselect old + if (selected_index_ >= 0 && selected_index_ < kMaxSuggestions) { + suggestion_views_[selected_index_]->SetSelected(false); + } - // Deselect old - if (selected_index_ >= 0 && selected_index_ < kMaxSuggestions) { - suggestion_views_[selected_index_]->SetSelected(false); - } + selected_index_ = index; - selected_index_ = index; + // Select new + if (selected_index_ >= 0 && selected_index_ < kMaxSuggestions) { + suggestion_views_[selected_index_]->SetSelected(true); + } + } - // Select new - if (selected_index_ >= 0 && selected_index_ < kMaxSuggestions) { - suggestion_views_[selected_index_]->SetSelected(true); + if (selection_explicitly_changed_) { + UpdateSelectionPreview(); + } else { + // Provider updates may automatically highlight the first row, but the + // user's query remains the sole textfield value until an explicit keyboard + // or pointer selection occurs. + ClearSelectionPreview(true); } // Update the input icon to reflect the selected match type @@ -1436,14 +1516,7 @@ void DaoCommandBarView::ApplySelectedSuggestion() { return; } - // A deletion (Backspace that absorbed ghost text, or any shrink of the - // query) means the user rejected the inline suggestion for this query. - // Honor that rejection on Enter: skip the auto-selected default match and - // fall through to typed-text navigation below. Explicit row selection - // (arrow keys / click) after the deletion still wins via the - // selection_explicitly_changed_ path. - if (EnhancedSuggestionsEnabled() && !suppress_ghost_for_current_query_ && - selected_index_ >= 0) { + if (selection_preview_active_) { if (selected_index_ == ask_ai_row_index_ && !user_input_text_.empty()) { SubmitAskAi(user_input_text_); return; @@ -1468,6 +1541,23 @@ void DaoCommandBarView::ApplySelectedSuggestion() { return; } + // Automatic highlighting is a visual default and must not mutate the + // textfield. Enter may still submit that highlighted action, unless the + // user has rejected a preview or edited under inline suppression. + if (!suppress_ghost_for_current_query_ && + rejected_selection_preview_text_.empty()) { + if (selected_index_ == ask_ai_row_index_ && !user_input_text_.empty()) { + SubmitAskAi(user_input_text_); + return; + } + + if (const AutocompleteMatch* selected_match = + GetSelectedVisibleAutocompleteMatch()) { + NavigateToMatch(*selected_match); + return; + } + } + Navigate(std::u16string(textfield_->GetText())); return; } diff --git a/src/dao/browser/ui/views/dao_command_bar_view.h b/src/dao/browser/ui/views/dao_command_bar_view.h index dfa25b9..702fdef 100644 --- a/src/dao/browser/ui/views/dao_command_bar_view.h +++ b/src/dao/browser/ui/views/dao_command_bar_view.h @@ -28,7 +28,6 @@ class Browser; namespace views { -class Label; class Textfield; } @@ -98,6 +97,18 @@ class DaoCommandBarView : public views::View, const std::u16string& GetInlineAutocompletionForTesting() const { return inline_autocompletion_; } + const std::u16string& GetUserInputTextForTesting() const { + return user_input_text_; + } + const std::u16string& GetSelectionPreviewTextForTesting() const { + return selection_preview_text_; + } + bool IsSelectionPreviewActiveForTesting() const { + return selection_preview_active_; + } + int GetAutocompleteStartCountForTesting() const { + return autocomplete_start_count_for_testing_; + } int GetAutocompleteProviderTypesForTesting() const { return GetAutocompleteProviderTypesForCurrentMode(); } @@ -130,15 +141,18 @@ class DaoCommandBarView : public views::View, void StopAutocomplete(); void ClearSuggestions(); void UpdateSuggestions(); - void UpdateGhostText(); - void PositionGhostText(); + void UpdateSelectionPreview(); + void ClearSelectionPreview(bool restore_user_input); + void AcceptSelectionPreview(); + std::u16string GetSelectionPreviewText() const; + void UpdateInlineAutocompletion(); + void ClearInlineAutocompletion(bool restore_user_input); void UpdateInputIcon(); void FillInput(const std::u16string& text); - // Derives inline completion from the current default action only. This keeps - // ghost text, auto-selection, and Enter submission aligned with browser - // omnibox behavior. Returns the empty string when nothing applies or when - // ghost text is suppressed for the current query. + // Derives inline completion from the current default action when no + // selection preview is active. Returns the empty string when nothing applies + // or suggested text is suppressed for the current query. std::u16string GetInlineAutocompletionForResult() const; bool IsAutocompleteResultStableForInlineAutocompletion() const; bool HasSubmittableInlineAutocompletion() const; @@ -167,7 +181,6 @@ class DaoCommandBarView : public views::View, raw_ptr card_container_ = nullptr; raw_ptr favicon_icon_ = nullptr; raw_ptr textfield_ = nullptr; - raw_ptr ghost_text_label_ = nullptr; raw_ptr dropdown_container_ = nullptr; std::vector> suggestion_views_; @@ -178,25 +191,27 @@ class DaoCommandBarView : public views::View, bool autocomplete_controller_uses_enhanced_suggestions_ = false; int selected_index_ = -1; - // In default mode, auto-selection keeps this false so Enter follows visible - // inline completion or typed input; arrow keys and clicks flip it so explicit - // suggestions win. Enhanced suggestions always submit the selected visible - // row first, matching the visual default action. + // Tracks whether the highlighted row was reached through explicit keyboard + // or pointer interaction. Automatic highlighting leaves this false so it + // cannot write a preview into the textfield. bool selection_explicitly_changed_ = false; std::u16string user_input_text_; + std::u16string selection_preview_text_; + // The last explicitly previewed value rejected with Backspace. Keep it + // across subsequent edits so a late result cannot restore that exact value. + std::u16string rejected_selection_preview_text_; + bool selection_preview_active_ = false; std::u16string inline_autocompletion_; bool updating_textfield_ = false; int visible_suggestion_count_ = 0; + int autocomplete_start_count_for_testing_ = 0; std::optional autocomplete_update_type_for_testing_; - // True while the user is deleting in the current query lifetime — set in - // ContentsChanged when the textfield shrinks and on the first Backspace - // that absorbs visible ghost text. Cleared on the next non-deletion query. - // Blocks both Chromium's async inline-autocomplete output and Dao's local - // fallback derivation so deletion does not feel sticky. Also gates Enter: - // while set, ApplySelectedSuggestion ignores the auto-selected default - // match and navigates by the typed text instead. + // True only for the unchanged query after deleting or rejecting suggested + // text. It scopes Chromium's prevent-inline-autocomplete input flag to the + // deletion request; a later insertion clears it so provider ranking remains + // unchanged for the new query. bool suppress_ghost_for_current_query_ = false; // Length of |user_input_text_| at the previous ContentsChanged tick. Used