Skip to content

Commit ca1ace4

Browse files
authored
Merge pull request #37 from Shishir435/feature/highlight-selection-context
feat: Add selection-to-chat functionality via context menu and a floa…
2 parents 9c98bac + 6db749e commit ca1ace4

File tree

11 files changed

+286
-10
lines changed

11 files changed

+286
-10
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "ollama-client",
33
"displayName": "Ollama Client - Chat with Local LLM Models",
4-
"version": "0.3.1",
4+
"version": "0.3.2",
55
"description": "Privacy-first Ollama Chrome extension to chat with local AI models like LLaMA, Mistral, Gemma — fully offline.",
66
"author": "Shishir Chaurasiya",
77
"keywords": [
@@ -149,7 +149,8 @@
149149
"storage",
150150
"sidePanel",
151151
"tabs",
152-
"declarativeNetRequest"
152+
"declarativeNetRequest",
153+
"contextMenus"
153154
],
154155
"browser_specific_settings": {
155156
"gecko": {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { browser } from "@/lib/browser-api"
2+
import { DEFAULT_CONTEXT_MENU_ID, MESSAGE_KEYS } from "@/lib/constants"
3+
import type { ChromeSidePanel } from "@/types"
4+
5+
export const initializeContextMenu = () => {
6+
browser.contextMenus.create(
7+
{
8+
id: DEFAULT_CONTEXT_MENU_ID,
9+
title: "Ask Ollama Client",
10+
contexts: ["selection"]
11+
},
12+
() => {
13+
if (browser.runtime.lastError) {
14+
console.log(
15+
"Context menu item already exists or error:",
16+
browser.runtime.lastError.message
17+
)
18+
}
19+
}
20+
)
21+
22+
browser.contextMenus.onClicked.addListener((info, tab) => {
23+
if (info.menuItemId === DEFAULT_CONTEXT_MENU_ID && info.selectionText) {
24+
// Open sidepanel if possible (Chrome specific)
25+
if ("sidePanel" in browser) {
26+
const sidePanel = (browser as unknown as { sidePanel: ChromeSidePanel })
27+
.sidePanel
28+
if (sidePanel.open && tab?.windowId) {
29+
sidePanel
30+
.open({ windowId: tab.windowId })
31+
.catch((err: unknown) =>
32+
console.error(
33+
"Failed to open sidepanel:",
34+
err instanceof Error ? err.message : String(err)
35+
)
36+
)
37+
}
38+
}
39+
40+
browser.runtime
41+
.sendMessage({
42+
type: MESSAGE_KEYS.BROWSER.ADD_SELECTION_TO_CHAT,
43+
payload: info.selectionText,
44+
fromBackground: true
45+
})
46+
.catch((err) => {
47+
console.log("Could not send selection to chat:", err)
48+
})
49+
}
50+
})
51+
}

src/background/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import "webextension-polyfill"
22

33
import { handleChatWithModel } from "@/background/handlers/handle-chat-with-model"
4+
import { initializeContextMenu } from "@/background/handlers/handle-context-menu"
45
import { handleDeleteModel } from "@/background/handlers/handle-delete-model"
56
import {
67
handleEmbedFileChunks,
@@ -33,6 +34,7 @@ import type {
3334
ChatWithModelMessage,
3435
ChromeMessage,
3536
ChromePort,
37+
ChromeSidePanel,
3638
ModelPullMessage,
3739
PortStatusFunction
3840
} from "@/types"
@@ -79,6 +81,7 @@ if (!isChromiumBased()) {
7981
if (isChromiumBased()) {
8082
browser.runtime.onInstalled.addListener(async (details) => {
8183
updateDNRRules()
84+
initializeContextMenu()
8285

8386
// Auto-download embedding model on first install
8487
if (details.reason === "install") {
@@ -270,6 +273,42 @@ browser.runtime.onMessage.addListener(
270273
})
271274
return true
272275
}
276+
277+
case MESSAGE_KEYS.BROWSER.ADD_SELECTION_TO_CHAT: {
278+
// Open sidepanel if possible (Chrome specific)
279+
if (isChromiumBased() && "sidePanel" in browser) {
280+
const sidePanel = (
281+
browser as unknown as { sidePanel: ChromeSidePanel }
282+
).sidePanel
283+
const windowId = _sender.tab?.windowId
284+
if (windowId && sidePanel.open) {
285+
sidePanel.open({ windowId }).catch((err: unknown) => {
286+
console.error(
287+
"Failed to open sidepanel:",
288+
err instanceof Error ? err.message : String(err)
289+
)
290+
})
291+
}
292+
}
293+
294+
setTimeout(() => {
295+
if ((message as ChromeMessage).fromBackground) return
296+
297+
browser.runtime
298+
.sendMessage({
299+
...message,
300+
fromBackground: true
301+
})
302+
.catch((err) => {
303+
console.log(
304+
"Could not forward selection to chat (sidepanel might be closed):",
305+
err
306+
)
307+
})
308+
}, 500)
309+
310+
return true
311+
}
273312
}
274313
}
275314
)

src/contents/selection-button.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import cssText from "data-text:~globals.css"
2+
import { useStorage } from "@plasmohq/storage/hook"
3+
import type { PlasmoCSConfig, PlasmoGetStyle } from "plasmo"
4+
import { useEffect, useState } from "react"
5+
6+
import { Button } from "@/components/ui/button"
7+
import { MESSAGE_KEYS, STORAGE_KEYS } from "@/lib/constants"
8+
import { Quote } from "@/lib/lucide-icon"
9+
import { plasmoGlobalStorage } from "@/lib/plasmo-global-storage"
10+
11+
export const config: PlasmoCSConfig = {
12+
matches: ["<all_urls>"],
13+
all_frames: true
14+
}
15+
16+
export const getStyle: PlasmoGetStyle = () => {
17+
const style = document.createElement("style")
18+
style.textContent = cssText
19+
// Fix for :root variables in Shadow DOM
20+
// Replace :root with :host to ensure variables apply within the shadow tree
21+
style.textContent = style.textContent.replace(/:root/g, ":host")
22+
return style
23+
}
24+
25+
const SelectionButton = () => {
26+
const [showButton, setShowButton] = useState(false)
27+
const [position, setPosition] = useState({ top: 0, left: 0 })
28+
const [selectionText, setSelectionText] = useState("")
29+
30+
const [isEnabled] = useStorage<boolean>(
31+
{
32+
key: STORAGE_KEYS.BROWSER.SHOW_SELECTION_BUTTON,
33+
instance: plasmoGlobalStorage
34+
},
35+
true
36+
)
37+
38+
useEffect(() => {
39+
if (!isEnabled) {
40+
setShowButton(false)
41+
return
42+
}
43+
44+
const handleSelectionChange = () => {
45+
const selection = window.getSelection()
46+
const text = selection?.toString().trim()
47+
48+
if (text && text.length > 0) {
49+
const range = selection?.getRangeAt(0)
50+
const rect = range?.getBoundingClientRect()
51+
52+
if (rect) {
53+
setPosition({
54+
top: rect.bottom + window.scrollY + 10,
55+
left: rect.right + window.scrollX - 30
56+
})
57+
setSelectionText(text)
58+
setShowButton(true)
59+
}
60+
} else {
61+
setShowButton(false)
62+
}
63+
}
64+
65+
document.addEventListener("mouseup", handleSelectionChange)
66+
document.addEventListener("keyup", handleSelectionChange)
67+
68+
return () => {
69+
document.removeEventListener("mouseup", handleSelectionChange)
70+
document.removeEventListener("keyup", handleSelectionChange)
71+
}
72+
}, [isEnabled])
73+
74+
const handleClick = async () => {
75+
try {
76+
await chrome.runtime.sendMessage({
77+
type: MESSAGE_KEYS.BROWSER.ADD_SELECTION_TO_CHAT,
78+
payload: selectionText
79+
})
80+
81+
setShowButton(false)
82+
window.getSelection()?.removeAllRanges()
83+
} catch (error) {
84+
console.error("Failed to send selection:", error)
85+
}
86+
}
87+
88+
if (!showButton || !isEnabled) return null
89+
90+
return (
91+
<div
92+
style={{
93+
position: "absolute",
94+
top: position.top,
95+
left: position.left,
96+
zIndex: 2147483647
97+
}}>
98+
<Button
99+
onClick={handleClick}
100+
variant="secondary"
101+
className="h-8 gap-2 rounded-lg px-3 shadow-lg transition-all hover:-translate-y-0.5 hover:shadow-xl"
102+
title="Add to Ollama Client">
103+
<Quote className="size-3.5" />
104+
<span className="text-xs font-medium">Ask Ollama client</span>
105+
</Button>
106+
</div>
107+
)
108+
}
109+
110+
export default SelectionButton

src/features/chat/components/chat-input-box.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useRef, useState } from "react"
1+
import { useCallback, useEffect, useRef, useState } from "react"
22

33
import { SettingsButton } from "@/components/settings-button"
44
import { Textarea } from "@/components/ui/textarea"
@@ -13,8 +13,10 @@ import { PromptSelectorDialog } from "@/features/prompt/components/prompt-select
1313
import { TabsSelect } from "@/features/tabs/components/tabs-select"
1414
import { TabsToggle } from "@/features/tabs/components/tabs-toggle"
1515
import { useAutoResizeTextarea } from "@/hooks/use-auto-resize-textarea"
16+
import { MESSAGE_KEYS } from "@/lib/constants"
1617
import type { ProcessedFile } from "@/lib/file-processors/types"
1718
import { cn } from "@/lib/utils"
19+
import type { ChromeMessage } from "@/types"
1820

1921
export const ChatInputBox = ({
2022
onSend,
@@ -27,7 +29,7 @@ export const ChatInputBox = ({
2729
) => void
2830
stopGeneration: () => void
2931
}) => {
30-
const { input, setInput } = useChatInput()
32+
const { input, setInput, appendInput } = useChatInput()
3133
const { isLoading } = useLoadStream()
3234
const textareaRef = useRef<HTMLTextAreaElement>(null)
3335
const selectionStartRef = useRef<number | null>(null)
@@ -134,6 +136,24 @@ export const ChatInputBox = ({
134136
[processFiles]
135137
)
136138

139+
useEffect(() => {
140+
const handleMessage = (message: unknown) => {
141+
const msg = message as ChromeMessage
142+
if (
143+
msg.type === MESSAGE_KEYS.BROWSER.ADD_SELECTION_TO_CHAT &&
144+
msg.payload &&
145+
msg.fromBackground
146+
) {
147+
const selectionText = `> ${(msg.payload as string).split("\n").join("\n> ")}\n`
148+
appendInput(selectionText)
149+
textareaRef.current?.focus()
150+
}
151+
}
152+
153+
chrome.runtime.onMessage.addListener(handleMessage)
154+
return () => chrome.runtime.onMessage.removeListener(handleMessage)
155+
}, [appendInput])
156+
137157
const hasFiles = processingStates.length > 0
138158
const successCount = processingStates.filter(
139159
(s) => s.status === "success"

src/features/chat/stores/chat-input-store.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@ import type { ChatInput } from "@/types"
55

66
export const chatInputStore = create<ChatInput>((set) => ({
77
input: "",
8-
setInput: (text) => set({ input: text })
8+
setInput: (text) => set({ input: text }),
9+
appendInput: (text) => set((state) => ({ input: state.input + text }))
910
}))
1011

1112
export const useChatInput = () => {
1213
return chatInputStore(
1314
useShallow((s) => ({
1415
input: s.input,
15-
setInput: s.setInput
16+
setInput: s.setInput,
17+
appendInput: s.appendInput
1618
}))
1719
)
1820
}

src/features/model/components/global-settings.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { Separator } from "@/components/ui/separator"
1919
import { Slider } from "@/components/ui/slider"
2020
import { Switch } from "@/components/ui/switch"
21+
import { SelectionButtonToggle } from "@/features/model/components/selection-button-toggle"
2122
import {
2223
CONTENT_SCRAPER_OPTIONS,
2324
SCROLL_STRATEGY_DESCRIPTIONS,
@@ -252,6 +253,21 @@ export const GlobalSettings = ({ config, onUpdate }: GlobalSettingsProps) => {
252253
/>
253254
</div>
254255

256+
<div className="rounded-lg border bg-card p-4">
257+
<div className="flex items-center justify-between space-x-2">
258+
<Label
259+
htmlFor="show-selection-button"
260+
className="flex flex-col space-y-1">
261+
<span>Show "Ask ollama client" button on selection</span>
262+
<span className="font-normal text-muted-foreground text-xs">
263+
Show a floating button when selecting text to quickly add it to
264+
chat
265+
</span>
266+
</Label>
267+
<SelectionButtonToggle />
268+
</div>
269+
</div>
270+
255271
<Separator />
256272

257273
{/* Content Scraper Selection */}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useStorage } from "@plasmohq/storage/hook"
2+
import { Switch } from "@/components/ui/switch"
3+
import { STORAGE_KEYS } from "@/lib/constants"
4+
import { plasmoGlobalStorage } from "@/lib/plasmo-global-storage"
5+
6+
export const SelectionButtonToggle = () => {
7+
const [showButton, setShowButton] = useStorage<boolean>(
8+
{
9+
key: STORAGE_KEYS.BROWSER.SHOW_SELECTION_BUTTON,
10+
instance: plasmoGlobalStorage
11+
},
12+
true
13+
)
14+
15+
return (
16+
<Switch
17+
id="show-selection-button"
18+
checked={showButton}
19+
onCheckedChange={setShowButton}
20+
/>
21+
)
22+
}

0 commit comments

Comments
 (0)