@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
JavaScript
#!/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 }));