Spaces:
Running
Running
Upload 5 files
Browse files- README.md +45 -7
- chat-engine.jsx +174 -0
- debug-panel.jsx +143 -0
- index.html +63 -17
- variant-terminal.jsx +248 -0
README.md
CHANGED
|
@@ -1,12 +1,50 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
-
short_description: Dog-loving BlenderBot. Zero-backend static showcase
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: This is Fetch
|
| 3 |
+
emoji: 🐕
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# This is Fetch
|
| 11 |
+
|
| 12 |
+
A friendly chatbot with a soft spot for dogs.
|
| 13 |
+
|
| 14 |
+
This is the **terminal** design variant, deployed as a HuggingFace Static
|
| 15 |
+
Space. It runs entirely in the browser — no GPU, no Python, no backend.
|
| 16 |
+
|
| 17 |
+
## How it works
|
| 18 |
+
|
| 19 |
+
Fetch answers using the same logic as the original Python reference:
|
| 20 |
+
|
| 21 |
+
- A small **keyword lookup** table (dogs, breeds, care, trivia, reactions).
|
| 22 |
+
If the user's question contains a known keyword, the matching fact is
|
| 23 |
+
returned verbatim.
|
| 24 |
+
- Three **fallback replies** cover the "I don't know" case.
|
| 25 |
+
- **Four-turn context** is kept in memory for the session.
|
| 26 |
+
|
| 27 |
+
The generative path (BlenderBot-400M-distill in the original, or an
|
| 28 |
+
LLM-backed helper in the Claude preview) is intentionally disabled in this
|
| 29 |
+
static build. Static Spaces don't run Python or serve models, so the
|
| 30 |
+
deterministic keyword-lookup path is the honest option here.
|
| 31 |
+
|
| 32 |
+
If you want the generative version, deploy the original `app.py` to a
|
| 33 |
+
Gradio Space instead (`sdk: gradio`, CPU hardware is fine).
|
| 34 |
+
|
| 35 |
+
## Files
|
| 36 |
+
|
| 37 |
+
- `index.html` — app shell, loads React + Babel, mounts the terminal variant
|
| 38 |
+
- `chat-engine.jsx` — keyword lookup, fallbacks, suggestion rotator, chat hook
|
| 39 |
+
- `variant-terminal.jsx` — the terminal CLI-style UI
|
| 40 |
+
- `debug-panel.jsx` — optional ⌘D devtools panel (exchange log, keyword table)
|
| 41 |
+
|
| 42 |
+
## Keyboard
|
| 43 |
+
|
| 44 |
+
- **⌘K / Ctrl+K** — clear the conversation
|
| 45 |
+
- **⌘D / Ctrl+D** — toggle the devtools panel
|
| 46 |
+
|
| 47 |
+
## Credits
|
| 48 |
+
|
| 49 |
+
Design and frontend: a set of explorations for *This is Fetch*.
|
| 50 |
+
Reference model: [facebook/blenderbot-400M-distill](https://huggingface.co/facebook/blenderbot-400M-distill).
|
chat-engine.jsx
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// chat-engine.jsx — shared chat logic for all Fetch variants.
|
| 2 |
+
// Mirrors the Python reference: 4-turn context, keyword lookup, fallbacks.
|
| 3 |
+
// Two backends: "claude" (window.claude.complete) and "local" (keyword
|
| 4 |
+
// lookup + fallback only — the path HuggingFace Static Spaces will take).
|
| 5 |
+
|
| 6 |
+
const KNOWLEDGE_BASE = [
|
| 7 |
+
["domesticate", "Dogs have been domesticated for at least 15,000 years, making them the oldest domesticated animal."],
|
| 8 |
+
["skills", "Dogs can understand up to 250 words and gestures, and can count up to 5."],
|
| 9 |
+
["border collie","Border Collies are considered the most intelligent dog breed, capable of learning a new command in under 5 seconds."],
|
| 10 |
+
["greyhound", "Greyhounds can reach speeds of up to 45 mph, yet are famously calm and lazy indoors."],
|
| 11 |
+
["exercise", "Dogs need mental stimulation as much as physical exercise. A bored dog is often a destructive dog."],
|
| 12 |
+
["dental", "Regular dental care is one of the most overlooked aspects of dog health. Most dogs show signs of dental disease by age 3."],
|
| 13 |
+
["bark", "The Basenji is the only dog breed that doesn't bark... it yodels."],
|
| 14 |
+
["smell", "Dogs have a sense of smell estimated to be 10,000 to 100,000 times more acute than humans."],
|
| 15 |
+
["dream", "Dogs dream just like humans do. Research suggests they replay their day while sleeping, and smaller breeds tend to dream more frequently than larger ones."],
|
| 16 |
+
["woof", "Did someone say woof? You speak my language."],
|
| 17 |
+
["good boi", "That would be me, obviously."],
|
| 18 |
+
["walk", "Did anybody say walk? I'm in."],
|
| 19 |
+
];
|
| 20 |
+
|
| 21 |
+
const FALLBACKS = [
|
| 22 |
+
"I don't know, but I'd bet a dog would!",
|
| 23 |
+
"Good question! I need more dog facts for that one.",
|
| 24 |
+
"I'm not sure about that, but dogs are great!",
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
+
const SUGGESTIONS = [
|
| 28 |
+
"Tell me about border collies",
|
| 29 |
+
"How good is a dog's sense of smell?",
|
| 30 |
+
"Do dogs dream?",
|
| 31 |
+
"Why is dental care important?",
|
| 32 |
+
"What's the fastest dog?",
|
| 33 |
+
"How long have dogs been domesticated?",
|
| 34 |
+
"Do all dogs bark?",
|
| 35 |
+
"Is my dog bored?",
|
| 36 |
+
];
|
| 37 |
+
|
| 38 |
+
const INITIAL_GREETING = "Hi there! Ask me something simple, ideally about dogs.";
|
| 39 |
+
|
| 40 |
+
function retrieveFact(query) {
|
| 41 |
+
const q = (query || "").toLowerCase();
|
| 42 |
+
for (const [keyword, fact] of KNOWLEDGE_BASE) {
|
| 43 |
+
if (q.includes(keyword)) return { keyword, fact };
|
| 44 |
+
}
|
| 45 |
+
return null;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function pickFallback() {
|
| 49 |
+
return FALLBACKS[Math.floor(Math.random() * FALLBACKS.length)];
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Build a BlenderBot-style prompt so the "claude" backend produces responses
|
| 53 |
+
// in the same voice the Python reference targeted.
|
| 54 |
+
function buildPrompt(history, userInput, fact, personality) {
|
| 55 |
+
const lastFour = history.slice(-4);
|
| 56 |
+
const lines = lastFour.map(m => `${m.role === "user" ? "User" : "Bot"}: ${m.content}`);
|
| 57 |
+
if (fact) lines.unshift(fact.fact);
|
| 58 |
+
lines.push(`User: ${userInput}`);
|
| 59 |
+
const toneByLevel = {
|
| 60 |
+
subtle: "Keep the dog references light; answer the question plainly.",
|
| 61 |
+
medium: "Be warm and playful, with a clear dog-lover streak.",
|
| 62 |
+
loud: "Go full dog mode: enthusiastic, tail-wagging, lots of 'woof' energy.",
|
| 63 |
+
};
|
| 64 |
+
const tone = toneByLevel[personality] || toneByLevel.subtle;
|
| 65 |
+
return (
|
| 66 |
+
"You are Fetch, a friendly dog-loving chatbot. "
|
| 67 |
+
+ "Answer in 1–2 short sentences. "
|
| 68 |
+
+ tone + " "
|
| 69 |
+
+ "If a fact is provided above the conversation, weave it naturally into your reply.\n\n"
|
| 70 |
+
+ lines.join("\n")
|
| 71 |
+
+ "\nBot:"
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
async function generateReply({ userInput, history, backend, personality }) {
|
| 76 |
+
const fact = retrieveFact(userInput);
|
| 77 |
+
|
| 78 |
+
if (backend === "claude" && typeof window !== "undefined" && window.claude?.complete) {
|
| 79 |
+
try {
|
| 80 |
+
const prompt = buildPrompt(history, userInput, fact, personality);
|
| 81 |
+
const raw = await window.claude.complete(prompt);
|
| 82 |
+
const text = (raw || "").trim().replace(/^Bot:\s*/i, "").split("\nUser:")[0].trim();
|
| 83 |
+
if (!text || text.split(/\s+/).length <= 2) {
|
| 84 |
+
return { text: pickFallback(), fact };
|
| 85 |
+
}
|
| 86 |
+
return { text, fact };
|
| 87 |
+
} catch (err) {
|
| 88 |
+
console.warn("Fetch: claude backend failed, falling back.", err);
|
| 89 |
+
// fall through to local path
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Local / offline / HF-static path: keyword fact wins, else fallback.
|
| 94 |
+
if (fact) return { text: fact.fact, fact };
|
| 95 |
+
return { text: pickFallback(), fact };
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Hook: owns conversation state + send/clear + rotating suggestions.
|
| 99 |
+
function useFetchChat({ backend = "claude", personality = "subtle" } = {}) {
|
| 100 |
+
const [messages, setMessages] = React.useState(() => [
|
| 101 |
+
{ id: "m0", role: "assistant", content: INITIAL_GREETING, ts: Date.now() },
|
| 102 |
+
]);
|
| 103 |
+
const [pending, setPending] = React.useState(false);
|
| 104 |
+
const [debug, setDebug] = React.useState([]);
|
| 105 |
+
const seq = React.useRef(1);
|
| 106 |
+
|
| 107 |
+
const nextId = () => `m${seq.current++}`;
|
| 108 |
+
|
| 109 |
+
const send = React.useCallback(async (rawInput) => {
|
| 110 |
+
const userInput = (rawInput || "").trim();
|
| 111 |
+
if (!userInput || pending) return;
|
| 112 |
+
|
| 113 |
+
const userMsg = { id: nextId(), role: "user", content: userInput, ts: Date.now() };
|
| 114 |
+
const placeholderId = nextId();
|
| 115 |
+
const placeholder = { id: placeholderId, role: "assistant", content: "…", pending: true, ts: Date.now() };
|
| 116 |
+
|
| 117 |
+
// Snapshot history BEFORE adding the new user message — that's what the
|
| 118 |
+
// prompt builder should see as prior context.
|
| 119 |
+
let priorHistory = [];
|
| 120 |
+
setMessages(prev => { priorHistory = prev; return [...prev, userMsg, placeholder]; });
|
| 121 |
+
setPending(true);
|
| 122 |
+
|
| 123 |
+
const t0 = performance.now();
|
| 124 |
+
const { text, fact } = await generateReply({
|
| 125 |
+
userInput,
|
| 126 |
+
history: priorHistory,
|
| 127 |
+
backend,
|
| 128 |
+
personality,
|
| 129 |
+
});
|
| 130 |
+
const ms = Math.round(performance.now() - t0);
|
| 131 |
+
|
| 132 |
+
setMessages(prev => prev.map(m =>
|
| 133 |
+
m.id === placeholderId
|
| 134 |
+
? { ...m, content: text, pending: false, fact, ts: Date.now() }
|
| 135 |
+
: m
|
| 136 |
+
));
|
| 137 |
+
setDebug(prev => [
|
| 138 |
+
{ t: Date.now(), user: userInput, reply: text, fact, ms, backend },
|
| 139 |
+
...prev,
|
| 140 |
+
].slice(0, 20));
|
| 141 |
+
setPending(false);
|
| 142 |
+
}, [backend, personality, pending]);
|
| 143 |
+
|
| 144 |
+
const reset = React.useCallback(() => {
|
| 145 |
+
seq.current = 1;
|
| 146 |
+
setMessages([{ id: "m0", role: "assistant", content: INITIAL_GREETING, ts: Date.now() }]);
|
| 147 |
+
setDebug([]);
|
| 148 |
+
}, []);
|
| 149 |
+
|
| 150 |
+
return { messages, pending, debug, send, reset };
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Rotating suggestion carousel — 3 chips visible, rotates on interval.
|
| 154 |
+
function useRotatingSuggestions({ visible = 3, intervalMs = 4200 } = {}) {
|
| 155 |
+
const [offset, setOffset] = React.useState(0);
|
| 156 |
+
React.useEffect(() => {
|
| 157 |
+
const id = setInterval(() => setOffset(o => (o + 1) % SUGGESTIONS.length), intervalMs);
|
| 158 |
+
return () => clearInterval(id);
|
| 159 |
+
}, [intervalMs]);
|
| 160 |
+
const current = React.useMemo(() => {
|
| 161 |
+
const out = [];
|
| 162 |
+
for (let i = 0; i < visible; i++) out.push(SUGGESTIONS[(offset + i) % SUGGESTIONS.length]);
|
| 163 |
+
return out;
|
| 164 |
+
}, [offset, visible]);
|
| 165 |
+
return current;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
Object.assign(window, {
|
| 169 |
+
useFetchChat,
|
| 170 |
+
useRotatingSuggestions,
|
| 171 |
+
retrieveFact,
|
| 172 |
+
FETCH_KNOWLEDGE_BASE: KNOWLEDGE_BASE,
|
| 173 |
+
FETCH_SUGGESTIONS: SUGGESTIONS,
|
| 174 |
+
});
|
debug-panel.jsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// debug-panel.jsx — floating developer panel shared across all variants.
|
| 2 |
+
// Surfaces: recent exchanges, fact-match status, backend, latency, and the
|
| 3 |
+
// full keyword-lookup table from the Python reference.
|
| 4 |
+
|
| 5 |
+
function DebugPanel({ debug, onClose, palette, font }) {
|
| 6 |
+
const [tab, setTab] = React.useState("log");
|
| 7 |
+
return (
|
| 8 |
+
<div style={{
|
| 9 |
+
position: "absolute", right: 16, bottom: 16, top: 16,
|
| 10 |
+
width: 380, maxWidth: "44vw",
|
| 11 |
+
background: palette.bg, color: palette.fg,
|
| 12 |
+
border: `1px solid ${palette.muted}`,
|
| 13 |
+
borderRadius: 10,
|
| 14 |
+
fontFamily: font, fontSize: 11,
|
| 15 |
+
display: "flex", flexDirection: "column",
|
| 16 |
+
boxShadow: "0 10px 30px rgba(0,0,0,.35)",
|
| 17 |
+
zIndex: 50,
|
| 18 |
+
overflow: "hidden",
|
| 19 |
+
}}>
|
| 20 |
+
<div style={{
|
| 21 |
+
padding: "8px 12px",
|
| 22 |
+
borderBottom: `1px solid ${palette.muted}`,
|
| 23 |
+
display: "flex", alignItems: "center", justifyContent: "space-between",
|
| 24 |
+
}}>
|
| 25 |
+
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
| 26 |
+
<span style={{ color: palette.accent }}>●</span>
|
| 27 |
+
<span style={{ letterSpacing: 0.8, textTransform: "uppercase", fontSize: 10 }}>
|
| 28 |
+
Fetch / devtools
|
| 29 |
+
</span>
|
| 30 |
+
</div>
|
| 31 |
+
<button onClick={onClose} aria-label="Close debug panel" style={{
|
| 32 |
+
background: "transparent", border: "none", color: palette.dim,
|
| 33 |
+
cursor: "pointer", fontSize: 14, padding: "0 4px",
|
| 34 |
+
}}>×</button>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div style={{
|
| 38 |
+
display: "flex", gap: 2,
|
| 39 |
+
padding: "6px 10px 0",
|
| 40 |
+
borderBottom: `1px solid ${palette.muted}`,
|
| 41 |
+
}}>
|
| 42 |
+
{[
|
| 43 |
+
{ id: "log", label: "Exchanges" },
|
| 44 |
+
{ id: "kb", label: "Keyword table" },
|
| 45 |
+
{ id: "about", label: "About" },
|
| 46 |
+
].map(t => (
|
| 47 |
+
<button key={t.id} onClick={() => setTab(t.id)} style={{
|
| 48 |
+
background: "transparent",
|
| 49 |
+
color: tab === t.id ? palette.fg : palette.dim,
|
| 50 |
+
borderBottom: tab === t.id ? `2px solid ${palette.accent}` : "2px solid transparent",
|
| 51 |
+
border: "none", borderRadius: 0,
|
| 52 |
+
padding: "6px 8px",
|
| 53 |
+
fontFamily: "inherit", fontSize: 11, cursor: "pointer",
|
| 54 |
+
}}>{t.label}</button>
|
| 55 |
+
))}
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div style={{ flex: 1, overflowY: "auto", padding: 12 }}>
|
| 59 |
+
{tab === "log" && <DebugLog debug={debug} palette={palette} />}
|
| 60 |
+
{tab === "kb" && <DebugKB palette={palette} />}
|
| 61 |
+
{tab === "about" && <DebugAbout palette={palette} />}
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function DebugLog({ debug, palette }) {
|
| 68 |
+
if (debug.length === 0) {
|
| 69 |
+
return (
|
| 70 |
+
<div style={{ color: palette.dim, fontStyle: "italic", padding: "8px 4px" }}>
|
| 71 |
+
No exchanges yet. Ask Fetch something.
|
| 72 |
+
</div>
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
return (
|
| 76 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
| 77 |
+
{debug.map((d, i) => (
|
| 78 |
+
<div key={i} style={{
|
| 79 |
+
border: `1px solid ${palette.muted}`,
|
| 80 |
+
borderRadius: 6,
|
| 81 |
+
padding: 8,
|
| 82 |
+
}}>
|
| 83 |
+
<div style={{ color: palette.dim, fontSize: 10, display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
|
| 84 |
+
<span>{new Date(d.t).toLocaleTimeString()}</span>
|
| 85 |
+
<span>{d.backend} · {d.ms}ms</span>
|
| 86 |
+
</div>
|
| 87 |
+
<div style={{ marginBottom: 4 }}>
|
| 88 |
+
<span style={{ color: palette.dim }}>user> </span>
|
| 89 |
+
{d.user}
|
| 90 |
+
</div>
|
| 91 |
+
<div style={{ marginBottom: 4 }}>
|
| 92 |
+
<span style={{ color: palette.accent }}>reply> </span>
|
| 93 |
+
{d.reply}
|
| 94 |
+
</div>
|
| 95 |
+
<div style={{ color: palette.dim, fontSize: 10 }}>
|
| 96 |
+
fact: {d.fact ? <span style={{ color: palette.accent }}>matched "{d.fact.keyword}"</span> : "none"}
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
))}
|
| 100 |
+
</div>
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function DebugKB({ palette }) {
|
| 105 |
+
return (
|
| 106 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
| 107 |
+
<div style={{ color: palette.dim, marginBottom: 4 }}>
|
| 108 |
+
{FETCH_KNOWLEDGE_BASE.length} keywords · first match wins
|
| 109 |
+
</div>
|
| 110 |
+
{FETCH_KNOWLEDGE_BASE.map(([k, v]) => (
|
| 111 |
+
<div key={k} style={{
|
| 112 |
+
border: `1px solid ${palette.muted}`,
|
| 113 |
+
borderRadius: 6,
|
| 114 |
+
padding: 8,
|
| 115 |
+
}}>
|
| 116 |
+
<div style={{ color: palette.accent, marginBottom: 3 }}>{k}</div>
|
| 117 |
+
<div style={{ color: palette.fg, lineHeight: 1.4 }}>{v}</div>
|
| 118 |
+
</div>
|
| 119 |
+
))}
|
| 120 |
+
</div>
|
| 121 |
+
);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
function DebugAbout({ palette }) {
|
| 125 |
+
return (
|
| 126 |
+
<div style={{ lineHeight: 1.6, color: palette.fg }}>
|
| 127 |
+
<div style={{ color: palette.dim, marginBottom: 8 }}>Fetch — a friendly dog-loving chatbot.</div>
|
| 128 |
+
<ul style={{ margin: 0, paddingLeft: 16, display: "flex", flexDirection: "column", gap: 4 }}>
|
| 129 |
+
<li>Reference model: BlenderBot-400M-distill</li>
|
| 130 |
+
<li>Context window: last 4 turns</li>
|
| 131 |
+
<li>Keyword lookup: first match prepended to the prompt</li>
|
| 132 |
+
<li>Fallbacks: 3 canned replies when generation is empty</li>
|
| 133 |
+
<li>Preview backend: window.claude.complete (Haiku 4.5)</li>
|
| 134 |
+
<li>HF Static Space fallback: keyword lookup + fallbacks only</li>
|
| 135 |
+
</ul>
|
| 136 |
+
<div style={{ marginTop: 10, color: palette.dim, fontSize: 10 }}>
|
| 137 |
+
Shortcuts: ⌘K clear · ⌘D toggle this panel
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
Object.assign(window, { DebugPanel });
|
index.html
CHANGED
|
@@ -1,19 +1,65 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 6 |
+
<title>This is Fetch</title>
|
| 7 |
+
<meta name="description" content="A friendly chatbot with a soft spot for dogs." />
|
| 8 |
+
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 12 |
+
|
| 13 |
+
<style>
|
| 14 |
+
html, body { margin: 0; padding: 0; background: #0a0f0a; color: #c8f7c5; height: 100%; }
|
| 15 |
+
body { font-family: "JetBrains Mono", ui-monospace, monospace; overflow: hidden; }
|
| 16 |
+
#root { position: fixed; inset: 0; }
|
| 17 |
+
|
| 18 |
+
@keyframes fetch-fadein {
|
| 19 |
+
from { opacity: 0; transform: translateY(4px); }
|
| 20 |
+
to { opacity: 1; transform: translateY(0); }
|
| 21 |
+
}
|
| 22 |
+
@keyframes fetch-blink {
|
| 23 |
+
0%, 49% { opacity: 0.8; }
|
| 24 |
+
50%, 100% { opacity: 0; }
|
| 25 |
+
}
|
| 26 |
+
.fetch-blink { animation: fetch-blink 1s steps(2) infinite; }
|
| 27 |
+
</style>
|
| 28 |
+
|
| 29 |
+
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
| 30 |
+
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
| 31 |
+
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
| 32 |
+
</head>
|
| 33 |
+
<body>
|
| 34 |
+
|
| 35 |
+
<div id="root"></div>
|
| 36 |
+
|
| 37 |
+
<script type="text/babel" src="chat-engine.jsx"></script>
|
| 38 |
+
<script type="text/babel" src="debug-panel.jsx"></script>
|
| 39 |
+
<script type="text/babel" src="variant-terminal.jsx"></script>
|
| 40 |
+
|
| 41 |
+
<script type="text/babel" data-presets="react">
|
| 42 |
+
// HF Static Space build: terminal variant only, local backend (keyword
|
| 43 |
+
// lookup + fallbacks — no network calls). The "claude" backend is
|
| 44 |
+
// unavailable outside the preview host, so this build pins backend="local".
|
| 45 |
+
const FONT_PAIR = {
|
| 46 |
+
mono: '"JetBrains Mono", ui-monospace, monospace',
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
function FetchApp() {
|
| 50 |
+
return (
|
| 51 |
+
<TerminalVariant
|
| 52 |
+
backend="local"
|
| 53 |
+
personality="subtle"
|
| 54 |
+
density="comfortable"
|
| 55 |
+
accent="green"
|
| 56 |
+
fontPair={FONT_PAIR}
|
| 57 |
+
/>
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
ReactDOM.createRoot(document.getElementById("root")).render(<FetchApp />);
|
| 62 |
+
</script>
|
| 63 |
+
|
| 64 |
+
</body>
|
| 65 |
</html>
|
variant-terminal.jsx
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// variant-terminal.jsx — Fetch as a retro CLI dog.
|
| 2 |
+
// Full-bleed terminal: `fetch>` prompt, you$ / dog# prefixes, facts shown as
|
| 3 |
+
// dimmed `// fact:` comment lines. Suggestions rotate as tips at bottom.
|
| 4 |
+
|
| 5 |
+
const termPalettes = {
|
| 6 |
+
green: { bg: "#0a0f0a", fg: "#c8f7c5", dim: "#6b8a6b", accent: "#7dfc82", muted: "#3a4a3a" },
|
| 7 |
+
amber: { bg: "#120d05", fg: "#f3d488", dim: "#8a7548", accent: "#ffbf47", muted: "#4a3a1a" },
|
| 8 |
+
mono: { bg: "#0b0b0c", fg: "#e6e6e6", dim: "#7a7a7a", accent: "#ffffff", muted: "#3a3a3a" },
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
function TerminalVariant({ backend, personality, density, accent, fontPair, bubble }) {
|
| 12 |
+
// bubble shape doesn't apply here; density affects line-height.
|
| 13 |
+
const { messages, pending, debug, send, reset } = useFetchChat({ backend, personality });
|
| 14 |
+
const suggestions = useRotatingSuggestions({ visible: 3 });
|
| 15 |
+
const [input, setInput] = React.useState("");
|
| 16 |
+
const [showDebug, setShowDebug] = React.useState(false);
|
| 17 |
+
const scrollRef = React.useRef(null);
|
| 18 |
+
const inputRef = React.useRef(null);
|
| 19 |
+
|
| 20 |
+
const palette = termPalettes[accent] || termPalettes.green;
|
| 21 |
+
|
| 22 |
+
const lineHeight = density === "compact" ? 1.35 : density === "cozy" ? 1.75 : 1.55;
|
| 23 |
+
const pad = density === "compact" ? 16 : density === "cozy" ? 32 : 24;
|
| 24 |
+
|
| 25 |
+
React.useEffect(() => {
|
| 26 |
+
const el = scrollRef.current;
|
| 27 |
+
if (el) el.scrollTop = el.scrollHeight;
|
| 28 |
+
}, [messages.length, pending]);
|
| 29 |
+
|
| 30 |
+
React.useEffect(() => {
|
| 31 |
+
const onKey = (e) => {
|
| 32 |
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "d") {
|
| 33 |
+
e.preventDefault();
|
| 34 |
+
setShowDebug(s => !s);
|
| 35 |
+
}
|
| 36 |
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
| 37 |
+
e.preventDefault();
|
| 38 |
+
reset();
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
window.addEventListener("keydown", onKey);
|
| 42 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 43 |
+
}, [reset]);
|
| 44 |
+
|
| 45 |
+
const submit = (e) => {
|
| 46 |
+
e?.preventDefault();
|
| 47 |
+
const v = input.trim();
|
| 48 |
+
if (!v || pending) return;
|
| 49 |
+
setInput("");
|
| 50 |
+
send(v);
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const quickSend = (text) => {
|
| 54 |
+
if (pending) return;
|
| 55 |
+
send(text);
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div style={{
|
| 60 |
+
position: "absolute", inset: 0,
|
| 61 |
+
background: palette.bg, color: palette.fg,
|
| 62 |
+
fontFamily: fontPair.mono,
|
| 63 |
+
fontSize: 14, lineHeight,
|
| 64 |
+
display: "flex", flexDirection: "column",
|
| 65 |
+
overflow: "hidden",
|
| 66 |
+
}}>
|
| 67 |
+
<header style={{
|
| 68 |
+
display: "flex", alignItems: "center", justifyContent: "space-between",
|
| 69 |
+
padding: `10px ${pad}px`,
|
| 70 |
+
borderBottom: `1px solid ${palette.muted}`,
|
| 71 |
+
color: palette.dim, fontSize: 12,
|
| 72 |
+
}}>
|
| 73 |
+
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
| 74 |
+
<span style={{ color: palette.accent }}>◉</span>
|
| 75 |
+
<span>fetch — blenderbot-400M-distill — 4-turn context</span>
|
| 76 |
+
</div>
|
| 77 |
+
<div style={{ display: "flex", gap: 16 }}>
|
| 78 |
+
<button onClick={reset} style={termBtn(palette)}>clear ⌘K</button>
|
| 79 |
+
<button onClick={() => setShowDebug(s => !s)} style={termBtn(palette)}>
|
| 80 |
+
debug ⌘D
|
| 81 |
+
</button>
|
| 82 |
+
</div>
|
| 83 |
+
</header>
|
| 84 |
+
|
| 85 |
+
<div ref={scrollRef} style={{
|
| 86 |
+
flex: 1, overflowY: "auto",
|
| 87 |
+
padding: `${pad}px ${pad * 1.5}px`,
|
| 88 |
+
scrollbarWidth: "thin", scrollbarColor: `${palette.muted} transparent`,
|
| 89 |
+
}}>
|
| 90 |
+
<pre style={{
|
| 91 |
+
margin: 0, fontFamily: "inherit", color: palette.dim,
|
| 92 |
+
whiteSpace: "pre-wrap", marginBottom: 16,
|
| 93 |
+
}}>
|
| 94 |
+
{`// this is fetch — a friendly dog-loving chatbot
|
| 95 |
+
// type a question and hit enter. try "tell me about border collies".
|
| 96 |
+
// shortcuts: ⌘K clear · ⌘D debug
|
| 97 |
+
`}
|
| 98 |
+
</pre>
|
| 99 |
+
|
| 100 |
+
{messages.filter(m => !m.pending).map((m) => (
|
| 101 |
+
<TerminalLine
|
| 102 |
+
key={m.id}
|
| 103 |
+
msg={m}
|
| 104 |
+
palette={palette}
|
| 105 |
+
/>
|
| 106 |
+
))}
|
| 107 |
+
{pending && <BlinkingCursor palette={palette} label="fetching an answer" />}
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<form onSubmit={submit} style={{
|
| 111 |
+
padding: `${pad - 4}px ${pad * 1.5}px ${pad}px`,
|
| 112 |
+
borderTop: `1px solid ${palette.muted}`,
|
| 113 |
+
display: "flex", flexDirection: "column", gap: 10,
|
| 114 |
+
}}>
|
| 115 |
+
<SuggestionsRow
|
| 116 |
+
suggestions={suggestions}
|
| 117 |
+
onPick={quickSend}
|
| 118 |
+
palette={palette}
|
| 119 |
+
pending={pending}
|
| 120 |
+
/>
|
| 121 |
+
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
| 122 |
+
<span style={{ color: palette.accent, userSelect: "none" }}>fetch></span>
|
| 123 |
+
<input
|
| 124 |
+
ref={inputRef}
|
| 125 |
+
value={input}
|
| 126 |
+
onChange={e => setInput(e.target.value)}
|
| 127 |
+
placeholder={pending ? "…waiting on dog" : "ask something"}
|
| 128 |
+
autoFocus
|
| 129 |
+
disabled={pending}
|
| 130 |
+
style={{
|
| 131 |
+
flex: 1, background: "transparent", border: "none", outline: "none",
|
| 132 |
+
color: palette.fg, fontFamily: "inherit", fontSize: 14, caretColor: palette.accent,
|
| 133 |
+
}}
|
| 134 |
+
/>
|
| 135 |
+
<button type="submit" disabled={pending || !input.trim()} style={{
|
| 136 |
+
...termBtn(palette),
|
| 137 |
+
color: palette.accent, borderColor: palette.accent,
|
| 138 |
+
opacity: pending || !input.trim() ? 0.4 : 1,
|
| 139 |
+
}}>↵ send</button>
|
| 140 |
+
</div>
|
| 141 |
+
</form>
|
| 142 |
+
|
| 143 |
+
{showDebug && (
|
| 144 |
+
<DebugPanel
|
| 145 |
+
debug={debug}
|
| 146 |
+
onClose={() => setShowDebug(false)}
|
| 147 |
+
palette={{ bg: palette.bg, fg: palette.fg, dim: palette.dim, accent: palette.accent, muted: palette.muted }}
|
| 148 |
+
font={fontPair.mono}
|
| 149 |
+
/>
|
| 150 |
+
)}
|
| 151 |
+
</div>
|
| 152 |
+
);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function termBtn(p) {
|
| 156 |
+
return {
|
| 157 |
+
background: "transparent",
|
| 158 |
+
color: p.dim,
|
| 159 |
+
border: `1px solid ${p.muted}`,
|
| 160 |
+
padding: "3px 8px",
|
| 161 |
+
borderRadius: 4,
|
| 162 |
+
fontFamily: "inherit",
|
| 163 |
+
fontSize: 11,
|
| 164 |
+
cursor: "pointer",
|
| 165 |
+
letterSpacing: 0.3,
|
| 166 |
+
};
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function TerminalLine({ msg, palette }) {
|
| 170 |
+
const isUser = msg.role === "user";
|
| 171 |
+
const prefix = isUser ? "you$" : "dog#";
|
| 172 |
+
const color = isUser ? palette.fg : palette.accent;
|
| 173 |
+
const [mounted, setMounted] = React.useState(false);
|
| 174 |
+
React.useEffect(() => {
|
| 175 |
+
const id = requestAnimationFrame(() => setMounted(true));
|
| 176 |
+
return () => cancelAnimationFrame(id);
|
| 177 |
+
}, []);
|
| 178 |
+
return (
|
| 179 |
+
<div style={{
|
| 180 |
+
marginBottom: 10,
|
| 181 |
+
opacity: mounted ? 1 : 0,
|
| 182 |
+
transform: mounted ? "translateY(0)" : "translateY(4px)",
|
| 183 |
+
transition: "opacity .18s ease, transform .18s ease",
|
| 184 |
+
}}>
|
| 185 |
+
{!isUser && msg.fact && !msg.pending && (
|
| 186 |
+
<div style={{ color: palette.dim, fontStyle: "italic", marginBottom: 2 }}>
|
| 187 |
+
{`// fact matched: "${msg.fact.keyword}"`}
|
| 188 |
+
</div>
|
| 189 |
+
)}
|
| 190 |
+
<div>
|
| 191 |
+
<span style={{ color: palette.dim, userSelect: "none" }}>{prefix} </span>
|
| 192 |
+
<span style={{ color, whiteSpace: "pre-wrap" }}>
|
| 193 |
+
{msg.pending ? <DotDotDot palette={palette} /> : msg.content}
|
| 194 |
+
</span>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
function DotDotDot({ palette }) {
|
| 201 |
+
const [n, setN] = React.useState(1);
|
| 202 |
+
React.useEffect(() => {
|
| 203 |
+
const id = setInterval(() => setN(x => (x % 3) + 1), 380);
|
| 204 |
+
return () => clearInterval(id);
|
| 205 |
+
}, []);
|
| 206 |
+
return <span style={{ color: palette.dim }}>{".".repeat(n)}</span>;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
function BlinkingCursor({ palette, label }) {
|
| 210 |
+
return (
|
| 211 |
+
<div style={{ color: palette.dim, fontStyle: "italic", marginTop: 4 }}>
|
| 212 |
+
{label}
|
| 213 |
+
<span className="fetch-blink" style={{
|
| 214 |
+
display: "inline-block", width: 8, height: 14, marginLeft: 6,
|
| 215 |
+
verticalAlign: "middle", background: palette.accent, opacity: 0.7,
|
| 216 |
+
}}/>
|
| 217 |
+
</div>
|
| 218 |
+
);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function SuggestionsRow({ suggestions, onPick, palette, pending }) {
|
| 222 |
+
return (
|
| 223 |
+
<div style={{
|
| 224 |
+
display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center",
|
| 225 |
+
color: palette.dim, fontSize: 11,
|
| 226 |
+
}}>
|
| 227 |
+
<span style={{ opacity: 0.7 }}>try:</span>
|
| 228 |
+
{suggestions.map((s, i) => (
|
| 229 |
+
<button
|
| 230 |
+
key={s}
|
| 231 |
+
type="button"
|
| 232 |
+
disabled={pending}
|
| 233 |
+
onClick={() => onPick(s)}
|
| 234 |
+
style={{
|
| 235 |
+
...termBtn(palette),
|
| 236 |
+
padding: "2px 8px",
|
| 237 |
+
fontSize: 11,
|
| 238 |
+
cursor: pending ? "default" : "pointer",
|
| 239 |
+
opacity: pending ? 0.5 : 1,
|
| 240 |
+
animation: `fetch-fadein .4s ease ${i * 60}ms both`,
|
| 241 |
+
}}
|
| 242 |
+
>{s}</button>
|
| 243 |
+
))}
|
| 244 |
+
</div>
|
| 245 |
+
);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
Object.assign(window, { TerminalVariant });
|