@nevescloud/pip
v3.9.2
Published
Floating assistant bubble + panel + chat runtime. ESM, no build.
Downloads
2,693
Maintainers
Readme
Pip
Floating assistant bubble + panel. ESM, no build, no dependencies. The module owns DOM, CSS, open/close, turn rendering. You provide the brains.
Use
import { createPip } from 'https://cdn.jsdelivr.net/npm/@nevescloud/pip@latest/pip-core.esm.js';
const pip = createPip({
introText: 'Ask me anything.',
placeholder: 'Type here…',
async onSubmit(text, ctx) {
return await yourAssistant(text);
},
});@latest (above) auto-tracks new releases on next jsdelivr/SW revalidation (~7d browser cache). Pin a specific version (@2.11.0) for production sites where a regression is costly — npm distributions are immutable per version, safe for forever-cached CDN delivery. @2 semver-locks the major (auto-updates minor/patch, catches breaking API changes) if you want a middle ground. See CONSUMERS.md for the trade-off.
Or via npm: npm install @nevescloud/pip.
Quickstart: bundle entries
Provider-named bundles re-export the three primitives most hosts compose, from a single import. Pick the brain you're wiring to:
// Anthropic — Claude on /v1/messages
import { createPip, createRuntime, anthropic } from 'https://cdn.jsdelivr.net/npm/@nevescloud/pip@latest/bundle/anthropic.esm.js';
const rt = createRuntime({ provider: anthropic({ model: 'claude-opus-4-7', apiKey: '…' }) });
const pip = createPip({ onSubmit: rt.onSubmit, onSlash: rt.onSlash, slashSource: rt.slashSource });// OpenAI-compatible — OpenAI, GitHub Models, Together, Groq, OpenRouter, LM Studio, llama.cpp
import { createPip, createRuntime, openai } from 'https://cdn.jsdelivr.net/npm/@nevescloud/pip@latest/bundle/openai.esm.js';// Local — in-browser inference via transformers.js + WebGPU
import { createPip, createRuntime, local } from 'https://cdn.jsdelivr.net/npm/@nevescloud/pip@latest/bundle/local.esm.js';
const rt = createRuntime({ provider: local({ model: 'LiquidAI/LFM2.5-350M-ONNX' }) });
const pip = createPip({ onSubmit: rt.onSubmit, onSlash: rt.onSlash, slashSource: rt.slashSource });
// `createTransformersRenderer` is also exported for hosts that drive
// a one-shot paint themselves (e.g. an offline fallback that bypasses
// the turn loop entirely).// Chrome — on-device Gemini Nano via the Prompt API (zero download for users on Chrome 138+ that already has weights; ~2B-effective-param quality)
import { createRuntime } from 'https://cdn.jsdelivr.net/npm/@nevescloud/pip@latest/runtime.esm.js';
import { createPip } from 'https://cdn.jsdelivr.net/npm/@nevescloud/pip@latest/pip-core.esm.js';
import { chrome } from 'https://cdn.jsdelivr.net/npm/@nevescloud/pip@latest/providers/chrome.esm.js';
const rt = createRuntime({ provider: chrome({ temperature: 0.1 }) });
const pip = createPip({ onSubmit: rt.onSubmit, onSlash: rt.onSlash, slashSource: rt.slashSource });
// No bundle — Chrome doesn't need its own re-export of createPip + createRuntime
// (`bundle/anthropic` already brings those, and chrome() composes alongside).On jsdelivr the .esm.js suffix is required — jsdelivr serves files by raw path, not via package.json exports. npm-installed consumers can use the shorter @nevescloud/pip/bundle/anthropic (Node ESM resolver honors the exports map). pip/bundle.esm.js (or pip/bundle via npm) is an alias for bundle/anthropic — the default when you haven't picked a brain. Bundles are sugar over the layered files; hosts with a different brain shape (UI only, custom provider, in-browser model) import the granular files directly. See CONSUMERS.md for the full entry-point list.
Options
| Key | Default | Notes |
|---|---|---|
| onSubmit(text, ctx) | — | Required. Returns the reply string (or a promise of one). |
| onSlash(text) | null | Legacy fallback intercept for /-prefixed input. Runs after registered commands and built-ins miss. New code should call pip.registerSlash() instead. |
| slashSource() | built-in | Override for the autocomplete dropdown's source. By default, pip enumerates pip.registerSlash() registrations + built-in /clear. ↑/↓ to cycle, Enter to run, Tab to accept without submitting (use when adding args), Esc to close; complete(partial) per-keystroke after the space supplies arg suggestions. |
| introText | "" | Muted message shown before first turn; auto-dismisses on submit. |
| introDismissMs | 7000 | How long the intro stays before fading. 0 to keep until first message. |
| placeholder | "Ask Pip…" | Input placeholder. |
| autoOpen | false | Open the panel on mount. |
| autoOpenDelayMs | 700 | Delay before auto-open. |
| maxLength | 4000 | Input character cap. |
| openHotkey | "/" | Global key that opens pip and prefills the input. "" or false to disable. Skipped while typing in another field. |
| historyLimit | 10 | Rolling history window passed to onSubmit via ctx.history. |
| fallbackReply | "Can't think right now — try again?" | Shown when onSubmit returns null / undefined. Override per host to point at recovery commands you actually expose, e.g. "Can't think right now — try \/model tiny`.". |
| emptyReply|"I don't have a good answer for that — tell me more?"| Shown whenonSubmitreturns"". |
| modelLabel|""| Small pill rendered in the header strip (active backend / model name). Update viapip.setModelLabel(label). |
| slashHint|true| Render a/key-cap button that seeds the input + opens the slash autocomplete. Discoverable affordance; setfalseto hide. |
|onAbort|null| Called when the user clicks the stop button while pip is responding. Wire to your "abort the in-flight LLM call" path. If omitted, the stop button still renders (visual consistency with the responding state) but click is a no-op. |
|mic|false| See [Mic input](#mic-input) below.truemounts the Web Speech button with default behavior; pass{ onChunk, onFinal }for advanced hooks. |
|container|document.body| Mount point. |
|onOpen, onClose|null` | Lifecycle hooks. |
Slash commands
pip.registerSlash({ name, handler, description?, complete? }) adds a command. Registry-first dispatch: registered commands win over built-ins so a host can override /clear by registering with the same name.
pip.registerSlash({
name: "model",
description: "switch backend",
complete: (partial) => ["claude", "gpt-4o"].filter(s => s.startsWith(partial)),
handler: (args) => {
setBackend(args.trim());
return { reply: `Switched to ${args.trim()}.` };
},
});
pip.unregisterSlash("model");Handler returns { reply?, clearedUI?, openCompletions?, passThrough? } | null. null falls through to onSlash (if provided) and then to the LLM. The autocomplete dropdown is the help surface — every registered + built-in command appears with its description as the user types /. Built-in /clear wipes pip's local history + DOM.
reply— render as an assistant turn in chat history.clearedUI— the handler already painted its own UI (viastartTurn,askInChat, etc.); pip-core skips creating a turn.openCompletions— re-open the autocomplete dropdown in arg-mode for this command. Use when the bare command (no arg) is meant to surface a sub-menu like a model picker — keeps the navigation inside the dropdown the user came from instead of logging a listing to history. Pip-core puts"/<cmd> "in the input, focuses it, and runs the registeredcomplete('')to populate the dropdown.passThrough— treat as if the handler didn't match; falls through toonSlashthen the LLM.
Tool-using turns
For hosts running multi-step LLM loops (computer-use, tool dispatch), pip's default single-reply model collapses the turn into one block and hides the step-by-step reasoning users want to see. The interleave primitives let a turn render as [text 1] [tool pill 1] [text 2] [image] [pill 2] [final text] in arrival order — same shape Anthropic computer-use / hatch / Codex-style UIs converge on.
const pip = createPip({
onSubmit: async (text, { turnEl }) => {
let reply = null;
for await (const ev of llmStream(text)) {
if (ev.type === 'text_delta') {
if (!reply) reply = pip.appendReplyBubble(turnEl);
reply.setText(ev.fullText);
} else if (ev.type === 'tool_start') {
reply = null; // next deltas land below the pill
const pill = pip.appendToolPill(turnEl, ev.name, { label: `${ev.name} …` });
ev.onFinish = ({ input, result, error, durationMs }) => {
pill.finish({ label: summarize(ev.name, result), input, result, error, durationMs });
};
} else if (ev.type === 'image') {
pip.appendTurnImage(turnEl, { src: ev.dataUrl, alt: ev.caption });
reply = null;
}
}
return ''; // we owned the rendering; let pip's default-reply stay hidden
},
});| Method | Returns | Notes |
|---|---|---|
| appendToolPill(turnEl, name, { label? }) | { el, finish({ label?, input?, result?, error?, durationMs? }) } | .running → completed/.error on finish. Disclosure button expands a pre with args + result (truncated at 240 chars per string). Hold the returned object across the await; pass it back to finish() without tracking the DOM separately. |
| appendReplyBubble(turnEl) | { el, setText(md), setHtml(html) } | Multi-bubble per turn. setText renders markdown via pip's built-in renderMd. setHtml skips escaping — sanitize first. |
| appendTurnImage(turnEl, { src, alt? }) | HTMLImageElement | Inline camera frame / screenshot. Capped at 220px tall. Lazy-loaded. |
| scrollToBottom() | — | Exposed for hosts interleaving their own DOM between primitives. |
Mic input
createPip({ mic: true }) mounts a Web Speech-backed mic button next to the send button. Click toggles sticky-mode dictation: final transcripts flow through onSubmit exactly as if the user had typed and sent them. Escape cancels. No-speech retry with sticky-disarm after 2 consecutive flakes. No-op when Web Speech isn't supported in the current browser (no broken affordance).
For advanced hosts — safety-verb instant-fire that needs sub-second response, mid-turn injection that bypasses the input field — pass an object:
const pip = createPip({
mic: {
onChunk: async (text) => {
// Fires on every Web Speech "final chunk" before the silence-commit
// window. Return true to consume — pip stops the mic + clears the
// input. Use for instant-fire safety verbs ("stop", "halt").
if (isSafetyVerb(text)) { handleSafetyVerb(text); return true; }
return false;
},
onFinal: async (text) => {
// Fires on final + silence-commit. Return true if you handled the
// transcript yourself (e.g. injected into an already-in-flight turn);
// pip skips its default submit.
if (isMidTurn()) { injectIntoActiveTurn(text); return true; }
return false;
},
},
});
// Suspend/resume drive the muted visual state for events the gate can't
// see (your TTS playback, anything else).
pip.suspendMic(); // drops active session + lights mute icon
pip.resumeMic(); // re-arms sticky if it was on
// Programmatic toggle — useful for /voice slash:
pip.toggleMic();
// Render an inline cyan notice for mic-related status (permission, retry).
pip.surfaceMicNotice("Didn't catch that — try again.");
// Check support before showing affordances:
if (pip.micSupported) { /* render Voice button */ }Styling
CSS is auto-injected. Hosts customize via CSS variables on :root or any ancestor:
Colors / surfaces
--pip-surface— panel background--pip-ink,--pip-ink-muted— text colors--pip-border— borders--pip-accent— focus / AI-generated highlight color
Type scale (defaults shown)
--pip-t-input— input field. Default14pxon desktop (pointer: fine) and16pxon touch (mobile floor — going below 16 makes iOS Safari zoom on focus).--pip-t-body: 14px— assistant replies (the primary content layer)--pip-t-caption: 12px— intro, echoed user question, code blocks
Class overrides (.pip-panel, .pip-input, .pip-bubble, .pip-notify, …) work too.
Runtime + providers
For a turn loop, tool dispatch, history, and an Anthropic provider, pair pip-core with runtime.esm.js. See docs/RUNTIME.md.
Demo
docs/index.html is a standalone demo wiring three stub providers (echo, reverse, danger) through the runtime. Open it locally to play with the slash autocomplete, /model switching, and the chat shell without needing an API key. The danger stub fires a delete_thing tool_use that's gated by a preToolUse hook (Run/Cancel via askInChat) — a working example of the runtime's hook events.
