UNPKG

@gwigz/homunculus-terminal

Version:

A third-party terminal client for interacting with Linden Lab's virtual world "Second Life"

489 lines (476 loc) 16.7 kB
#!/usr/bin/env node // src/index.tsx import "dotenv/config"; import { Client } from "@gwigz/homunculus-core"; import { render } from "ink"; import meow from "meow"; // src/app.tsx import { useState as useState5 } from "react"; // src/chat.tsx import { Constants } from "@gwigz/homunculus-core"; import { Box, measureElement, Text, useInput } from "ink"; import TextInput from "ink-text-input"; import { useCallback as useCallback2, useEffect as useEffect2, useRef, useState as useState2 } from "react"; import stripAnsi from "strip-ansi"; // src/hooks.ts import { useStdout } from "ink"; import { useCallback, useEffect, useState } from "react"; function useScreenSize(onResize) { const { stdout } = useStdout(); const getSize = useCallback( () => [stdout.columns, stdout.rows], [stdout] ); const [dimensions, setDimensions] = useState(getSize); useEffect(() => { const handler = () => { setDimensions(getSize()); onResize?.(); }; stdout.on("resize", handler); return () => { stdout.off("resize", handler); }; }, [stdout, getSize, onResize]); return dimensions; } // src/logger.ts import fs from "node:fs"; import path from "node:path"; var logFile = path.join(import.meta.dirname, "homunculus-error.log"); function logError(error) { const timestamp = (/* @__PURE__ */ new Date()).toISOString(); const message = error instanceof Error ? error.stack || error.message : error; fs.appendFileSync(logFile, `[${timestamp}] ${message} `); } // src/chat.tsx import { jsx, jsxs } from "react/jsx-runtime"; var inaudibleChatTypes = [ Constants.ChatTypes.TYPING, Constants.ChatTypes.STOPPED ]; var chatInputHeight = 3; function Chat({ client: client2, initialMessages = [], onExit }) { const [messages, setMessages] = useState2(initialMessages); const [input, setInput] = useState2(""); const [nearbyInfo, setNearbyInfo] = useState2([]); const [scrollTop, setScrollTop] = useState2(0); const chatWrapperRef = useRef(null); const [, terminalHeight] = useScreenSize(() => { const newTotalHeight = chatWrapperRef.current ? measureElement(chatWrapperRef.current).height : 0; setScrollTop( Math.max(0, newTotalHeight - (terminalHeight - chatInputHeight)) ); }); const totalHeight = chatWrapperRef.current ? measureElement(chatWrapperRef.current).height : 0; const visibleHeight = terminalHeight - chatInputHeight; useEffect2(() => { const maxScroll = Math.max(0, totalHeight - visibleHeight); setScrollTop((prev) => Math.min(prev, maxScroll)); }, [totalHeight, visibleHeight]); const addMessage = useCallback2( (message) => { if (!message.message?.length) { return; } const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit", hourCycle: "h24" }); setMessages((prev) => { const newMessages = [ ...prev, { ...message, timestamp, id: `${timestamp}-${Math.random().toString(36).slice(2)}` } ]; if (newMessages.length > 1e3) { newMessages.splice(0, newMessages.length - 1e3); } setTimeout(() => { const newTotalHeight = chatWrapperRef.current ? measureElement(chatWrapperRef.current).height : 0; setScrollTop(Math.max(0, newTotalHeight - visibleHeight)); }, 0); return newMessages; }); }, [visibleHeight] ); const updateNearbyInfo = useCallback2(() => { const info = []; for (const region of client2.regions.values()) { if (region.name) { info.push(`\x1B[1m${region.name}\x1B[22m`); } else { info.push(`Loading... (\x1B[90m${region.handle}\x1B[39m)`); } for (const agent of region.agents.values()) { if (agent.key === client2.self?.key) continue; const name = agent.name; info.push( name ? name : `Loading... (\x1B[90m${agent.key.slice(0, 8)}\x1B[39m)` ); } info.push("\u2014"); info.push(`${region.objects.size} objects`); info.push(`${region.agents.size} agents`); } setNearbyInfo(info); }, [client2.regions, client2.self?.key]); const handleSubmit = useCallback2( (message) => { if (!message.trim()) { return; } setInput(""); if (message === "/quit" || message === "/exit") { return void onExit(); } if (message.startsWith("/")) { if (message.startsWith("/shout ")) { client2.nearby.shout(message.substring(7)); } else if (message.startsWith("/whisper ")) { client2.nearby.whisper(message.substring(9)); } else { const match = message.match(/^\/(\d+)/); let channel = 0; if (match?.[1]) { const parsedChannel = Number.parseInt(match[1]); if (parsedChannel >= 1) { channel = parsedChannel; } } client2.nearby.say(message, channel); } } else { client2.nearby.say(message); } }, [client2.nearby, onExit] ); useEffect2(() => { function handleDebug(message) { addMessage({ fromName: "[DEBUG]", message: `/me ${message}`, chatType: Constants.ChatTypes.DEBUG, sourceType: Constants.ChatSources.SYSTEM }); } function handleWarning(message) { addMessage({ fromName: "[WARNING]", message: `/me ${message}`, sourceType: Constants.ChatSources.SYSTEM }); } function handleError(error) { addMessage({ fromName: "[ERROR]", message: `/me ${error.message}`, sourceType: Constants.ChatSources.SYSTEM }); logError(error); } function handleChat(chat) { if (!inaudibleChatTypes.includes(chat.chatType) && chat.message.length) { addMessage({ fromName: chat.fromName, message: chat.message, chatType: chat.chatType, sourceType: chat.sourceType }); } updateNearbyInfo(); } client2.on("debug", handleDebug); client2.on("warning", handleWarning); client2.on("error", handleError); client2.nearby.on("chat", handleChat); function updateRegions() { for (const region of client2.regions.values()) { region.agents.on("set", updateNearbyInfo); region.agents.on("delete", updateNearbyInfo); } } client2.regions.on("set", updateRegions); client2.regions.on("delete", updateRegions); return () => { client2.off("debug", handleDebug); client2.off("warning", handleWarning); client2.off("error", handleError); client2.nearby.off("chat", handleChat); }; }, [client2, addMessage, updateNearbyInfo]); useEffect2(() => { const interval = setInterval(updateNearbyInfo, 1e3); return () => clearInterval(interval); }, [updateNearbyInfo]); useInput((_, key) => { if (key.upArrow) { setScrollTop((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { setScrollTop((prev) => Math.min(totalHeight - visibleHeight, prev + 1)); } else if (key.pageUp) { setScrollTop((prev) => Math.max(0, prev - 10)); } else if (key.pageDown) { setScrollTop((prev) => Math.min(totalHeight - visibleHeight, prev + 10)); } }); return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", height: terminalHeight - 1, children: [ /* @__PURE__ */ jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [ /* @__PURE__ */ jsx(Box, { flexGrow: 1, flexDirection: "column", overflow: "hidden", children: /* @__PURE__ */ jsx( Box, { ref: chatWrapperRef, flexShrink: 0, flexDirection: "column", marginTop: -scrollTop, children: messages.map((chat) => /* @__PURE__ */ jsxs( Text, { italic: chat.chatType === Constants.ChatTypes.WHISPER, bold: chat.chatType === Constants.ChatTypes.SHOUT, children: [ "[", chat.timestamp, "]", " ", /* @__PURE__ */ jsx( Text, { bold: true, color: chat.chatType === Constants.ChatTypes.OWNERSAY ? "yellow" : chat.sourceType === Constants.ChatSources.OBJECT ? "green" : chat.sourceType === Constants.ChatSources.SYSTEM ? "cyan" : void 0, children: stripAnsi(chat.fromName?.replace(/ Resident$/, "") ?? "") } ), chat.chatType === Constants.ChatTypes.SHOUT ? " shouts: " : chat.chatType === Constants.ChatTypes.WHISPER ? " whispers: " : chat.message?.startsWith("/me ") ? " " : chat.message?.startsWith("/me'") ? "'" : ": ", /* @__PURE__ */ jsx( Text, { color: chat.chatType === Constants.ChatTypes.OWNERSAY ? "yellow" : chat.sourceType === Constants.ChatSources.OBJECT ? "green" : chat.sourceType === Constants.ChatSources.SYSTEM ? "cyan" : void 0, children: stripAnsi( chat.message?.startsWith("/me ") ? chat.message.substring(4) : chat.message?.startsWith("/me'") ? chat.message.substring(4) : chat.message ?? "" ) } ) ] }, chat.id )) } ) }), /* @__PURE__ */ jsx( Box, { width: 20, flexShrink: 0, marginLeft: 2, flexDirection: "column", overflow: "hidden", children: nearbyInfo.map((line, i) => /* @__PURE__ */ jsx(Text, { children: line }, `nearby-${i}-${line}`)) } ) ] }), /* @__PURE__ */ jsx( Box, { borderStyle: "single", borderColor: "gray", borderDimColor: true, overflow: "hidden", borderLeft: false, borderRight: false, borderBottom: false, children: /* @__PURE__ */ jsx( TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: 'Type to nearby chat (\u2191/\u2193 to scroll, "/exit" to quit)' } ) } ) ] }); } // src/loading.tsx import { Constants as Constants2 } from "@gwigz/homunculus-core"; import { Box as Box2, Text as Text2 } from "ink"; import Spinner from "ink-spinner"; import { useCallback as useCallback3, useEffect as useEffect3, useState as useState3 } from "react"; import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime"; function Loading({ client: client2, onReady }) { const [initialMessages, setInitialMessages] = useState3([]); const handleDebug = useCallback3((message) => { setInitialMessages((messages) => [ ...messages, { id: crypto.randomUUID(), fromName: "[DEBUG]", chatType: Constants2.ChatTypes.DEBUG, sourceType: Constants2.ChatSources.SYSTEM, message: `/me ${message}`, timestamp: (/* @__PURE__ */ new Date()).toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit", hourCycle: "h24" }) } ]); }, []); const ready = useCallback3( () => onReady(initialMessages), [initialMessages, onReady] ); useEffect3(() => { client2.on("ready", ready); client2.on("debug", handleDebug); return () => { client2.off("ready", ready); client2.off("debug", handleDebug); }; }, [client2, handleDebug, ready]); return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [ /* @__PURE__ */ jsxs2(Box2, { flexDirection: "row", children: [ /* @__PURE__ */ jsx2(Spinner, { type: "dots" }), /* @__PURE__ */ jsx2(Text2, { children: " Connecting to Second Life..." }) ] }), /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: initialMessages.map(({ id, message }) => /* @__PURE__ */ jsx2(Text2, { color: "gray", children: message?.replace(/^\/me /, "") }, id)) }) ] }); } // src/login.tsx import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink"; import BigText from "ink-big-text"; import TextInput2 from "ink-text-input"; import { useState as useState4 } from "react"; import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime"; function Login({ onSubmit }) { const [username, setUsername] = useState4(process.env.SL_USERNAME || ""); const [password, setPassword] = useState4(process.env.SL_PASSWORD || ""); const [isSubmitting, setIsSubmitting] = useState4(false); const [activeField, setActiveField] = useState4( "username" ); useInput2((_, key) => { if (key.escape) { process.exit(0); } else if (key.return) { if (activeField === "username" && password && !isSubmitting) { setIsSubmitting(true); onSubmit(username, password); } else if (activeField === "username") { setActiveField("password"); } else if (activeField === "password" && username && password && !isSubmitting) { setIsSubmitting(true); onSubmit(username, password); } } else if (key.tab || key.downArrow || key.upArrow) { setActiveField(activeField === "username" ? "password" : "username"); } }); return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [ /* @__PURE__ */ jsx3(Box3, { marginLeft: -1, children: /* @__PURE__ */ jsx3(BigText, { text: "HOMUNCULUS", font: "tiny" }) }), /* @__PURE__ */ jsxs3(Text3, { children: [ "Login to ", /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Second Life" }) ] }), /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, children: [ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "Username " }), /* @__PURE__ */ jsx3( TextInput2, { value: username, onChange: setUsername, placeholder: "Enter username", focus: activeField === "username" } ) ] }), /* @__PURE__ */ jsxs3(Box3, { marginBottom: 1, children: [ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "Password " }), /* @__PURE__ */ jsx3( TextInput2, { value: password, onChange: setPassword, placeholder: "Enter password", mask: "*", focus: activeField === "password" } ) ] }), /* @__PURE__ */ jsx3(Text3, { color: "gray", italic: true, dimColor: true, children: "Press tab or \u2191/\u2193 to switch fields, enter to login, and escape to quit" }) ] }) }); } // src/app.tsx import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime"; function App({ client: client2, start: start2 }) { const [state, setState] = useState5("login"); const [initialMessages, setInitialMessages] = useState5([]); async function handleLogin(username, password) { setState("loading"); await client2.connect({ username, password, start: start2 }); } function handleReady(initialMessages2) { setInitialMessages(initialMessages2); setState("chat"); } async function handleExit() { await client2.disconnect(); process.exit(0); } return /* @__PURE__ */ jsxs4(Fragment, { children: [ state === "login" && /* @__PURE__ */ jsx4(Login, { onSubmit: handleLogin }), state === "loading" && /* @__PURE__ */ jsx4(Loading, { client: client2, onReady: handleReady }), state === "chat" && /* @__PURE__ */ jsx4( Chat, { client: client2, initialMessages, onExit: handleExit } ) ] }); } // src/index.tsx import { jsx as jsx5 } from "react/jsx-runtime"; process.title = "homunculus"; var cli = meow( ` Usage $ homunculus Options --start=<uri|last|home> Examples $ homunculus --start="uri:Hippo Hollow&128&128&0" `, { importMeta: import.meta, flags: { start: { type: "string" } } } ); var start = cli.flags.start || process.env.SL_START; var client = new Client({ logger: false }); process.on("uncaughtException", async (error) => { logError(error); await client.disconnect(); console.error("Uncaught Exception:", error); process.exit(1); }); process.on("unhandledRejection", async (reason) => { logError(reason instanceof Error ? reason : new Error(String(reason))); await client.disconnect(); console.error("Unhandled Rejection:", reason); process.exit(1); }); render(/* @__PURE__ */ jsx5(App, { client, start }));