From c87e510ca9e7731fe54f6164cbf3f7827d973945 Mon Sep 17 00:00:00 2001 From: Sir Blob Date: Tue, 30 Jun 2026 20:58:44 +0000 Subject: [PATCH] Add interactive terminal commands --- src/lib/components/TerminalTUI.svelte | 43 ++- src/lib/components/tui/TuiBody.svelte | 174 +++++++++++- src/lib/components/tui/terminal-commands.ts | 284 ++++++++++++++++++++ src/lib/components/tui/terminal-keyboard.ts | 6 + 4 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 src/lib/components/tui/terminal-commands.ts diff --git a/src/lib/components/TerminalTUI.svelte b/src/lib/components/TerminalTUI.svelte index ba8667a..d279154 100644 --- a/src/lib/components/TerminalTUI.svelte +++ b/src/lib/components/TerminalTUI.svelte @@ -1,11 +1,24 @@ -
+ +
{#each processedGroups as group, gi (gi)} {#if group.kind === "inline"}
@@ -127,7 +203,41 @@ {/if} {/each} - {#if terminalSettings.showCursor && !isTyping && displayedLines.length > 0} + {#if interactiveInput} + {#if !isTyping} +
+ + {user.username}@{user.hostname} + :~$ + + + { + inputFocused = true; + syncCaret(); + }} + onblur={() => (inputFocused = false)} + spellcheck="false" + autocomplete="off" + autocapitalize="off" + autocorrect="off" + aria-label="Terminal command input" + /> + +
+ {/if} + {:else if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
{user.username} {/if}
+ + diff --git a/src/lib/components/tui/terminal-commands.ts b/src/lib/components/tui/terminal-commands.ts new file mode 100644 index 0000000..dfc87e8 --- /dev/null +++ b/src/lib/components/tui/terminal-commands.ts @@ -0,0 +1,284 @@ +import type { TerminalAPI, TerminalLine } from './types'; +import { user } from '$lib/config'; + +export interface CommandContext { + terminal: TerminalAPI; + navigate: (path: string, external?: boolean) => void; + toggleMode: () => void; + setColorTheme: (name: string) => void; + getMode: () => string; + getColorTheme: () => string; + themeNames: string[]; + user: typeof user; + skills: Record; + navigation: Array<{ name: string; path: string; external?: boolean }>; +} + +export interface Command { + name: string; + description: string; + usage?: string; + aliases?: string[]; + hidden?: boolean; + run: (args: string[], ctx: CommandContext) => void; +} + +const out = (content: string): TerminalLine => ({ type: 'output', content }); +const ok = (content: string): TerminalLine => ({ type: 'success', content }); +const err = (content: string): TerminalLine => ({ type: 'error', content }); +const info = (content: string): TerminalLine => ({ type: 'info', content }); +const blank = (): TerminalLine => ({ type: 'blank', content: '' }); + +const pageRoutes: Record = { + home: '/', + about: '/about', + portfolio: '/portfolio', + projects: '/projects', + models: '/models', + components: '/components', + links: '/links' +}; + +const baseCommands: Command[] = [ + { + name: 'help', + aliases: ['h', '?'], + description: 'List all available commands', + run: (_args, ctx) => { + const rows = commands + .filter((command) => !command.hidden) + .map((command) => [ + `(&primary,bold)${command.name}(&)` + + (command.aliases?.length ? ` (&muted)(${command.aliases.join(', ')})(&)` : ''), + command.description + ]); + ctx.terminal.write({ + type: 'table', + content: 'Available Commands', + tableHeaders: ['Command', 'Description'], + tableRows: rows, + style: 'accent' + } as TerminalLine); + ctx.terminal.write(info('Tip: try (&accent)neofetch(&), (&accent)cd projects(&), or (&accent)theme(&)')); + ctx.terminal.write(blank()); + } + }, + { + name: 'clear', + aliases: ['cls'], + description: 'Clear the terminal', + run: (_args, ctx) => ctx.terminal.clear() + }, + { + name: 'whoami', + description: 'Show a short bio', + run: (_args, ctx) => { + ctx.terminal.writeLines([ + out(`(&primary,bold)${ctx.user.name}(&) (&muted)— ${ctx.user.title}(&)`), + out(`(&muted)${ctx.user.bio}(&)`), + blank() + ]); + } + }, + { + name: 'neofetch', + aliases: ['banner'], + description: 'Display system info', + run: (_args, ctx) => { + ctx.terminal.writeLines([ + out(`(&accent,bold)${ctx.user.username}(&)(&muted)@(&)(&primary,bold)${ctx.user.hostname}(&)`), + out('(&muted)------------------------------(&)'), + out(`(&blue,bold)Name(&): ${ctx.user.name}`), + out(`(&blue,bold)Title(&): ${ctx.user.title}`), + out(`(&blue,bold)Location(&): ${ctx.user.location}`), + out(`(&blue,bold)Shell(&): tui-terminal`), + out(`(&blue,bold)Languages(&): ${ctx.skills.languages.slice(0, 6).join(', ')}`), + blank() + ]); + } + }, + { + name: 'skills', + description: 'List skills by category', + run: (_args, ctx) => { + const order = ['languages', 'frameworks', 'tools', 'platforms', 'applications', 'databases', 'interests']; + const lines: TerminalLine[] = []; + for (const key of order) { + const values = ctx.skills[key]; + if (!values?.length) continue; + lines.push(info(`(&blue,bold)${key}(&)`)); + lines.push(out(' ' + values.map((value) => `(&cyan)${value}(&)`).join(' (&muted)•(&) '))); + } + lines.push(blank()); + ctx.terminal.writeLines(lines); + } + }, + { + name: 'contact', + aliases: ['socials'], + description: 'Show social links', + run: (_args, ctx) => { + ctx.terminal.write(info('(&accent,bold)Find me online:(&)')); + ctx.terminal.writeLines( + ctx.user.socials.map((social) => ({ + type: 'button', + content: social.name, + icon: social.icon, + href: social.link, + style: 'primary', + inline: true + })) as TerminalLine[] + ); + ctx.terminal.write(blank()); + } + }, + { + name: 'email', + description: 'Open a mail draft', + run: (_args, ctx) => { + ctx.terminal.write(ok(`Opening mail to (&accent)${ctx.user.email}(&)`)); + ctx.navigate(`mailto:${ctx.user.email}`, true); + } + }, + { + name: 'cd', + aliases: ['goto', 'open'], + description: 'Navigate to a page', + usage: 'cd ', + run: (args, ctx) => { + const target = (args[0] || 'home').toLowerCase().replace(/^\/+|\/+$/g, ''); + const navItem = ctx.navigation.find((item) => item.name.toLowerCase() === target); + if (navItem) { + ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`)); + ctx.navigate(navItem.path, navItem.external); + return; + } + if (pageRoutes[target] !== undefined) { + ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`)); + ctx.navigate(pageRoutes[target]); + return; + } + ctx.terminal.write(err(`No such page: ${target}. Try (&bold)ls(&).`)); + } + }, + { + name: 'ls', + description: 'List the pages you can visit', + run: (_args, ctx) => { + const names = Object.keys(pageRoutes); + ctx.navigation.forEach((item) => { + if (item.external && !names.includes(item.name)) names.push(item.name); + }); + ctx.terminal.write(out(names.map((name) => `(&blue)${name}(&)`).join(' '))); + } + }, + { + name: 'theme', + description: 'List or set the color theme', + usage: 'theme [name]', + run: (args, ctx) => { + if (!args.length) { + ctx.terminal.write(out('Themes: ' + ctx.themeNames.map((name) => `(&blue)${name}(&)`).join(' '))); + ctx.terminal.write(info(`Current: (&accent)${ctx.getColorTheme()}(&)`)); + return; + } + const name = args[0].toLowerCase(); + if (ctx.themeNames.includes(name)) { + ctx.setColorTheme(name); + ctx.terminal.write(ok(`Theme set to (&accent)${name}(&)`)); + } else { + ctx.terminal.write(err(`Unknown theme: ${name}. Available: ${ctx.themeNames.join(', ')}`)); + } + } + }, + { + name: 'mode', + description: 'Toggle or set dark/light mode', + usage: 'mode [dark|light]', + run: (args, ctx) => { + const current = ctx.getMode(); + if (!args.length) { + ctx.toggleMode(); + ctx.terminal.write(ok(`Switched to ${current === 'dark' ? 'light' : 'dark'} mode`)); + return; + } + const requested = args[0].toLowerCase(); + if (requested === 'dark' || requested === 'light') { + if (requested !== current) ctx.toggleMode(); + ctx.terminal.write(ok(`${requested} mode active`)); + } else { + ctx.terminal.write(err('Usage: mode [dark|light]')); + } + } + }, + { + name: 'echo', + description: 'Print text back', + usage: 'echo ', + run: (args, ctx) => ctx.terminal.write(out(args.join(' '))) + }, + { + name: 'date', + description: 'Show the current date and time', + run: (_args, ctx) => ctx.terminal.write(out(new Date().toString())) + }, + { + name: 'sudo', + hidden: true, + description: 'Elevated privileges', + run: (_args, ctx) => + ctx.terminal.write( + err(`(&error)Nice try.(&) ${ctx.user.username} is not in the sudoers file. This incident will be reported.`) + ) + }, + { + name: 'blog', + hidden: true, + description: 'Open the blog', + run: (_args, ctx) => { + const blogItem = ctx.navigation.find((item) => item.name.toLowerCase() === 'blog'); + if (blogItem) ctx.navigate(blogItem.path, blogItem.external); + } + } +]; + +const pageCommands: Command[] = Object.entries(pageRoutes).map(([name, path]) => ({ + name, + description: `Go to the ${name} page`, + hidden: true, + run: (_args, ctx) => ctx.navigate(path) +})); + +const socialCommands: Command[] = user.socials.map((social) => ({ + name: social.name.toLowerCase(), + description: `Open ${social.name}`, + hidden: true, + run: (_args, ctx) => ctx.navigate(social.link, true) +})); + +export const commands: Command[] = [...baseCommands, ...pageCommands, ...socialCommands]; + +function findCommand(name: string): Command | undefined { + const lower = name.toLowerCase(); + return commands.find( + (command) => command.name === lower || command.aliases?.some((alias) => alias.toLowerCase() === lower) + ); +} + +export function runCommand(input: string, ctx: CommandContext): void { + const raw = input.trim(); + ctx.terminal.write({ type: 'command', content: raw }); + if (!raw) return; + + const parts = raw.split(/\s+/); + const name = parts[0]; + const args = parts.slice(1); + const command = findCommand(name); + + if (!command) { + ctx.terminal.write(err(`command not found: (&bold)${name}(&) — type (&accent)help(&) for a list.`)); + return; + } + + command.run(args, ctx); +} diff --git a/src/lib/components/tui/terminal-keyboard.ts b/src/lib/components/tui/terminal-keyboard.ts index a45c809..823f2b3 100644 --- a/src/lib/components/tui/terminal-keyboard.ts +++ b/src/lib/components/tui/terminal-keyboard.ts @@ -87,6 +87,12 @@ export function createKeyboardHandler(options: KeyboardHandlerOptions) { } = options; return function handleKeydown(event: KeyboardEvent): void { + // Ignore key events originating from editable fields (e.g. command input) + const target = event.target as HTMLElement | null; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) { + return; + } + // Skip animation on Y key if (getIsTyping() && (event.key === 'y' || event.key === 'Y')) { event.preventDefault();