129 lines
3.7 KiB
Svelte
129 lines
3.7 KiB
Svelte
<script lang="ts">
|
|
import { MAX_COMMAND_HISTORY, MAX_MESSAGE_LENGTH } from '$lib/config';
|
|
|
|
// parent hands us the submit handler and an optional completion source.
|
|
let {
|
|
onsubmit,
|
|
complete
|
|
}: { onsubmit: (raw: string) => void; complete?: (prefix: string) => string[] } = $props();
|
|
|
|
let value = $state('');
|
|
let history = $state<string[]>([]);
|
|
// -1 means "not in history" (editing the live buffer).
|
|
let historyIndex = $state(-1);
|
|
let draft = $state('');
|
|
let input = $state<HTMLInputElement | null>(null);
|
|
// where we are in the completion candidates on repeated tab.
|
|
let completionCycle = $state(0);
|
|
|
|
// length counter for plain messages (not commands). only shows once you
|
|
// pass 80% of the limit so it stays out of the way.
|
|
let isCommand = $derived(value.trimStart().startsWith('/'));
|
|
let showCounter = $derived(!isCommand && value.length >= Math.floor(MAX_MESSAGE_LENGTH * 0.8));
|
|
let remaining = $derived(MAX_MESSAGE_LENGTH - value.length);
|
|
|
|
function submit() {
|
|
const raw = value.trim();
|
|
if (raw.length === 0) return;
|
|
// cap message length (also guards command length).
|
|
const bounded = raw.slice(0, MAX_MESSAGE_LENGTH);
|
|
|
|
history = [...history.slice(-(MAX_COMMAND_HISTORY - 1)), bounded];
|
|
historyIndex = -1;
|
|
draft = '';
|
|
value = '';
|
|
onsubmit(bounded);
|
|
}
|
|
|
|
function recallHistory(direction: -1 | 1) {
|
|
if (history.length === 0) return;
|
|
if (historyIndex === -1) {
|
|
// stepping into history: stash the current draft first.
|
|
if (direction === 1) return; // nothing newer than the live draft
|
|
draft = value;
|
|
historyIndex = history.length - 1;
|
|
} else {
|
|
historyIndex += direction;
|
|
}
|
|
if (historyIndex >= history.length) {
|
|
// past the newest entry: back to the live draft.
|
|
historyIndex = -1;
|
|
value = draft;
|
|
return;
|
|
}
|
|
if (historyIndex < 0) historyIndex = 0;
|
|
value = history[historyIndex] ?? '';
|
|
}
|
|
|
|
function tabComplete() {
|
|
if (!complete) return;
|
|
// only complete the first token (the command name).
|
|
if (/\s/.test(value.trim())) return;
|
|
const candidates = complete(value.trim());
|
|
if (candidates.length === 0) return;
|
|
if (candidates.length === 1) {
|
|
value = candidates[0] ?? value;
|
|
completionCycle = 0;
|
|
return;
|
|
}
|
|
// multiple matches: cycle through them on repeated tab presses.
|
|
value = candidates[completionCycle % candidates.length] ?? value;
|
|
completionCycle += 1;
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (event.key !== 'Tab') completionCycle = 0;
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
submit();
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
recallHistory(-1);
|
|
} else if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
recallHistory(1);
|
|
} else if (event.key === 'Tab') {
|
|
event.preventDefault();
|
|
tabComplete();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<form
|
|
class="flex items-center gap-2 border-t px-3 py-2"
|
|
style="border-color: var(--color-border); background: var(--color-surface);"
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
submit();
|
|
}}
|
|
>
|
|
<label for="command-input" class="select-none font-bold" style="color: var(--color-accent);">
|
|
>
|
|
</label>
|
|
<input
|
|
bind:this={input}
|
|
bind:value
|
|
id="command-input"
|
|
name="command-input"
|
|
type="text"
|
|
autocomplete="off"
|
|
autocapitalize="off"
|
|
spellcheck="false"
|
|
maxlength={MAX_MESSAGE_LENGTH}
|
|
placeholder="Type a message, or /help for commands"
|
|
class="flex-1 bg-transparent outline-none"
|
|
style="color: var(--color-text-primary); font-family: var(--font-terminal);"
|
|
aria-label="command input"
|
|
onkeydown={onKeydown}
|
|
/>
|
|
{#if showCounter}
|
|
<span
|
|
class="shrink-0 select-none text-xs tabular-nums"
|
|
style="color: {remaining <= 0 ? 'var(--color-error)' : 'var(--color-text-muted)'};"
|
|
aria-live="polite"
|
|
>
|
|
{remaining}
|
|
</span>
|
|
{/if}
|
|
</form>
|