InlineAgent — selection menu inside editables
A floating menu that pops up below selected text inside an
<input>,<textarea>, or[contenteditable]. Pick an action; the selection is replaced by the LLM's edit.
Opt-in. Disabled by default; attach an InlineAgent instance to turn it on.
When to use
- Rewriting / translating / shortening / fixing text the user is currently editing
- Domain-specific transforms that should land directly into the form field (e.g. "convert this email draft to a customer-support tone")
Don't reach for InlineAgent when:
- The selection is in non-editable page content — that's the palette flow (
palette.context.selectionText), not this module - You want the LLM to generate freeform output for the user to read — use Subtitle + an agent task instead
- You want to drive multi-step edits across many fields — use a skill so the user sees one progress UI, not seven popovers
Import
import { InlineAgent } from '@perhapxin/dddk';
import type { InlineAgentConfig, InlineAction } from '@perhapxin/dddk';
Minimum setup
import { InlineAgent } from '@perhapxin/dddk';
const inline = new InlineAgent({
llm,
locale: 'zh-TW', // built-in i18n: 'en' | 'zh-TW'
});
inline.attachTo(dddk);
That's it. Any selection ≥ 2 chars inside an editable element will pop the menu.
Built-in actions
Seven defaults ship out of the box:
id |
English label | 中文 label |
|---|---|---|
translate |
Translate | 翻譯 |
improve |
Improve writing | 改寫 |
fix |
Fix spelling & grammar | 修文法 |
shorter |
Make shorter | 縮短 |
longer |
Make longer | 延長 |
tone |
Change to professional tone | 改成正式語氣 |
explain |
Explain this | 解釋 |
Translate is a dynamic action — clicking it opens a language sub-menu. Override the offered languages with translateTargets:
new InlineAgent({
llm,
translateTargets: [
{ code: 'en', label: 'English' },
{ code: 'zh-TW', label: '繁體中文' },
{ code: 'ja', label: '日本語' },
],
});
How a call is built
InlineAgent does NOT just send the selected fragment to the LLM. It assembles a context window of ~400 chars before + after the selection and marks the fragment with [[SEL]] / [[/SEL]]. The LLM is asked for a JSON object — only the replacement field is applied:
System: …strict edit-file instructions…
User: Instruction: Translate to Japanese.
Context (with selection marked):
"""
她…[[SEL]]今天去買菜[[/SEL]],然後…
"""
Return JSON: { "replacement": "..." }
The call uses thinking: 'off', jsonMode: true, temperature: 0 — deterministic edits with no chain-of-thought leakage. The replacement is spliced into the editable at the known offsets (selectionStart / selectionEnd for <input> / <textarea>; the live Range for contenteditable). No fuzzy matching, no transcript leak.
Configuration
| Option | Default | Description |
|---|---|---|
llm |
— | LLMSource (required). |
actions |
seven defaults | Override the entire action list. |
locale |
'en' |
'en' | 'zh-TW' for built-in labels. |
translateTargets |
en / zh-TW / ja | Languages offered by the Translate sub-menu. |
hideAfterMs |
0 |
Auto-hide after N ms of no selection change. 0 = never. |
ignoreSelector |
— | Never trigger inside elements matching this. |
shortcut |
mod+. |
Reserved — keyboard shortcut to open the menu programmatically (not yet wired). |
Runtime API
inline.addAction({
id: 'my-action',
label: '…',
icon: '…',
instruction: 'Rewrite as if the user were a pirate.',
});
inline.removeAction('translate'); // drop a default
inline.updateAction('improve', { instruction: 'New prompt…' });
inline.setActions([...]); // wholesale replace
inline.setEnabled(false); // detach without unmount
inline.isEnabled(); // boolean
inline.destroy(); // unmount listeners + menu
Dynamic instructions — ask before run
An action's build() runs at activation time. Use it to gather input before the LLM call. The built-in Translate uses this for its language picker; any custom flow works:
{
id: 'translate-custom',
label: 'Translate to…',
icon: '文',
build: async ({ text, agent }) => {
const target = await myPicker(); // your own UI / modal / palette
if (!target) return null; // null = cancel cleanly, no LLM call
return `Translate to ${target}. Output only the translation.`;
},
}
build wins over instruction if both are set.
Keyboard
ArrowDown/ArrowUp(orCtrl+N/Ctrl+P) — navigateMod+Enter(Cmd / Ctrl / Alt + Enter) — fire the highlighted action. Plain Enter is preserved so users can still add a newline in the editable.Esc— close the menu
Persistence — when the menu shows / hides
The menu stays visible as long as the selection is alive. Scroll repositions it (does not hide). It only hides when:
- Selection is cleared or shrinks below 2 chars
- User clicks somewhere with no selection
- An action runs to completion
Escis pressedinline.setEnabled(false)is called
Selections outside an editable element are NOT handled here — they ride as palette context (palette.context.selectionText) for the agent.
Positioning
The menu opens below the selection by default:
- Vertical: anchored 6px below
selection.rect.bottom, growing downward. The menu only flips to above the selection when there isn't enough room below AND there's more room above. Opening alongside the selection (top-aligned) was overlapping the user's own text on narrow screens. - Horizontal: anchored at the right edge of the selection. Flips left when overflowing the viewport.
For <input> and <textarea> the selection rect is computed via a mirror-div technique (clone typographic styles, find caret coords for selectionStart / selectionEnd). This matters: anchoring to the input element's bbox would put the menu next to the textarea border, not next to the selected text.
Error handling
LLM provider errors are humanised into a one-line subtitle so the user doesn't see a stack trace. The full raw error goes to console.warn. Common cases:
| Status | English subtitle | 中文 subtitle |
|---|---|---|
| 429 | <vendor> rate limit — wait a few seconds and retry |
<vendor> 速率上限,稍等幾秒再試 |
| 5xx | <vendor> is having a moment — please retry |
<vendor> 暫時忙線,請再試一次 |
| 400 | <vendor> rejected the request (bad shape) |
<vendor> 拒絕請求(請求格式問題) |
| 401 / 403 | <vendor> auth failed |
<vendor> 認證失敗 |
Tear-down
inline.destroy();
Removes listeners, the menu, and the sub-menu. Always call this in your SPA route-change / unmount path.
See also: ./dwell.md for the non-editable counterpart, ./overview.md for the module index.