Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed public/spacebar-click-keyboard-199448.mp3
Binary file not shown.
16 changes: 16 additions & 0 deletions src/components/TerminalHandler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,20 @@ describe('TerminalHandler input handling', () => {
expect(lastPrompt(buffer)).not.toBe(first)
expect(screen.getByText('clear')).toBeInTheDocument()
})

it("uses a command's compact plain rendering for the 3D buffer", async () => {
const { buffer } = renderTerminal()
await runCommand('help', buffer)
const lines = lastBuffer(buffer)
expect(lines).toContain('$ help')
expect(lines.some((l) => l.startsWith('Commands'))).toBe(true)
// The rich version lists every command on its own line; the compact one does not
expect(lines).not.toContain('Copy files or directories')
})

it('keeps the spaces between prompt, command and arguments in the 3D buffer', async () => {
const { buffer } = renderTerminal()
await runCommand('echo hello world', buffer)
expect(lastBuffer(buffer)).toContain('$ echo hello world')
})
})
39 changes: 26 additions & 13 deletions src/components/TerminalHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { commandMap } from "./commands/CommandMap";
import { extractText } from "../utils/textExtraction";
import { loadPersistedColor } from "../utils/colorPersistence";
import { uid } from "../utils/uid";
import { fileSystem } from "../services/filesystem";
import { aliasService } from "../services/alias";
import { envService } from "../services/env";
Expand Down Expand Up @@ -63,10 +64,10 @@
}, []);

/* helper to push a React line + its text twin */
const pushLine = (node: ReactNode) => {
const pushLine = (node: ReactNode, plain?: string) => {
setOutput((prev) => [...prev, node]);

const extracted = extractText(node);
const extracted = plain ?? extractText(node);
const split = extracted
.split(/\r?\n/) // break on \n
.filter(Boolean); // toss empties
Expand Down Expand Up @@ -257,9 +258,9 @@

if (expansionError) {
pushLine(
<div key={crypto.randomUUID()} className="mb-4">
<div key={uid()} className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-terminal">❯</span>
<span className="text-terminal">❯</span>{" "}
<span className="font-medium text-white">{cmd}</span>
</div>
<div className="ml-4">
Expand Down Expand Up @@ -292,23 +293,35 @@
}

if (command) {

const result = await command.run(args, context);
let result: React.ReactNode | null = null;
try {
result = await command.run(args, context);
} catch (err) {
// A crashing command must not leave the input line stuck with its trailing newline
result = (
<p className="text-red-400">
{command.name} failed: {err instanceof Error ? err.message : String(err)}
</p>
);
}
if (command.name === "exit") setTimeout(() => router.push("/"), 1000);
// The CRT is ~60x19; commands may supply a compact rendering for it
const plain = headless ? command.plain?.(args, context) ?? undefined : undefined;
if (result) {
pushLine(
<div key={crypto.randomUUID()} className="mb-4">
<div key={uid()} className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-terminal">❯</span>
<span className="text-terminal">❯</span>{" "}
<span className="font-medium text-white">{base}</span>
{args.length > 0 && (
<span className="text-gray-500">{args.join(" ")}</span>
<span className="text-gray-500"> {args.join(" ")}</span>
)}
</div>
<div className="ml-4">
{result}
</div>
</div>
</div>,
plain === undefined ? undefined : `${PROMPT}${cmd}\n${plain}`
);
}
setLastCommandTokens(finalTokens);
Expand All @@ -318,12 +331,12 @@
});
} else {
pushLine(
<div key={crypto.randomUUID()} className="mb-4">
<div key={uid()} className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-terminal">❯</span>
<span className="text-terminal">❯</span>{" "}
<span className="font-medium text-white">{base}</span>
{args.length > 0 && (
<span className="text-gray-500">{args.join(" ")}</span>
<span className="text-gray-500"> {args.join(" ")}</span>
)}
</div>
<div className="ml-4">
Expand All @@ -347,7 +360,7 @@
};

processCommand();
}, [text]);

Check warning on line 363 in src/components/TerminalHandler.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has missing dependencies: 'clearText', 'context', 'headless', 'lastCommandTokens', 'router', and 'searchMode'. Either include them or remove the dependency array

/* Sync terminal buffer to 3D CRT canvas for headless mode */
useEffect(() => {
Expand All @@ -359,7 +372,7 @@
);
const liveWithPrompt = PROMPT + live;
onBufferChange([...mapped, liveWithPrompt]);
}, [plainLines, text]);

Check warning on line 375 in src/components/TerminalHandler.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'onBufferChange'. Either include it or remove the dependency array. If 'onBufferChange' changes too often, find the parent component that defines it and wrap that definition in useCallback

