Initial commit: Nosterm client (terminal-style Nostr chat SPA)
ci / build (push) Successful in 1m3s
ci / image (push) Successful in 41s

This commit is contained in:
Robert Goodall
2026-07-24 13:47:53 -04:00
commit cd2b900639
147 changed files with 16504 additions and 0 deletions
+128
View File
@@ -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);">
&gt;
</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>