UI Components Medium
Prompt Input
Enhanced prompt textarea with live token counter, character limit bar, attach file button, and submit shortcut. No libraries.
Open in Lab
MCP
vanilla-js css react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f9fafb;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.demo {
width: 100%;
max-width: 620px;
display: flex;
flex-direction: column;
gap: 12px;
}
.pi-card {
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: border-color 0.15s, box-shadow 0.15s;
}
.pi-card:focus-within {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
}
.pi-top {
padding: 12px 14px 0;
}
.model-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: #f3f4f6;
border: none;
border-radius: 20px;
padding: 5px 12px;
font-size: 12px;
font-weight: 600;
color: #374151;
cursor: pointer;
transition: background 0.15s;
}
.model-pill:hover {
background: #e5e7eb;
}
.model-dot {
width: 7px;
height: 7px;
background: #16a34a;
border-radius: 50%;
}
.pi-textarea {
width: 100%;
padding: 12px 16px;
border: none;
outline: none;
font-size: 15px;
color: #111827;
background: transparent;
resize: none;
font-family: inherit;
line-height: 1.6;
min-height: 80px;
max-height: 260px;
overflow-y: auto;
}
.pi-textarea::placeholder {
color: #9ca3af;
}
.pi-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px 12px;
border-top: 1px solid #f3f4f6;
}
.pi-limit {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.pi-limit-bar {
flex: 1;
height: 3px;
background: #f3f4f6;
border-radius: 2px;
overflow: hidden;
}
.pi-limit-fill {
height: 100%;
background: #6366f1;
border-radius: 2px;
width: 0%;
transition: width 0.1s, background 0.15s;
}
.pi-limit-fill.warn {
background: #d97706;
}
.pi-limit-fill.over {
background: #dc2626;
}
.pi-token-count {
font-size: 11px;
color: #9ca3af;
white-space: nowrap;
}
.pi-token-count.warn {
color: #d97706;
}
.pi-token-count.over {
color: #dc2626;
font-weight: 700;
}
.pi-actions {
display: flex;
align-items: center;
gap: 6px;
}
.pi-icon-btn {
width: 32px;
height: 32px;
background: none;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #9ca3af;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
}
.pi-icon-btn:hover {
color: #374151;
border-color: #d1d5db;
}
.pi-send-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: opacity 0.15s;
}
.pi-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pi-send-btn:not(:disabled):hover {
opacity: 0.85;
}
.pi-hint {
text-align: center;
font-size: 12px;
color: #9ca3af;
}
.pi-hint kbd {
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 4px;
padding: 1px 5px;
font-size: 11px;
color: #374151;
}
.pi-sent {
text-align: center;
font-size: 14px;
font-weight: 600;
color: #16a34a;
padding: 8px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}const textarea = document.getElementById("piInput");
const limitFill = document.getElementById("limitFill");
const tokenCount = document.getElementById("tokenCount");
const sendBtn = document.getElementById("piSend");
const piSent = document.getElementById("piSent");
const MAX_TOKENS = 4096;
function estimateTokens(text) {
return Math.ceil(text.length / 4);
}
textarea.addEventListener("input", () => {
// Auto-resize
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 260) + "px";
const tokens = estimateTokens(textarea.value);
const pct = Math.min((tokens / MAX_TOKENS) * 100, 100);
limitFill.style.width = pct + "%";
limitFill.className = "pi-limit-fill" + (pct >= 100 ? " over" : pct >= 75 ? " warn" : "");
tokenCount.textContent = `~${tokens} / ${MAX_TOKENS}`;
tokenCount.className = "pi-token-count" + (pct >= 100 ? " over" : pct >= 75 ? " warn" : "");
sendBtn.disabled = !textarea.value.trim() || tokens > MAX_TOKENS;
piSent.hidden = true;
});
function doSend() {
if (sendBtn.disabled) return;
piSent.hidden = false;
textarea.value = "";
textarea.style.height = "auto";
limitFill.style.width = "0%";
tokenCount.textContent = "0 / 4096";
sendBtn.disabled = true;
}
sendBtn.addEventListener("click", doSend);
textarea.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
doSend();
}
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prompt Input</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<div class="pi-card">
<div class="pi-top">
<button class="model-pill" id="modelPill">
<span class="model-dot"></span>
<span id="modelName">Claude Sonnet</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
</button>
</div>
<textarea class="pi-textarea" id="piInput" placeholder="Ask anything… (⌘+Enter to send)" rows="3"></textarea>
<div class="pi-footer">
<div class="pi-limit">
<div class="pi-limit-bar" id="limitBar">
<div class="pi-limit-fill" id="limitFill"></div>
</div>
<span class="pi-token-count" id="tokenCount">0 / 4096</span>
</div>
<div class="pi-actions">
<button class="pi-icon-btn" id="attachBtn" title="Attach file">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
<button class="pi-send-btn" id="piSend" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
<span>Send</span>
</button>
</div>
</div>
</div>
<div class="pi-hint">Press <kbd>⌘</kbd><kbd>Enter</kbd> to send · <kbd>Shift</kbd><kbd>Enter</kbd> for newline</div>
<div class="pi-sent" id="piSent" hidden>
<span>✓ Sent!</span>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useEffect } from "react";
const MODELS = ["claude-opus-4", "claude-sonnet-4", "gpt-4o", "gemini-2.0"];
const MAX_TOKENS = 4096;
const CHARS_PER_TOKEN = 4;
function estimateTokens(text: string) {
return Math.ceil(text.length / CHARS_PER_TOKEN);
}
interface Attachment {
id: number;
name: string;
size: string;
}
let fileId = 0;
const DEMO_FILES = [
{ name: "context.pdf", size: "128 KB" },
{ name: "data.csv", size: "42 KB" },
{ name: "screenshot.png", size: "1.1 MB" },
{ name: "schema.json", size: "8 KB" },
];
export default function PromptInputRC() {
const [text, setText] = useState("");
const [model, setModel] = useState(MODELS[0]);
const [showModelMenu, setShowModelMenu] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [submitted, setSubmitted] = useState<string | null>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const demoIdx = useRef(0);
const tokens = estimateTokens(text);
const pct = Math.min((tokens / MAX_TOKENS) * 100, 100);
const atLimit = tokens >= MAX_TOKENS;
// Auto-resize textarea
useEffect(() => {
const ta = taRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, 240) + "px";
}, [text]);
const handleKey = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
submit();
}
};
const submit = () => {
if (!text.trim() || atLimit) return;
setSubmitted(text.trim());
setText("");
setAttachments([]);
setTimeout(() => setSubmitted(null), 3000);
};
const addFile = () => {
const demo = DEMO_FILES[demoIdx.current % DEMO_FILES.length];
demoIdx.current++;
setAttachments((prev) => [...prev, { id: ++fileId, ...demo }]);
};
const barColor = pct > 90 ? "bg-red-500" : pct > 70 ? "bg-yellow-400" : "bg-[#58a6ff]";
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex justify-center items-center">
<div className="w-full max-w-[680px] space-y-4">
{submitted && (
<div className="flex items-center gap-2 px-4 py-3 bg-green-500/10 border border-green-500/30 rounded-xl text-[12px] text-green-400">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<polyline points="20 6 9 17 4 12" />
</svg>
Prompt submitted: "{submitted.slice(0, 60)}
{submitted.length > 60 ? "…" : ""}"
</div>
)}
<div className="bg-[#161b22] border border-[#30363d] rounded-2xl overflow-hidden focus-within:border-[#58a6ff] transition-colors">
{/* Attachments */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pt-3">
{attachments.map((f) => (
<div
key={f.id}
className="flex items-center gap-1.5 px-2.5 py-1 bg-[#21262d] border border-[#30363d] rounded-lg text-[11px] text-[#8b949e]"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span>{f.name}</span>
<span className="text-[#484f58]">{f.size}</span>
<button
onClick={() => setAttachments((p) => p.filter((x) => x.id !== f.id))}
className="text-[#484f58] hover:text-[#e6edf3] ml-0.5 transition-colors"
>
×
</button>
</div>
))}
</div>
)}
{/* Textarea */}
<textarea
ref={taRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKey}
placeholder="Ask anything… (⌘+Enter to send)"
className="w-full bg-transparent px-4 pt-3 pb-2 text-[14px] text-[#e6edf3] placeholder-[#484f58] resize-none outline-none leading-relaxed min-h-[80px]"
style={{ height: "80px" }}
/>
{/* Token bar */}
<div className="h-0.5 mx-4 mb-0 bg-[#21262d] rounded-full overflow-hidden">
<div
className={`h-full ${barColor} transition-all duration-200 rounded-full`}
style={{ width: `${pct}%` }}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5">
<div className="flex items-center gap-2">
{/* Attach */}
<button
onClick={addFile}
title="Attach file"
className="p-1.5 rounded-lg text-[#8b949e] hover:text-[#e6edf3] hover:bg-white/[0.04] transition-colors"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
</button>
{/* Model selector */}
<div className="relative">
<button
onClick={() => setShowModelMenu((v) => !v)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-[#21262d] border border-[#30363d] rounded-lg text-[11px] font-semibold text-[#8b949e] hover:text-[#e6edf3] hover:border-[#8b949e] transition-colors"
>
<span className="w-1.5 h-1.5 rounded-full bg-[#58a6ff]" />
{model}
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{showModelMenu && (
<div className="absolute bottom-full mb-1 left-0 bg-[#21262d] border border-[#30363d] rounded-xl py-1 z-10 min-w-[170px] shadow-xl">
{MODELS.map((m) => (
<button
key={m}
onClick={() => {
setModel(m);
setShowModelMenu(false);
}}
className={`w-full text-left px-3 py-1.5 text-[12px] transition-colors flex items-center gap-2 ${
m === model
? "text-[#58a6ff]"
: "text-[#8b949e] hover:text-[#e6edf3] hover:bg-white/[0.04]"
}`}
>
{m === model && (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{m !== model && <span className="w-2.5" />}
{m}
</button>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
{/* Token count */}
<span
className={`text-[11px] font-mono ${atLimit ? "text-red-400" : "text-[#484f58]"}`}
>
{tokens.toLocaleString()} / {MAX_TOKENS.toLocaleString()}
</span>
{/* Submit */}
<button
onClick={submit}
disabled={!text.trim() || atLimit}
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#58a6ff] rounded-lg text-[12px] font-semibold text-white disabled:opacity-30 disabled:cursor-not-allowed hover:bg-[#79b8ff] transition-colors"
>
Send
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
);
}<script setup>
import { ref, computed, watch, nextTick } from "vue";
const MODELS = ["claude-opus-4", "claude-sonnet-4", "gpt-4o", "gemini-2.0"];
const MAX_TOKENS = 4096;
const CHARS_PER_TOKEN = 4;
const DEMO_FILES = [
{ name: "context.pdf", size: "128 KB" },
{ name: "data.csv", size: "42 KB" },
{ name: "screenshot.png", size: "1.1 MB" },
{ name: "schema.json", size: "8 KB" },
];
const text = ref("");
const model = ref(MODELS[0]);
const showModelMenu = ref(false);
const attachments = ref([]);
const submitted = ref(null);
const taRef = ref(null);
let demoIdx = 0;
let fileId = 0;
function estimateTokens(t) {
return Math.ceil(t.length / CHARS_PER_TOKEN);
}
const tokens = computed(() => estimateTokens(text.value));
const pct = computed(() => Math.min((tokens.value / MAX_TOKENS) * 100, 100));
const atLimit = computed(() => tokens.value >= MAX_TOKENS);
const barColor = computed(() =>
pct.value > 90 ? "bg-red-500" : pct.value > 70 ? "bg-yellow-400" : "bg-[#58a6ff]"
);
watch(text, () => {
nextTick(() => {
const ta = taRef.value;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, 240) + "px";
});
});
function handleKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
submit();
}
}
function submit() {
if (!text.value.trim() || atLimit.value) return;
submitted.value = text.value.trim();
text.value = "";
attachments.value = [];
setTimeout(() => (submitted.value = null), 3000);
}
function addFile() {
const demo = DEMO_FILES[demoIdx % DEMO_FILES.length];
demoIdx++;
fileId++;
attachments.value = [...attachments.value, { id: fileId, ...demo }];
}
function removeFile(id) {
attachments.value = attachments.value.filter((x) => x.id !== id);
}
function selectModel(m) {
model.value = m;
showModelMenu.value = false;
}
</script>
<template>
<div class="min-h-screen bg-[#0d1117] p-6 flex justify-center items-center">
<div class="w-full max-w-[680px] space-y-4">
<div
v-if="submitted"
class="flex items-center gap-2 px-4 py-3 bg-green-500/10 border border-green-500/30 rounded-xl text-[12px] text-green-400"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Prompt submitted: "{{ submitted.slice(0, 60) }}{{ submitted.length > 60 ? '...' : '' }}"
</div>
<div class="bg-[#161b22] border border-[#30363d] rounded-2xl overflow-hidden focus-within:border-[#58a6ff] transition-colors">
<!-- Attachments -->
<div v-if="attachments.length > 0" class="flex flex-wrap gap-2 px-4 pt-3">
<div
v-for="f in attachments"
:key="f.id"
class="flex items-center gap-1.5 px-2.5 py-1 bg-[#21262d] border border-[#30363d] rounded-lg text-[11px] text-[#8b949e]"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span>{{ f.name }}</span>
<span class="text-[#484f58]">{{ f.size }}</span>
<button @click="removeFile(f.id)" class="text-[#484f58] hover:text-[#e6edf3] ml-0.5 transition-colors">
x
</button>
</div>
</div>
<!-- Textarea -->
<textarea
ref="taRef"
v-model="text"
@keydown="handleKey"
placeholder="Ask anything... (Cmd+Enter to send)"
class="w-full bg-transparent px-4 pt-3 pb-2 text-[14px] text-[#e6edf3] placeholder-[#484f58] resize-none outline-none leading-relaxed min-h-[80px]"
style="height: 80px"
/>
<!-- Token bar -->
<div class="h-0.5 mx-4 mb-0 bg-[#21262d] rounded-full overflow-hidden">
<div
:class="['h-full transition-all duration-200 rounded-full', barColor]"
:style="{ width: pct + '%' }"
/>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-4 py-2.5">
<div class="flex items-center gap-2">
<!-- Attach -->
<button
@click="addFile"
title="Attach file"
class="p-1.5 rounded-lg text-[#8b949e] hover:text-[#e6edf3] hover:bg-white/[0.04] transition-colors"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<!-- Model selector -->
<div class="relative">
<button
@click="showModelMenu = !showModelMenu"
class="flex items-center gap-1.5 px-2.5 py-1 bg-[#21262d] border border-[#30363d] rounded-lg text-[11px] font-semibold text-[#8b949e] hover:text-[#e6edf3] hover:border-[#8b949e] transition-colors"
>
<span class="w-1.5 h-1.5 rounded-full bg-[#58a6ff]" />
{{ model }}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div v-if="showModelMenu" class="absolute bottom-full mb-1 left-0 bg-[#21262d] border border-[#30363d] rounded-xl py-1 z-10 min-w-[170px] shadow-xl">
<button
v-for="m in MODELS"
:key="m"
@click="selectModel(m)"
:class="['w-full text-left px-3 py-1.5 text-[12px] transition-colors flex items-center gap-2', m === model ? 'text-[#58a6ff]' : 'text-[#8b949e] hover:text-[#e6edf3] hover:bg-white/[0.04]']"
>
<svg v-if="m === model" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
<span v-else class="w-2.5" />
{{ m }}
</button>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Token count -->
<span :class="['text-[11px] font-mono', atLimit ? 'text-red-400' : 'text-[#484f58]']">
{{ tokens.toLocaleString() }} / {{ MAX_TOKENS.toLocaleString() }}
</span>
<!-- Submit -->
<button
@click="submit"
:disabled="!text.trim() || atLimit"
class="flex items-center gap-1.5 px-3 py-1.5 bg-[#58a6ff] rounded-lg text-[12px] font-semibold text-white disabled:opacity-30 disabled:cursor-not-allowed hover:bg-[#79b8ff] transition-colors"
>
Send
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style><script>
import { afterUpdate } from "svelte";
const MODELS = ["claude-opus-4", "claude-sonnet-4", "gpt-4o", "gemini-2.0"];
const MAX_TOKENS = 4096;
const CHARS_PER_TOKEN = 4;
const DEMO_FILES = [
{ name: "context.pdf", size: "128 KB" },
{ name: "data.csv", size: "42 KB" },
{ name: "screenshot.png", size: "1.1 MB" },
{ name: "schema.json", size: "8 KB" },
];
let text = "";
let model = MODELS[0];
let showModelMenu = false;
let attachments = [];
let submitted = null;
let taEl;
let demoIdx = 0;
let fileId = 0;
function estimateTokens(t) {
return Math.ceil(t.length / CHARS_PER_TOKEN);
}
$: tokens = estimateTokens(text);
$: pct = Math.min((tokens / MAX_TOKENS) * 100, 100);
$: atLimit = tokens >= MAX_TOKENS;
$: barColor = pct > 90 ? "bg-red-500" : pct > 70 ? "bg-yellow-400" : "bg-[#58a6ff]";
afterUpdate(() => {
if (taEl) {
taEl.style.height = "auto";
taEl.style.height = Math.min(taEl.scrollHeight, 240) + "px";
}
});
function handleKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
submit();
}
}
function submit() {
if (!text.trim() || atLimit) return;
submitted = text.trim();
text = "";
attachments = [];
setTimeout(() => (submitted = null), 3000);
}
function addFile() {
const demo = DEMO_FILES[demoIdx % DEMO_FILES.length];
demoIdx++;
fileId++;
attachments = [...attachments, { id: fileId, ...demo }];
}
function removeFile(id) {
attachments = attachments.filter((x) => x.id !== id);
}
function selectModel(m) {
model = m;
showModelMenu = false;
}
</script>
<div class="min-h-screen bg-[#0d1117] p-6 flex justify-center items-center">
<div class="w-full max-w-[680px] space-y-4">
{#if submitted}
<div class="flex items-center gap-2 px-4 py-3 bg-green-500/10 border border-green-500/30 rounded-xl text-[12px] text-green-400">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Prompt submitted: "{submitted.slice(0, 60)}{submitted.length > 60 ? '...' : ''}"
</div>
{/if}
<div class="bg-[#161b22] border border-[#30363d] rounded-2xl overflow-hidden focus-within:border-[#58a6ff] transition-colors">
<!-- Attachments -->
{#if attachments.length > 0}
<div class="flex flex-wrap gap-2 px-4 pt-3">
{#each attachments as f (f.id)}
<div class="flex items-center gap-1.5 px-2.5 py-1 bg-[#21262d] border border-[#30363d] rounded-lg text-[11px] text-[#8b949e]">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span>{f.name}</span>
<span class="text-[#484f58]">{f.size}</span>
<button on:click={() => removeFile(f.id)} class="text-[#484f58] hover:text-[#e6edf3] ml-0.5 transition-colors">
x
</button>
</div>
{/each}
</div>
{/if}
<!-- Textarea -->
<textarea
bind:this={taEl}
bind:value={text}
on:keydown={handleKey}
placeholder="Ask anything... (Cmd+Enter to send)"
class="w-full bg-transparent px-4 pt-3 pb-2 text-[14px] text-[#e6edf3] placeholder-[#484f58] resize-none outline-none leading-relaxed min-h-[80px]"
style="height: 80px;"
/>
<!-- Token bar -->
<div class="h-0.5 mx-4 mb-0 bg-[#21262d] rounded-full overflow-hidden">
<div
class="h-full {barColor} transition-all duration-200 rounded-full"
style="width: {pct}%;"
/>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-4 py-2.5">
<div class="flex items-center gap-2">
<!-- Attach -->
<button
on:click={addFile}
title="Attach file"
class="p-1.5 rounded-lg text-[#8b949e] hover:text-[#e6edf3] hover:bg-white/[0.04] transition-colors"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<!-- Model selector -->
<div class="relative">
<button
on:click={() => (showModelMenu = !showModelMenu)}
class="flex items-center gap-1.5 px-2.5 py-1 bg-[#21262d] border border-[#30363d] rounded-lg text-[11px] font-semibold text-[#8b949e] hover:text-[#e6edf3] hover:border-[#8b949e] transition-colors"
>
<span class="w-1.5 h-1.5 rounded-full bg-[#58a6ff]" />
{model}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{#if showModelMenu}
<div class="absolute bottom-full mb-1 left-0 bg-[#21262d] border border-[#30363d] rounded-xl py-1 z-10 min-w-[170px] shadow-xl">
{#each MODELS as m}
<button
on:click={() => selectModel(m)}
class="w-full text-left px-3 py-1.5 text-[12px] transition-colors flex items-center gap-2 {m === model ? 'text-[#58a6ff]' : 'text-[#8b949e] hover:text-[#e6edf3] hover:bg-white/[0.04]'}"
>
{#if m === model}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
{:else}
<span class="w-2.5" />
{/if}
{m}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="flex items-center gap-3">
<!-- Token count -->
<span class="text-[11px] font-mono {atLimit ? 'text-red-400' : 'text-[#484f58]'}">
{tokens.toLocaleString()} / {MAX_TOKENS.toLocaleString()}
</span>
<!-- Submit -->
<button
on:click={submit}
disabled={!text.trim() || atLimit}
class="flex items-center gap-1.5 px-3 py-1.5 bg-[#58a6ff] rounded-lg text-[12px] font-semibold text-white disabled:opacity-30 disabled:cursor-not-allowed hover:bg-[#79b8ff] transition-colors"
>
Send
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>AI prompt textarea with live token estimate, progress bar approaching the limit, file attach icon, model selector pill, and Ctrl+Enter / ⌘+Enter submit. Auto-resizes as the user types.