/* Ensure scrolling when output changes */
useEffect(() => {
Expand Down
6 changes: 5 additions & 1 deletion src/components/commands/Command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@ export default interface Command {
usage?: React.ReactNode;
args: string[];
run: (args: string[], context: KeyPressContextType) => Promise<React.ReactNode | null>;

/**
* Optional compact plain-text rendering for the 3D CRT (~60 columns, 19 rows).
* Return null to fall back to flattening the rich output.
*/
plain?: (args: string[], context: KeyPressContextType) => string | null;
}
2 changes: 2 additions & 0 deletions src/components/commands/CommandMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SchoolCommand } from "./SchoolCommand";
import { CurlCommand } from "./CurlCommand";
import { WeatherCommand } from "./WeatherCommand";
import { ShortenCommand } from "./ShortenCommand";
import { SceneCommand } from "./SceneCommand";
// Filesystem commands
import { PwdCommand } from "./fs/PwdCommand";
import { CdCommand } from "./fs/CdCommand";
Expand Down Expand Up @@ -52,6 +53,7 @@ commandMap.set(CalcCommand.name, CalcCommand);
commandMap.set(CurlCommand.name, CurlCommand);
commandMap.set(WeatherCommand.name, WeatherCommand);
commandMap.set(ShortenCommand.name, ShortenCommand);
commandMap.set(SceneCommand.name, SceneCommand);
// Filesystem commands
commandMap.set(PwdCommand.name, PwdCommand);
commandMap.set(CdCommand.name, CdCommand);
Expand Down
39 changes: 39 additions & 0 deletions src/components/commands/HelpCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
// CommandMap first: it registers every command and HelpCommand reads it back (circular import)
import { commandMap } from './CommandMap'
import { HelpCommand, plainHelp } from './HelpCommand'
import type { KeyPressContextType } from '../../context/KeypressedContext'

const context = { headless: true } as unknown as KeyPressContextType
const registered = commandMap.size

describe('plainHelp', () => {
it('lists one category per line and wraps long ones to the screen width', () => {
expect(registered).toBeGreaterThan(0)
const lines = plainHelp(58).split('\n')
expect(lines[0]).toContain('help <name>')
expect(lines.some((l) => l.startsWith('System') && l.includes('scene'))).toBe(true)
expect(lines.some((l) => l.startsWith('Code') && l.includes('python'))).toBe(true)
lines.forEach((l) => expect(l.length).toBeLessThanOrEqual(58))
// Filesystem is too wide for one row and continues indented
const fs = lines.findIndex((l) => l.startsWith('Filesystem'))
expect(lines[fs + 1].startsWith(' '.repeat(12))).toBe(true)
expect(lines.length).toBeLessThan(15)
})
})

describe('HelpCommand.plain', () => {
it('renders the listing without arguments', () => {
expect(HelpCommand.plain!([], context)).toBe(plainHelp())
})

it('renders a command summary plus flattened usage', () => {
const text = HelpCommand.plain!(['echo'], context)!
expect(text.startsWith('echo — ')).toBe(true)
expect(text).toContain('Usage:')
})

it('reports unknown commands', () => {
expect(HelpCommand.plain!(['nope'], context)).toBe('Command not found: nope')
})
})
65 changes: 46 additions & 19 deletions src/components/commands/HelpCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,53 @@ import React from 'react'
import Command from './Command'
import { commandMap } from './CommandMap'
import { HelpCircle, Terminal, ChevronRight, AlertCircle } from 'lucide-react'
import { extractText } from '../../utils/textExtraction'

// Group commands by category
const commandCategories: Record<string, string[]> = {
'System': ['clear', 'exit', 'echo', 'color', 'alias', 'export', 'unset'],
'System': ['clear', 'exit', 'echo', 'color', 'scene', 'alias', 'export', 'unset'],
'Info': ['help', 'ip', 'weather'],
'Portfolio': ['projects', 'school', 'cv', 'certs'],
'Tools': ['calc', 'curl', 'shorten'],
'Filesystem': ['pwd', 'cd', 'ls', 'touch', 'cat', 'mkdir', 'rm', 'cp', 'mv', 'find', 'grep', 'clearfs'],
'Git': ['git'],
'Editors': ['vim'],
'Code': ['node', 'python'],
}

/** Every command with its description, grouped by category (clear is built into the shell) */
function categorize() {
const allCommands = new Map<string, { desc: string }>()
allCommands.set('clear', { desc: 'Clear the terminal screen' })
for (const [name, cmd] of commandMap) {
allCommands.set(name, { desc: cmd.description })
}
return Object.entries(commandCategories).map(([category, commands]) => ({
category,
commands: commands
.filter((name) => allCommands.has(name))
.map((name) => ({ name, desc: allCommands.get(name)?.desc || '' })),
}))
}

/** Compact listing for the 3D CRT: one category per line, names wrapped to the screen width */
export function plainHelp(width = 58): string {
const label = 12
const lines = ['Commands — help <name> for usage', '']
categorize().forEach(({ category, commands }) => {
let line = category.padEnd(label)
commands.forEach(({ name }, i) => {
const next = line + (i ? ' ' : '') + name
if (next.length > width) {
lines.push(line)
line = ' '.repeat(label) + name
} else {
line = next
}
})
lines.push(line)
})
return lines.join('\n')
}

export const HelpCommand: Command = {
Expand All @@ -29,6 +66,13 @@ export const HelpCommand: Command = {
</div>
),
args: [],
plain: (args: string[]) => {
if (args.length === 0) return plainHelp()
const command = commandMap.get(args[0])
if (!command) return `Command not found: ${args[0]}`
const usage = command.usage ? extractText(command.usage).trim() : ''
return [`${command.name} — ${command.description}`, ...(usage ? ['', usage] : [])].join('\n')
},
run: async (args: string[]) => {
// Show specific command help
if (args.length > 0) {
Expand Down Expand Up @@ -66,24 +110,7 @@ export const HelpCommand: Command = {
)
}

// Build command list with descriptions
const allCommands = new Map<string, { desc: string }>()
allCommands.set('clear', { desc: 'Clear the terminal screen' })

for (const [name, cmd] of commandMap) {
allCommands.set(name, { desc: cmd.description })
}

// Get categorized commands
const categorizedCommands = Object.entries(commandCategories).map(([category, commands]) => ({
category,
commands: commands
.filter(name => allCommands.has(name))
.map(name => ({
name,
desc: allCommands.get(name)?.desc || '',
})),
}))
const categorizedCommands = categorize()

return (
<div className="font-mono text-sm">
Expand Down
Loading
Loading