Initial commit: Nosterm client (terminal-style Nostr chat SPA)
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user