IRC support

This commit is contained in:
Andy Dixon
2026-06-17 08:55:56 +01:00
parent 0e6f54901b
commit e1381e66d7
24 changed files with 2729 additions and 51 deletions
+2
View File
@@ -23,6 +23,7 @@
<link rel="stylesheet" href="/src/css/modals.css" />
<link rel="stylesheet" href="/src/css/github.css" />
<link rel="stylesheet" href="/src/css/vnc.css" />
<link rel="stylesheet" href="/src/css/irc.css" />
<link rel="stylesheet" href="/src/css/leviathan.css" />
</head>
<body>
@@ -334,6 +335,7 @@
<script src="/src/js/github.js"></script>
<script src="/src/js/lib/novnc.min.js"></script>
<script src="/src/js/vnc.js"></script>
<script src="/src/js/irc.js"></script>
<script src="/src/js/settings.js"></script>
<script src="/src/js/commands.js"></script>
<script src="/src/js/secrets.js"></script>
+289
View File
@@ -0,0 +1,289 @@
/* ==========================================================================
Nexus Terminal - IRC viewer
========================================================================== */
.irc-viewer-panel {
background: var(--bg-base);
}
.irc-viewer {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.irc-main {
display: grid;
grid-template-rows: 34px minmax(0, 1fr) 42px;
min-width: 0;
min-height: 0;
}
.irc-room-tabs {
display: flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
padding: var(--space-1) var(--space-2);
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
}
.irc-room-tab {
height: 24px;
max-width: 180px;
min-width: 44px;
padding: 0 var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.irc-room-tab:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.irc-room-tab.active {
background: var(--bg-active);
border-color: var(--border-default);
color: var(--text-primary);
}
.irc-room-tab.has-unread::after {
content: "";
display: inline-block;
width: 5px;
height: 5px;
margin-left: var(--space-1);
border-radius: 50%;
background: var(--protocol-irc);
vertical-align: middle;
}
.irc-terminal {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: var(--space-2);
}
.irc-terminal .xterm {
height: 100%;
}
.irc-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2);
padding: var(--space-2);
background: var(--bg-surface);
border-top: 1px solid var(--border-subtle);
}
.irc-input {
min-width: 0;
height: 26px;
padding: 0 var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.irc-input:focus,
.irc-join-input:focus,
.irc-filter-input:focus {
outline: none;
border-color: var(--border-focus);
}
.irc-send {
height: 26px;
}
.irc-sidebar {
display: grid;
grid-template-rows: minmax(88px, auto) minmax(120px, 1fr) minmax(120px, 1fr);
min-width: 0;
min-height: 0;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
}
.irc-sidebar-section {
min-height: 0;
padding: var(--space-2);
border-bottom: 1px solid var(--border-subtle);
overflow: hidden;
}
.irc-users-section {
border-bottom: 0;
}
.irc-sidebar-title,
.irc-sidebar-title-row {
min-height: 24px;
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.irc-sidebar-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.irc-join-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.irc-join-input,
.irc-filter-input {
min-width: 0;
height: 24px;
padding: 0 var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font-size: var(--text-xs);
}
.irc-filter-input {
width: 100%;
margin-bottom: var(--space-2);
}
.irc-sidebar-list,
.irc-room-list,
.irc-user-list {
min-height: 0;
max-height: 100%;
overflow-y: auto;
}
.irc-room-row,
.irc-user-row,
.irc-listed-room-main {
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
}
.irc-room-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
height: 26px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
}
.irc-room-row:hover,
.irc-user-row:hover,
.irc-listed-room:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.irc-room-row.active {
background: var(--bg-active);
color: var(--text-primary);
}
.irc-room-name,
.irc-listed-room-name,
.irc-listed-room-topic {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.irc-room-count,
.irc-listed-room-count {
color: var(--text-tertiary);
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.irc-listed-room {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: 34px;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
.irc-listed-room-main {
display: grid;
gap: 1px;
}
.irc-listed-room-name {
color: var(--text-primary);
font-size: var(--text-xs);
}
.irc-listed-room-topic {
color: var(--text-tertiary);
font-size: 10px;
}
.irc-user-row {
display: block;
height: 24px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.irc-empty {
padding: var(--space-2);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
@media (max-width: 900px) {
.irc-viewer {
grid-template-columns: minmax(0, 1fr);
}
.irc-sidebar {
display: none;
}
}
+7
View File
@@ -304,6 +304,9 @@
.sidebar-item .protocol-indicator.proto-vnc::before {
content: "⊞";
}
.sidebar-item .protocol-indicator.proto-irc::before {
content: "#";
}
.sidebar-item .protocol-indicator.proto-local::before {
content: "›_";
}
@@ -328,6 +331,10 @@
background: rgba(232, 93, 74, 0.18);
color: var(--protocol-vnc);
}
.sidebar-item .protocol-indicator.proto-irc {
background: rgba(216, 178, 76, 0.18);
color: var(--protocol-irc);
}
.sidebar-item .protocol-indicator.proto-local {
background: rgba(139, 195, 74, 0.18);
color: var(--protocol-local);
+4
View File
@@ -118,6 +118,10 @@
background: rgba(232, 93, 74, 0.15);
color: var(--protocol-vnc);
}
.tab-badge.badge-irc {
background: rgba(216, 178, 76, 0.15);
color: var(--protocol-irc);
}
.tab-badge.badge-local {
background: rgba(139, 195, 74, 0.15);
color: var(--protocol-local);
+1
View File
@@ -58,6 +58,7 @@
--protocol-raw: #ab47bc;
--protocol-github: #7c8aff;
--protocol-vnc: #e85d4a;
--protocol-irc: #d8b24c;
--protocol-local: #8bc34a;
/* -----------------------------------------------------------------------
+50 -3
View File
@@ -35,6 +35,7 @@
this.terminals = new TerminalManager(this);
this.profileEditor = new ProfileEditor(this);
this.rawViewer = new RawViewer(this);
window.NexusTerminal.IRCViewer?.setApp(this);
this.settings = new Settings(this);
this.commandPalette = new CommandPalette(this);
this.secretPrompt = new SecretPrompt(this);
@@ -239,6 +240,7 @@
setTimeout(fit, 50);
setTimeout(fit, 300);
setTimeout(fit, 800);
setTimeout(() => window.NexusTerminal.IRCViewer?.fit(payload.sessionID), 50);
}
});
@@ -288,6 +290,12 @@
);
});
// IRC events
window.runtime.EventsOn("session:irc:event", (payload) => {
window.NexusTerminal.IRCViewer.handleEvent(payload);
this.tabs.markActivity(payload.sessionID);
});
// App-wide commands from the native menu
window.runtime.EventsOn("app:open-about", () => {
this.settings.openTab("about");
@@ -801,7 +809,12 @@
const decision = await this._confirmReuseSession(profile, preferred);
if (decision === "existing") {
this.tabs.setActive(preferred.id);
if (preferred.protocol !== "raw" && preferred.protocol !== "github") {
if (
preferred.protocol !== "raw" &&
preferred.protocol !== "github" &&
preferred.protocol !== "vnc" &&
preferred.protocol !== "irc"
) {
this.terminals.focusTerminal(preferred.id);
}
return;
@@ -845,6 +858,15 @@
profile,
);
}
} else if (profile.protocol === "irc") {
const container = document.getElementById(panelId);
if (container) {
window.NexusTerminal.IRCViewer.createViewer(
sessionInfo.id,
container,
profile,
);
}
} else {
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
}
@@ -901,6 +923,7 @@
<option value="ssh">SSH</option>
<option value="telnet">Telnet</option>
<option value="raw">RAW TCP</option>
<option value="irc">IRC</option>
</select>
</div>
<div class="form-group">
@@ -915,6 +938,10 @@
<label class="form-label">Username</label>
<input class="form-input qc-username" type="text" placeholder="root" />
</div>
<div class="form-group qc-nick-group" style="display:none">
<label class="form-label">Nick</label>
<input class="form-input qc-nick" type="text" value="nexus" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm qc-cancel">Cancel</button>
@@ -931,14 +958,17 @@
const protocolSelect = modal.querySelector(".qc-protocol");
const portInput = modal.querySelector(".qc-port");
const usernameGroup = modal.querySelector(".qc-username-group");
const nickGroup = modal.querySelector(".qc-nick-group");
const hostInput = modal.querySelector(".qc-host");
// Update port default and username visibility when protocol changes
protocolSelect.addEventListener("change", () => {
const proto = protocolSelect.value;
const defaultPorts = { ssh: 22, telnet: 23, raw: 23 };
const defaultPorts = { ssh: 22, telnet: 23, raw: 23, irc: 6667 };
portInput.value = defaultPorts[proto] || 22;
usernameGroup.style.display = proto === "ssh" ? "" : "none";
usernameGroup.style.display =
proto === "ssh" || proto === "irc" ? "" : "none";
nickGroup.style.display = proto === "irc" ? "" : "none";
});
const close = (value) => {
@@ -965,6 +995,7 @@
host,
port: parseInt(portInput.value, 10) || 22,
username: modal.querySelector(".qc-username").value.trim(),
nick: modal.querySelector(".qc-nick").value.trim(),
});
});
backdrop.addEventListener("click", (e) => {
@@ -989,6 +1020,13 @@
displayName: result.host,
tags: [],
};
if (result.protocol === "irc") {
profile.irc = {
nick: result.nick || "nexus",
realName: "Nexus Terminal",
autoJoinRooms: [],
};
}
const sessionInfo = await window.NexusTerminal.wailsService.ConnectQuick(profile);
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
@@ -999,6 +1037,15 @@
protocol: "raw",
raw: {},
});
} else if (result.protocol === "irc") {
const container = document.getElementById(panelId);
if (container) {
window.NexusTerminal.IRCViewer.createViewer(
sessionInfo.id,
container,
profile,
);
}
} else {
this.terminals.createTerminal(sessionInfo.id, panelId, {
protocol: result.protocol,
+617
View File
@@ -0,0 +1,617 @@
(function () {
/**
* Nexus Terminal - IRC viewer.
* Uses xterm.js as the chat stream, with room subtabs, a composer, and
* room/user sidebars.
*/
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon */
const { createElement } = window.NexusTerminal;
const SERVER_TARGET = "server";
const MAX_LINES_PER_TARGET = 2000;
const MAX_LISTED_ROOMS = 200;
class IRCViewer {
constructor() {
this._viewers = new Map();
this.app = null;
}
setApp(app) {
this.app = app;
}
createViewer(sessionID, container, profile) {
if (this._viewers.has(sessionID) || !container) return;
const viewer = new IRCViewerInstance(sessionID, container, profile, this.app);
this._viewers.set(sessionID, viewer);
viewer.render();
}
destroyViewer(sessionID) {
const viewer = this._viewers.get(sessionID);
if (!viewer) return;
viewer.destroy();
this._viewers.delete(sessionID);
}
handleEvent(payload) {
const sessionID = payload?.sessionID;
const event = payload?.event || payload;
const viewer = this._viewers.get(sessionID);
if (viewer) viewer.handleEvent(event);
}
fit(sessionID) {
const viewer = this._viewers.get(sessionID);
if (viewer) viewer.fit();
}
}
class IRCViewerInstance {
constructor(sessionID, container, profile, app) {
this.sessionID = sessionID;
this.container = container;
this.profile = profile || {};
this.app = app || null;
this.term = null;
this.fitAddon = null;
this.searchAddon = null;
this.observer = null;
this.activeTarget = SERVER_TARGET;
this.targets = new Map();
this.users = new Map();
this.joinedRooms = [];
this.roomList = [];
this.roomFilter = "";
this.unreadTargets = new Set();
this.tabsEl = null;
this.termHost = null;
this.inputEl = null;
this.roomsEl = null;
this.usersEl = null;
this.roomListEl = null;
this.roomFilterEl = null;
this._ensureTarget(SERVER_TARGET, "Server");
}
render() {
this.container.innerHTML = "";
this.container.classList.add("irc-viewer-panel");
const wrapper = createElement("div", "irc-viewer");
const main = createElement("div", "irc-main");
this.tabsEl = createElement("div", "irc-room-tabs");
main.appendChild(this.tabsEl);
this.termHost = createElement("div", "irc-terminal");
main.appendChild(this.termHost);
const composer = createElement("form", "irc-composer");
this.inputEl = createElement("input", "irc-input", {
type: "text",
placeholder: "Message or /command",
autocomplete: "off",
});
const sendBtn = createElement("button", "btn btn-sm btn-primary irc-send", {
type: "submit",
textContent: "Send",
});
composer.appendChild(this.inputEl);
composer.appendChild(sendBtn);
composer.addEventListener("submit", (event) => {
event.preventDefault();
this._sendComposer();
});
this.inputEl.addEventListener("keydown", (event) => {
if (event.key === "Tab" && this._completeMention()) {
event.preventDefault();
}
});
main.appendChild(composer);
const sidebar = this._buildSidebar();
wrapper.appendChild(main);
wrapper.appendChild(sidebar);
this.container.appendChild(wrapper);
this._openTerminal();
this._renderTabs();
this._renderRooms();
this._renderUsers();
this._renderRoomList();
this._appendLine(SERVER_TARGET, {
type: "system",
text: "Connecting...",
timestamp: Date.now(),
});
}
handleEvent(event) {
if (!event || !event.type) return;
if (event.type === "rooms") {
this.joinedRooms = Array.isArray(event.rooms) ? event.rooms : [];
for (const room of this.joinedRooms) {
this._ensureTarget(room.name, room.name);
}
this._renderTabs();
this._renderRooms();
return;
}
if (event.type === "users") {
const target = event.target;
if (target) {
this.users.set(target, Array.isArray(event.users) ? event.users : []);
if (target === this.activeTarget) this._renderUsers();
}
return;
}
if (event.type === "room-list") {
this.roomList = Array.isArray(event.rooms) ? event.rooms : [];
this._renderRoomList();
return;
}
const target = this._eventTarget(event);
this._ensureTarget(target, this._targetLabel(target));
this._appendLine(target, event);
if (target !== this.activeTarget) {
this.unreadTargets.add(target);
this._renderTabs();
}
if (["join", "part", "topic"].includes(event.type)) {
this._renderRooms();
}
}
fit() {
if (!this.fitAddon) return;
requestAnimationFrame(() => {
try {
this.fitAddon.fit();
} catch (_) {
/* noop */
}
});
}
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.term) {
this.term.dispose();
this.term = null;
}
this.container.classList.remove("irc-viewer-panel");
this.container.innerHTML = "";
}
_buildSidebar() {
const sidebar = createElement("aside", "irc-sidebar");
const joinSection = createElement("div", "irc-sidebar-section");
joinSection.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Rooms" }),
);
const joinRow = createElement("form", "irc-join-row");
const joinInput = createElement("input", "irc-join-input", {
type: "text",
placeholder: "#room",
autocomplete: "off",
});
const joinBtn = createElement("button", "btn btn-xs btn-secondary", {
type: "submit",
textContent: "Join",
});
joinRow.appendChild(joinInput);
joinRow.appendChild(joinBtn);
joinRow.addEventListener("submit", async (event) => {
event.preventDefault();
const room = joinInput.value.trim();
if (!room) return;
try {
await window.NexusTerminal.wailsService.IRCJoin(this.sessionID, room);
joinInput.value = "";
} catch (err) {
this._notifyError("Join failed: " + err);
}
});
joinSection.appendChild(joinRow);
this.roomsEl = createElement("div", "irc-sidebar-list");
joinSection.appendChild(this.roomsEl);
sidebar.appendChild(joinSection);
const listSection = createElement("div", "irc-sidebar-section");
const listTitleRow = createElement("div", "irc-sidebar-title-row");
listTitleRow.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Room List" }),
);
const listBtn = createElement("button", "btn btn-xs btn-outline", {
type: "button",
textContent: "List",
});
listBtn.addEventListener("click", async () => {
this.roomList = [];
this._renderRoomList("Loading rooms...");
try {
await window.NexusTerminal.wailsService.IRCListRooms(this.sessionID);
} catch (err) {
this._renderRoomList("Room list failed");
this._notifyError("Room list failed: " + err);
}
});
listTitleRow.appendChild(listBtn);
listSection.appendChild(listTitleRow);
this.roomFilterEl = createElement("input", "irc-filter-input", {
type: "text",
placeholder: "Filter rooms",
autocomplete: "off",
});
this.roomFilterEl.addEventListener("input", () => {
this.roomFilter = this.roomFilterEl.value.toLowerCase();
this._renderRoomList();
});
listSection.appendChild(this.roomFilterEl);
this.roomListEl = createElement("div", "irc-room-list");
listSection.appendChild(this.roomListEl);
sidebar.appendChild(listSection);
const usersSection = createElement("div", "irc-sidebar-section irc-users-section");
usersSection.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Users" }),
);
this.usersEl = createElement("div", "irc-user-list");
usersSection.appendChild(this.usersEl);
sidebar.appendChild(usersSection);
return sidebar;
}
_openTerminal() {
const themeName =
(this.app?.state?.preferences || {}).terminalTheme || "charcoal";
this.term = new Terminal({
theme: window.NexusTerminal.getTerminalTheme(themeName),
fontFamily:
this.profile.terminalFontFamily ||
'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
fontSize: this.profile.terminalFontSize || 14,
scrollback: this.profile.terminalScrollback || 10000,
cursorBlink: false,
convertEol: true,
disableStdin: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.searchAddon = new SearchAddon.SearchAddon();
this.term.loadAddon(this.fitAddon);
this.term.loadAddon(this.searchAddon);
try {
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_event, uri) => {
window.runtime.BrowserOpenURL(uri);
}));
} catch (_) {
/* optional */
}
this.term.open(this.termHost);
this.fit();
this.observer = new ResizeObserver(() => this.fit());
this.observer.observe(this.termHost);
}
_renderTabs() {
if (!this.tabsEl) return;
this.tabsEl.innerHTML = "";
for (const [target, state] of this.targets) {
const btn = createElement(
"button",
`irc-room-tab ${target === this.activeTarget ? "active" : ""} ${
this.unreadTargets.has(target) ? "has-unread" : ""
}`,
{
type: "button",
textContent: state.label,
title: state.label,
},
);
btn.addEventListener("click", () => this._setActiveTarget(target));
this.tabsEl.appendChild(btn);
}
}
_renderRooms() {
if (!this.roomsEl) return;
this.roomsEl.innerHTML = "";
if (this.joinedRooms.length === 0) {
this.roomsEl.appendChild(
createElement("div", "irc-empty", { textContent: "No joined rooms" }),
);
return;
}
for (const room of this.joinedRooms) {
const row = createElement("button", "irc-room-row", {
type: "button",
title: room.topic || room.name,
});
row.classList.toggle("active", room.name === this.activeTarget);
row.appendChild(createElement("span", "irc-room-name", { textContent: room.name }));
row.appendChild(
createElement("span", "irc-room-count", {
textContent: String(room.users || 0),
}),
);
row.addEventListener("click", () => this._setActiveTarget(room.name));
this.roomsEl.appendChild(row);
}
}
_renderUsers() {
if (!this.usersEl) return;
this.usersEl.innerHTML = "";
const users = this.users.get(this.activeTarget) || [];
if (!this._isRoom(this.activeTarget)) {
this.usersEl.appendChild(
createElement("div", "irc-empty", { textContent: "No room selected" }),
);
return;
}
if (users.length === 0) {
this.usersEl.appendChild(
createElement("div", "irc-empty", { textContent: "No users yet" }),
);
return;
}
for (const user of users) {
const row = createElement("button", "irc-user-row", {
type: "button",
textContent: this._displayNick(user),
title: user,
});
row.addEventListener("click", () => {
this._insertMention(this._displayNick(user));
});
this.usersEl.appendChild(row);
}
}
_renderRoomList(message = "") {
if (!this.roomListEl) return;
this.roomListEl.innerHTML = "";
if (message) {
this.roomListEl.appendChild(
createElement("div", "irc-empty", { textContent: message }),
);
return;
}
let rooms = this.roomList;
if (this.roomFilter) {
rooms = rooms.filter((room) => {
const haystack = `${room.name || ""} ${room.topic || ""}`.toLowerCase();
return haystack.includes(this.roomFilter);
});
}
rooms = rooms.slice(0, MAX_LISTED_ROOMS);
if (rooms.length === 0) {
this.roomListEl.appendChild(
createElement("div", "irc-empty", { textContent: "No rooms loaded" }),
);
return;
}
for (const room of rooms) {
const row = createElement("div", "irc-listed-room");
const meta = createElement("button", "irc-listed-room-main", {
type: "button",
title: room.topic || room.name,
});
meta.appendChild(
createElement("span", "irc-listed-room-name", {
textContent: room.name || "",
}),
);
meta.appendChild(
createElement("span", "irc-listed-room-topic", {
textContent: room.topic || "",
}),
);
meta.addEventListener("click", () => this._joinRoom(room.name));
const count = createElement("span", "irc-listed-room-count", {
textContent: String(room.users || 0),
});
row.appendChild(meta);
row.appendChild(count);
this.roomListEl.appendChild(row);
}
}
_setActiveTarget(target) {
this._ensureTarget(target, this._targetLabel(target));
this.activeTarget = target;
this.unreadTargets.delete(target);
this._renderTabs();
this._renderRooms();
this._renderUsers();
this._replayActiveTarget();
if (this.inputEl) this.inputEl.focus();
}
_appendLine(target, event) {
const state = this._ensureTarget(target, this._targetLabel(target));
const line = this._formatEvent(event);
if (!line) return;
state.lines.push(line);
if (state.lines.length > MAX_LINES_PER_TARGET) {
state.lines.splice(0, state.lines.length - MAX_LINES_PER_TARGET);
}
if (target === this.activeTarget && this.term) {
this.term.writeln(line);
this.term.scrollToBottom();
}
}
_replayActiveTarget() {
if (!this.term) return;
this.term.clear();
const state = this.targets.get(this.activeTarget);
for (const line of state?.lines || []) {
this.term.writeln(line);
}
this.term.scrollToBottom();
this.fit();
}
async _sendComposer() {
const text = this.inputEl.value;
if (!text.trim()) return;
this.inputEl.value = "";
try {
await window.NexusTerminal.wailsService.IRCHandleInput(
this.sessionID,
this.activeTarget,
text,
);
} catch (err) {
this._notifyError(String(err));
this._appendLine(this.activeTarget, {
type: "error",
text: String(err),
timestamp: Date.now(),
});
}
}
async _joinRoom(room) {
if (!room) return;
try {
await window.NexusTerminal.wailsService.IRCJoin(this.sessionID, room);
} catch (err) {
this._notifyError("Join failed: " + err);
}
}
_completeMention() {
if (!this.inputEl || !this._isRoom(this.activeTarget)) return false;
const start = this.inputEl.selectionStart || 0;
const before = this.inputEl.value.slice(0, start);
const match = before.match(/(^|\s)@([^\s@]*)$/);
if (!match) return false;
const prefix = match[2].toLowerCase();
const users = this.users.get(this.activeTarget) || [];
const nick = users
.map((user) => this._displayNick(user))
.find((user) => user.toLowerCase().startsWith(prefix));
if (!nick) return false;
const replacement = `${match[1]}@${nick} `;
const nextValue =
this.inputEl.value.slice(0, start - match[0].length) +
replacement +
this.inputEl.value.slice(start);
const caret = start - match[0].length + replacement.length;
this.inputEl.value = nextValue;
this.inputEl.setSelectionRange(caret, caret);
return true;
}
_insertMention(nick) {
if (!this.inputEl) return;
const current = this.inputEl.value;
const spacer = current && !/\s$/.test(current) ? " " : "";
this.inputEl.value = `${current}${spacer}@${nick} `;
this.inputEl.focus();
}
_ensureTarget(target, label) {
const safeTarget = target || SERVER_TARGET;
if (!this.targets.has(safeTarget)) {
this.targets.set(safeTarget, {
label: label || this._targetLabel(safeTarget),
lines: [],
});
}
return this.targets.get(safeTarget);
}
_eventTarget(event) {
if (!event) return SERVER_TARGET;
if (event.target) return event.target;
return SERVER_TARGET;
}
_targetLabel(target) {
return target === SERVER_TARGET ? "Server" : target;
}
_formatEvent(event) {
const time = this._time(event.timestamp);
const from = this._sanitize(event.from || "");
const text = this._sanitize(event.text || "");
switch (event.type) {
case "message":
return `[${time}] <${from}> ${text}`;
case "action":
return `[${time}] * ${from} ${text}`;
case "notice":
return `[${time}] -${from || "notice"}- ${text}`;
case "join":
return `[${time}] --> ${from || "user"} joined`;
case "part":
return `[${time}] <-- ${from || "user"} left${text ? ` (${text})` : ""}`;
case "quit":
return `[${time}] <-- ${from || "user"} quit${text ? ` (${text})` : ""}`;
case "nick":
return `[${time}] ${from || "user"} is now known as ${text}`;
case "topic":
return `[${time}] topic: ${text}`;
case "error":
return `[${time}] error: ${text}`;
case "system":
default:
return `[${time}] ${text}`;
}
}
_time(value) {
const date = value ? new Date(value) : new Date();
const hours = String(date.getHours()).padStart(2, "0");
const mins = String(date.getMinutes()).padStart(2, "0");
const secs = String(date.getSeconds()).padStart(2, "0");
return `${hours}:${mins}:${secs}`;
}
_sanitize(value) {
return String(value || "")
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, "")
.replace(/[\x02\x0f\x16\x1d\x1f]/g, "");
}
_displayNick(user) {
return String(user || "").replace(/^[~&@%+]+/, "");
}
_isRoom(target) {
return /^[#&+!]/.test(target || "");
}
_notifyError(message) {
if (this.app?.notifications) {
this.app.notifications.error(message);
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.IRCViewer = new IRCViewer();
})();
+270 -2
View File
@@ -86,6 +86,31 @@
sshAgentAuth: false,
},
},
irc: {
port: 6667,
protocol: "irc",
irc: {
nick: "nexus",
realName: "Nexus Terminal",
useTLS: false,
serverPasswordRequired: false,
serverPasswordMode: "ask-every-time",
nickServIdentify: false,
nickServAccount: "",
nickServMode: "saved",
autoJoinRooms: [],
sshTunnel: false,
sshHost: "",
sshPort: 22,
sshUsername: "",
sshAuthMethod: "publickey",
sshSecretMode: "saved",
sshPrivateKey: "",
sshAgentAuth: false,
autoReconnect: true,
maxReconnectTries: 5,
},
},
};
class ProfileEditor {
@@ -162,10 +187,11 @@
drawer.appendChild(header);
// ── Protocol selector tabs ──────────────────────────────
const protocols = ["ssh", "telnet", "raw", "github", "vnc"];
const protocols = ["ssh", "telnet", "raw", "github", "vnc", "irc"];
const tabBar = createElement("div", "drawer-protocol-tabs");
for (const proto of protocols) {
const label = proto === "github" ? "GitHub" : proto.toUpperCase();
const label =
proto === "github" ? "GitHub" : proto === "irc" ? "IRC" : proto.toUpperCase();
const btn = createElement(
"button",
`drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`,
@@ -208,6 +234,10 @@
case "vnc":
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
break;
case "irc":
this._renderIRCSections(profile).forEach((s) => body.appendChild(s));
body.appendChild(sshKeyDatalist);
break;
}
// Terminal section (SSH and Telnet only)
@@ -1075,6 +1105,185 @@
return sections;
}
/** @private */
_renderIRCSections(profile) {
const sections = [];
const irc = profile.irc || DEFAULTS.irc.irc;
sections.push(
this._renderSection("IRC", [
this._field("text", "irc.nick", "Nick", irc.nick, {
placeholder: "yourNick",
}),
this._field("text", "irc.realName", "Real Name", irc.realName, {
placeholder: "Shown in WHOIS",
}),
this._field("toggle", "irc.useTLS", "Use TLS", irc.useTLS, {
help: "Use port 6697 for most TLS IRC networks.",
}),
this._field(
"toggle",
"irc.serverPasswordRequired",
"Server Password",
irc.serverPasswordRequired,
{
help: "Prompt for an IRC PASS secret before registration.",
},
),
this._field(
"select",
"irc.serverPasswordMode",
"Server Password Storage",
irc.serverPasswordMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "ask-every-time", label: "Ask every time" },
],
},
),
]),
);
sections.push(
this._renderSection("NickServ", [
this._field(
"toggle",
"irc.nickServIdentify",
"Identify on Connect",
irc.nickServIdentify,
),
this._field(
"text",
"irc.nickServAccount",
"Account",
irc.nickServAccount,
{
placeholder: "Optional if it matches your nick",
},
),
this._field(
"select",
"irc.nickServMode",
"Password Storage",
irc.nickServMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "ask-every-time", label: "Ask every time" },
],
},
),
]),
);
sections.push(
this._renderSection("Rooms", [
this._field(
"textarea",
"irc.autoJoinRoomsText",
"Auto-Join Rooms",
this._roomsToText(irc.autoJoinRooms),
{
placeholder: "#general\n#ops",
rows: 4,
help: "One room per line or comma-separated.",
},
),
]),
);
sections.push(
this._renderSection("SSH Tunnel", [
this._field(
"toggle",
"irc.sshTunnel",
"Enable SSH Tunnel",
irc.sshTunnel,
),
this._field("text", "irc.sshHost", "SSH Host", irc.sshHost, {
placeholder: "jump.example.com",
}),
this._field("number", "irc.sshPort", "SSH Port", irc.sshPort, {
min: 1,
max: 65535,
}),
this._field(
"text",
"irc.sshUsername",
"SSH Username",
irc.sshUsername,
{
placeholder: "admin",
},
),
this._field(
"select",
"irc.sshAuthMethod",
"SSH Auth Method",
irc.sshAuthMethod,
{
options: [
{ value: "publickey", label: "Public Key" },
{ value: "password", label: "Password" },
],
},
),
this._field(
"text",
"irc.sshPrivateKey",
"SSH Private Key Path",
irc.sshPrivateKey,
{
placeholder: "~/.ssh/id_ed25519",
datalistId: "ssh-key-path-options",
},
),
this._field(
"toggle",
"irc.sshAgentAuth",
"Use SSH Agent",
irc.sshAgentAuth,
),
this._field(
"select",
"irc.sshSecretMode",
"SSH Secret Storage",
irc.sshSecretMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "ask-every-time", label: "Ask every time" },
],
},
),
]),
);
sections.push(
this._renderSection("Reconnection", [
this._field(
"toggle",
"irc.autoReconnect",
"Auto-Reconnect",
irc.autoReconnect,
),
this._field(
"number",
"irc.maxReconnectTries",
"Max Reconnect Attempts",
irc.maxReconnectTries || 5,
{ min: 1, max: 20 },
),
]),
);
return sections;
}
/** @private */
_renderTerminalSection(profile) {
return this._renderSection("Terminal", [
@@ -2275,6 +2484,34 @@
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
}
// IRC-specific
if (p.protocol === "irc") {
if (!p.irc) p.irc = {};
p.irc.nick = val("irc.nick") || "nexus";
p.irc.realName = val("irc.realName") || "Nexus Terminal";
p.irc.useTLS = val("irc.useTLS") || false;
p.irc.serverPasswordRequired =
val("irc.serverPasswordRequired") || false;
p.irc.serverPasswordMode =
val("irc.serverPasswordMode") || "ask-every-time";
p.irc.nickServIdentify = val("irc.nickServIdentify") || false;
p.irc.nickServAccount = val("irc.nickServAccount") || "";
p.irc.nickServMode = val("irc.nickServMode") || "saved";
p.irc.autoJoinRooms = this._parseRoomList(
val("irc.autoJoinRoomsText") || "",
);
p.irc.sshTunnel = val("irc.sshTunnel") || false;
p.irc.sshHost = val("irc.sshHost") || "";
p.irc.sshPort = val("irc.sshPort") || 22;
p.irc.sshUsername = val("irc.sshUsername") || "";
p.irc.sshAuthMethod = val("irc.sshAuthMethod") || "publickey";
p.irc.sshPrivateKey = val("irc.sshPrivateKey") || "";
p.irc.sshAgentAuth = val("irc.sshAgentAuth") || false;
p.irc.sshSecretMode = val("irc.sshSecretMode") || "saved";
p.irc.autoReconnect = val("irc.autoReconnect") !== false;
p.irc.maxReconnectTries = val("irc.maxReconnectTries") || 5;
}
}
// ── Validation ───────────────────────────────────────────────────
@@ -2300,6 +2537,25 @@
if (!profile.port || profile.port < 1 || profile.port > 65535) {
errors.push("Port must be between 1 and 65535.");
}
if (profile.protocol === "irc") {
const nick = profile.irc?.nick || "";
if (!nick.trim()) {
errors.push("IRC nick is required.");
}
for (const room of profile.irc?.autoJoinRooms || []) {
if (!/^[#&+!][^\s,]+$/.test(room)) {
errors.push(`Invalid IRC room "${room}".`);
}
}
if (profile.irc?.sshTunnel) {
if (!profile.irc.sshHost?.trim()) {
errors.push("SSH Host is required when tunnelling IRC.");
}
if (!profile.irc.sshUsername?.trim()) {
errors.push("SSH Username is required when tunnelling IRC.");
}
}
}
return errors;
}
@@ -2476,6 +2732,7 @@
raw: null,
github: null,
vnc: null,
irc: null,
};
const defaults = DEFAULTS[protocol] || {};
@@ -2494,6 +2751,17 @@
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
_roomsToText(rooms) {
return Array.isArray(rooms) ? rooms.join("\n") : "";
}
_parseRoomList(value) {
return String(value || "")
.split(/[\n,]/)
.map((room) => room.trim())
.filter(Boolean);
}
}
window.NexusTerminal = window.NexusTerminal || {};
+2
View File
@@ -926,6 +926,8 @@
return "TELNET";
case "raw":
return "RAW";
case "irc":
return "IRC";
default:
return (protocol || "").toUpperCase() || "UNKNOWN";
}
+1
View File
@@ -191,6 +191,7 @@
this.app.terminals.destroyTerminal(sessionId);
this.app.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
window.NexusTerminal.IRCViewer.destroyViewer(sessionId);
this.app.leviathan?.destroyPanel(sessionId);
this.removeTab(sessionId);
this.app.state.removeSession(sessionId);
+2
View File
@@ -290,6 +290,8 @@
return "proto-github";
case "vnc":
return "proto-vnc";
case "irc":
return "proto-irc";
case "local":
return "proto-local";
default:
+12
View File
@@ -80,6 +80,18 @@ export function GetVNCWebSocketURL(arg1:string):Promise<string>;
export function HasTOTPSecret(arg1:string):Promise<boolean>;
export function IRCHandleInput(arg1:string,arg2:string,arg3:string):Promise<void>;
export function IRCJoin(arg1:string,arg2:string):Promise<void>;
export function IRCListRooms(arg1:string):Promise<void>;
export function IRCPart(arg1:string,arg2:string):Promise<void>;
export function IRCRooms(arg1:string):Promise<Array<Record<string, any>>>;
export function IRCUsers(arg1:string,arg2:string):Promise<Array<string>>;
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
+24
View File
@@ -154,6 +154,30 @@ export function HasTOTPSecret(arg1) {
return window['go']['api']['Service']['HasTOTPSecret'](arg1);
}
export function IRCHandleInput(arg1, arg2, arg3) {
return window['go']['api']['Service']['IRCHandleInput'](arg1, arg2, arg3);
}
export function IRCJoin(arg1, arg2) {
return window['go']['api']['Service']['IRCJoin'](arg1, arg2);
}
export function IRCListRooms(arg1) {
return window['go']['api']['Service']['IRCListRooms'](arg1);
}
export function IRCPart(arg1, arg2) {
return window['go']['api']['Service']['IRCPart'](arg1, arg2);
}
export function IRCRooms(arg1) {
return window['go']['api']['Service']['IRCRooms'](arg1);
}
export function IRCUsers(arg1, arg2) {
return window['go']['api']['Service']['IRCUsers'](arg1, arg2);
}
export function ImportProfiles(arg1, arg2) {
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
}
+50
View File
@@ -181,6 +181,54 @@ export namespace models {
this.updatedAt = source["updatedAt"];
}
}
export class IRCConfig {
nick: string;
realName: string;
useTLS: boolean;
serverPasswordRequired: boolean;
serverPasswordMode: string;
nickServIdentify: boolean;
nickServAccount: string;
nickServMode: string;
autoJoinRooms: string[];
sshTunnel: boolean;
sshHost: string;
sshPort: number;
sshUsername: string;
sshAuthMethod: string;
sshSecretMode: string;
sshPrivateKeyPath: string;
sshAgentAuth: boolean;
autoReconnect: boolean;
maxReconnectTries: number;
static createFrom(source: any = {}) {
return new IRCConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.nick = source["nick"];
this.realName = source["realName"];
this.useTLS = source["useTLS"];
this.serverPasswordRequired = source["serverPasswordRequired"];
this.serverPasswordMode = source["serverPasswordMode"];
this.nickServIdentify = source["nickServIdentify"];
this.nickServAccount = source["nickServAccount"];
this.nickServMode = source["nickServMode"];
this.autoJoinRooms = source["autoJoinRooms"];
this.sshTunnel = source["sshTunnel"];
this.sshHost = source["sshHost"];
this.sshPort = source["sshPort"];
this.sshUsername = source["sshUsername"];
this.sshAuthMethod = source["sshAuthMethod"];
this.sshSecretMode = source["sshSecretMode"];
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
this.sshAgentAuth = source["sshAgentAuth"];
this.autoReconnect = source["autoReconnect"];
this.maxReconnectTries = source["maxReconnectTries"];
}
}
export class JumpHost {
host: string;
port: number;
@@ -520,6 +568,7 @@ export namespace models {
raw?: RAWConfig;
github?: GitHubConfig;
vnc?: VNCConfig;
irc?: IRCConfig;
static createFrom(source: any = {}) {
return new Profile(source);
@@ -553,6 +602,7 @@ export namespace models {
this.raw = this.convertValues(source["raw"], RAWConfig);
this.github = this.convertValues(source["github"], GitHubConfig);
this.vnc = this.convertValues(source["vnc"], VNCConfig);
this.irc = this.convertValues(source["irc"], IRCConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {