feat: improve local terminal workflows

This commit is contained in:
Andy Dixon
2026-07-22 11:57:52 +01:00
parent 4fc3b19441
commit 5d5993d9c4
20 changed files with 967 additions and 160 deletions
+1
View File
@@ -61,6 +61,7 @@
class="sidebar-title sidebar-mode-toggle"
id="mode-leviathan"
type="button"
hidden
>
Leviathan
</button>
+67
View File
@@ -23,6 +23,17 @@ function assertNotContains(file, needle, message) {
}
}
function assertBefore(file, first, second, message) {
const text = read(file);
const firstIndex = text.indexOf(first);
const secondIndex = text.indexOf(second);
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
throw new Error(
`${message}\nExpected ${JSON.stringify(first)} before ${JSON.stringify(second)} in ${file}`,
);
}
}
function assertTreeNotContains(relDir, needle, message) {
const dir = path.join(repo, relDir);
const offenders = [];
@@ -74,6 +85,26 @@ assertContains(
"cancelScheduledFit",
"terminal teardown must cancel pending fit work",
);
assertContains(
"frontend/src/js/terminal.js",
"this._sendTerminalSize(sessionId, term);",
"every completed terminal fit must publish its geometry to the backend PTY",
);
assertContains(
"frontend/src/js/terminal.js",
"[Exited - press enter to restart]",
"exited local shells must show the inline restart prompt",
);
assertContains(
"frontend/src/js/terminal.js",
"RestartLocalSession",
"pressing enter after a local shell exit must restart the existing session",
);
assertContains(
"frontend/src/js/app.js",
"!localExited &&",
"local shell exits must not show the disconnected overlay",
);
assertNotContains(
"frontend/src/js/terminal.js",
"new ResizeObserver(() => {\n try {\n fitAddon.fit();",
@@ -114,6 +145,31 @@ assertContains(
"mode-leviathan",
"Leviathan mode toggle must be present beside Nexus Terminal",
);
assertContains(
"frontend/index.html",
'id="mode-leviathan"\n type="button"\n hidden',
"Leviathan mode toggle must be hidden before preferences load",
);
assertContains(
"frontend/src/js/app.js",
"prefs.leviathanEnabled === true",
"Leviathan mode must only be exposed by an explicit persisted preference",
);
assertContains(
"frontend/src/js/settings.js",
"KONAMI_SEQUENCE",
"the About window must recognise the Konami sequence",
);
assertContains(
"frontend/src/js/settings.js",
"_toggleLeviathanFromKonami",
"the About-window Konami sequence must toggle Leviathan",
);
assertContains(
"frontend/src/js/settings.js",
"SavePreferences(prefs)",
"the Konami toggle must persist the Leviathan preference immediately",
);
assertContains(
"frontend/src/css/sidebar.css",
"user-select: none",
@@ -299,6 +355,17 @@ assertContains(
"setMode(\"leviathan\")",
"App mode switch must route to Leviathan",
);
assertBefore(
"frontend/src/js/splitpane.js",
"localLabel.textContent = 'Local Machine';",
"profiles.forEach(function(profile)",
"split-pane connection choices must put Local Machine before saved profiles",
);
assertContains(
"frontend/src/js/splitpane.js",
"wailsService.ConnectLocal()",
"the Local Machine split-pane choice must open a real local shell",
);
assertContains(
"internal/api/leviathan.go",
"LeviathanRunTool",
+4
View File
@@ -66,6 +66,10 @@
text-align: right;
}
#mode-leviathan[hidden] {
display: none;
}
.sidebar-mode-toggle.is-active {
color: var(--text-primary);
}
+25 -2
View File
@@ -191,13 +191,23 @@
);
this.updateStatusBar();
const session = this.state.getSession(payload.sessionID);
const isLocal = session?.protocol === "local";
const localExited =
isLocal && payload.state === "disconnected";
const showDisconnectedOverlay =
payload.state === "disconnected" || payload.state === "error";
!localExited &&
(payload.state === "disconnected" || payload.state === "error");
this.terminals.setDisconnectedOverlay(
payload.sessionID,
showDisconnectedOverlay,
payload.error,
);
if (localExited) {
this.terminals.setLocalExited(payload.sessionID, true);
} else if (isLocal && payload.state === "connected") {
this.terminals.setLocalExited(payload.sessionID, false);
}
if (payload.state === "error" && payload.error) {
this._handleSessionError(payload.sessionID, payload.error);
@@ -612,7 +622,10 @@
* @param {"nexus"|"leviathan"} mode
*/
setMode(mode) {
this.mode = mode === "leviathan" ? "leviathan" : "nexus";
const leviathanEnabled =
this.state.preferences?.leviathanEnabled === true;
this.mode =
mode === "leviathan" && leviathanEnabled ? "leviathan" : "nexus";
const root = document.getElementById("app");
if (root) {
root.classList.toggle("mode-leviathan", this.mode === "leviathan");
@@ -1374,6 +1387,16 @@
prefs.showStatusBar === false ? "none" : "flex";
}
// Leviathan is an opt-in mode, revealed from the About window.
const leviathanEnabled = prefs.leviathanEnabled === true;
const leviathanButton = document.getElementById("mode-leviathan");
if (leviathanButton) {
leviathanButton.hidden = !leviathanEnabled;
}
if (!leviathanEnabled && this.mode === "leviathan") {
this.setMode("nexus");
}
// Refit any visible terminal after geometry changes.
requestAnimationFrame(() => {
this.terminals.fitActive();
+83
View File
@@ -18,6 +18,19 @@
{ key: "about", label: "About" },
];
const KONAMI_SEQUENCE = [
"ArrowUp",
"ArrowUp",
"ArrowDown",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"ArrowLeft",
"ArrowRight",
"b",
"a",
];
class Settings {
/**
* @param {App} app
@@ -36,6 +49,10 @@
/** @type {Array<Object>} Cached known hosts list. */
this._knownHosts = [];
this._aboutKonamiHandler = null;
this._konamiIndex = 0;
this._konamiTogglePending = false;
}
// ── Public API ───────────────────────────────────────────────────
@@ -75,6 +92,7 @@
* Closes the settings modal.
*/
close() {
this._detachAboutKonamiListener();
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
@@ -198,6 +216,7 @@
* @private
*/
_renderTabContent(body) {
this._detachAboutKonamiListener();
body.innerHTML = "";
body.classList.remove("help-layout");
switch (this._activeTab) {
@@ -1058,10 +1077,74 @@
}
});
}
this._attachAboutKonamiListener();
}
// ── Form helpers ─────────────────────────────────────────────────
_attachAboutKonamiListener() {
this._detachAboutKonamiListener();
this._konamiIndex = 0;
this._aboutKonamiHandler = (event) => {
if (!this._overlay || this._activeTab !== "about") return;
if (event.ctrlKey || event.metaKey || event.altKey) {
this._konamiIndex = 0;
return;
}
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (key === KONAMI_SEQUENCE[this._konamiIndex]) {
event.preventDefault();
this._konamiIndex += 1;
if (this._konamiIndex === KONAMI_SEQUENCE.length) {
this._konamiIndex = 0;
void this._toggleLeviathanFromKonami();
}
return;
}
this._konamiIndex = key === KONAMI_SEQUENCE[0] ? 1 : 0;
};
window.addEventListener("keydown", this._aboutKonamiHandler);
}
_detachAboutKonamiListener() {
if (this._aboutKonamiHandler) {
window.removeEventListener("keydown", this._aboutKonamiHandler);
this._aboutKonamiHandler = null;
}
this._konamiIndex = 0;
}
async _toggleLeviathanFromKonami() {
if (this._konamiTogglePending) return;
this._konamiTogglePending = true;
const enabled =
this.app.state.preferences?.leviathanEnabled !== true;
const prefs = {
...this.app.state.preferences,
leviathanEnabled: enabled,
};
try {
await window.NexusTerminal.wailsService.SavePreferences(prefs);
this._prefs.leviathanEnabled = enabled;
this.app.state.setPreferences(prefs);
this.app._applyPreferences?.();
this.app.notifications.success(
enabled ? "Leviathan enabled" : "Leviathan disabled",
);
} catch (err) {
this.app.notifications.error(
"Failed to update Leviathan setting: " + err,
);
} finally {
this._konamiTogglePending = false;
}
}
/**
* Renders a single settings form field.
* @param {string} type
+93 -42
View File
@@ -385,35 +385,51 @@ class SplitPaneManager {
// Build profile list
var profiles = this.app.state.profiles || [];
if (profiles.length > 0) {
var list = document.createElement('div');
list.className = 'split-pane-profile-list';
var list = document.createElement('div');
list.className = 'split-pane-profile-list';
var self = this;
profiles.forEach(function(profile) {
var btn = document.createElement('button');
btn.className = 'split-pane-profile-btn';
var self = this;
var localBtn = document.createElement('button');
localBtn.className = 'split-pane-profile-btn';
var badge = document.createElement('span');
badge.className = 'protocol-badge';
badge.textContent = (profile.protocol || 'ssh').toUpperCase();
badge.style.color = self._protocolColor(profile.protocol);
var localBadge = document.createElement('span');
localBadge.className = 'protocol-badge';
localBadge.textContent = 'LOCAL';
localBadge.style.color = self._protocolColor('local');
var label = document.createElement('span');
label.textContent = profile.displayName || profile.host || profile.id;
var localLabel = document.createElement('span');
localLabel.textContent = 'Local Machine';
btn.appendChild(badge);
btn.appendChild(label);
localBtn.appendChild(localBadge);
localBtn.appendChild(localLabel);
localBtn.addEventListener('click', function() {
self._connectLocalInPane(paneEl, ownerSessionId);
});
list.appendChild(localBtn);
btn.addEventListener('click', function() {
self._connectInPane(paneEl, profile, ownerSessionId);
});
profiles.forEach(function(profile) {
var btn = document.createElement('button');
btn.className = 'split-pane-profile-btn';
list.appendChild(btn);
var badge = document.createElement('span');
badge.className = 'protocol-badge';
badge.textContent = (profile.protocol || 'ssh').toUpperCase();
badge.style.color = self._protocolColor(profile.protocol);
var label = document.createElement('span');
label.textContent = profile.displayName || profile.host || profile.id;
btn.appendChild(badge);
btn.appendChild(label);
btn.addEventListener('click', function() {
self._connectInPane(paneEl, profile, ownerSessionId);
});
placeholder.appendChild(list);
}
list.appendChild(btn);
});
placeholder.appendChild(list);
paneEl.appendChild(placeholder);
}
@@ -427,7 +443,6 @@ class SplitPaneManager {
*/
_connectInPane(paneEl, profile, ownerSessionId) {
var self = this;
var node = paneEl._splitNode;
// Remove placeholder
var placeholder = paneEl.querySelector('.split-pane-placeholder');
@@ -435,25 +450,7 @@ class SplitPaneManager {
// Connect via backend
window.NexusTerminal.wailsService.ConnectSession(profile.id).then(function(sessionInfo) {
self.app.state.addSession(sessionInfo);
// Create a terminal container inside this pane
var termContainer = document.createElement('div');
termContainer.className = 'terminal-container';
termContainer.id = 'split-term-' + sessionInfo.id;
termContainer.style.position = 'relative';
termContainer.style.flex = '1';
termContainer.style.width = '100%';
termContainer.style.height = '100%';
paneEl.appendChild(termContainer);
// Track which session is in this pane
if (node) {
node.sessionId = sessionInfo.id;
}
// Create terminal
self.app.terminals.createTerminal(sessionInfo.id, termContainer.id, profile);
self._attachTerminalToPane(paneEl, sessionInfo, profile);
self.app.notifications.info('Connecting to ' + (profile.displayName || profile.host) + '…');
}).catch(function(err) {
@@ -463,6 +460,59 @@ class SplitPaneManager {
});
}
/**
* Opens a local shell in a specific pane.
* @param {HTMLElement} paneEl
* @param {string} ownerSessionId
* @private
*/
_connectLocalInPane(paneEl, ownerSessionId) {
var self = this;
var placeholder = paneEl.querySelector('.split-pane-placeholder');
if (placeholder) placeholder.remove();
window.NexusTerminal.wailsService.ConnectLocal().then(function(sessionInfo) {
var prefs = self.app.state.preferences || {};
self._attachTerminalToPane(paneEl, sessionInfo, {
terminalFontFamily: prefs.defaultFontFamily,
terminalFontSize: prefs.defaultFontSize,
terminalScrollback: prefs.defaultScrollback,
terminalCursorStyle: prefs.defaultCursorStyle
});
self.app.notifications.info('Opening local terminal…');
}).catch(function(err) {
self.app.notifications.error('Local terminal failed: ' + err);
self._renderPlaceholder(paneEl, ownerSessionId);
});
}
/**
* Attaches a connected terminal session to a split pane.
* @param {HTMLElement} paneEl
* @param {Object} sessionInfo
* @param {Object} terminalProfile
* @private
*/
_attachTerminalToPane(paneEl, sessionInfo, terminalProfile) {
this.app.state.addSession(sessionInfo);
var termContainer = document.createElement('div');
termContainer.className = 'terminal-container';
termContainer.id = 'split-term-' + sessionInfo.id;
termContainer.style.position = 'relative';
termContainer.style.flex = '1';
termContainer.style.width = '100%';
termContainer.style.height = '100%';
paneEl.appendChild(termContainer);
var node = paneEl._splitNode;
if (node) {
node.sessionId = sessionInfo.id;
}
this.app.terminals.createTerminal(sessionInfo.id, termContainer.id, terminalProfile);
}
// ── Tree traversal helpers ───────────────────────────────────────
/**
@@ -588,7 +638,8 @@ class SplitPaneManager {
ssh: '#4ecdc4',
telnet: '#ffa726',
raw: '#ab47bc',
github: '#7c8aff'
github: '#7c8aff',
local: '#66bb6a'
};
return colors[protocol] || '#4ecdc4';
}
+61 -5
View File
@@ -224,6 +224,10 @@
try {
fitAddon.fit();
// Forced fits do not emit onResize when xterm's geometry is
// unchanged. Always publish the fitted size so a new local PTY
// cannot remain at its 80x24 fallback dimensions.
this._sendTerminalSize(sessionId, term);
} catch (_) {
/* noop */
}
@@ -249,6 +253,14 @@
// Forward user keystrokes to the backend
term.onData((data) => {
const entry = this.terminals.get(sessionId);
if (entry?.localExited) {
if (data === "\r" || data === "\n") {
this._restartLocalTerminal(sessionId, entry);
}
return;
}
try {
const b64 = utf8ToBase64(data);
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
@@ -330,11 +342,7 @@
// Notify backend when terminal dimensions change
term.onResize(({ cols, rows }) => {
try {
window.NexusTerminal.wailsService.ResizeSession(sessionId, cols, rows);
} catch (err) {
console.error("ResizeSession error:", err);
}
this._sendTerminalSize(sessionId, { cols, rows });
});
// Re-fit when the container itself resizes. ResizeObserver can fire
@@ -354,6 +362,8 @@
scheduleFit,
cancelScheduledFit,
refreshTerminal,
localExited: false,
localRestarting: false,
decoder:
typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null,
});
@@ -461,6 +471,24 @@
overlay.classList.toggle("is-visible", isDisconnected);
}
/**
* Marks a local shell as exited without obscuring its terminal buffer.
* Enter is intercepted by the terminal input handler and restarts it.
* @param {string} sessionId
* @param {boolean} isExited
*/
setLocalExited(sessionId, isExited) {
const entry = this.terminals.get(sessionId);
if (!entry) return;
if (isExited && !entry.localExited) {
entry.term.write("\r\n[Exited - press enter to restart]\r\n");
}
entry.localExited = isExited;
entry.localRestarting = false;
if (isExited) entry.term.focus();
}
/**
* Searches within a terminal using the search addon.
* @param {string} sessionId
@@ -550,6 +578,34 @@
// ── Private helpers ──────────────────────────────────────────────
_sendTerminalSize(sessionId, dimensions) {
const cols = Number(dimensions?.cols || 0);
const rows = Number(dimensions?.rows || 0);
if (cols <= 0 || rows <= 0) return;
Promise.resolve(
window.NexusTerminal.wailsService.ResizeSession(sessionId, cols, rows),
).catch((err) => console.error("ResizeSession error:", err));
}
async _restartLocalTerminal(sessionId, entry) {
if (entry.localRestarting) return;
entry.localRestarting = true;
try {
await window.NexusTerminal.wailsService.RestartLocalSession(
sessionId,
entry.term.cols,
entry.term.rows,
);
} catch (err) {
entry.localRestarting = false;
entry.term.write(
`\r\n[Restart failed: ${String(err)} - press enter to retry]\r\n`,
);
}
}
/**
* Ensures the disconnect overlay exists for a session panel.
* @param {HTMLElement} panel
+14
View File
@@ -44,6 +44,8 @@ export function DisconnectSession(arg1:string):Promise<void>;
export function DownloadGitHubRepoArchive(arg1:string,arg2:string,arg3:string):Promise<string>;
export function DownloadGitHubRepoArchiveToPath(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
export function ExportProfiles(arg1:boolean):Promise<string>;
@@ -124,6 +126,8 @@ export function LeviathanRunTool(arg1:string,arg2:Record<string, any>):Promise<R
export function LeviathanSaveWorking():Promise<string>;
export function LeviathanSaveWorkingToPath(arg1:string):Promise<string>;
export function LeviathanSetByte(arg1:number,arg2:string):Promise<Record<string, any>>;
export function LeviathanState():Promise<api.leviathanDocState>;
@@ -174,8 +178,14 @@ export function RenameSession(arg1:string,arg2:string):Promise<void>;
export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise<void>;
export function RespondSecret(arg1:string,arg2:string,arg3:boolean):Promise<void>;
export function RestartLocalSession(arg1:string,arg2:number,arg3:number):Promise<void>;
export function SFTPDownload(arg1:string,arg2:string):Promise<void>;
export function SFTPDownloadToPath(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SFTPList(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
export function SFTPMkdir(arg1:string,arg2:string):Promise<void>;
@@ -198,6 +208,8 @@ export function SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string):
export function SFTPUploadFinish(arg1:string):Promise<void>;
export function SFTPUploadPath(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SFTPUploadStart(arg1:string,arg2:string,arg3:string,arg4:number):Promise<string>;
export function SaveFile(arg1:string,arg2:string):Promise<string>;
@@ -214,6 +226,8 @@ export function SendBreak(arg1:string):Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetEventSink(arg1:any):Promise<void>;
export function SetFavourite(arg1:string,arg2:boolean):Promise<void>;
export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<void>;
+28
View File
@@ -82,6 +82,10 @@ export function DownloadGitHubRepoArchive(arg1, arg2, arg3) {
return window['go']['api']['Service']['DownloadGitHubRepoArchive'](arg1, arg2, arg3);
}
export function DownloadGitHubRepoArchiveToPath(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['DownloadGitHubRepoArchiveToPath'](arg1, arg2, arg3, arg4);
}
export function DuplicateProfile(arg1) {
return window['go']['api']['Service']['DuplicateProfile'](arg1);
}
@@ -242,6 +246,10 @@ export function LeviathanSaveWorking() {
return window['go']['api']['Service']['LeviathanSaveWorking']();
}
export function LeviathanSaveWorkingToPath(arg1) {
return window['go']['api']['Service']['LeviathanSaveWorkingToPath'](arg1);
}
export function LeviathanSetByte(arg1, arg2) {
return window['go']['api']['Service']['LeviathanSetByte'](arg1, arg2);
}
@@ -342,10 +350,22 @@ export function ResizeSession(arg1, arg2, arg3) {
return window['go']['api']['Service']['ResizeSession'](arg1, arg2, arg3);
}
export function RespondSecret(arg1, arg2, arg3) {
return window['go']['api']['Service']['RespondSecret'](arg1, arg2, arg3);
}
export function RestartLocalSession(arg1, arg2, arg3) {
return window['go']['api']['Service']['RestartLocalSession'](arg1, arg2, arg3);
}
export function SFTPDownload(arg1, arg2) {
return window['go']['api']['Service']['SFTPDownload'](arg1, arg2);
}
export function SFTPDownloadToPath(arg1, arg2, arg3) {
return window['go']['api']['Service']['SFTPDownloadToPath'](arg1, arg2, arg3);
}
export function SFTPList(arg1, arg2) {
return window['go']['api']['Service']['SFTPList'](arg1, arg2);
}
@@ -390,6 +410,10 @@ export function SFTPUploadFinish(arg1) {
return window['go']['api']['Service']['SFTPUploadFinish'](arg1);
}
export function SFTPUploadPath(arg1, arg2, arg3) {
return window['go']['api']['Service']['SFTPUploadPath'](arg1, arg2, arg3);
}
export function SFTPUploadStart(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['SFTPUploadStart'](arg1, arg2, arg3, arg4);
}
@@ -422,6 +446,10 @@ export function SetContext(arg1) {
return window['go']['api']['Service']['SetContext'](arg1);
}
export function SetEventSink(arg1) {
return window['go']['api']['Service']['SetEventSink'](arg1);
}
export function SetFavourite(arg1, arg2) {
return window['go']['api']['Service']['SetFavourite'](arg1, arg2);
}
+2
View File
@@ -81,6 +81,7 @@ export namespace models {
defaultCursorStyle: string;
useNativePaste: boolean;
localShell: string;
leviathanEnabled: boolean;
static createFrom(source: any = {}) {
return new AppPreferences(source);
@@ -101,6 +102,7 @@ export namespace models {
this.defaultCursorStyle = source["defaultCursorStyle"];
this.useNativePaste = source["useNativePaste"];
this.localShell = source["localShell"];
this.leviathanEnabled = source["leviathanEnabled"];
}
}
export class AuditEntry {