detached 31d7d6f

feat(omnibox): add fish-style inline URL suggestions with ghost text

commit
31d7d6fa1756526f4f6775c426771e7e07cce7c0
author
bnema · · 2025-11-29 17:10 UTC
parent
7d324aa
signature
GPG Unverified2B82207D370127A0 The key is unknown
14 files · +549 -7
M CHANGELOG.md +1 -0
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Inline URL suggestions**: Fish/zsh-style ghost text completion in omnibox. As you type, shows grayed-out URL completion from history. Accept with Right Arrow (full) or Ctrl+Right (word). Ctrl+Y also accepts full suggestion. Uses trie-based prefix search with debounced queries for smooth typing.
89 - **Filter list auto-update**: Async version checking using HTTP HEAD request with ETag. 2-minute startup delay, skips if checked within 24 hours. Stores version per filter list.
910 - **Configurable filter lists**: New `content_filtering.filter_lists` config option. Defaults include EasyList, EasyPrivacy, and uBlock filters (annoyances, quick-fixes).
1011 - **Demo video**: Added feature showcase video to README (docs/media/dumber-demo.mp4).
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Inline URL suggestions**: Fish/zsh-style ghost text completion in omnibox. As you type, shows grayed-out URL completion from history. Accept with Right Arrow (full) or Ctrl+Right (word). Ctrl+Y also accepts full suggestion. Uses trie-based prefix search with debounced queries for smooth typing.
8 - **Filter list auto-update**: Async version checking using HTTP HEAD request with ETag. 2-minute startup delay, skips if checked within 24 hours. Stores version per filter list.9 - **Filter list auto-update**: Async version checking using HTTP HEAD request with ETag. 2-minute startup delay, skips if checked within 24 hours. Stores version per filter list.
9 - **Configurable filter lists**: New `content_filtering.filter_lists` config option. Defaults include EasyList, EasyPrivacy, and uBlock filters (annoyances, quick-fixes).10 - **Configurable filter lists**: New `content_filtering.filter_lists` config option. Defaults include EasyList, EasyPrivacy, and uBlock filters (annoyances, quick-fixes).
10 - **Demo video**: Added feature showcase video to README (docs/media/dumber-demo.mp4).11 - **Demo video**: Added feature showcase video to README (docs/media/dumber-demo.mp4).
M gui/src/components/omnibox/Omnibox.svelte +8 -0
@@ -358,6 +358,14 @@
358358 } catch (error) {
359359 console.error('Error in setActive:', error);
360360 }
361+ },
362+ setInlineSuggestion: (url) => {
363+ try {
364+ console.log('[INLINE] window.__dumber_omnibox.setInlineSuggestion called:', url);
365+ omniboxBridge.setInlineSuggestion(url);
366+ } catch (error) {
367+ console.error('Error in setInlineSuggestion:', error);
368+ }
361369 }
362370 };
363371
@@ -358,6 +358,14 @@
358 } catch (error) {358 } catch (error) {
359 console.error('Error in setActive:', error);359 console.error('Error in setActive:', error);
360 }360 }
361+ },
362+ setInlineSuggestion: (url) => {
363+ try {
364+ console.log('[INLINE] window.__dumber_omnibox.setInlineSuggestion called:', url);
365+ omniboxBridge.setInlineSuggestion(url);
366+ } catch (error) {
367+ console.error('Error in setInlineSuggestion:', error);
368+ }
361 }369 }
362 };370 };
363 371
M gui/src/components/omnibox/OmniboxInput.svelte +180 -6
@@ -5,7 +5,7 @@
55 -->
66 <script lang="ts">
77 import { omniboxStore } from './stores.svelte.ts';
8- import { debouncedQuery, debouncedFind, omniboxBridge } from './messaging';
8+ import { debouncedQuery, debouncedFind, debouncedPrefixQuery, omniboxBridge } from './messaging';
99 import { findInPage } from './find';
1010
1111 // Reactive state
@@ -17,6 +17,11 @@
1717 let favorites = $derived(omniboxStore.favorites);
1818 let matches = $derived(omniboxStore.matches);
1919 let searchShortcuts = $derived(omniboxStore.searchShortcuts);
20+ let inlineCompletion = $derived(omniboxStore.inlineCompletion);
21+ let inlineSuggestion = $derived(omniboxStore.inlineSuggestion);
22+
23+ // Cursor tracking for ghost text visibility
24+ let cursorAtEnd = $state(true);
2025
2126 // Input element ref and responsive styles (props for parent to bind)
2227 interface Props {
@@ -84,6 +89,14 @@
8489 }
8590
8691 let activeBadge = $state<CommandBadge | null>(null);
92+ // Ghost text visibility - only show when cursor is at end and no command badge active
93+ let showGhostText = $derived(
94+ mode === 'omnibox' &&
95+ inlineCompletion &&
96+ cursorAtEnd &&
97+ inputValue.length > 0 &&
98+ !activeBadge
99+ );
87100 let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);
88101 let inputPadding = $state(DEFAULT_PADDING.join(' '));
89102 let baseLeftPadding = $state(DEFAULT_PADDING[3]);
@@ -97,6 +110,19 @@
97110 basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);
98111 });
99112
113+ // Debug: log when inline suggestion state changes
114+ $effect(() => {
115+ console.log('[INLINE] State changed:', {
116+ inlineCompletion,
117+ inlineSuggestion,
118+ showGhostText,
119+ cursorAtEnd,
120+ mode,
121+ inputValueLength: inputValue?.length,
122+ activeBadge: !!activeBadge
123+ });
124+ });
125+
100126 $effect(() => {
101127 const value = inputValue || '';
102128 const trimmedValue = value.trimStart();
@@ -110,6 +136,57 @@
110136 inputPadding = `${top} ${right} ${bottom} ${leftPadding}`;
111137 });
112138
139+ // Track cursor position for ghost text visibility
140+ function updateCursorPosition() {
141+ if (inputElement) {
142+ cursorAtEnd = inputElement.selectionStart === inputValue.length &&
143+ inputElement.selectionEnd === inputValue.length;
144+ }
145+ }
146+
147+ // Accept inline suggestion (full or word-by-word)
148+ function acceptInlineSuggestion(acceptMode: 'full' | 'word') {
149+ if (!inlineSuggestion || !inlineCompletion) return;
150+
151+ switch (acceptMode) {
152+ case 'full':
153+ // Accept entire suggestion
154+ omniboxStore.setInputValue(inlineSuggestion);
155+ omniboxStore.clearInlineSuggestion();
156+ // Query for next prefix match
157+ debouncedPrefixQuery(inlineSuggestion);
158+ // Also update the search results
159+ debouncedQuery(inlineSuggestion);
160+ break;
161+
162+ case 'word':
163+ // Accept next word at cursor position
164+ const cursorPos = inputElement?.selectionStart ?? inputValue.length;
165+ const remainingCompletion = inlineSuggestion.slice(cursorPos);
166+
167+ // Find next word boundary (handles /, ., -, _ as boundaries)
168+ const wordMatch = remainingCompletion.match(/^([^\s\/\.\-\_]+[\s\/\.\-\_]?)/);
169+ if (wordMatch && wordMatch[1]) {
170+ const wordToInsert = wordMatch[1];
171+ const newValue = inputValue.slice(0, cursorPos) + wordToInsert + inputValue.slice(cursorPos);
172+ const newCursorPos = cursorPos + wordToInsert.length;
173+
174+ omniboxStore.setInputValue(newValue);
175+
176+ requestAnimationFrame(() => {
177+ if (inputElement) {
178+ inputElement.setSelectionRange(newCursorPos, newCursorPos);
179+ updateCursorPosition();
180+ }
181+ });
182+
183+ debouncedPrefixQuery(newValue);
184+ debouncedQuery(newValue);
185+ }
186+ break;
187+ }
188+ }
189+
113190 // Handle input changes
114191 function handleInput(event: Event) {
115192 const target = event.target as HTMLInputElement;
@@ -117,6 +194,7 @@
117194
118195 omniboxStore.setInputValue(value);
119196 omniboxStore.setFaded(false);
197+ updateCursorPosition();
120198
121199 if (mode === 'omnibox') {
122200 // Only query backend if we're in history view
@@ -125,9 +203,12 @@
125203 if (value === '') {
126204 // Input is empty - fetch initial history based on config
127205 omniboxBridge.fetchInitialHistory();
206+ omniboxStore.clearInlineSuggestion();
128207 } else {
129208 // Input has content - perform search
130209 debouncedQuery(value);
210+ // Also query for inline suggestion (fish-style ghost text)
211+ debouncedPrefixQuery(value);
131212 }
132213 }
133214 } else if (mode === 'find') {
@@ -190,6 +271,33 @@
190271 handleArrowKeys(event.key);
191272 break;
192273
274+ case 'ArrowRight':
275+ // Handle inline suggestion acceptance (fish-style)
276+ if (mode === 'omnibox' && inlineCompletion) {
277+ if (event.ctrlKey || event.metaKey) {
278+ // Ctrl+Right: accept next word
279+ event.preventDefault();
280+ event.stopPropagation();
281+ acceptInlineSuggestion('word');
282+ } else if (cursorAtEnd) {
283+ // Right at end: accept full suggestion
284+ event.preventDefault();
285+ event.stopPropagation();
286+ acceptInlineSuggestion('full');
287+ }
288+ // If cursor not at end and no modifier, let default behavior happen
289+ }
290+ break;
291+
292+ case 'y':
293+ // Ctrl+Y: accept full inline suggestion (alternative to Right Arrow)
294+ if (event.ctrlKey && mode === 'omnibox' && inlineCompletion) {
295+ event.preventDefault();
296+ event.stopPropagation();
297+ acceptInlineSuggestion('full');
298+ }
299+ break;
300+
193301 default:
194302 // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results
195303 // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.)
@@ -365,10 +473,6 @@
365473 omniboxStore.setFaded(false);
366474 }
367475
368- function handleClick() {
369- omniboxStore.setFaded(false);
370- }
371-
372476 function handleFocus() {
373477 omniboxStore.setFaded(false);
374478 if (inputElement) {
@@ -394,6 +498,17 @@
394498 }
395499 }
396500 }
501+
502+ // Handle selection changes (for cursor tracking)
503+ function handleSelect() {
504+ updateCursorPosition();
505+ }
506+
507+ // Handle click with cursor tracking
508+ function handleClickWithCursor() {
509+ omniboxStore.setFaded(false);
510+ updateCursorPosition();
511+ }
397512 </script>
398513
399514 <div class="omnibox-input-wrapper">
@@ -405,17 +520,30 @@
405520 >{activeBadge.label}</span>
406521 {/if}
407522
523+ <!-- Ghost text overlay (fish-style inline suggestion) -->
524+ {#if showGhostText}
525+ <div
526+ class="omnibox-ghost-text"
527+ aria-hidden="true"
528+ style={`padding: ${inputPadding}; font-size: ${inputFontSize};`}
529+ >
530+ <span class="ghost-spacer">{inputValue}</span><span class="ghost-completion">{inlineCompletion}</span>
531+ </div>
532+ {/if}
533+
408534 <input
409535 bind:this={inputElement}
410536 type="text"
411537 {placeholder}
412538 value={inputValue}
413539 class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"
540+ class:has-ghost-text={showGhostText}
414541 style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}
415542 oninput={handleInput}
416543 onkeydown={handleKeyDown}
417544 onmousedown={handleMouseDown}
418- onclick={handleClick}
545+ onclick={handleClickWithCursor}
546+ onselect={handleSelect}
419547 onfocus={handleFocus}
420548 onblur={handleBlur}
421549 onmouseenter={handleMouseEnter}
@@ -481,4 +609,50 @@
481609 box-shadow: 0 0 0 1px var(--dynamic-border, transparent);
482610 z-index: 1;
483611 }
612+
613+ /* Ghost text overlay for fish-style inline suggestions */
614+ .omnibox-ghost-text {
615+ position: absolute;
616+ top: 0;
617+ left: 0;
618+ right: 0;
619+ bottom: 0;
620+ pointer-events: none;
621+ display: flex;
622+ align-items: center;
623+ white-space: nowrap;
624+ overflow: hidden;
625+ font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
626+ letter-spacing: normal;
627+ z-index: 0;
628+ box-sizing: border-box;
629+ border: 1px solid transparent;
630+ }
631+
632+ .ghost-spacer {
633+ visibility: hidden;
634+ white-space: pre;
635+ }
636+
637+ .ghost-completion {
638+ color: var(--dynamic-muted);
639+ opacity: 0.5;
640+ white-space: pre;
641+ transition: opacity 100ms ease-out;
642+ }
643+
644+ .omnibox-ghost-text {
645+ transition: opacity 80ms ease-out;
646+ }
647+
648+ /* Make input transparent when showing ghost text */
649+ .omnibox-input-field.has-ghost-text {
650+ background: transparent !important;
651+ }
652+
653+ /* Ensure wrapper provides background when ghost text is active */
654+ .omnibox-input-wrapper:has(.has-ghost-text) {
655+ background: var(--dumber-input-bg, var(--dynamic-bg));
656+ border-radius: 2px;
657+ }
484658 </style>
@@ -5,7 +5,7 @@
5 -->5 -->
6 <script lang="ts">6 <script lang="ts">
7 import { omniboxStore } from './stores.svelte.ts';7 import { omniboxStore } from './stores.svelte.ts';
8- import { debouncedQuery, debouncedFind, omniboxBridge } from './messaging';8+ import { debouncedQuery, debouncedFind, debouncedPrefixQuery, omniboxBridge } from './messaging';
9 import { findInPage } from './find';9 import { findInPage } from './find';
10 10
11 // Reactive state11 // Reactive state
@@ -17,6 +17,11 @@
17 let favorites = $derived(omniboxStore.favorites);17 let favorites = $derived(omniboxStore.favorites);
18 let matches = $derived(omniboxStore.matches);18 let matches = $derived(omniboxStore.matches);
19 let searchShortcuts = $derived(omniboxStore.searchShortcuts);19 let searchShortcuts = $derived(omniboxStore.searchShortcuts);
20+ let inlineCompletion = $derived(omniboxStore.inlineCompletion);
21+ let inlineSuggestion = $derived(omniboxStore.inlineSuggestion);
22+
23+ // Cursor tracking for ghost text visibility
24+ let cursorAtEnd = $state(true);
20 25
21 // Input element ref and responsive styles (props for parent to bind)26 // Input element ref and responsive styles (props for parent to bind)
22 interface Props {27 interface Props {
@@ -84,6 +89,14 @@
84 }89 }
85 90
86 let activeBadge = $state<CommandBadge | null>(null);91 let activeBadge = $state<CommandBadge | null>(null);
92+ // Ghost text visibility - only show when cursor is at end and no command badge active
93+ let showGhostText = $derived(
94+ mode === 'omnibox' &&
95+ inlineCompletion &&
96+ cursorAtEnd &&
97+ inputValue.length > 0 &&
98+ !activeBadge
99+ );
87 let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);100 let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);
88 let inputPadding = $state(DEFAULT_PADDING.join(' '));101 let inputPadding = $state(DEFAULT_PADDING.join(' '));
89 let baseLeftPadding = $state(DEFAULT_PADDING[3]);102 let baseLeftPadding = $state(DEFAULT_PADDING[3]);
@@ -97,6 +110,19 @@
97 basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);110 basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);
98 });111 });
99 112
113+ // Debug: log when inline suggestion state changes
114+ $effect(() => {
115+ console.log('[INLINE] State changed:', {
116+ inlineCompletion,
117+ inlineSuggestion,
118+ showGhostText,
119+ cursorAtEnd,
120+ mode,
121+ inputValueLength: inputValue?.length,
122+ activeBadge: !!activeBadge
123+ });
124+ });
125+
100 $effect(() => {126 $effect(() => {
101 const value = inputValue || '';127 const value = inputValue || '';
102 const trimmedValue = value.trimStart();128 const trimmedValue = value.trimStart();
@@ -110,6 +136,57 @@
110 inputPadding = `${top} ${right} ${bottom} ${leftPadding}`;136 inputPadding = `${top} ${right} ${bottom} ${leftPadding}`;
111 });137 });
112 138
139+ // Track cursor position for ghost text visibility
140+ function updateCursorPosition() {
141+ if (inputElement) {
142+ cursorAtEnd = inputElement.selectionStart === inputValue.length &&
143+ inputElement.selectionEnd === inputValue.length;
144+ }
145+ }
146+
147+ // Accept inline suggestion (full or word-by-word)
148+ function acceptInlineSuggestion(acceptMode: 'full' | 'word') {
149+ if (!inlineSuggestion || !inlineCompletion) return;
150+
151+ switch (acceptMode) {
152+ case 'full':
153+ // Accept entire suggestion
154+ omniboxStore.setInputValue(inlineSuggestion);
155+ omniboxStore.clearInlineSuggestion();
156+ // Query for next prefix match
157+ debouncedPrefixQuery(inlineSuggestion);
158+ // Also update the search results
159+ debouncedQuery(inlineSuggestion);
160+ break;
161+
162+ case 'word':
163+ // Accept next word at cursor position
164+ const cursorPos = inputElement?.selectionStart ?? inputValue.length;
165+ const remainingCompletion = inlineSuggestion.slice(cursorPos);
166+
167+ // Find next word boundary (handles /, ., -, _ as boundaries)
168+ const wordMatch = remainingCompletion.match(/^([^\s\/\.\-\_]+[\s\/\.\-\_]?)/);
169+ if (wordMatch && wordMatch[1]) {
170+ const wordToInsert = wordMatch[1];
171+ const newValue = inputValue.slice(0, cursorPos) + wordToInsert + inputValue.slice(cursorPos);
172+ const newCursorPos = cursorPos + wordToInsert.length;
173+
174+ omniboxStore.setInputValue(newValue);
175+
176+ requestAnimationFrame(() => {
177+ if (inputElement) {
178+ inputElement.setSelectionRange(newCursorPos, newCursorPos);
179+ updateCursorPosition();
180+ }
181+ });
182+
183+ debouncedPrefixQuery(newValue);
184+ debouncedQuery(newValue);
185+ }
186+ break;
187+ }
188+ }
189+
113 // Handle input changes190 // Handle input changes
114 function handleInput(event: Event) {191 function handleInput(event: Event) {
115 const target = event.target as HTMLInputElement;192 const target = event.target as HTMLInputElement;
@@ -117,6 +194,7 @@
117 194
118 omniboxStore.setInputValue(value);195 omniboxStore.setInputValue(value);
119 omniboxStore.setFaded(false);196 omniboxStore.setFaded(false);
197+ updateCursorPosition();
120 198
121 if (mode === 'omnibox') {199 if (mode === 'omnibox') {
122 // Only query backend if we're in history view200 // Only query backend if we're in history view
@@ -125,9 +203,12 @@
125 if (value === '') {203 if (value === '') {
126 // Input is empty - fetch initial history based on config204 // Input is empty - fetch initial history based on config
127 omniboxBridge.fetchInitialHistory();205 omniboxBridge.fetchInitialHistory();
206+ omniboxStore.clearInlineSuggestion();
128 } else {207 } else {
129 // Input has content - perform search208 // Input has content - perform search
130 debouncedQuery(value);209 debouncedQuery(value);
210+ // Also query for inline suggestion (fish-style ghost text)
211+ debouncedPrefixQuery(value);
131 }212 }
132 }213 }
133 } else if (mode === 'find') {214 } else if (mode === 'find') {
@@ -190,6 +271,33 @@
190 handleArrowKeys(event.key);271 handleArrowKeys(event.key);
191 break;272 break;
192 273
274+ case 'ArrowRight':
275+ // Handle inline suggestion acceptance (fish-style)
276+ if (mode === 'omnibox' && inlineCompletion) {
277+ if (event.ctrlKey || event.metaKey) {
278+ // Ctrl+Right: accept next word
279+ event.preventDefault();
280+ event.stopPropagation();
281+ acceptInlineSuggestion('word');
282+ } else if (cursorAtEnd) {
283+ // Right at end: accept full suggestion
284+ event.preventDefault();
285+ event.stopPropagation();
286+ acceptInlineSuggestion('full');
287+ }
288+ // If cursor not at end and no modifier, let default behavior happen
289+ }
290+ break;
291+
292+ case 'y':
293+ // Ctrl+Y: accept full inline suggestion (alternative to Right Arrow)
294+ if (event.ctrlKey && mode === 'omnibox' && inlineCompletion) {
295+ event.preventDefault();
296+ event.stopPropagation();
297+ acceptInlineSuggestion('full');
298+ }
299+ break;
300+
193 default:301 default:
194 // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results302 // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results
195 // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.)303 // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.)
@@ -365,10 +473,6 @@
365 omniboxStore.setFaded(false);473 omniboxStore.setFaded(false);
366 }474 }
367 475
368- function handleClick() {
369- omniboxStore.setFaded(false);
370- }
371-
372 function handleFocus() {476 function handleFocus() {
373 omniboxStore.setFaded(false);477 omniboxStore.setFaded(false);
374 if (inputElement) {478 if (inputElement) {
@@ -394,6 +498,17 @@
394 }498 }
395 }499 }
396 }500 }
501+
502+ // Handle selection changes (for cursor tracking)
503+ function handleSelect() {
504+ updateCursorPosition();
505+ }
506+
507+ // Handle click with cursor tracking
508+ function handleClickWithCursor() {
509+ omniboxStore.setFaded(false);
510+ updateCursorPosition();
511+ }
397 </script>512 </script>
398 513
399 <div class="omnibox-input-wrapper">514 <div class="omnibox-input-wrapper">
@@ -405,17 +520,30 @@
405 >{activeBadge.label}</span>520 >{activeBadge.label}</span>
406 {/if}521 {/if}
407 522
523+ <!-- Ghost text overlay (fish-style inline suggestion) -->
524+ {#if showGhostText}
525+ <div
526+ class="omnibox-ghost-text"
527+ aria-hidden="true"
528+ style={`padding: ${inputPadding}; font-size: ${inputFontSize};`}
529+ >
530+ <span class="ghost-spacer">{inputValue}</span><span class="ghost-completion">{inlineCompletion}</span>
531+ </div>
532+ {/if}
533+
408 <input534 <input
409 bind:this={inputElement}535 bind:this={inputElement}
410 type="text"536 type="text"
411 {placeholder}537 {placeholder}
412 value={inputValue}538 value={inputValue}
413 class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"539 class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"
540+ class:has-ghost-text={showGhostText}
414 style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}541 style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}
415 oninput={handleInput}542 oninput={handleInput}
416 onkeydown={handleKeyDown}543 onkeydown={handleKeyDown}
417 onmousedown={handleMouseDown}544 onmousedown={handleMouseDown}
418- onclick={handleClick}545+ onclick={handleClickWithCursor}
546+ onselect={handleSelect}
419 onfocus={handleFocus}547 onfocus={handleFocus}
420 onblur={handleBlur}548 onblur={handleBlur}
421 onmouseenter={handleMouseEnter}549 onmouseenter={handleMouseEnter}
@@ -481,4 +609,50 @@
481 box-shadow: 0 0 0 1px var(--dynamic-border, transparent);609 box-shadow: 0 0 0 1px var(--dynamic-border, transparent);
482 z-index: 1;610 z-index: 1;
483 }611 }
612+
613+ /* Ghost text overlay for fish-style inline suggestions */
614+ .omnibox-ghost-text {
615+ position: absolute;
616+ top: 0;
617+ left: 0;
618+ right: 0;
619+ bottom: 0;
620+ pointer-events: none;
621+ display: flex;
622+ align-items: center;
623+ white-space: nowrap;
624+ overflow: hidden;
625+ font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
626+ letter-spacing: normal;
627+ z-index: 0;
628+ box-sizing: border-box;
629+ border: 1px solid transparent;
630+ }
631+
632+ .ghost-spacer {
633+ visibility: hidden;
634+ white-space: pre;
635+ }
636+
637+ .ghost-completion {
638+ color: var(--dynamic-muted);
639+ opacity: 0.5;
640+ white-space: pre;
641+ transition: opacity 100ms ease-out;
642+ }
643+
644+ .omnibox-ghost-text {
645+ transition: opacity 80ms ease-out;
646+ }
647+
648+ /* Make input transparent when showing ghost text */
649+ .omnibox-input-field.has-ghost-text {
650+ background: transparent !important;
651+ }
652+
653+ /* Ensure wrapper provides background when ghost text is active */
654+ .omnibox-input-wrapper:has(.has-ghost-text) {
655+ background: var(--dumber-input-bg, var(--dynamic-bg));
656+ border-radius: 2px;
657+ }
484 </style>658 </style>
M gui/src/components/omnibox/messaging.ts +50 -0
@@ -185,6 +185,28 @@ export class OmniboxBridge implements OmniboxMessageBridge {
185185 this.postMessage({ type: "toggle_favorite", url, title, faviconURL });
186186 }
187187
188+ /**
189+ * Handle inline suggestion from backend (fish-style ghost text)
190+ */
191+ setInlineSuggestion(url: string | null): void {
192+ console.log('[INLINE] bridge.setInlineSuggestion called:', url);
193+ const inputValue = omniboxStore.inputValue;
194+ omniboxStore.setInlineSuggestion(url, inputValue);
195+ }
196+
197+ /**
198+ * Query for prefix-matching URL (for inline suggestions)
199+ */
200+ prefixQuery(prefix: string): void {
201+ console.log('[INLINE] prefixQuery called:', prefix);
202+ if (!prefix || prefix.trim().length < MIN_SEARCH_LENGTH) {
203+ omniboxStore.clearInlineSuggestion();
204+ return;
205+ }
206+ this.postMessage({ type: "prefix_query", q: prefix });
207+ console.log('[INLINE] prefix_query message sent');
208+ }
209+
188210 // Suggestions are returned via setSuggestions() when native handler responds
189211 }
190212
@@ -246,6 +268,33 @@ export function debouncedFind(query: string, findFn: (q: string) => void): void
246268 createDebouncedSearch("find", findFn, () => findFn(""))(query);
247269 }
248270
271+// Debounce for inline suggestions - balanced between responsiveness and smoothness
272+const PREFIX_DEBOUNCE_MS = 80;
273+let prefixDebounceTimer = 0;
274+
275+/**
276+ * Debounced prefix query for inline suggestions (fish-style ghost text)
277+ */
278+export function debouncedPrefixQuery(query: string): void {
279+ if (prefixDebounceTimer) {
280+ clearTimeout(prefixDebounceTimer);
281+ prefixDebounceTimer = 0;
282+ }
283+
284+ const trimmed = (query || "").trim();
285+ if (trimmed.length < MIN_SEARCH_LENGTH) {
286+ // Clear after a short delay to avoid flicker
287+ prefixDebounceTimer = window.setTimeout(() => {
288+ omniboxStore.clearInlineSuggestion();
289+ }, 50);
290+ return;
291+ }
292+
293+ prefixDebounceTimer = window.setTimeout(() => {
294+ omniboxBridge.prefixQuery(trimmed);
295+ }, PREFIX_DEBOUNCE_MS);
296+}
297+
249298 // Extend global window interface for Go bridge compatibility
250299 declare global {
251300 interface Window {
@@ -259,6 +308,7 @@ declare global {
259308 // Omnibox API for Go bridge
260309 __dumber_omnibox?: {
261310 setSuggestions: (suggestions: Suggestion[]) => void;
311+ setInlineSuggestion: (url: string | null) => void;
262312 toggle: () => void;
263313 open: (mode?: string, query?: string) => void;
264314 close: () => void;
@@ -185,6 +185,28 @@ export class OmniboxBridge implements OmniboxMessageBridge {
185 this.postMessage({ type: "toggle_favorite", url, title, faviconURL });185 this.postMessage({ type: "toggle_favorite", url, title, faviconURL });
186 }186 }
187 187
188+ /**
189+ * Handle inline suggestion from backend (fish-style ghost text)
190+ */
191+ setInlineSuggestion(url: string | null): void {
192+ console.log('[INLINE] bridge.setInlineSuggestion called:', url);
193+ const inputValue = omniboxStore.inputValue;
194+ omniboxStore.setInlineSuggestion(url, inputValue);
195+ }
196+
197+ /**
198+ * Query for prefix-matching URL (for inline suggestions)
199+ */
200+ prefixQuery(prefix: string): void {
201+ console.log('[INLINE] prefixQuery called:', prefix);
202+ if (!prefix || prefix.trim().length < MIN_SEARCH_LENGTH) {
203+ omniboxStore.clearInlineSuggestion();
204+ return;
205+ }
206+ this.postMessage({ type: "prefix_query", q: prefix });
207+ console.log('[INLINE] prefix_query message sent');
208+ }
209+
188 // Suggestions are returned via setSuggestions() when native handler responds210 // Suggestions are returned via setSuggestions() when native handler responds
189 }211 }
190 212
@@ -246,6 +268,33 @@ export function debouncedFind(query: string, findFn: (q: string) => void): void
246 createDebouncedSearch("find", findFn, () => findFn(""))(query);268 createDebouncedSearch("find", findFn, () => findFn(""))(query);
247 }269 }
248 270
271+// Debounce for inline suggestions - balanced between responsiveness and smoothness
272+const PREFIX_DEBOUNCE_MS = 80;
273+let prefixDebounceTimer = 0;
274+
275+/**
276+ * Debounced prefix query for inline suggestions (fish-style ghost text)
277+ */
278+export function debouncedPrefixQuery(query: string): void {
279+ if (prefixDebounceTimer) {
280+ clearTimeout(prefixDebounceTimer);
281+ prefixDebounceTimer = 0;
282+ }
283+
284+ const trimmed = (query || "").trim();
285+ if (trimmed.length < MIN_SEARCH_LENGTH) {
286+ // Clear after a short delay to avoid flicker
287+ prefixDebounceTimer = window.setTimeout(() => {
288+ omniboxStore.clearInlineSuggestion();
289+ }, 50);
290+ return;
291+ }
292+
293+ prefixDebounceTimer = window.setTimeout(() => {
294+ omniboxBridge.prefixQuery(trimmed);
295+ }, PREFIX_DEBOUNCE_MS);
296+}
297+
249 // Extend global window interface for Go bridge compatibility298 // Extend global window interface for Go bridge compatibility
250 declare global {299 declare global {
251 interface Window {300 interface Window {
@@ -259,6 +308,7 @@ declare global {
259 // Omnibox API for Go bridge308 // Omnibox API for Go bridge
260 __dumber_omnibox?: {309 __dumber_omnibox?: {
261 setSuggestions: (suggestions: Suggestion[]) => void;310 setSuggestions: (suggestions: Suggestion[]) => void;
311+ setInlineSuggestion: (url: string | null) => void;
262 toggle: () => void;312 toggle: () => void;
263 open: (mode?: string, query?: string) => void;313 open: (mode?: string, query?: string) => void;
264 close: () => void;314 close: () => void;
M gui/src/components/omnibox/stores.svelte.ts +43 -0
@@ -51,6 +51,10 @@ let prevOverflow = $state("");
5151 let debounceTimer = $state(0);
5252 let searchShortcuts = $state<Record<string, SearchShortcut>>({});
5353
54+// Inline suggestion state (fish-style ghost text)
55+let inlineSuggestion = $state<string | null>(null);
56+let inlineCompletion = $state<string | null>(null);
57+
5458 // Configuration
5559 let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);
5660
@@ -98,6 +102,12 @@ export const omniboxStore = {
98102 get searchShortcuts() {
99103 return searchShortcuts;
100104 },
105+ get inlineSuggestion() {
106+ return inlineSuggestion;
107+ },
108+ get inlineCompletion() {
109+ return inlineCompletion;
110+ },
101111
102112 // Computed getters
103113 get hasContent() {
@@ -294,6 +304,37 @@ export const omniboxStore = {
294304 }
295305 },
296306
307+ setInlineSuggestion(url: string | null, currentInput: string) {
308+ console.log('[INLINE] setInlineSuggestion called:', { url, currentInput });
309+ inlineSuggestion = url;
310+ if (!url || !currentInput) {
311+ inlineCompletion = null;
312+ console.log('[INLINE] Cleared - missing url or input');
313+ return;
314+ }
315+
316+ // Normalize URL by stripping protocol and www for matching
317+ let normalizedUrl = url.toLowerCase();
318+ normalizedUrl = normalizedUrl.replace(/^https?:\/\//, '');
319+ normalizedUrl = normalizedUrl.replace(/^www\./, '');
320+
321+ const normalizedInput = currentInput.toLowerCase();
322+
323+ if (normalizedUrl.startsWith(normalizedInput)) {
324+ // Show the completion (rest of normalized URL after what user typed)
325+ inlineCompletion = normalizedUrl.slice(normalizedInput.length);
326+ console.log('[INLINE] Set completion:', inlineCompletion);
327+ } else {
328+ inlineCompletion = null;
329+ console.log('[INLINE] Cleared - no prefix match after normalization');
330+ }
331+ },
332+
333+ clearInlineSuggestion() {
334+ inlineSuggestion = null;
335+ inlineCompletion = null;
336+ },
337+
297338 reset() {
298339 visible = false;
299340 mode = "omnibox";
@@ -306,6 +347,8 @@ export const omniboxStore = {
306347 faded = false;
307348 inputValue = "";
308349 prevOverflow = "";
350+ inlineSuggestion = null;
351+ inlineCompletion = null;
309352 this.clearHighlights();
310353 this.clearDebounceTimer();
311354 },
@@ -51,6 +51,10 @@ let prevOverflow = $state("");
51 let debounceTimer = $state(0);51 let debounceTimer = $state(0);
52 let searchShortcuts = $state<Record<string, SearchShortcut>>({});52 let searchShortcuts = $state<Record<string, SearchShortcut>>({});
53 53
54+// Inline suggestion state (fish-style ghost text)
55+let inlineSuggestion = $state<string | null>(null);
56+let inlineCompletion = $state<string | null>(null);
57+
54 // Configuration58 // Configuration
55 let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);59 let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);
56 60
@@ -98,6 +102,12 @@ export const omniboxStore = {
98 get searchShortcuts() {102 get searchShortcuts() {
99 return searchShortcuts;103 return searchShortcuts;
100 },104 },
105+ get inlineSuggestion() {
106+ return inlineSuggestion;
107+ },
108+ get inlineCompletion() {
109+ return inlineCompletion;
110+ },
101 111
102 // Computed getters112 // Computed getters
103 get hasContent() {113 get hasContent() {
@@ -294,6 +304,37 @@ export const omniboxStore = {
294 }304 }
295 },305 },
296 306
307+ setInlineSuggestion(url: string | null, currentInput: string) {
308+ console.log('[INLINE] setInlineSuggestion called:', { url, currentInput });
309+ inlineSuggestion = url;
310+ if (!url || !currentInput) {
311+ inlineCompletion = null;
312+ console.log('[INLINE] Cleared - missing url or input');
313+ return;
314+ }
315+
316+ // Normalize URL by stripping protocol and www for matching
317+ let normalizedUrl = url.toLowerCase();
318+ normalizedUrl = normalizedUrl.replace(/^https?:\/\//, '');
319+ normalizedUrl = normalizedUrl.replace(/^www\./, '');
320+
321+ const normalizedInput = currentInput.toLowerCase();
322+
323+ if (normalizedUrl.startsWith(normalizedInput)) {
324+ // Show the completion (rest of normalized URL after what user typed)
325+ inlineCompletion = normalizedUrl.slice(normalizedInput.length);
326+ console.log('[INLINE] Set completion:', inlineCompletion);
327+ } else {
328+ inlineCompletion = null;
329+ console.log('[INLINE] Cleared - no prefix match after normalization');
330+ }
331+ },
332+
333+ clearInlineSuggestion() {
334+ inlineSuggestion = null;
335+ inlineCompletion = null;
336+ },
337+
297 reset() {338 reset() {
298 visible = false;339 visible = false;
299 mode = "omnibox";340 mode = "omnibox";
@@ -306,6 +347,8 @@ export const omniboxStore = {
306 faded = false;347 faded = false;
307 inputValue = "";348 inputValue = "";
308 prevOverflow = "";349 prevOverflow = "";
350+ inlineSuggestion = null;
351+ inlineCompletion = null;
309 this.clearHighlights();352 this.clearHighlights();
310 this.clearDebounceTimer();353 this.clearDebounceTimer();
311 },354 },
M gui/src/components/omnibox/types.ts +1 -1
@@ -23,7 +23,7 @@ export interface SearchShortcut {
2323 }
2424
2525 export interface OmniboxMessage {
26- type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite";
26+ type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite" | "prefix_query";
2727 url?: string;
2828 q?: string;
2929 limit?: number;
@@ -23,7 +23,7 @@ export interface SearchShortcut {
23 }23 }
24 24
25 export interface OmniboxMessage {25 export interface OmniboxMessage {
26- type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite";26+ type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite" | "prefix_query";
27 url?: string;27 url?: string;
28 q?: string;28 q?: string;
29 limit?: number;29 limit?: number;
M gui/src/injected/bootstrap.ts +1 -0
@@ -27,6 +27,7 @@ declare global {
2727 __dumber_keyboard?: KeyboardService;
2828 __dumber_omnibox?: {
2929 setSuggestions: (suggestions: Suggestion[]) => void;
30+ setInlineSuggestion: (url: string | null) => void;
3031 toggle: () => void;
3132 open: (mode?: string, query?: string) => void;
3233 close: () => void;
@@ -27,6 +27,7 @@ declare global {
27 __dumber_keyboard?: KeyboardService;27 __dumber_keyboard?: KeyboardService;
28 __dumber_omnibox?: {28 __dumber_omnibox?: {
29 setSuggestions: (suggestions: Suggestion[]) => void;29 setSuggestions: (suggestions: Suggestion[]) => void;
30+ setInlineSuggestion: (url: string | null) => void;
30 toggle: () => void;31 toggle: () => void;
31 open: (mode?: string, query?: string) => void;32 open: (mode?: string, query?: string) => void;
32 close: () => void;33 close: () => void;
M gui/src/injected/main-world.ts +7 -0
@@ -32,6 +32,7 @@ declare global {
3232 ) => number | void;
3333 __dumber_showZoomToast?: (level: number) => void;
3434 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
35+ __dumber_omnibox_inline_suggestion?: (url: string | null) => void;
3536 __dumber_favorites?: (favorites: unknown[]) => void;
3637 __dumber_webview_id?: string | number;
3738 __dumber_is_active?: boolean;
@@ -373,6 +374,12 @@ interface DumberAPI {
373374 }
374375 };
375376
377+ // Inline suggestion bridge (fish-style ghost text)
378+ window.__dumber_omnibox_inline_suggestion = (url: string | null) => {
379+ console.log("[INLINE] main-world received inline suggestion:", url);
380+ bridgeDispatch("dumber:omnibox:inline-suggestion", { url });
381+ };
382+
376383 // Favorites bridge
377384 window.__dumber_favorites = (favorites: unknown[]) => {
378385 bridgeDispatch("dumber:favorites", { favorites });
@@ -32,6 +32,7 @@ declare global {
32 ) => number | void;32 ) => number | void;
33 __dumber_showZoomToast?: (level: number) => void;33 __dumber_showZoomToast?: (level: number) => void;
34 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;34 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
35+ __dumber_omnibox_inline_suggestion?: (url: string | null) => void;
35 __dumber_favorites?: (favorites: unknown[]) => void;36 __dumber_favorites?: (favorites: unknown[]) => void;
36 __dumber_webview_id?: string | number;37 __dumber_webview_id?: string | number;
37 __dumber_is_active?: boolean;38 __dumber_is_active?: boolean;
@@ -373,6 +374,12 @@ interface DumberAPI {
373 }374 }
374 };375 };
375 376
377+ // Inline suggestion bridge (fish-style ghost text)
378+ window.__dumber_omnibox_inline_suggestion = (url: string | null) => {
379+ console.log("[INLINE] main-world received inline suggestion:", url);
380+ bridgeDispatch("dumber:omnibox:inline-suggestion", { url });
381+ };
382+
376 // Favorites bridge383 // Favorites bridge
377 window.__dumber_favorites = (favorites: unknown[]) => {384 window.__dumber_favorites = (favorites: unknown[]) => {
378 bridgeDispatch("dumber:favorites", { favorites });385 bridgeDispatch("dumber:favorites", { favorites });
M gui/src/injected/modules/omnibox.ts +22 -0
@@ -132,6 +132,28 @@ export async function initializeOmnibox(
132132 handleSuggestionsEvent,
133133 false,
134134 );
135+
136+ // Bridge: listen for inline suggestion events (fish-style ghost text)
137+ const handleInlineSuggestionEvent = (e: Event) => {
138+ try {
139+ const detail = (e as CustomEvent).detail;
140+ const url = detail?.url ?? null;
141+ console.log("[INLINE] omnibox module received event:", url);
142+ if (window.__dumber_omnibox?.setInlineSuggestion) {
143+ window.__dumber_omnibox.setInlineSuggestion(url);
144+ } else {
145+ console.warn("[INLINE] __dumber_omnibox.setInlineSuggestion not available");
146+ }
147+ } catch (err) {
148+ console.warn("[Bridge] Failed handling inline suggestion event:", err);
149+ }
150+ };
151+
152+ document.addEventListener(
153+ "dumber:omnibox:inline-suggestion",
154+ handleInlineSuggestionEvent,
155+ false,
156+ );
135157 } catch (error) {
136158 console.error("❌ Failed to initialize omnibox component:", error);
137159 throw error;
@@ -132,6 +132,28 @@ export async function initializeOmnibox(
132 handleSuggestionsEvent,132 handleSuggestionsEvent,
133 false,133 false,
134 );134 );
135+
136+ // Bridge: listen for inline suggestion events (fish-style ghost text)
137+ const handleInlineSuggestionEvent = (e: Event) => {
138+ try {
139+ const detail = (e as CustomEvent).detail;
140+ const url = detail?.url ?? null;
141+ console.log("[INLINE] omnibox module received event:", url);
142+ if (window.__dumber_omnibox?.setInlineSuggestion) {
143+ window.__dumber_omnibox.setInlineSuggestion(url);
144+ } else {
145+ console.warn("[INLINE] __dumber_omnibox.setInlineSuggestion not available");
146+ }
147+ } catch (err) {
148+ console.warn("[Bridge] Failed handling inline suggestion event:", err);
149+ }
150+ };
151+
152+ document.addEventListener(
153+ "dumber:omnibox:inline-suggestion",
154+ handleInlineSuggestionEvent,
155+ false,
156+ );
135 } catch (error) {157 } catch (error) {
136 console.error("❌ Failed to initialize omnibox component:", error);158 console.error("❌ Failed to initialize omnibox component:", error);
137 throw error;159 throw error;
M internal/app/messaging/handler.go +30 -0
@@ -127,6 +127,8 @@ func (h *Handler) Handle(payload string) {
127127 h.handleIsFavorite(msg)
128128 case "keyboard_blocking":
129129 h.handleKeyboardBlocking(msg)
130+ case "prefix_query":
131+ h.handlePrefixQuery(msg)
130132 }
131133 }
132134
@@ -871,3 +873,31 @@ func (h *Handler) handleKeyboardBlocking(msg Message) {
871873 logging.Error(fmt.Sprintf("[ERROR] Failed to inject keyboard blocking script: %v", err))
872874 }
873875 }
876+
877+// handlePrefixQuery returns the best prefix-matching URL for inline suggestions
878+func (h *Handler) handlePrefixQuery(msg Message) {
879+ if h.webView == nil || h.browserService == nil {
880+ return
881+ }
882+
883+ query := msg.Q
884+ if query == "" {
885+ _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
886+ return
887+ }
888+
889+ ctx := context.Background()
890+ prefixURL := h.browserService.GetBestPrefixMatch(ctx, query)
891+
892+ if prefixURL != "" {
893+ // Escape the URL for JavaScript string
894+ escaped, err := json.Marshal(prefixURL)
895+ if err != nil {
896+ _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
897+ return
898+ }
899+ _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(" + string(escaped) + ")")
900+ } else {
901+ _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
902+ }
903+}
@@ -127,6 +127,8 @@ func (h *Handler) Handle(payload string) {
127 h.handleIsFavorite(msg)127 h.handleIsFavorite(msg)
128 case "keyboard_blocking":128 case "keyboard_blocking":
129 h.handleKeyboardBlocking(msg)129 h.handleKeyboardBlocking(msg)
130+ case "prefix_query":
131+ h.handlePrefixQuery(msg)
130 }132 }
131 }133 }
132 134
@@ -871,3 +873,31 @@ func (h *Handler) handleKeyboardBlocking(msg Message) {
871 logging.Error(fmt.Sprintf("[ERROR] Failed to inject keyboard blocking script: %v", err))873 logging.Error(fmt.Sprintf("[ERROR] Failed to inject keyboard blocking script: %v", err))
872 }874 }
873 }875 }
876+
877+// handlePrefixQuery returns the best prefix-matching URL for inline suggestions
878+func (h *Handler) handlePrefixQuery(msg Message) {
879+ if h.webView == nil || h.browserService == nil {
880+ return
881+ }
882+
883+ query := msg.Q
884+ if query == "" {
885+ _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
886+ return
887+ }
888+
889+ ctx := context.Background()
890+ prefixURL := h.browserService.GetBestPrefixMatch(ctx, query)
891+
892+ if prefixURL != "" {
893+ // Escape the URL for JavaScript string
894+ escaped, err := json.Marshal(prefixURL)
895+ if err != nil {
896+ _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
897+ return
898+ }
899+ _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(" + string(escaped) + ")")
900+ } else {
901+ _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
902+ }
903+}
M internal/cache/fuzzy_cache.go +11 -0
@@ -322,6 +322,17 @@ func (cm *CacheManager) GetTopEntries(ctx context.Context) (*FuzzyResult, error)
322322 return cache.getTopEntries(cm.config), nil
323323 }
324324
325+// GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix.
326+// Used for fish-style inline autosuggestions.
327+func (cm *CacheManager) GetBestPrefixMatch(ctx context.Context, prefix string) string {
328+ cache, err := cm.GetCache(ctx)
329+ if err != nil || prefix == "" {
330+ return ""
331+ }
332+
333+ return cache.getBestPrefixMatch(prefix)
334+}
335+
325336 // Stats returns cache statistics.
326337 func (cm *CacheManager) Stats() CacheStats {
327338 cm.mu.RLock()
@@ -322,6 +322,17 @@ func (cm *CacheManager) GetTopEntries(ctx context.Context) (*FuzzyResult, error)
322 return cache.getTopEntries(cm.config), nil322 return cache.getTopEntries(cm.config), nil
323 }323 }
324 324
325+// GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix.
326+// Used for fish-style inline autosuggestions.
327+func (cm *CacheManager) GetBestPrefixMatch(ctx context.Context, prefix string) string {
328+ cache, err := cm.GetCache(ctx)
329+ if err != nil || prefix == "" {
330+ return ""
331+ }
332+
333+ return cache.getBestPrefixMatch(prefix)
334+}
335+
325 // Stats returns cache statistics.336 // Stats returns cache statistics.
326 func (cm *CacheManager) Stats() CacheStats {337 func (cm *CacheManager) Stats() CacheStats {
327 cm.mu.RLock()338 cm.mu.RLock()
M internal/cache/fuzzy_cache_test.go +146 -0
@@ -632,6 +632,152 @@ func BenchmarkGetTopEntries(b *testing.B) {
632632 }
633633 }
634634
635+func TestGetBestPrefixMatch(t *testing.T) {
636+ ctrl := gomock.NewController(t)
637+ defer ctrl.Finish()
638+
639+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
640+ history := createTestHistory()
641+
642+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
643+
644+ tempDir, err := os.MkdirTemp("", "dumber_prefix_test")
645+ if err != nil {
646+ t.Fatalf("Failed to create temp dir: %v", err)
647+ }
648+ defer func() {
649+ if err := os.RemoveAll(tempDir); err != nil {
650+ t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
651+ }
652+ }()
653+
654+ config := DefaultCacheConfig()
655+ config.CacheFile = filepath.Join(tempDir, "prefix_cache.bin")
656+
657+ manager := NewCacheManager(mockQuerier, config)
658+ ctx := context.Background()
659+
660+ // Build cache first
661+ _, err = manager.GetCache(ctx)
662+ if err != nil {
663+ t.Fatalf("Failed to get cache: %v", err)
664+ }
665+
666+ tests := []struct {
667+ name string
668+ prefix string
669+ expectURL string
670+ expectEmpty bool
671+ }{
672+ {
673+ name: "exact prefix match - github",
674+ prefix: "https://github",
675+ expectURL: "https://github.com",
676+ },
677+ {
678+ name: "partial prefix match - you",
679+ prefix: "https://you",
680+ expectURL: "https://youtube.com",
681+ },
682+ {
683+ name: "partial prefix match - stack",
684+ prefix: "https://stack",
685+ expectURL: "https://stackoverflow.com",
686+ },
687+ {
688+ name: "no match",
689+ prefix: "https://nonexistent",
690+ expectEmpty: true,
691+ },
692+ {
693+ name: "empty prefix",
694+ prefix: "",
695+ expectEmpty: true,
696+ },
697+ {
698+ name: "case insensitive match",
699+ prefix: "https://GITHUB",
700+ expectURL: "https://github.com",
701+ },
702+ }
703+
704+ for _, tt := range tests {
705+ t.Run(tt.name, func(t *testing.T) {
706+ result := manager.GetBestPrefixMatch(ctx, tt.prefix)
707+
708+ if tt.expectEmpty {
709+ if result != "" {
710+ t.Errorf("Expected empty result, got %q", result)
711+ }
712+ return
713+ }
714+
715+ if result != tt.expectURL {
716+ t.Errorf("Expected %q, got %q", tt.expectURL, result)
717+ }
718+ })
719+ }
720+}
721+
722+func TestGetBestPrefixMatch_ReturnsHighestScore(t *testing.T) {
723+ ctrl := gomock.NewController(t)
724+ defer ctrl.Finish()
725+
726+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
727+
728+ // Create history with multiple entries sharing the same prefix
729+ // "https://go" matches both golang.org and google.com
730+ now := time.Now()
731+ history := []db.History{
732+ {
733+ ID: 1,
734+ Url: "https://golang.org",
735+ Title: sql.NullString{String: "Go Programming Language", Valid: true},
736+ VisitCount: sql.NullInt64{Int64: 5, Valid: true},
737+ LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
738+ },
739+ {
740+ ID: 2,
741+ Url: "https://google.com",
742+ Title: sql.NullString{String: "Google", Valid: true},
743+ VisitCount: sql.NullInt64{Int64: 100, Valid: true}, // Much higher visits
744+ LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, // More recent
745+ },
746+ }
747+
748+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
749+
750+ tempDir, err := os.MkdirTemp("", "dumber_prefix_score_test")
751+ if err != nil {
752+ t.Fatalf("Failed to create temp dir: %v", err)
753+ }
754+ defer func() {
755+ if err := os.RemoveAll(tempDir); err != nil {
756+ t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
757+ }
758+ }()
759+
760+ config := DefaultCacheConfig()
761+ config.CacheFile = filepath.Join(tempDir, "prefix_score_cache.bin")
762+
763+ manager := NewCacheManager(mockQuerier, config)
764+ ctx := context.Background()
765+
766+ // Build cache first
767+ _, err = manager.GetCache(ctx)
768+ if err != nil {
769+ t.Fatalf("Failed to get cache: %v", err)
770+ }
771+
772+ // Query with prefix that matches both
773+ result := manager.GetBestPrefixMatch(ctx, "https://go")
774+
775+ // Should return google.com because it has higher score (more visits, more recent)
776+ if result != "https://google.com" {
777+ t.Errorf("Expected https://google.com (highest score), got %q", result)
778+ }
779+}
780+
635781 // BenchmarkGetTopEntriesParallel benchmarks concurrent access performance
636782 func BenchmarkGetTopEntriesParallel(b *testing.B) {
637783 ctrl := gomock.NewController(b)
@@ -632,6 +632,152 @@ func BenchmarkGetTopEntries(b *testing.B) {
632 }632 }
633 }633 }
634 634
635+func TestGetBestPrefixMatch(t *testing.T) {
636+ ctrl := gomock.NewController(t)
637+ defer ctrl.Finish()
638+
639+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
640+ history := createTestHistory()
641+
642+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
643+
644+ tempDir, err := os.MkdirTemp("", "dumber_prefix_test")
645+ if err != nil {
646+ t.Fatalf("Failed to create temp dir: %v", err)
647+ }
648+ defer func() {
649+ if err := os.RemoveAll(tempDir); err != nil {
650+ t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
651+ }
652+ }()
653+
654+ config := DefaultCacheConfig()
655+ config.CacheFile = filepath.Join(tempDir, "prefix_cache.bin")
656+
657+ manager := NewCacheManager(mockQuerier, config)
658+ ctx := context.Background()
659+
660+ // Build cache first
661+ _, err = manager.GetCache(ctx)
662+ if err != nil {
663+ t.Fatalf("Failed to get cache: %v", err)
664+ }
665+
666+ tests := []struct {
667+ name string
668+ prefix string
669+ expectURL string
670+ expectEmpty bool
671+ }{
672+ {
673+ name: "exact prefix match - github",
674+ prefix: "https://github",
675+ expectURL: "https://github.com",
676+ },
677+ {
678+ name: "partial prefix match - you",
679+ prefix: "https://you",
680+ expectURL: "https://youtube.com",
681+ },
682+ {
683+ name: "partial prefix match - stack",
684+ prefix: "https://stack",
685+ expectURL: "https://stackoverflow.com",
686+ },
687+ {
688+ name: "no match",
689+ prefix: "https://nonexistent",
690+ expectEmpty: true,
691+ },
692+ {
693+ name: "empty prefix",
694+ prefix: "",
695+ expectEmpty: true,
696+ },
697+ {
698+ name: "case insensitive match",
699+ prefix: "https://GITHUB",
700+ expectURL: "https://github.com",
701+ },
702+ }
703+
704+ for _, tt := range tests {
705+ t.Run(tt.name, func(t *testing.T) {
706+ result := manager.GetBestPrefixMatch(ctx, tt.prefix)
707+
708+ if tt.expectEmpty {
709+ if result != "" {
710+ t.Errorf("Expected empty result, got %q", result)
711+ }
712+ return
713+ }
714+
715+ if result != tt.expectURL {
716+ t.Errorf("Expected %q, got %q", tt.expectURL, result)
717+ }
718+ })
719+ }
720+}
721+
722+func TestGetBestPrefixMatch_ReturnsHighestScore(t *testing.T) {
723+ ctrl := gomock.NewController(t)
724+ defer ctrl.Finish()
725+
726+ mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
727+
728+ // Create history with multiple entries sharing the same prefix
729+ // "https://go" matches both golang.org and google.com
730+ now := time.Now()
731+ history := []db.History{
732+ {
733+ ID: 1,
734+ Url: "https://golang.org",
735+ Title: sql.NullString{String: "Go Programming Language", Valid: true},
736+ VisitCount: sql.NullInt64{Int64: 5, Valid: true},
737+ LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
738+ },
739+ {
740+ ID: 2,
741+ Url: "https://google.com",
742+ Title: sql.NullString{String: "Google", Valid: true},
743+ VisitCount: sql.NullInt64{Int64: 100, Valid: true}, // Much higher visits
744+ LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, // More recent
745+ },
746+ }
747+
748+ mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
749+
750+ tempDir, err := os.MkdirTemp("", "dumber_prefix_score_test")
751+ if err != nil {
752+ t.Fatalf("Failed to create temp dir: %v", err)
753+ }
754+ defer func() {
755+ if err := os.RemoveAll(tempDir); err != nil {
756+ t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
757+ }
758+ }()
759+
760+ config := DefaultCacheConfig()
761+ config.CacheFile = filepath.Join(tempDir, "prefix_score_cache.bin")
762+
763+ manager := NewCacheManager(mockQuerier, config)
764+ ctx := context.Background()
765+
766+ // Build cache first
767+ _, err = manager.GetCache(ctx)
768+ if err != nil {
769+ t.Fatalf("Failed to get cache: %v", err)
770+ }
771+
772+ // Query with prefix that matches both
773+ result := manager.GetBestPrefixMatch(ctx, "https://go")
774+
775+ // Should return google.com because it has higher score (more visits, more recent)
776+ if result != "https://google.com" {
777+ t.Errorf("Expected https://google.com (highest score), got %q", result)
778+ }
779+}
780+
635 // BenchmarkGetTopEntriesParallel benchmarks concurrent access performance781 // BenchmarkGetTopEntriesParallel benchmarks concurrent access performance
636 func BenchmarkGetTopEntriesParallel(b *testing.B) {782 func BenchmarkGetTopEntriesParallel(b *testing.B) {
637 ctrl := gomock.NewController(b)783 ctrl := gomock.NewController(b)
M internal/cache/trie.go +40 -0
@@ -329,3 +329,43 @@ func (c *DmenuFuzzyCache) buildSortedIndex() {
329329 return c.entries[c.sortedIndex[i]].Score > c.entries[c.sortedIndex[j]].Score
330330 })
331331 }
332+
333+// getBestPrefixMatch returns the highest-scoring URL that starts with the given prefix.
334+// Used for fish-style inline autosuggestions.
335+func (c *DmenuFuzzyCache) getBestPrefixMatch(prefix string) string {
336+ c.mu.RLock()
337+ defer c.mu.RUnlock()
338+
339+ if c.prefixTrie == nil || prefix == "" {
340+ return ""
341+ }
342+
343+ normalizedPrefix := normalizeText(prefix)
344+ candidates := c.prefixTrie.PrefixSearch(normalizedPrefix)
345+ if len(candidates) == 0 {
346+ return ""
347+ }
348+
349+ var bestURL string
350+ var bestScore uint16
351+
352+ for _, entryID := range candidates {
353+ if int(entryID) >= len(c.entries) {
354+ continue
355+ }
356+ entry := &c.entries[entryID]
357+
358+ // Only match if URL actually starts with the prefix (case-insensitive)
359+ normalizedURL := normalizeText(entry.URL)
360+ if !strings.HasPrefix(normalizedURL, normalizedPrefix) {
361+ continue
362+ }
363+
364+ if entry.Score > bestScore {
365+ bestScore = entry.Score
366+ bestURL = entry.URL
367+ }
368+ }
369+
370+ return bestURL
371+}
@@ -329,3 +329,43 @@ func (c *DmenuFuzzyCache) buildSortedIndex() {
329 return c.entries[c.sortedIndex[i]].Score > c.entries[c.sortedIndex[j]].Score329 return c.entries[c.sortedIndex[i]].Score > c.entries[c.sortedIndex[j]].Score
330 })330 })
331 }331 }
332+
333+// getBestPrefixMatch returns the highest-scoring URL that starts with the given prefix.
334+// Used for fish-style inline autosuggestions.
335+func (c *DmenuFuzzyCache) getBestPrefixMatch(prefix string) string {
336+ c.mu.RLock()
337+ defer c.mu.RUnlock()
338+
339+ if c.prefixTrie == nil || prefix == "" {
340+ return ""
341+ }
342+
343+ normalizedPrefix := normalizeText(prefix)
344+ candidates := c.prefixTrie.PrefixSearch(normalizedPrefix)
345+ if len(candidates) == 0 {
346+ return ""
347+ }
348+
349+ var bestURL string
350+ var bestScore uint16
351+
352+ for _, entryID := range candidates {
353+ if int(entryID) >= len(c.entries) {
354+ continue
355+ }
356+ entry := &c.entries[entryID]
357+
358+ // Only match if URL actually starts with the prefix (case-insensitive)
359+ normalizedURL := normalizeText(entry.URL)
360+ if !strings.HasPrefix(normalizedURL, normalizedPrefix) {
361+ continue
362+ }
363+
364+ if entry.Score > bestScore {
365+ bestScore = entry.Score
366+ bestURL = entry.URL
367+ }
368+ }
369+
370+ return bestURL
371+}
M internal/services/browser.go +9 -0
@@ -366,6 +366,15 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit
366366 return result, nil
367367 }
368368
369+// GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix.
370+// Used for fish-style inline autosuggestions in the omnibox.
371+func (s *BrowserService) GetBestPrefixMatch(ctx context.Context, prefix string) string {
372+ if s.fuzzyCache == nil || prefix == "" {
373+ return ""
374+ }
375+ return s.fuzzyCache.GetBestPrefixMatch(ctx, prefix)
376+}
377+
369378 // DeleteHistoryEntry removes a specific history entry.
370379 func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
371380 return s.dbQueries.DeleteHistory(ctx, id)
@@ -366,6 +366,15 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit
366 return result, nil366 return result, nil
367 }367 }
368 368
369+// GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix.
370+// Used for fish-style inline autosuggestions in the omnibox.
371+func (s *BrowserService) GetBestPrefixMatch(ctx context.Context, prefix string) string {
372+ if s.fuzzyCache == nil || prefix == "" {
373+ return ""
374+ }
375+ return s.fuzzyCache.GetBestPrefixMatch(ctx, prefix)
376+}
377+
369 // DeleteHistoryEntry removes a specific history entry.378 // DeleteHistoryEntry removes a specific history entry.
370 func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {379 func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
371 return s.dbQueries.DeleteHistory(ctx, id)380 return s.dbQueries.DeleteHistory(ctx, id)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close