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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 0.3.0 (2026-05-30)

### Features

- Keyboard shortcuts to control the chat overlay: **Alt+G** toggles open/closed from anywhere on the page, **Escape** closes it
- Shortcuts are configurable from the settings panel — click a shortcut field, press the key combination to rebind, or reset to default

### Fixes

- Shortcuts now work while the chat input is focused (the input previously swallowed the key events), so Alt+G and Escape reliably toggle/close the chat from inside the overlay

## 0.2.0 (2026-04-06)

### Features
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,19 @@ Click the gear icon in the chat header:
- **Model**: Switch between Gemma 4 E2B (~500MB) and E4B (~1.5GB). Selection persists across sessions.
- **Thinking**: Toggle native Gemma 4 thinking
- **Max iterations**: Cap on tool call loops per request
- **Shortcuts**: Rebind the keyboard shortcuts that toggle and close the chat. Click a shortcut field, press the key combination you want, and it's saved. The `↺` button resets a shortcut to its default. Defaults: **Alt+G** toggles the chat, **Escape** closes it. Bindings persist across sessions.
- **Clear context**: Reset conversation history for the current page
- **Disable on this site**: Disable the extension per-hostname (persisted)

## Keyboard Shortcuts

| Shortcut | Action | Default |
|----------|--------|---------|
| Toggle chat | Open/close the chat overlay from anywhere on the page | `Alt+G` |
| Close chat | Close the overlay when it's open | `Escape` |

Both are rebindable from the gear-icon settings panel (see **Shortcuts** above).

## Development

```bash
Expand Down
240 changes: 234 additions & 6 deletions content/chat-overlay.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { marked } from 'marked'
import { MODELS, DEFAULT_MODEL_ID, type ModelId } from '@/shared/models'
import {
DEFAULT_SHORTCUTS,
formatShortcut,
shortcutFromEvent,
type Shortcut,
type ShortcutsConfig,
} from '@/shared/shortcuts'

marked.setOptions({ breaks: true })

Expand Down Expand Up @@ -124,6 +131,30 @@ const STYLES = `
}
.setting-disable:hover { background: rgba(239, 68, 68, 0.25); }

/* Shortcut rebinding */
.settings-divider {
height: 1px; background: rgba(139, 92, 246, 0.15); margin: 2px 0;
}
.settings-section-label {
font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em;
}
.shortcut-controls { display: flex; align-items: center; gap: 6px; }
.shortcut-key {
min-width: 76px; background: rgba(30, 30, 50, 0.6); border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 4px; padding: 3px 8px; color: #e2e8f0; font-size: 12px;
font-family: 'SF Mono', Menlo, Consolas, monospace; text-align: center; cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.shortcut-key:hover { border-color: rgba(139, 92, 246, 0.5); }
.shortcut-key.recording {
border-color: rgba(165, 180, 252, 0.8); color: #a5b4fc;
}
.shortcut-reset {
background: none; border: none; color: #64748b; cursor: pointer;
font-size: 13px; padding: 0 2px; line-height: 1; transition: color 0.2s;
}
.shortcut-reset:hover { color: #e2e8f0; }

/* Messages */
.chat-messages {
flex: 1; overflow-y: auto; padding: 12px;
Expand Down Expand Up @@ -250,6 +281,36 @@ const STYLES = `
.chat-stop { background: rgba(239, 68, 68, 0.5); }
.chat-stop:hover { background: rgba(239, 68, 68, 0.7); }
.chat-send svg, .chat-stop svg { width: 18px; height: 18px; }

/* Model picker */
.model-picker {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 24px;
gap: 14px;
}
.model-picker.visible { display: flex; }
.model-picker-title { font-size: 15px; font-weight: 600; color: #c4b5fd; text-align: center; }
.model-picker-subtitle { font-size: 11px; color: #64748b; text-align: center; line-height: 1.4; }
.model-options { display: flex; flex-direction: column; gap: 10px; width: 100%; }
.model-option {
background: rgba(30, 30, 50, 0.8);
border: 1px solid rgba(139, 92, 246, 0.25);
border-radius: 10px;
padding: 14px 16px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
text-align: center;
user-select: none;
}
.model-option:hover { background: rgba(139, 92, 246, 0.15); border-color: rgba(139, 92, 246, 0.5); }
.model-option:active { background: rgba(139, 92, 246, 0.2); }
.model-option-name { font-size: 14px; font-weight: 600; color: #e2e8f0; }
.model-option-size { font-size: 11px; color: #a5b4fc; margin-top: 3px; }
.model-option-desc { font-size: 11px; color: #64748b; margin-top: 2px; }
`

export interface ChatOverlayCallbacks {
Expand All @@ -259,6 +320,7 @@ export interface ChatOverlayCallbacks {
onClearContext: () => void
onDisableSite: () => void
onModelSwitch: (modelId: ModelId) => void
onShortcutsChange: (shortcuts: ShortcutsConfig) => void
}

export class ChatOverlay {
Expand All @@ -275,13 +337,21 @@ export class ChatOverlay {
private iterationsTag: HTMLElement
private modelTag: HTMLElement
private modelSelect: HTMLSelectElement
private modelPickerEl: HTMLElement
private inputAreaEl: HTMLElement
private typingEl: HTMLElement | null = null
private streamEl: HTMLElement | null = null
private streamText = ''
private thinkingStreamEl: HTMLElement | null = null
private thinkingStreamText = ''
private visible = false
settings: ChatSettings = { ...DEFAULT_SETTINGS }
private shortcuts: ShortcutsConfig = {
toggle: { ...DEFAULT_SHORTCUTS.toggle },
close: { ...DEFAULT_SHORTCUTS.close },
}
private shortcutKeyEls: Partial<Record<keyof ShortcutsConfig, HTMLElement>> = {}
private stopRecording: (() => void) | null = null

constructor(callbacks: ChatOverlayCallbacks) {
this.host = document.createElement('div')
Expand Down Expand Up @@ -354,6 +424,18 @@ export class ChatOverlay {
</div>
`
this.modelSelect = this.settingsPanel.querySelector('[data-setting="modelId"]') as HTMLSelectElement

// Shortcuts section
const divider = document.createElement('div')
divider.className = 'settings-divider'
const shortcutsLabel = document.createElement('span')
shortcutsLabel.className = 'settings-section-label'
shortcutsLabel.textContent = 'Shortcuts'
this.settingsPanel.appendChild(divider)
this.settingsPanel.appendChild(shortcutsLabel)
this.settingsPanel.appendChild(this.createShortcutRow('toggle', 'Toggle chat', callbacks))
this.settingsPanel.appendChild(this.createShortcutRow('close', 'Close chat', callbacks))

const disableBtn = document.createElement('button')
disableBtn.className = 'setting-disable'
disableBtn.textContent = 'Disable on this site'
Expand Down Expand Up @@ -405,13 +487,30 @@ export class ChatOverlay {
statusBar.appendChild(tags)
statusBar.appendChild(clearBtn)

// Model picker
this.modelPickerEl = document.createElement('div')
this.modelPickerEl.className = 'model-picker'
this.modelPickerEl.innerHTML = `
<div class="model-picker-title">Choose a model</div>
<div class="model-picker-subtitle">Select which Gemma 4 model to download and run locally via WebGPU.</div>
<div class="model-options">
${Object.values(MODELS).map(m => `
<div class="model-option" data-model="${m.id}">
<div class="model-option-name">${m.label}</div>
<div class="model-option-size">${m.downloadSize}</div>
<div class="model-option-desc">${m.id === 'gemma-4-e2b' ? 'Faster, less RAM' : 'Smarter, more RAM'}</div>
</div>
`).join('')}
</div>
`

// Messages
this.messagesEl = document.createElement('div')
this.messagesEl.className = 'chat-messages'

// Input area
const inputArea = document.createElement('div')
inputArea.className = 'chat-input-area'
this.inputAreaEl = document.createElement('div')
this.inputAreaEl.className = 'chat-input-area'
this.inputEl = document.createElement('textarea')
this.inputEl.className = 'chat-input'
this.inputEl.placeholder = 'Ask about this page...'
Expand All @@ -425,17 +524,31 @@ export class ChatOverlay {
this.stopBtn.style.display = 'none'
this.stopBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'

inputArea.appendChild(this.inputEl)
inputArea.appendChild(this.sendBtn)
inputArea.appendChild(this.stopBtn)
this.inputAreaEl.appendChild(this.inputEl)
this.inputAreaEl.appendChild(this.sendBtn)
this.inputAreaEl.appendChild(this.stopBtn)

this.container.appendChild(header)
this.container.appendChild(this.settingsPanel)
this.container.appendChild(statusBar)
this.container.appendChild(this.modelPickerEl)
this.container.appendChild(this.messagesEl)
this.container.appendChild(inputArea)
this.container.appendChild(this.inputAreaEl)

this.shadow.appendChild(this.container)

// Model picker click handlers
const pickerOptions = this.modelPickerEl.querySelectorAll('.model-option')
for (const opt of pickerOptions) {
opt.addEventListener('click', () => {
const modelId = (opt as HTMLElement).dataset.model as ModelId
if (modelId) {
this.hideModelPicker()
callbacks.onModelSwitch(modelId)
}
})
}

this.sendBtn.addEventListener('click', () => this.handleSend(callbacks.onSend))
this.stopBtn.addEventListener('click', () => callbacks.onStop())

Expand All @@ -457,6 +570,99 @@ export class ChatOverlay {
this.iterationsTag.textContent = `\u{1F504} ${this.settings.maxIterations} iters`
}

private createShortcutRow(
action: keyof ShortcutsConfig,
label: string,
callbacks: ChatOverlayCallbacks,
): HTMLElement {
const row = document.createElement('div')
row.className = 'setting-row'

const labelEl = document.createElement('span')
labelEl.className = 'setting-label'
labelEl.textContent = label

const controls = document.createElement('div')
controls.className = 'shortcut-controls'

const keyEl = document.createElement('button')
keyEl.className = 'shortcut-key'
keyEl.title = 'Click, then press the keys to bind'
keyEl.addEventListener('click', () => this.beginRecording(action, callbacks))

const resetEl = document.createElement('button')
resetEl.className = 'shortcut-reset'
resetEl.textContent = '↺'
resetEl.title = 'Reset to default'
resetEl.addEventListener('click', () => {
this.cancelRecording()
this.shortcuts[action] = { ...DEFAULT_SHORTCUTS[action] }
this.renderShortcut(action)
callbacks.onShortcutsChange(this.cloneShortcuts())
})

controls.appendChild(keyEl)
controls.appendChild(resetEl)
row.appendChild(labelEl)
row.appendChild(controls)

this.shortcutKeyEls[action] = keyEl
this.renderShortcut(action)
return row
}

private renderShortcut(action: keyof ShortcutsConfig): void {
const keyEl = this.shortcutKeyEls[action]
if (!keyEl) return
keyEl.classList.remove('recording')
keyEl.textContent = formatShortcut(this.shortcuts[action])
}

private cloneShortcuts(): ShortcutsConfig {
return { toggle: { ...this.shortcuts.toggle }, close: { ...this.shortcuts.close } }
}

private cancelRecording(): void {
if (this.stopRecording) this.stopRecording()
}

isRecording(): boolean {
return this.stopRecording !== null
}

private beginRecording(action: keyof ShortcutsConfig, callbacks: ChatOverlayCallbacks): void {
// A second click on the same field cancels recording.
if (this.stopRecording) {
this.stopRecording()
return
}

const keyEl = this.shortcutKeyEls[action]
if (!keyEl) return
keyEl.classList.add('recording')
keyEl.textContent = 'Press keys…'

const onKeyDown = (e: KeyboardEvent) => {
const shortcut = shortcutFromEvent(e)
// Modifier-only press: keep waiting for the real key.
if (!shortcut) return
// Capture phase + stop both: keeps the bound keys (e.g. Escape) from also
// firing the page-level toggle/close handler while we record.
e.preventDefault()
e.stopPropagation()
this.shortcuts[action] = shortcut
this.stopRecording?.()
callbacks.onShortcutsChange(this.cloneShortcuts())
}

document.addEventListener('keydown', onKeyDown, true)
this.stopRecording = () => {
document.removeEventListener('keydown', onKeyDown, true)
this.stopRecording = null
this.renderShortcut(action)
}
}

private handleSend(onSend: (text: string) => void): void {
const text = this.inputEl.value.trim()
if (!text) return
Expand All @@ -476,6 +682,10 @@ export class ChatOverlay {
this.container.style.display = 'none'
}

isVisible(): boolean {
return this.visible
}

appendStream(text: string): void {
this.hideTyping()
this.streamText += text
Expand Down Expand Up @@ -637,6 +847,12 @@ export class ChatOverlay {
this.modelTag.textContent = MODELS[modelId].label
}

setShortcuts(shortcuts: ShortcutsConfig): void {
this.shortcuts = { toggle: { ...shortcuts.toggle }, close: { ...shortcuts.close } }
this.renderShortcut('toggle')
this.renderShortcut('close')
}

updateStatus(status: string): void {
this.statusEl.textContent = status
}
Expand Down Expand Up @@ -664,6 +880,18 @@ export class ChatOverlay {
}
}

showModelPicker(): void {
this.modelPickerEl.classList.add('visible')
this.messagesEl.style.display = 'none'
this.inputAreaEl.style.display = 'none'
}

hideModelPicker(): void {
this.modelPickerEl.classList.remove('visible')
this.messagesEl.style.display = 'flex'
this.inputAreaEl.style.display = 'flex'
}

getElement(): HTMLElement {
return this.host
}
Expand Down
Loading